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:
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user