templates2.Base.Nuxt/server/utils/payments.ts
w4c-templates a7a56fd5e0
Some checks failed
CI / quality (push) Has been cancelled
chore(template): publish Base.Nuxt
2026-10-11 23:23:38 +03:00

83 lines
3.8 KiB
TypeScript

import { createHmac, timingSafeEqual } from 'node:crypto';
import type { NitroEvent } from './h3-event';
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: the checkout route activates the subscription directly (no redirect). */
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). */
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' };
}
}
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(event: NitroEvent): PaymentProvider {
const config = useRuntimeConfig(event);
if (config.stripeSecretKey) return new StripeProvider(config.stripeSecretKey, config.stripeWebhookSecret);
return new MockProvider();
}