87 lines
4 KiB
TypeScript
87 lines
4 KiB
TypeScript
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<CheckoutResult>;
|
|
parseWebhook(rawBody: string, signature: string | null): Promise<WebhookEvent>;
|
|
}
|
|
|
|
/**
|
|
* 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<CheckoutResult> {
|
|
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<WebhookEvent> {
|
|
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<CheckoutResult> {
|
|
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<WebhookEvent> {
|
|
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();
|
|
}
|