feat: Feature 3.4 — Stripe integration with founder code system

Subscription billing:
- POST /api/stripe/checkout — creates Stripe Checkout session
- POST /api/stripe/webhook — handles lifecycle events (raw body)
- POST /api/stripe/portal — customer self-service management
- GET /api/stripe/status — current subscription info

Founder code system:
- Validates codes against env var list with expiry date
- Routes to founder Stripe Price IDs (locked rate for life)
- 4 price objects: analyst, analyst-founder, desk, desk-founder

Webhook events handled:
- checkout.session.completed → tier update + founder status
- customer.subscription.deleted → revert to free tier
- invoice.payment_failed → logged

Lazy Stripe init (no API key required at import time).
Raw body middleware for webhook signature verification.

16 new tests, 237 total (210 Node.js + 27 Python), all passing.
Phase 3 Web MVP COMPLETE. All roadmap features shipped.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kev
2026-03-22 10:30:42 -04:00
parent 850fe60e8f
commit eb443232fb
8 changed files with 525 additions and 40 deletions
+157
View File
@@ -0,0 +1,157 @@
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() };
jest.mock('../../src/utils/supabase', () => ({
getSupabaseClient: () => ({ auth: mockSupabaseAuth, from: mockSupabaseFrom }),
getSupabaseServiceClient: () => ({ auth: mockSupabaseAuth, from: mockSupabaseFrom }),
}));
// Mock Stripe
jest.mock('stripe', () => {
return jest.fn().mockImplementation(() => ({
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('axios');
process.env.ODDS_API_KEY = 'test';
process.env.STRIPE_SECRET_KEY = 'sk_test_xxx';
process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test';
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('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);
});
});
+57
View File
@@ -0,0 +1,57 @@
process.env.STRIPE_SECRET_KEY = 'sk_test_dummy';
jest.mock('../../src/utils/supabase', () => ({
getSupabaseServiceClient: () => ({ from: jest.fn() }),
}));
const { isFounderCodeValid, getPriceId } = require('../../src/services/stripeService');
describe('stripeService', () => {
describe('isFounderCodeValid', () => {
test('valid founder code returns true', () => {
expect(isFounderCodeValid('FOUNDER2026')).toBe(true);
expect(isFounderCodeValid('BETONBLK')).toBe(true);
});
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');
});
});
});