Build 2 Phase B: checkout claims atomically, webhook finalizes, bypass retired
Stripe wired to the Phase-A mechanism. Live prices verified READ-ONLY; no Stripe object was created and no payment was run. B1 PRICE KEY -> ID + BOOT ASSERTION (src/config/stripePrices.js). claim_founder_slot returns a price KEY; this module is the only place a key becomes a Stripe id, and it reads env (legacy STRIPE_PRICE_ANALYST/DESK accepted as fallbacks so an existing deploy keeps working). assertPricesConfigured() is wired into server.js and FAILS BOOT when any of the four is unset — verified by deleting one: it throws "BOOT FAILED - unset Stripe price env for: desk_founder". A blank price can no longer sell at the wrong rate or 503 a customer at checkout. B2 CHECKOUT CLAIMS BEFORE CREATING THE SESSION. resolveCheckoutPrice previously called founderSeatsAvailable() — a COUNT read, which WAS the race (two checkouts at seat 99 both read 99, both got founder). It now calls claim_founder_slot and uses the returned key. The promo-code bypass is retired: founderCode no longer influences price or metadata, and getPriceId THROWS if handed a code rather than silently granting a founder rate. metadata.is_founder is renamed is_founder_audit and the webhook no longer reads it — caller-supplied metadata must never decide who pays the lifetime founder price. TRANSIENT-FAILURE POLICY (a real design call, not a default): if the claim RPC errors we now fail RETRYABLY (503 claim_failed) instead of silently selling at standing. Both silent options are irreversible — standing permanently overcharges someone who was entitled to founder, and granting founder without a slot pushes past the 100 cap at permanent prices. A full cap is NOT an error and still returns standing normally, per "never error to the customer": a full cap is a real answer, a DB blip is not. B3 WEBHOOK FINALIZES THROUGH THE SINGLE WRITER. checkout.session.completed calls finalize_founder_slot, which flips user_profiles.founder_pricing (canonical) and mirrors users.founder_status in the SAME txn, so they cannot drift again (they already had, 1 vs 0). Verify-after-write re-reads the profile and logs the end state. If finalize errors, the tier is still set so a PAID customer is never left unentitled, but no founder flag is guessed. B4 SIGNATURE VERIFICATION was already present (constructEvent with STRIPE_WEBHOOK_SECRET + express.raw). The live endpoint exists and is enabled: https://api.vyndr.app/api/stripe/webhook subscribing checkout.session.completed, customer.subscription.created/updated/deleted, invoice.payment_succeeded/failed. VERIFICATION: V1 boot assertion proven by simulation. V2 all four prices retrieved live and confirmed active with correct amounts and monthly recurrence (14.99 / 24.99 / 44.99 / 59.99) — read-only, nothing created. V3 no code path grants founder except the claim (greps clean; the legacy helper now throws). V4 the handler reads customer/subscription/metadata.user_id and calls finalize with signature verification in place. V5 reset to a pristine 100 free / 0 claimed baseline with both founder flags at 0. Secrets live only in .env (0600, gitignored, untracked). A pre-commit scan confirmed NO tracked file contains the key material. Floor: 320 suites / 3984 passed, 3 skipped (superseded founder-code tests), web build exit 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QJs13VsyiSKYQP6rj3NNmc
This commit is contained in:
@@ -27,6 +27,7 @@ const PRICE_MAP = {
|
||||
// checks for it and returns 503 with a friendly message rather than
|
||||
// passing "null" to Stripe.
|
||||
const PRICE_UNCONFIGURED = '__unconfigured__';
|
||||
const { idFor } = require('../config/stripePrices');
|
||||
|
||||
// VYNDR is the canonical brand promo. BETONBLK stays in the default list so
|
||||
// codes distributed before the rebrand keep redeeming during the transition.
|
||||
@@ -43,7 +44,16 @@ function isFounderCodeValid(code) {
|
||||
return VALID_FOUNDER_CODES.includes(code.toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated B2 (2026-07-31) — RETIRED as a founder path. founder_slots is the
|
||||
* only founder gate; price comes from claim_founder_slot via resolveCheckoutPrice.
|
||||
* Kept only so any stale import fails loudly rather than silently granting a
|
||||
* founder rate off a promo code.
|
||||
*/
|
||||
function getPriceId(tier, founderCode) {
|
||||
if (founderCode) {
|
||||
throw new Error('[stripe] getPriceId is retired as a founder path — use resolveCheckoutPrice (claim_founder_slot).');
|
||||
}
|
||||
const isFounder = isFounderCodeValid(founderCode);
|
||||
if (tier === 'analyst') {
|
||||
// Session 15 — if a valid founder code is presented but the
|
||||
@@ -65,13 +75,15 @@ function getPriceId(tier, founderCode) {
|
||||
throw new Error(`Invalid tier: ${tier}`);
|
||||
}
|
||||
|
||||
async function createCheckoutSession(userId, email, tier, founderCode) {
|
||||
// B2 — `founderCode` is accepted for signature back-compat ONLY. It no longer
|
||||
// influences price or metadata: founder_slots is the sole founder gate.
|
||||
async function createCheckoutSession(userId, email, tier, founderCode) { // eslint-disable-line no-unused-vars
|
||||
const supabase = getSupabaseServiceClient();
|
||||
// Item B — the price is SEAT-GATED, not code-gated: while founder seats
|
||||
// remain (< FOUNDER_SEATS_TOTAL, same truth as the meter) checkout attaches
|
||||
// the founder price automatically; at seat 100 it flips to standard. The old
|
||||
// founderCode param is accepted for back-compat but no longer drives price.
|
||||
const { priceId, isFounder } = await resolveCheckoutPrice(tier);
|
||||
const { priceId, isFounder } = await resolveCheckoutPrice(tier, { userId });
|
||||
// Session 14 — tier is valid but the upstream Stripe product hasn't been
|
||||
// provisioned. Surface a clean 503 with `code: 'tier_unconfigured'`.
|
||||
if (priceId === PRICE_UNCONFIGURED) {
|
||||
@@ -116,7 +128,9 @@ async function createCheckoutSession(userId, email, tier, founderCode) {
|
||||
mode: 'subscription',
|
||||
success_url: `${frontendUrl}/upgrade/success?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${frontendUrl}/upgrade/cancel`,
|
||||
metadata: { user_id: userId, tier, is_founder: String(isFounder) },
|
||||
// is_founder is recorded for AUDIT only. The webhook does NOT read it —
|
||||
// caller-supplied metadata must never decide who pays the founder rate.
|
||||
metadata: { user_id: userId, tier, is_founder_audit: String(isFounder) },
|
||||
});
|
||||
|
||||
return { checkout_url: session.url, session_id: session.id };
|
||||
@@ -152,26 +166,37 @@ async function handleWebhookEvent(event) {
|
||||
const session = event.data.object;
|
||||
const userId = session.metadata?.user_id;
|
||||
const tier = session.metadata?.tier;
|
||||
const isFounder = session.metadata?.is_founder === 'true';
|
||||
|
||||
if (userId && tier) {
|
||||
await supabase
|
||||
.from('users')
|
||||
.update({
|
||||
tier,
|
||||
stripe_customer_id: session.customer,
|
||||
founder_status: isFounder,
|
||||
grace_period_until: null,
|
||||
mfa_setup_prompted: false,
|
||||
})
|
||||
.eq('id', userId);
|
||||
await mirrorToUserProfile(supabase, userId, {
|
||||
tier,
|
||||
subscription_status: 'active',
|
||||
grace_period_until: null,
|
||||
mfa_setup_prompted: false,
|
||||
founder_pricing: isFounder,
|
||||
// B3 — finalize through the SINGLE-WRITER function. It flips
|
||||
// user_profiles.founder_pricing (canonical) AND mirrors
|
||||
// users.founder_status in the SAME transaction, so the two can never
|
||||
// drift again (they already had: 1 vs 0 in prod). Founder truth comes
|
||||
// from the CLAIM, not from checkout metadata — metadata is caller-
|
||||
// supplied and must never decide who pays the founder rate.
|
||||
const { data: wasFounder, error: finErr } = await supabase.rpc('finalize_founder_slot', {
|
||||
p_user_id: userId,
|
||||
p_stripe_subscription_id: session.subscription || null,
|
||||
p_stripe_customer_id: session.customer || null,
|
||||
p_tier: tier,
|
||||
});
|
||||
if (finErr) {
|
||||
console.error('[stripe] finalize_founder_slot failed:', finErr.message);
|
||||
// Never leave a PAID customer unentitled: set the tier even if the
|
||||
// slot bookkeeping failed. founder_pricing stays whatever the claim
|
||||
// made it — we do not guess a founder flag here.
|
||||
await supabase.from('users').update({ tier, stripe_customer_id: session.customer,
|
||||
grace_period_until: null, mfa_setup_prompted: false }).eq('id', userId);
|
||||
await mirrorToUserProfile(supabase, userId, { tier, subscription_status: 'active',
|
||||
grace_period_until: null, mfa_setup_prompted: false });
|
||||
} else {
|
||||
// VERIFY-AFTER-WRITE: re-read the end state rather than trusting the call.
|
||||
const { data: chk } = await supabase.from('user_profiles')
|
||||
.select('tier, subscription_status, founder_pricing, stripe_subscription_id')
|
||||
.eq('id', userId).single();
|
||||
console.log('[stripe] finalized', userId, 'founder=', wasFounder, 'verified=', JSON.stringify(chk));
|
||||
await supabase.from('users').update({ grace_period_until: null, mfa_setup_prompted: false }).eq('id', userId);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -346,18 +371,43 @@ async function founderSeatsAvailable(deps = {}) {
|
||||
// The price a checkout attaches for a tier RIGHT NOW, seat-gated. Returns the
|
||||
// price id + whether it's the founder price (for the founder_pricing webhook flag).
|
||||
async function resolveCheckoutPrice(tier, deps = {}) {
|
||||
const founder = await founderSeatsAvailable(deps);
|
||||
// B2 (2026-07-31) — THE ATOMIC CLAIM REPLACES THE COUNT.
|
||||
// This used to call founderSeatsAvailable() — a COUNT read, which is exactly
|
||||
// the race: two checkouts at seat 99 both read 99 and both got founder.
|
||||
// claim_founder_slot takes a real slot inside one statement serialized by the
|
||||
// unique index, and returns a price KEY (never a Stripe id — see
|
||||
// config/stripePrices). No count is read anywhere in this path.
|
||||
const t = String(tier || '').toLowerCase();
|
||||
if (t === 'analyst') {
|
||||
if (founder && PRICE_MAP.analyst_founder) return { priceId: PRICE_MAP.analyst_founder, isFounder: true };
|
||||
return { priceId: PRICE_MAP.analyst || PRICE_UNCONFIGURED, isFounder: false };
|
||||
if (t !== 'analyst' && t !== 'desk') {
|
||||
return { priceId: PRICE_UNCONFIGURED, isFounder: false, slotNumber: null };
|
||||
}
|
||||
if (t === 'desk') {
|
||||
if (founder && PRICE_MAP.desk_founder) return { priceId: PRICE_MAP.desk_founder, isFounder: true };
|
||||
return { priceId: PRICE_MAP.desk || PRICE_UNCONFIGURED, isFounder: false };
|
||||
const supabase = deps.supabase || getSupabaseServiceClient();
|
||||
const userId = deps.userId || null;
|
||||
if (!userId) return { priceId: idFor(`${t}_standing`) || PRICE_UNCONFIGURED, isFounder: false, slotNumber: null };
|
||||
|
||||
const { data, error } = await supabase.rpc('claim_founder_slot', { p_user_id: userId, p_tier: t });
|
||||
if (error) {
|
||||
// A transient claim failure must NOT silently sell at standing: the founder
|
||||
// rate is LIFETIME, so an overcharge here is permanent. Nor may we grant
|
||||
// founder without a slot — that would push past the 100 cap, and those
|
||||
// prices are permanent too. Both silent options are irreversible, so we
|
||||
// fail RETRYABLY instead and let the customer try again.
|
||||
// (This is distinct from the cap being FULL, which is not an error: that
|
||||
// path returns the standing price normally, per "never error to the
|
||||
// customer" — a full cap is a real answer, a DB blip is not.)
|
||||
console.error('[stripe] claim_founder_slot failed:', error.message);
|
||||
const err = new Error('Could not reserve your rate just now. Please try again.');
|
||||
err.code = 'claim_failed';
|
||||
err.statusCode = 503;
|
||||
throw err;
|
||||
}
|
||||
if (t === 'africa') return { priceId: PRICE_MAP.africa || PRICE_UNCONFIGURED, isFounder: false };
|
||||
return { priceId: PRICE_UNCONFIGURED, isFounder: false };
|
||||
const row = Array.isArray(data) ? data[0] : data;
|
||||
const priceKey = (row && row.price_key) || `${t}_standing`;
|
||||
return {
|
||||
priceId: idFor(priceKey) || PRICE_UNCONFIGURED,
|
||||
isFounder: !!(row && row.is_founder),
|
||||
slotNumber: (row && row.slot_number) || null,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
Reference in New Issue
Block a user