Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions AI-Sports-Almanac/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,9 @@ BACKEND_URL=http://backend:4000
# Leave blank while Stripe account is being set up — app works in demo mode
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PRICE_SINGLE=price_...
STRIPE_PRICE_TRIPLE=price_...
STRIPE_PRICE_ALL_ACCESS=price_...
STRIPE_PRICE_SINGLE=price_1TAAKQPfPehc9iSUDPPWTaqb
STRIPE_PRICE_TRIPLE=price_1TAALHPfPehc9iSUuHHj00NO
STRIPE_PRICE_ALL_ACCESS=price_1TAAM8PfPehc9iSU9mJxnfDQ
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...

# ── Email ─────────────────────────────────────────────────────────────────────
Expand Down
7 changes: 4 additions & 3 deletions AI-Sports-Almanac/backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,11 @@ PRIVATE_KEY=your-wallet-private-key-never-commit
# ── Stripe ────────────────────────────────────────────────────────────────────
# Leave blank while Stripe account is being set up — app works in demo mode
STRIPE_SECRET_KEY=sk_live_...
STRIPE_PUBLISHABLE_KEY=pk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PRICE_SINGLE=price_...
STRIPE_PRICE_TRIPLE=price_...
STRIPE_PRICE_ALL_ACCESS=price_...
STRIPE_PRICE_SINGLE=price_1TAAKQPfPehc9iSUDPPWTaqb
STRIPE_PRICE_TRIPLE=price_1TAALHPfPehc9iSUuHHj00NO
STRIPE_PRICE_ALL_ACCESS=price_1TAAM8PfPehc9iSU9mJxnfDQ

# ── Email ─────────────────────────────────────────────────────────────────────
# Leave blank to use console fallback logging instead of sending real emails
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
-- Migration 004: Add Stripe subscription fields
-- These columns may already exist if created from 001_initial_schema.sql.
-- Using IF NOT EXISTS ensures this is safe to run on existing databases.

ALTER TABLE users
ADD COLUMN IF NOT EXISTS stripe_customer_id VARCHAR(255),
ADD COLUMN IF NOT EXISTS stripe_subscription_id VARCHAR(255),
ADD COLUMN IF NOT EXISTS trial_ends_at TIMESTAMP WITH TIME ZONE;
57 changes: 37 additions & 20 deletions AI-Sports-Almanac/backend/src/api/routes/stripe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,20 @@ function getStripeClient(): Stripe | null {
return new Stripe(process.env.STRIPE_SECRET_KEY);
}

