bedbb8c008
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
254 lines
9.0 KiB
JavaScript
254 lines
9.0 KiB
JavaScript
const request = require('supertest');
|
|
|
|
// Mock Redis
|
|
const mockRedis = { get: jest.fn(), set: jest.fn(), hset: jest.fn(), hgetall: jest.fn(), expire: jest.fn() };
|
|
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, 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
|
|
// per-case. The service caches `new Stripe()` once; the mock returns
|
|
// the same instance every time so the override applies to the cached
|
|
// reference too.
|
|
const mockStripeInstance = {
|
|
customers: {
|
|
create: jest.fn().mockResolvedValue({ id: 'cus_test123' }),
|
|
},
|
|
checkout: {
|
|
sessions: {
|
|
create: jest.fn().mockResolvedValue({ url: 'https://checkout.stripe.com/test', id: 'cs_test' }),
|
|
},
|
|
},
|
|
billingPortal: {
|
|
sessions: {
|
|
create: jest.fn().mockResolvedValue({ url: 'https://billing.stripe.com/test' }),
|
|
},
|
|
},
|
|
subscriptions: {
|
|
list: jest.fn().mockResolvedValue({ data: [] }),
|
|
},
|
|
webhooks: {
|
|
constructEvent: jest.fn(),
|
|
},
|
|
};
|
|
jest.mock('stripe', () => jest.fn(() => mockStripeInstance));
|
|
|
|
jest.mock('axios');
|
|
process.env.ODDS_API_KEY = 'test';
|
|
process.env.STRIPE_SECRET_KEY = 'sk_test_xxx';
|
|
process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test';
|
|
// Session 15 — PRICE_MAP was hardened to drop the fake-string
|
|
// fallbacks (would 400 from Stripe in production). The integration
|
|
// tests need real-looking Stripe price IDs in env so getPriceId
|
|
// doesn't return the unconfigured sentinel and 503 the happy path.
|
|
process.env.STRIPE_PRICE_ANALYST = process.env.STRIPE_PRICE_ANALYST || 'price_test_analyst';
|
|
process.env.STRIPE_PRICE_DESK = process.env.STRIPE_PRICE_DESK || 'price_test_desk';
|
|
|
|
const app = require('../../src/app');
|
|
|
|
const MOCK_USER = {
|
|
id: 'user-1', email: 'test@test.com', tier: 'free',
|
|
scan_count: 0, stripe_customer_id: null, founder_status: false,
|
|
};
|
|
|
|
function setupAuthMocks(user = MOCK_USER) {
|
|
mockSupabaseAuth.getUser.mockResolvedValue({
|
|
data: { user: { id: user.id, email: user.email } }, error: null,
|
|
});
|
|
mockSupabaseFrom.mockImplementation((table) => {
|
|
if (table === 'users') {
|
|
return {
|
|
select: () => ({
|
|
eq: () => ({
|
|
single: () => Promise.resolve({ data: user, error: null }),
|
|
}),
|
|
}),
|
|
update: () => ({
|
|
eq: () => Promise.resolve({ error: null }),
|
|
}),
|
|
};
|
|
}
|
|
return { select: () => ({ eq: () => Promise.resolve({ data: [] }) }) };
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
mockRedis.get.mockResolvedValue(null);
|
|
mockRedis.set.mockResolvedValue('OK');
|
|
mockRedis.hset.mockResolvedValue(1);
|
|
mockRedis.hgetall.mockResolvedValue({});
|
|
mockRedis.expire.mockResolvedValue(1);
|
|
});
|
|
|
|
describe('POST /api/stripe/checkout', () => {
|
|
test('creates checkout session and returns URL', async () => {
|
|
setupAuthMocks();
|
|
const res = await request(app)
|
|
.post('/api/stripe/checkout')
|
|
.set('Authorization', 'Bearer valid-token')
|
|
.send({ tier: 'analyst' })
|
|
.expect(200);
|
|
|
|
expect(res.body.checkout_url).toBeDefined();
|
|
expect(res.body.session_id).toBeDefined();
|
|
});
|
|
|
|
test('returns 400 for invalid tier', async () => {
|
|
setupAuthMocks();
|
|
const res = await request(app)
|
|
.post('/api/stripe/checkout')
|
|
.set('Authorization', 'Bearer valid-token')
|
|
.send({ tier: 'gold' })
|
|
.expect(400);
|
|
|
|
expect(res.body.error).toContain('tier');
|
|
});
|
|
|
|
test('returns 401 without auth', async () => {
|
|
await request(app)
|
|
.post('/api/stripe/checkout')
|
|
.send({ tier: 'analyst' })
|
|
.expect(401);
|
|
});
|
|
|
|
describe('Session 14 — Africa tier checkout', () => {
|
|
const originalAfricaPrice = process.env.STRIPE_PRICE_AFRICA;
|
|
afterAll(() => {
|
|
if (originalAfricaPrice == null) delete process.env.STRIPE_PRICE_AFRICA;
|
|
else process.env.STRIPE_PRICE_AFRICA = originalAfricaPrice;
|
|
});
|
|
|
|
test("'africa' is now an accepted tier (validation passes)", async () => {
|
|
setupAuthMocks();
|
|
process.env.STRIPE_PRICE_AFRICA = 'price_africa_test';
|
|
const res = await request(app)
|
|
.post('/api/stripe/checkout')
|
|
.set('Authorization', 'Bearer valid-token')
|
|
.send({ tier: 'africa' });
|
|
// We DON'T assert on the status here — the downstream Stripe
|
|
// mock may produce 200 OR the test's supabase fake may take a
|
|
// different path. The important assertion: the request didn't
|
|
// 400 with "tier must be analyst or desk".
|
|
expect(res.status).not.toBe(400);
|
|
});
|
|
|
|
test('returns 503 with code:tier_unconfigured when STRIPE_PRICE_AFRICA is unset', async () => {
|
|
setupAuthMocks();
|
|
delete process.env.STRIPE_PRICE_AFRICA;
|
|
const res = await request(app)
|
|
.post('/api/stripe/checkout')
|
|
.set('Authorization', 'Bearer valid-token')
|
|
.send({ tier: 'africa' })
|
|
.expect(503);
|
|
expect(res.body.code).toBe('tier_unconfigured');
|
|
expect(res.body.error).toMatch(/africa/i);
|
|
});
|
|
|
|
test('still rejects unrelated tiers with 400', async () => {
|
|
setupAuthMocks();
|
|
const res = await request(app)
|
|
.post('/api/stripe/checkout')
|
|
.set('Authorization', 'Bearer valid-token')
|
|
.send({ tier: 'gold' })
|
|
.expect(400);
|
|
expect(res.body.error).toMatch(/tier/);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('POST /api/stripe/portal', () => {
|
|
test('returns portal URL for existing customer', async () => {
|
|
setupAuthMocks({ ...MOCK_USER, stripe_customer_id: 'cus_existing' });
|
|
const res = await request(app)
|
|
.post('/api/stripe/portal')
|
|
.set('Authorization', 'Bearer valid-token')
|
|
.expect(200);
|
|
|
|
expect(res.body.portal_url).toBeDefined();
|
|
});
|
|
|
|
test('returns 400 when no subscription', async () => {
|
|
setupAuthMocks();
|
|
const res = await request(app)
|
|
.post('/api/stripe/portal')
|
|
.set('Authorization', 'Bearer valid-token')
|
|
.expect(400);
|
|
|
|
expect(res.body.error).toContain('No active subscription');
|
|
});
|
|
});
|
|
|
|
describe('GET /api/stripe/status', () => {
|
|
test('returns tier and subscription info', async () => {
|
|
setupAuthMocks({ ...MOCK_USER, tier: 'analyst', founder_status: true });
|
|
const res = await request(app)
|
|
.get('/api/stripe/status')
|
|
.set('Authorization', 'Bearer valid-token')
|
|
.expect(200);
|
|
|
|
expect(res.body.tier).toBe('analyst');
|
|
expect(res.body.is_founder).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('POST /api/stripe/webhook', () => {
|
|
test('returns 400 without signature', async () => {
|
|
await request(app)
|
|
.post('/api/stripe/webhook')
|
|
.send('{}')
|
|
.set('Content-Type', 'application/json')
|
|
.expect(400);
|
|
});
|
|
|
|
test('returns 400 when signature header is present but invalid', async () => {
|
|
// Header present, but constructEvent throws — Stripe's real behavior
|
|
// when the signed payload doesn't match the secret. The route must
|
|
// surface 400 and never invoke the event-dispatch path.
|
|
mockStripeInstance.webhooks.constructEvent.mockImplementationOnce(() => {
|
|
throw new Error('No signatures found matching the expected signature');
|
|
});
|
|
|
|
const res = await request(app)
|
|
.post('/api/stripe/webhook')
|
|
.set('Content-Type', 'application/json')
|
|
.set('stripe-signature', 't=1700000000,v1=deadbeef')
|
|
.send(Buffer.from('{"id":"evt_forged","type":"checkout.session.completed"}'))
|
|
.expect(400);
|
|
|
|
expect(res.body.error).toMatch(/signature/i);
|
|
expect(mockStripeInstance.webhooks.constructEvent).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test('valid signature dispatches to handler and returns 200', async () => {
|
|
// Positive case: route → service → 200 with `received: true`. We can't
|
|
// observe the supabase write here without entangling with auth setup;
|
|
// the unit suite (stripeService.test.js) already proves the dispatch
|
|
// wiring per event type. This test pins the route-level contract.
|
|
mockStripeInstance.webhooks.constructEvent.mockImplementationOnce(() => ({
|
|
id: 'evt_ok',
|
|
type: 'invoice.payment_failed', // chosen because it requires no users-table fixture beyond the default
|
|
data: { object: { customer: 'cus_test123' } },
|
|
}));
|
|
|
|
const res = await request(app)
|
|
.post('/api/stripe/webhook')
|
|
.set('Content-Type', 'application/json')
|
|
.set('stripe-signature', 't=1700000000,v1=stub')
|
|
.send(Buffer.from('{}'))
|
|
.expect(200);
|
|
|
|
expect(res.body.received).toBe(true);
|
|
});
|
|
});
|