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:
Kev
2026-07-31 18:42:44 -04:00
parent 7c6fd95e68
commit bedbb8c008
6 changed files with 207 additions and 80 deletions
+50
View File
@@ -0,0 +1,50 @@
'use strict';
/**
* B1 — PRICE KEY -> STRIPE PRICE ID (2026-07-31).
*
* `claim_founder_slot` returns a price KEY, never a Stripe id: a hardcoded id
* inside a SQL function becomes a permanent silent mis-charge on a typo. This
* module is the ONLY place a key becomes an id, and it reads env.
*
* BOOT ASSERTION: `assertPricesConfigured()` fails LOUDLY when any of the four
* is missing, so a blank env var stops the process instead of quietly selling
* at the wrong price (or 503-ing the customer at checkout).
*
* Legacy names are accepted as fallbacks so an existing deploy whose env still
* uses STRIPE_PRICE_ANALYST / STRIPE_PRICE_DESK keeps working.
*/
const PRICE_KEYS = Object.freeze(['analyst_founder', 'analyst_standing', 'desk_founder', 'desk_standing']);
function idFor(priceKey) {
switch (String(priceKey || '')) {
case 'analyst_founder': return process.env.STRIPE_PRICE_ANALYST_FOUNDER || null;
case 'analyst_standing': return process.env.STRIPE_PRICE_ANALYST_STANDING || process.env.STRIPE_PRICE_ANALYST || null;
case 'desk_founder': return process.env.STRIPE_PRICE_DESK_FOUNDER || null;
case 'desk_standing': return process.env.STRIPE_PRICE_DESK_STANDING || process.env.STRIPE_PRICE_DESK || null;
default: return null;
}
}
/** Which of the four are unset? (empty array = fully configured) */
function missingPriceKeys() {
return PRICE_KEYS.filter((k) => !idFor(k));
}
/**
* Fail boot rather than mis-charge. Skipped under NODE_ENV=test so the suite
* runs without live price ids.
*/
function assertPricesConfigured({ throwOnMissing = true } = {}) {
if (process.env.NODE_ENV === 'test') return { ok: true, missing: [], skipped: true };
const missing = missingPriceKeys();
if (missing.length && throwOnMissing) {
throw new Error(
'[stripePrices] BOOT FAILED — unset Stripe price env for: ' + missing.join(', ')
+ '. Refusing to start: a missing price would sell at the wrong rate or 503 checkout.',
);
}
return { ok: missing.length === 0, missing };
}
module.exports = { PRICE_KEYS, idFor, missingPriceKeys, assertPricesConfigured };
+3
View File
@@ -4,6 +4,9 @@
require('./preflight').runPreflight();
const app = require('./app');
// B1 — fail boot loudly if any Stripe price env is unset. A blank price would
// otherwise sell at the wrong rate or 503 checkout in front of a customer.
require('./config/stripePrices').assertPricesConfigured();
// Session 20 — surface which providers are actually configured at
// boot. A silently-missing key (e.g. ODDSPAPI_KEY unset in prod)
// otherwise only manifests when the gateway tries to fall over and
+79 -29
View File
@@ -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 = {
+6 -2
View File
@@ -7,9 +7,13 @@ jest.mock('../../src/utils/redis', () => ({ getRedisClient: () => mockRedis }));
// Mock Supabase
const mockSupabaseFrom = jest.fn();
const mockSupabaseAuth = { getUser: jest.fn() };
const mockSupabaseRpc = jest.fn(async () => ({ data: [{ is_founder: true, price_key: 'analyst_founder', slot_number: 1 }], error: null }));
jest.mock('../../src/utils/supabase', () => ({
getSupabaseClient: () => ({ auth: mockSupabaseAuth, from: mockSupabaseFrom }),
getSupabaseServiceClient: () => ({ auth: mockSupabaseAuth, from: mockSupabaseFrom }),
getSupabaseClient: () => ({ auth: mockSupabaseAuth, from: mockSupabaseFrom, rpc: mockSupabaseRpc }),
// Build 2 Phase B — checkout now claims a founder slot via RPC before creating
// the Stripe session. A mock without .rpc makes the claim fail, which now
// (correctly) returns a retryable 503 rather than silently mis-pricing.
getSupabaseServiceClient: () => ({ auth: mockSupabaseAuth, from: mockSupabaseFrom, rpc: mockSupabaseRpc }),
}));
// Mock Stripe — singleton handle so tests can override constructEvent
+68 -48
View File
@@ -46,58 +46,74 @@ describe('stripeService', () => {
// Item B — checkout price is SEAT-GATED (founder while < FOUNDER_SEATS_TOTAL,
// standard when sold out), reading the same countFounderSeats() truth as the
// meter. Inject the seat count so we test both ends without hitting Stripe.
describe('resolveCheckoutPrice — seat-gated founder pricing', () => {
const seats = (n) => ({ countFounderSeats: async () => n });
describe('resolveCheckoutPrice — SUPERSEDED by the atomic claim (Build 2 Phase B)', () => {
// The old contract was seat-gated on a COUNT: "seat 99 is still founder, seat
// 100 flips", and "count unverifiable -> honour founder, never overcharge".
// That count WAS the race — two checkouts at 99 both read 99 and both won.
// Price now comes from claim_founder_slot (a real slot, taken atomically), so
// these assertions describe a mechanism that no longer exists.
const svc = require('../../src/services/stripeService');
test('seat count 0 → founder price (the advertised $34.99 desk)', async () => {
const a = await resolveCheckoutPrice('analyst', seats(0));
expect(a.priceId).toBe('price_test_analyst_founder');
expect(a.isFounder).toBe(true);
const d = await resolveCheckoutPrice('desk', seats(0));
expect(d.priceId).toBe('price_test_desk_founder');
expect(d.isFounder).toBe(true);
});
test('seat count 100 (SOLD OUT) → standard price, isFounder false', async () => {
const a = await resolveCheckoutPrice('analyst', seats(FOUNDER_SEATS_TOTAL));
expect(a.priceId).toBe('price_test_analyst_monthly');
expect(a.isFounder).toBe(false);
const d = await resolveCheckoutPrice('desk', seats(FOUNDER_SEATS_TOTAL));
expect(d.priceId).toBe('price_test_desk_monthly');
expect(d.isFounder).toBe(false);
});
test('seat 99 is still founder, seat 100 flips (the boundary)', async () => {
expect((await resolveCheckoutPrice('desk', seats(99))).isFounder).toBe(true);
expect((await resolveCheckoutPrice('desk', seats(100))).isFounder).toBe(false);
});
test('count unverifiable (null) → honor the advertised founder price, never overcharge', async () => {
const d = await resolveCheckoutPrice('desk', { countFounderSeats: async () => null });
expect(d.priceId).toBe('price_test_desk_founder');
expect(d.isFounder).toBe(true);
});
test('the gate reads countFounderSeats truth (same source as the meter)', async () => {
expect(await founderSeatsAvailable(seats(0))).toBe(true);
expect(await founderSeatsAvailable(seats(FOUNDER_SEATS_TOTAL))).toBe(false);
});
it('no longer reads a seat COUNT to decide the price', () => {
const src = require('fs').readFileSync(
require('path').join(__dirname, '../../src/services/stripeService.js'), 'utf8');
const fn = src.slice(src.indexOf('async function resolveCheckoutPrice'));
const nxt = fn.indexOf('\nmodule.exports');
const body = nxt > 0 ? fn.slice(0, nxt) : fn;
// strip comments first — the function's own docstring QUOTES the retired
// call to explain the fix, and matching that is a false positive.
const code = body.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
expect(code).toMatch(/claim_founder_slot/);
expect(code).not.toMatch(/founderSeatsAvailable\(/);
expect(code).not.toMatch(/countFounderSeats\(/);
});
it('an unknown tier is unconfigured, never a guessed founder price', async () => {
const out = await svc.resolveCheckoutPrice('nonsense', { userId: 'u1' });
expect(out.isFounder).toBe(false);
expect(out.slotNumber).toBeNull();
});
it('a transient claim failure FAILS RETRYABLY — never a silent mis-price', async () => {
const supabase = {
rpc: jest.fn(async () => ({ data: true, error: null })), rpc: async () => ({ data: null, error: { message: 'boom' } }) };
await expect(svc.resolveCheckoutPrice('analyst', { userId: 'u1', supabase }))
.rejects.toMatchObject({ code: 'claim_failed', statusCode: 503 });
});
it('a granted claim yields the FOUNDER key; a full cap yields STANDING', async () => {
const grant = { rpc: async () => ({ data: [{ is_founder: true, price_key: 'analyst_founder', slot_number: 7 }], error: null }) };
const full = { rpc: async () => ({ data: [{ is_founder: false, price_key: 'analyst_standing', slot_number: null }], error: null }) };
expect((await svc.resolveCheckoutPrice('analyst', { userId: 'u1', supabase: grant })).isFounder).toBe(true);
expect((await svc.resolveCheckoutPrice('analyst', { userId: 'u1', supabase: full })).isFounder).toBe(false);
});
});
describe('grace period — a decline spans Stripe retries (item B)', () => {
test('payment_failed grace is 14 days (Stripe Smart Retry window), not 48h', () => {
expect(PAYMENT_RETRY_GRACE_MS).toBe(14 * 24 * 60 * 60 * 1000);
});
});
describe('getPriceId', () => {
describe('getPriceId — RETIRED as a founder path (B2)', () => {
const svc = require('../../src/services/stripeService');
it('THROWS if anyone passes a founder code — no silent founder grant', () => {
expect(() => svc.getPriceId('analyst', 'FOUNDER2026')).toThrow(/retired as a founder path/);
expect(() => svc.getPriceId('desk', 'VYNDR')).toThrow(/retired as a founder path/);
});
it('still resolves a plain tier price with no code', () => {
expect(() => svc.getPriceId('analyst')).not.toThrow();
});
});
describe('getPriceId', () => {
test('analyst without founder code returns standard price', () => {
const id = getPriceId('analyst', null);
expect(id).toContain('analyst');
expect(id).not.toContain('founder');
});
test('analyst with valid founder code returns founder price', () => {
test.skip('SUPERSEDED B2 — a founder CODE no longer grants founder pricing; founder_slots is the only gate', () => {
const id = getPriceId('analyst', 'FOUNDER2026');
expect(id).toContain('founder');
});
@@ -108,7 +124,7 @@ describe('stripeService', () => {
expect(id).not.toContain('founder');
});
test('desk with valid founder code returns founder price', () => {
test.skip('SUPERSEDED B2 — desk founder code retired; the atomic claim decides the price', () => {
const id = getPriceId('desk', 'BETONBLK');
expect(id).toContain('founder');
});
@@ -134,7 +150,7 @@ describe('stripeService', () => {
expect(typeof result).toBe('string');
});
test("africa never returns a founder-discounted variant (the tier IS the discount)", () => {
test.skip("SUPERSEDED B2 — africa parked; getPriceId retired as a founder path", () => {
const a = getPriceId('africa', null);
const b = getPriceId('africa', 'FOUNDER2026');
expect(a).toBe(b);
@@ -180,24 +196,28 @@ describe('stripeService', () => {
return proxy;
},
};
fake.rpcCalls = [];
fake.rpc = (fn, args) => { fake.rpcCalls.push({ fn, args }); return Promise.resolve({ data: true, error: null }); };
return fake;
}
test('checkout.session.completed updates users + mirrors to user_profiles, clears grace', async () => {
test('checkout.session.completed finalizes through the single-writer RPC', async () => {
const fake = makeFake();
mockSupabaseClient.current = fake;
await handleWebhookEvent({
type: 'checkout.session.completed',
data: { object: { metadata: { user_id: 'u1', tier: 'analyst', is_founder: 'true' }, customer: 'cus_1' } },
});
const usersUpdate = fake.updates.find((u) => u.table === 'users');
const profilesUpdate = fake.updates.find((u) => u.table === 'user_profiles');
expect(usersUpdate.patch.tier).toBe('analyst');
expect(usersUpdate.patch.grace_period_until).toBeNull();
expect(usersUpdate.patch.mfa_setup_prompted).toBe(false);
expect(profilesUpdate.patch.tier).toBe('analyst');
expect(profilesUpdate.patch.subscription_status).toBe('active');
expect(profilesUpdate.patch.founder_pricing).toBe(true);
// NEW CONTRACT: the webhook no longer writes the founder flags directly.
// finalize_founder_slot is the SINGLE writer of both, in one txn, so they
// cannot drift again (they already had: 1 vs 0 in prod).
const fin = fake.rpcCalls.find((c) => c.fn === 'finalize_founder_slot');
expect(fin).toBeTruthy();
expect(fin.args.p_user_id).toBe('u1');
expect(fin.args.p_tier).toBe('analyst');
expect(fin.args.p_stripe_customer_id).toBe('cus_1');
// and founder truth comes from the CLAIM, never from caller metadata
expect(JSON.stringify(fin.args)).not.toMatch(/is_founder/);
});
test('invoice.payment_failed sets a 14-DAY grace (spans Stripe retries, item B)', async () => {
+1 -1
View File
File diff suppressed because one or more lines are too long