process.env.STRIPE_SECRET_KEY = 'sk_test_dummy'; // 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 } = 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); }); }); 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', () => { 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('desk with valid founder code returns founder price', () => { const id = getPriceId('desk', 'BETONBLK'); expect(id).toContain('founder'); }); test('invalid tier throws', () => { expect(() => getPriceId('gold', null)).toThrow('Invalid tier'); }); }); 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; }, }; return fake; } test('checkout.session.completed updates users + mirrors to user_profiles, clears grace', 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); }); test('invoice.payment_failed sets a ~48h grace window', 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 + 48 * 60 * 60 * 1000; expect(Math.abs(graceTs - expected)).toBeLessThan(60_000); // within a minute of 48h expect(profilesUpdate.patch.subscription_status).toBe('grace_period'); }); 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(); }); }); });