import 'server-only'; import { createHmac, timingSafeEqual } from 'node:crypto'; import { env } from '@/lib/env'; export type CheckoutInput = { userId: string; email: string; plan: string; successUrl: string; cancelUrl: string }; export type CheckoutResult = { id: string; url: string }; export type WebhookEvent = { type: 'checkout.completed' | 'subscription.canceled' | 'ignored'; userId?: string; plan?: string }; export interface PaymentProvider { name: string; createCheckout(input: CheckoutInput): Promise; parseWebhook(rawBody: string, signature: string | null): Promise; } /** * Zero-config provider. It never redirects off-site: the checkout route marks the * subscription active directly, so "plan → checkout → return → active" works with no keys. */ export class MockProvider implements PaymentProvider { name = 'mock'; async createCheckout(input: CheckoutInput): Promise { const id = `mock_${input.userId.slice(0, 8)}_${input.plan}_${Date.now().toString(36)}`; return { id, url: `${input.successUrl}?billing=success&provider=mock&plan=${encodeURIComponent(input.plan)}` }; } async parseWebhook(rawBody: string): Promise { try { const data = JSON.parse(rawBody) as { type?: string; userId?: string; plan?: string }; if (!data.type) return { type: 'ignored' }; return { type: data.type as WebhookEvent['type'], userId: data.userId, plan: data.plan }; } catch { return { type: 'ignored' }; } } } /** Stripe provider over the REST API (no SDK dependency), activated by STRIPE_SECRET_KEY. */ export class StripeProvider implements PaymentProvider { name = 'stripe'; constructor(private secretKey: string, private webhookSecret: string) {} async createCheckout(input: CheckoutInput): Promise { const body = new URLSearchParams(); body.set('mode', 'subscription'); body.set('success_url', input.successUrl); body.set('cancel_url', input.cancelUrl); body.set('client_reference_id', input.userId); body.set('customer_email', input.email); body.set('line_items[0][price]', input.plan); body.set('line_items[0][quantity]', '1'); const res = await fetch('https://api.stripe.com/v1/checkout/sessions', { method: 'POST', headers: { Authorization: `Bearer ${this.secretKey}`, 'Content-Type': 'application/x-www-form-urlencoded' }, body, }); if (!res.ok) throw new Error(`Stripe error ${res.status}: ${await res.text()}`); const data = (await res.json()) as { id: string; url: string }; return { id: data.id, url: data.url }; } async parseWebhook(rawBody: string, signature: string | null): Promise { if (!this.webhookSecret) throw new Error('STRIPE_WEBHOOK_SECRET is not configured'); if (!verifyStripeSignature(rawBody, signature, this.webhookSecret)) return { type: 'ignored' }; const event = JSON.parse(rawBody) as { type?: string; data?: { object?: { client_reference_id?: string } } }; if (event.type === 'checkout.session.completed') { return { type: 'checkout.completed', userId: event.data?.object?.client_reference_id }; } if (event.type === 'customer.subscription.deleted') return { type: 'subscription.canceled' }; return { type: 'ignored' }; } } // Minimal Stripe signature check (t=timestamp,v1=signature) to make webhooks idempotent + trusted. function verifyStripeSignature(payload: string, header: string | null, secret: string): boolean { if (!header) return false; const parts = Object.fromEntries(header.split(',').map((p) => p.split('=') as [string, string])); if (!parts.t || !parts.v1) return false; const expected = createHmac('sha256', secret).update(`${parts.t}.${payload}`).digest('hex'); const a = Buffer.from(expected); const b = Buffer.from(parts.v1); return a.length === b.length && timingSafeEqual(a, b); } export function getPayments(): PaymentProvider { if (env.STRIPE_SECRET_KEY) return new StripeProvider(env.STRIPE_SECRET_KEY, env.STRIPE_WEBHOOK_SECRET); return new MockProvider(); }