process.env.STRIPE_SECRET_KEY = 'sk_test_dummy'; // Session 15 — the production fallback strings ('price_analyst_monthly' // etc.) were dropped because they'd 400 from Stripe in live mode. Tests // that assert getPriceId returns a string containing 'analyst' / // 'founder' must now provide the env values BEFORE requiring the // module (PRICE_MAP is frozen at require time). process.env.STRIPE_PRICE_ANALYST = 'price_test_analyst_monthly'; process.env.STRIPE_PRICE_ANALYST_FOUNDER = 'price_test_analyst_founder'; process.env.STRIPE_PRICE_DESK = 'price_test_desk_monthly'; process.env.STRIPE_PRICE_DESK_FOUNDER = 'price_test_desk_founder'; // Default mock for the founder-code / price-id tests (no DB interaction). // Webhook tests below replace the implementation per-test. const mockSupabaseClient = { current: { from: jest.fn() } }; jest.mock('../../src/utils/supabase', () => ({ getSupabaseServiceClient: () => mockSupabaseClient.current, })); const { isFounderCodeValid, getPriceId, handleWebhookEvent, resolveCheckoutPrice, founderSeatsAvailable, FOUNDER_SEATS_TOTAL, PAYMENT_RETRY_GRACE_MS, } = require('../../src/services/stripeService'); describe('stripeService', () => { describe('isFounderCodeValid', () => { test('valid founder code returns true', () => { expect(isFounderCodeValid('FOUNDER2026')).toBe(true); expect(isFounderCodeValid('VYNDR')).toBe(true); expect(isFounderCodeValid('BETONBLK')).toBe(true); // legacy promo, still honored }); test('case insensitive', () => { expect(isFounderCodeValid('founder2026')).toBe(true); }); test('invalid code returns false', () => { expect(isFounderCodeValid('INVALID')).toBe(false); }); test('null/empty returns false', () => { expect(isFounderCodeValid(null)).toBe(false); expect(isFounderCodeValid('')).toBe(false); }); }); // 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 — 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'); 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 — 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.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'); }); test('desk without founder code returns standard price', () => { const id = getPriceId('desk', null); expect(id).toContain('desk'); expect(id).not.toContain('founder'); }); test.skip('SUPERSEDED B2 — desk founder code retired; the atomic claim decides the price', () => { const id = getPriceId('desk', 'BETONBLK'); expect(id).toContain('founder'); }); test('invalid tier throws', () => { expect(() => getPriceId('gold', null)).toThrow('Invalid tier'); }); describe('Session 14 — africa tier', () => { const original = process.env.STRIPE_PRICE_AFRICA; afterAll(() => { if (original == null) delete process.env.STRIPE_PRICE_AFRICA; else process.env.STRIPE_PRICE_AFRICA = original; }); test('africa returns the configured price ID when set', () => { // We can't re-import to pick up the env change after the // module loaded its PRICE_MAP at require-time, so this test // asserts the contract: getPriceId('africa') returns either // a price ID OR the sentinel. The route-layer integration // test covers the env-flip → 503 path end-to-end. const result = getPriceId('africa', null); expect(typeof result).toBe('string'); }); 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); }); test('exports PRICE_UNCONFIGURED sentinel', () => { const { PRICE_UNCONFIGURED } = require('../../src/services/stripeService'); expect(typeof PRICE_UNCONFIGURED).toBe('string'); expect(PRICE_UNCONFIGURED.length).toBeGreaterThan(0); }); }); }); describe('handleWebhookEvent', () => { // A chainable supabase fake whose final-chain return is configurable per call. // Records every update payload so tests can assert grace_period_until etc. function makeFake({ findUserById = 'user-1' } = {}) { const updates = []; const fake = { updates, from(table) { const ctx = { table, filters: [] }; const proxy = { update(patch) { ctx.patch = patch; ctx.action = 'update'; updates.push(ctx); return proxy; }, select() { ctx.action = 'select'; return proxy; }, eq(col, val) { ctx.filters.push([col, val]); if (ctx.action === 'update') return Promise.resolve({ error: null }); return proxy; }, single() { return Promise.resolve({ data: findUserById ? { id: findUserById } : null }); }, }; 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 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' } }, }); // 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 () => { const fake = makeFake(); mockSupabaseClient.current = fake; const before = Date.now(); await handleWebhookEvent({ type: 'invoice.payment_failed', data: { object: { customer: 'cus_2' } }, }); const usersUpdate = fake.updates.find((u) => u.table === 'users'); const profilesUpdate = fake.updates.find((u) => u.table === 'user_profiles'); const graceTs = new Date(usersUpdate.patch.grace_period_until).getTime(); const expected = before + 14 * 24 * 60 * 60 * 1000; // a decline is not a cancel expect(Math.abs(graceTs - expected)).toBeLessThan(60_000); // within a minute of 14d expect(profilesUpdate.patch.subscription_status).toBe('grace_period'); }); test('customer.subscription.updated active → flips tier to the new plan + clears grace', async () => { // Plan-change flow (portal-driven upgrade/downgrade). Stripe sends // the new price on items.data[0].price.id; the service must map it // back to a tier via PRICE_MAP and reflect that on the user. const fake = makeFake(); mockSupabaseClient.current = fake; const newPriceId = process.env.STRIPE_PRICE_DESK || 'price_desk_monthly'; await handleWebhookEvent({ type: 'customer.subscription.updated', data: { object: { customer: 'cus_active', status: 'active', items: { data: [{ price: { id: newPriceId } }] }, }, }, }); const usersUpdate = fake.updates.find((u) => u.table === 'users'); const profilesUpdate = fake.updates.find((u) => u.table === 'user_profiles'); expect(usersUpdate.patch.tier).toBe('desk'); expect(usersUpdate.patch.grace_period_until).toBeNull(); expect(profilesUpdate.patch.tier).toBe('desk'); expect(profilesUpdate.patch.subscription_status).toBe('active'); }); test('customer.subscription.deleted sets grace, does not flip tier immediately', async () => { const fake = makeFake(); mockSupabaseClient.current = fake; await handleWebhookEvent({ type: 'customer.subscription.deleted', data: { object: { customer: 'cus_3' } }, }); const usersUpdate = fake.updates.find((u) => u.table === 'users'); expect(usersUpdate.patch.grace_period_until).toBeTruthy(); // tier is intentionally NOT downgraded here — the grace period gate // handles read-time enforcement. expect(usersUpdate.patch.tier).toBeUndefined(); }); test('payment_failed with unknown customer logs but does not throw', async () => { const fake = makeFake({ findUserById: null }); mockSupabaseClient.current = fake; await expect( handleWebhookEvent({ type: 'invoice.payment_failed', data: { object: { customer: 'cus_ghost' } } }) ).resolves.toBeUndefined(); }); }); });