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:
+28
-38
@@ -4,7 +4,7 @@
|
||||
2026-03-22
|
||||
|
||||
## Current Phase
|
||||
Phase 3 — Web MVP (IN PROGRESS)
|
||||
Phase 3 — Web MVP (COMPLETE)
|
||||
|
||||
## What Has Shipped
|
||||
|
||||
@@ -13,41 +13,35 @@ Phase 3 — Web MVP (IN PROGRESS)
|
||||
- Feature 1.2 — NBA_API Stats Wrapper (FastAPI microservice)
|
||||
- Feature 1.3 — Prop Analysis Engine (6-step grading pipeline)
|
||||
- Feature 1.4 — Database Schema (9 tables, RLS, triggers)
|
||||
- Feature 1.5 — Bet Submission (3 methods)
|
||||
- Feature 1.5 — Bet Submission (3 methods + performance tracking)
|
||||
|
||||
### Phase 2 — Core Product (COMPLETE)
|
||||
- Feature 2.1 — Parlay Scan (correlation detection, monetization)
|
||||
- Feature 2.2 — Line Movement + Cascade Detection
|
||||
|
||||
### Feature 3.1 — Landing Page + Blog (COMPLETE)
|
||||
- Next.js 14+ App Router in web/ directory
|
||||
- Landing page: Hero, How It Works, Features, Pricing (3 tiers + founder badges), Footer with email capture
|
||||
- Blog: MDX-powered at /blog with [slug] dynamic routes, reading time, OG tags, JSON-LD
|
||||
- Auth pages: /login, /signup (Supabase Auth ready)
|
||||
- 1 seed blog post: "How to Read Line Movement Like a Sharp"
|
||||
- Design system: dark theme, Inter + JetBrains Mono, grade colors (A=green, B=yellow, C=orange, D=red)
|
||||
- BetonBLK voice throughout all copy
|
||||
- Build passes: 7 static pages generated
|
||||
|
||||
## Test Summary
|
||||
- Node.js: 194 tests passing (backend unit + integration)
|
||||
- Python: 27 tests passing
|
||||
- Total: 221 tests, all green
|
||||
- Both Next.js builds: clean
|
||||
|
||||
## What's Next
|
||||
- Feature 3.2 — Scan UI (/scan page)
|
||||
- Feature 3.3 — Bet Tracker UI (/tracker page)
|
||||
- Feature 3.4 — Stripe Integration
|
||||
### Phase 3 — Web MVP (COMPLETE)
|
||||
- Feature 3.1 — Landing Page + Blog (Next.js, MDX, BetonBLK voice, SEO)
|
||||
- Feature 3.2 — Scan UI (leg builder, grade results, upgrade pitch)
|
||||
- Feature 3.3 — Bet Tracker (performance dashboard, quick slip, settle flow)
|
||||
- Feature 3.4 — Stripe Integration (checkout, webhooks, portal, founder codes)
|
||||
|
||||
## Also Shipped (Separate Repo)
|
||||
### Mastermind Agency Site
|
||||
- Next.js 14+ at /home/kev/mastermind/agency-site/
|
||||
- Glitch aesthetic: scan lines, CRT flicker, RGB split, noise grain
|
||||
- JetBrains Mono throughout, cyan/magenta accents on dark (#050505)
|
||||
- Pages: Home (hero + services + projects + process + contact), BetonBLK case study, Contact
|
||||
- Respects prefers-reduced-motion
|
||||
- Build passes: 5 static pages generated
|
||||
- `/home/kev/mastermind/agency-site/`
|
||||
- Glitch aesthetic, scan lines, CRT flicker, JetBrains Mono
|
||||
- Home, BetonBLK case study, Contact pages
|
||||
|
||||
## Test Summary
|
||||
- Node.js: 210 tests passing (unit + integration)
|
||||
- Python: 27 tests passing
|
||||
- Total: 237 tests, all green
|
||||
- Both Next.js projects build clean
|
||||
|
||||
## All Features Complete
|
||||
Every feature on the roadmap has shipped:
|
||||
- 7 backend features (Phase 1 + 2 + 1.5)
|
||||
- 4 frontend features (Phase 3)
|
||||
- 1 separate agency portfolio site
|
||||
|
||||
## Active Blockers
|
||||
- BLOCKER-003: WSL2 DNS cannot resolve *.supabase.co
|
||||
@@ -60,13 +54,9 @@ Phase 3 — Web MVP (IN PROGRESS)
|
||||
- 221 backend tests passing
|
||||
|
||||
### Session 7 — 2026-03-22
|
||||
- Built Feature 3.1: BetonBLK Landing Page + Blog (web/ directory)
|
||||
- Hero, HowItWorks, Features, Pricing, Footer, GradeCard components
|
||||
- Blog system: MDX parsing, index page, [slug] pages, SEO tags
|
||||
- Auth pages: login, signup
|
||||
- 1 seed blog post
|
||||
- Built Mastermind Agency Site (separate repo: agency-site/)
|
||||
- GlitchText, ProjectCard components
|
||||
- Glitch CSS: scan lines, CRT flicker, RGB split, noise grain
|
||||
- Home, BetonBLK case study, Contact pages
|
||||
- Both Next.js projects build clean
|
||||
- Built Feature 3.1: Landing page + blog (Hero, Pricing, Blog/MDX, Auth pages)
|
||||
- Built Mastermind Agency Site (glitch aesthetic, 5 pages)
|
||||
- Built Features 3.2 + 3.3: Scan UI + Bet Tracker
|
||||
- Built Feature 3.4: Stripe Integration (checkout, webhooks, portal, founder codes)
|
||||
- ALL FEATURES COMPLETE
|
||||
- Total: 237 tests (210 Node.js + 27 Python), all green
|
||||
|
||||
Generated
+19
-1
@@ -14,7 +14,8 @@
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^5.2.1",
|
||||
"ioredis": "^5.10.1",
|
||||
"postgres": "^3.4.8"
|
||||
"postgres": "^3.4.8",
|
||||
"stripe": "^20.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jest": "^30.3.0",
|
||||
@@ -5065,6 +5066,23 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/stripe": {
|
||||
"version": "20.4.1",
|
||||
"resolved": "https://registry.npmjs.org/stripe/-/stripe-20.4.1.tgz",
|
||||
"integrity": "sha512-axCguHItc8Sxt0HC6aSkdVRPffjYPV7EQqZRb2GkIa8FzWDycE7nHJM19C6xAIynH1Qp1/BHiopSi96jGBxT0w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": ">=16"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/superagent": {
|
||||
"version": "10.3.0",
|
||||
"resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz",
|
||||
|
||||
+2
-1
@@ -30,7 +30,8 @@
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^5.2.1",
|
||||
"ioredis": "^5.10.1",
|
||||
"postgres": "^3.4.8"
|
||||
"postgres": "^3.4.8",
|
||||
"stripe": "^20.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"jest": "^30.3.0",
|
||||
|
||||
@@ -6,8 +6,13 @@ const scanRoutes = require('./routes/scan');
|
||||
const movementsRoutes = require('./routes/movements');
|
||||
const alertsRoutes = require('./routes/alerts');
|
||||
const betsRoutes = require('./routes/bets');
|
||||
const stripeRoutes = require('./routes/stripe');
|
||||
|
||||
const app = express();
|
||||
|
||||
// Stripe webhook needs raw body — must be before express.json()
|
||||
app.use('/api/stripe/webhook', express.raw({ type: 'application/json' }));
|
||||
|
||||
app.use(express.json());
|
||||
app.use('/api/odds', oddsRoutes);
|
||||
app.use('/api/analyze', analyzeRoutes);
|
||||
@@ -15,5 +20,6 @@ app.use('/api/scan', scanRoutes);
|
||||
app.use('/api/movements', movementsRoutes);
|
||||
app.use('/api/alerts', alertsRoutes);
|
||||
app.use('/api/bets', betsRoutes);
|
||||
app.use('/api/stripe', stripeRoutes);
|
||||
|
||||
module.exports = app;
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
const express = require('express');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
const {
|
||||
createCheckoutSession,
|
||||
handleWebhookEvent,
|
||||
createPortalSession,
|
||||
getSubscriptionStatus,
|
||||
constructWebhookEvent,
|
||||
} = require('../services/stripeService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Checkout — creates Stripe checkout session
|
||||
router.post('/checkout', requireAuth, async (req, res) => {
|
||||
const { tier, founder_code } = req.body;
|
||||
|
||||
if (!tier || !['analyst', 'desk'].includes(tier)) {
|
||||
return res.status(400).json({ error: 'tier must be "analyst" or "desk"' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await createCheckoutSession(req.user.id, req.user.email, tier, founder_code);
|
||||
return res.json(result);
|
||||
} catch (err) {
|
||||
console.error('[BetonBLK] Checkout error:', err.message);
|
||||
return res.status(503).json({ error: 'Checkout creation failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Webhook — Stripe sends events here
|
||||
// IMPORTANT: This route needs raw body, not parsed JSON
|
||||
// Must be registered with express.raw() in app.js
|
||||
router.post('/webhook', async (req, res) => {
|
||||
const signature = req.headers['stripe-signature'];
|
||||
if (!signature) {
|
||||
return res.status(400).json({ error: 'Missing stripe-signature header' });
|
||||
}
|
||||
|
||||
let event;
|
||||
try {
|
||||
event = constructWebhookEvent(req.body, signature);
|
||||
} catch (err) {
|
||||
console.error('[BetonBLK] Webhook signature failed:', err.message);
|
||||
return res.status(400).json({ error: 'Invalid signature' });
|
||||
}
|
||||
|
||||
try {
|
||||
await handleWebhookEvent(event);
|
||||
return res.json({ received: true });
|
||||
} catch (err) {
|
||||
console.error('[BetonBLK] Webhook handler error:', err.message);
|
||||
return res.status(500).json({ error: 'Webhook processing failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Portal — subscription management
|
||||
router.post('/portal', requireAuth, async (req, res) => {
|
||||
if (!req.user.stripe_customer_id) {
|
||||
return res.status(400).json({ error: 'No active subscription' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await createPortalSession(req.user.stripe_customer_id);
|
||||
return res.json(result);
|
||||
} catch (err) {
|
||||
console.error('[BetonBLK] Portal error:', err.message);
|
||||
return res.status(503).json({ error: 'Portal creation failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Status — current subscription info
|
||||
router.get('/status', requireAuth, async (req, res) => {
|
||||
try {
|
||||
let subStatus = { subscription_status: 'none', current_period_end: null, cancel_at_period_end: false };
|
||||
if (req.user.stripe_customer_id) {
|
||||
subStatus = await getSubscriptionStatus(req.user.stripe_customer_id);
|
||||
}
|
||||
|
||||
return res.json({
|
||||
tier: req.user.tier,
|
||||
is_founder: req.user.founder_status,
|
||||
...subStatus,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[BetonBLK] Status error:', err.message);
|
||||
return res.status(503).json({ error: 'Status check failed' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,166 @@
|
||||
const Stripe = require('stripe');
|
||||
const { getSupabaseServiceClient } = require('../utils/supabase');
|
||||
|
||||
let _stripe = null;
|
||||
function getStripe() {
|
||||
if (!_stripe) _stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
|
||||
return _stripe;
|
||||
}
|
||||
|
||||
const PRICE_MAP = {
|
||||
analyst: process.env.STRIPE_PRICE_ANALYST || 'price_analyst_monthly',
|
||||
analyst_founder: process.env.STRIPE_PRICE_ANALYST_FOUNDER || 'price_analyst_founder',
|
||||
desk: process.env.STRIPE_PRICE_DESK || 'price_desk_monthly',
|
||||
desk_founder: process.env.STRIPE_PRICE_DESK_FOUNDER || 'price_desk_founder',
|
||||
};
|
||||
|
||||
const VALID_FOUNDER_CODES = (process.env.FOUNDER_CODES || 'FOUNDER2026,BETONBLK,EARLYBIRD').split(',');
|
||||
const FOUNDER_EXPIRY = new Date(process.env.FOUNDER_CODE_EXPIRY || '2026-06-30');
|
||||
|
||||
function isFounderCodeValid(code) {
|
||||
if (!code) return false;
|
||||
if (new Date() > FOUNDER_EXPIRY) return false;
|
||||
return VALID_FOUNDER_CODES.includes(code.toUpperCase());
|
||||
}
|
||||
|
||||
function getPriceId(tier, founderCode) {
|
||||
const isFounder = isFounderCodeValid(founderCode);
|
||||
if (tier === 'analyst') return isFounder ? PRICE_MAP.analyst_founder : PRICE_MAP.analyst;
|
||||
if (tier === 'desk') return isFounder ? PRICE_MAP.desk_founder : PRICE_MAP.desk;
|
||||
throw new Error(`Invalid tier: ${tier}`);
|
||||
}
|
||||
|
||||
async function createCheckoutSession(userId, email, tier, founderCode) {
|
||||
const supabase = getSupabaseServiceClient();
|
||||
const priceId = getPriceId(tier, founderCode);
|
||||
const isFounder = isFounderCodeValid(founderCode);
|
||||
|
||||
// Get or create Stripe customer
|
||||
const { data: user } = await supabase
|
||||
.from('users')
|
||||
.select('stripe_customer_id')
|
||||
.eq('id', userId)
|
||||
.single();
|
||||
|
||||
let customerId = user?.stripe_customer_id;
|
||||
if (!customerId) {
|
||||
const customer = await getStripe().customers.create({
|
||||
email,
|
||||
metadata: { user_id: userId },
|
||||
});
|
||||
customerId = customer.id;
|
||||
await supabase
|
||||
.from('users')
|
||||
.update({ stripe_customer_id: customerId })
|
||||
.eq('id', userId);
|
||||
}
|
||||
|
||||
const baseUrl = process.env.BASE_URL || 'http://localhost:3001';
|
||||
const session = await getStripe().checkout.sessions.create({
|
||||
customer: customerId,
|
||||
line_items: [{ price: priceId, quantity: 1 }],
|
||||
mode: 'subscription',
|
||||
success_url: `${baseUrl}/scan?upgraded=true`,
|
||||
cancel_url: `${baseUrl}/#pricing`,
|
||||
metadata: { user_id: userId, tier, is_founder: String(isFounder) },
|
||||
});
|
||||
|
||||
return { checkout_url: session.url, session_id: session.id };
|
||||
}
|
||||
|
||||
async function handleWebhookEvent(event) {
|
||||
const supabase = getSupabaseServiceClient();
|
||||
|
||||
switch (event.type) {
|
||||
case 'checkout.session.completed': {
|
||||
const session = event.data.object;
|
||||
const userId = session.metadata?.user_id;
|
||||
const tier = session.metadata?.tier;
|
||||
const isFounder = session.metadata?.is_founder === 'true';
|
||||
|
||||
if (userId && tier) {
|
||||
await supabase
|
||||
.from('users')
|
||||
.update({
|
||||
tier,
|
||||
stripe_customer_id: session.customer,
|
||||
founder_status: isFounder,
|
||||
})
|
||||
.eq('id', userId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'customer.subscription.updated': {
|
||||
// Handle plan changes if needed
|
||||
break;
|
||||
}
|
||||
|
||||
case 'customer.subscription.deleted': {
|
||||
const subscription = event.data.object;
|
||||
const customerId = subscription.customer;
|
||||
|
||||
// Find user by stripe_customer_id and revert to free
|
||||
const { data: user } = await supabase
|
||||
.from('users')
|
||||
.select('id')
|
||||
.eq('stripe_customer_id', customerId)
|
||||
.single();
|
||||
|
||||
if (user) {
|
||||
await supabase
|
||||
.from('users')
|
||||
.update({ tier: 'free' })
|
||||
.eq('id', user.id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'invoice.payment_failed': {
|
||||
console.warn('[BetonBLK] Payment failed for customer:', event.data.object.customer);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function createPortalSession(stripeCustomerId) {
|
||||
const baseUrl = process.env.BASE_URL || 'http://localhost:3001';
|
||||
const session = await getStripe().billingPortal.sessions.create({
|
||||
customer: stripeCustomerId,
|
||||
return_url: `${baseUrl}/tracker`,
|
||||
});
|
||||
return { portal_url: session.url };
|
||||
}
|
||||
|
||||
async function getSubscriptionStatus(stripeCustomerId) {
|
||||
const subscriptions = await getStripe().subscriptions.list({
|
||||
customer: stripeCustomerId,
|
||||
status: 'active',
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
if (subscriptions.data.length === 0) {
|
||||
return { subscription_status: 'none', current_period_end: null, cancel_at_period_end: false };
|
||||
}
|
||||
|
||||
const sub = subscriptions.data[0];
|
||||
return {
|
||||
subscription_status: sub.status,
|
||||
current_period_end: new Date(sub.current_period_end * 1000).toISOString(),
|
||||
cancel_at_period_end: sub.cancel_at_period_end,
|
||||
};
|
||||
}
|
||||
|
||||
function constructWebhookEvent(body, signature) {
|
||||
return getStripe().webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createCheckoutSession,
|
||||
handleWebhookEvent,
|
||||
createPortalSession,
|
||||
getSubscriptionStatus,
|
||||
constructWebhookEvent,
|
||||
isFounderCodeValid,
|
||||
getPriceId,
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user