const PLAN_PRICE_MAP: Record<string, string | undefined> = {
single: process.env.STRIPE_PRICE_SINGLE,
triple: process.env.STRIPE_PRICE_TRIPLE,
all_access: process.env.STRIPE_PRICE_ALL_ACCESS,
// Map Stripe price IDs to internal plan names
const PRICE_PLAN_MAP: Record<string, string> = {
price_1TAAKQPfPehc9iSUDPPWTaqb: 'single',
price_1TAALHPfPehc9iSUuHHj00NO: 'triple',
price_1TAAM8PfPehc9iSU9mJxnfDQ: 'all_access',
};

// POST /api/stripe/create-checkout-session
router.post('/create-checkout-session', defaultRateLimit, authenticateJWT, async (req: AuthRequest, res: Response) => {
const { plan, sports } = req.body as { plan?: string; sports?: string[] };
const { priceId, sports } = req.body as { priceId?: string; sports?: string[] };

if (!plan || !['single', 'triple', 'all_access'].includes(plan)) {
res.status(400).json({ error: 'Invalid plan. Must be single, triple, or all_access' });
const planName = priceId ? PRICE_PLAN_MAP[priceId] : undefined;
if (!priceId || !planName) {
res.status(400).json({ error: 'Invalid or missing priceId' });
return;
}

Expand All @@ -34,12 +36,6 @@ router.post('/create-checkout-session', defaultRateLimit, authenticateJWT, async
return;
}

const priceId = PLAN_PRICE_MAP[plan];
if (!priceId) {
res.status(500).json({ error: `Price ID for plan "${plan}" not configured` });
return;
}

try {
const userResult = await pool.query<{ email: string; username: string; stripe_customer_id: string | null }>(
`SELECT email, username, stripe_customer_id FROM users WHERE id = $1`,
Expand Down Expand Up @@ -67,10 +63,10 @@ router.post('/create-checkout-session', defaultRateLimit, authenticateJWT, async
mode: 'subscription',
payment_method_types: ['card'],
line_items: [{ price: priceId, quantity: 1 }],
subscription_data: { trial_period_days: 7, metadata: { plan, sports: JSON.stringify(sports || []) } },
success_url: `${process.env.FRONTEND_URL || 'http://localhost:3000'}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.FRONTEND_URL || 'http://localhost:3000'}/checkout`,
metadata: { plan, sports: JSON.stringify(sports || []), userId: req.user!.id },
subscription_data: { trial_period_days: 7, metadata: { plan: planName, sports: JSON.stringify(sports || []) } },
success_url: `${process.env.FRONTEND_URL || 'https://aisportsalmanac.io'}/dashboard?subscription=success`,
cancel_url: `${process.env.FRONTEND_URL || 'https://aisportsalmanac.io'}/pricing`,
metadata: { plan: planName, sports: JSON.stringify(sports || []), userId: req.user!.id },
});

res.json({ url: session.url, sessionId: session.id });
Expand Down Expand Up @@ -120,11 +116,11 @@ router.post('/webhook', async (req: Request, res: Response) => {
if (userId && plan) {
await pool.query(
`UPDATE users SET plan = $1, selected_sports = $2, stripe_subscription_id = $3,
subscription_status = 'active', trial_ends_at = NOW() + INTERVAL '7 days', updated_at = NOW()
subscription_status = 'trialing', trial_ends_at = NOW() + INTERVAL '7 days', updated_at = NOW()
WHERE id = $4`,
[plan, sports, subscriptionId || null, userId]
);
logger.info(`Subscription activated for user ${userId} — plan: ${plan}`);
logger.info(`Subscription trial started for user ${userId} — plan: ${plan}`);

const userRow = await pool.query<{ email: string; username: string }>(
`SELECT email, username FROM users WHERE id = $1`, [userId]
Expand All @@ -137,14 +133,35 @@ router.post('/webhook', async (req: Request, res: Response) => {
});
}
}
} else if (event.type === 'customer.subscription.updated') {
const subscription = event.data.object as Stripe.Subscription;
await pool.query(
`UPDATE users SET subscription_status = $1, updated_at = NOW()
WHERE stripe_subscription_id = $2`,
[subscription.status, subscription.id]
);
logger.info(`Subscription updated: ${subscription.id} — status: ${subscription.status}`);
} else if (event.type === 'customer.subscription.deleted') {
const subscription = event.data.object as Stripe.Subscription;
await pool.query(
`UPDATE users SET subscription_status = 'inactive', stripe_subscription_id = NULL, updated_at = NOW()
`UPDATE users SET plan = 'free', subscription_status = 'cancelled', stripe_subscription_id = NULL, updated_at = NOW()
WHERE stripe_subscription_id = $1`,
[subscription.id]
);
logger.info(`Subscription cancelled: ${subscription.id}`);
} else if (event.type === 'invoice.payment_failed') {
const invoice = event.data.object as Stripe.Invoice;
const subscriptionId = typeof invoice.subscription === 'string'
? invoice.subscription
: invoice.subscription?.id;
if (subscriptionId) {
await pool.query(
`UPDATE users SET subscription_status = 'past_due', updated_at = NOW()
WHERE stripe_subscription_id = $1`,
[subscriptionId]
);
logger.warn(`Payment failed for subscription: ${subscriptionId}`);
}
}
} catch (err) {
logger.error('Stripe webhook handler error', err);
Expand Down
Loading