import type { Env } from './types'; export class StripeError extends Error { constructor( message: string, public readonly status = 502, public readonly code?: string, ) { super(message); } } export type StripeCheckoutSession = { id: string; object: 'checkout.session'; url?: string | null; status?: 'open' | 'complete' | 'expired' | null; payment_status?: 'paid' | 'unpaid' | 'no_payment_required'; amount_total?: number | null; currency?: string | null; client_reference_id?: string | null; payment_intent?: string | { id?: string } | null; metadata?: Record | null; }; export type StripeRefund = { id: string; object: 'refund'; amount: number; currency?: string | null; payment_intent?: string | null; status?: 'pending' | 'requires_action' | 'succeeded' | 'failed' | 'canceled' | null; metadata?: Record | null; failure_reason?: string | null; }; type StripePaymentIntent = { id: string; object: 'payment_intent'; latest_charge?: string | { id?: string; receipt_url?: string | null } | null; }; type StripeCharge = { id: string; object: 'charge'; receipt_url?: string | null; }; type StripeEvent = { id: string; type: string; data?: { object?: StripeCheckoutSession | StripeRefund }; }; function stripeSecret(env: Env): string { const key = env.STRIPE_SECRET_KEY?.trim(); if (!key) throw new StripeError('Stripe is not configured for this deployment.', 503); return key; } async function stripeRequest(env: Env, path: string, init: RequestInit = {}): Promise { let response: Response; try { response = await fetch(`https://api.stripe.com${path}`, { ...init, headers: { Authorization: `Bearer ${stripeSecret(env)}`, ...(init.body ? { 'Content-Type': 'application/x-www-form-urlencoded' } : {}), ...(init.headers ?? {}), }, }); } catch { throw new StripeError('Could not reach Stripe. Try again in a moment.', 502); } const data = (await response.json().catch(() => ({}))) as T & { error?: { message?: string; code?: string }; }; if (!response.ok) { throw new StripeError( data.error?.message || `Stripe request failed (${response.status}).`, response.status >= 500 ? 502 : response.status, data.error?.code, ); } return data; } export function stripeConfigured(env: Env): boolean { return Boolean(env.STRIPE_SECRET_KEY?.trim() && env.STRIPE_WEBHOOK_SECRET?.trim()); } export type StripeMode = 'test' | 'live' | 'unknown'; export function stripeMode(env: Env): StripeMode { const key = env.STRIPE_SECRET_KEY?.trim() ?? ''; if (key.startsWith('sk_test_')) return 'test'; if (key.startsWith('sk_live_')) return 'live'; return 'unknown'; } export function stripeCheckoutSessionMode(sessionId: string | null | undefined): StripeMode { const id = sessionId?.trim() ?? ''; if (id.startsWith('cs_test_')) return 'test'; if (id.startsWith('cs_live_')) return 'live'; return 'unknown'; } export async function createStripeCheckoutSession( env: Env, booking: { id: string; email: string; durationMinutes: number; amountMinor: number; currency: string; label: string; }, ): Promise { const params = new URLSearchParams(); params.set('mode', 'payment'); const appUrl = env.APP_URL.replace(/\/+$/, ''); params.set('success_url', `${appUrl}/?payment=success&session_id={CHECKOUT_SESSION_ID}`); params.set('cancel_url', `${appUrl}/api/payments/cancel-return?booking_id=${encodeURIComponent(booking.id)}`); params.set('client_reference_id', booking.id); // A time-slot reservation cannot safely wait days for a delayed bank payment. // Card Checkout still supports 3DS and wallets backed by cards, while giving us // a definitive paid/unpaid result before the calendar invitation is sent. params.set('payment_method_types[0]', 'card'); params.set('expires_at', String(Math.floor(Date.now() / 1000) + 30 * 60)); params.set('customer_email', booking.email); params.set('metadata[booking_id]', booking.id); params.set('payment_intent_data[metadata][booking_id]', booking.id); // Explicitly attach the booking email to the PaymentIntent receipt. This makes // live-mode receipts independent of the account's automatic-email toggle. params.set('payment_intent_data[receipt_email]', booking.email); params.set('line_items[0][price_data][currency]', booking.currency.toLowerCase()); params.set('line_items[0][price_data][unit_amount]', String(booking.amountMinor)); params.set('line_items[0][price_data][product_data][name]', booking.label); params.set( 'line_items[0][price_data][product_data][description]', `${booking.durationMinutes}-minute booking`, ); params.set('line_items[0][quantity]', '1'); return stripeRequest(env, '/v1/checkout/sessions', { method: 'POST', body: params.toString(), }); } export async function retrieveStripeCheckoutSession(env: Env, sessionId: string): Promise { if (!/^cs_(test_|live_)?[A-Za-z0-9_]+$/.test(sessionId)) { throw new StripeError('Invalid Stripe Checkout session id.', 400); } return stripeRequest(env, `/v1/checkout/sessions/${encodeURIComponent(sessionId)}`); } export async function expireStripeCheckoutSession(env: Env, sessionId: string): Promise { if (!/^cs_(test_|live_)?[A-Za-z0-9_]+$/.test(sessionId)) { throw new StripeError('Invalid Stripe Checkout session id.', 400); } return stripeRequest( env, `/v1/checkout/sessions/${encodeURIComponent(sessionId)}/expire`, { method: 'POST' }, ); } function constantTimeEqual(left: string, right: string): boolean { if (left.length !== right.length) return false; let result = 0; for (let index = 0; index < left.length; index += 1) { result |= left.charCodeAt(index) ^ right.charCodeAt(index); } return result === 0; } function bytesToHex(bytes: Uint8Array): string { return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); } async function hmacSha256Hex(secret: string, message: string): Promise { const encoder = new TextEncoder(); const key = await crypto.subtle.importKey( 'raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'], ); const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(message)); return bytesToHex(new Uint8Array(signature)); } export async function verifyStripeWebhook( env: Env, rawBody: string, signatureHeader: string | null, toleranceSeconds = 300, ): Promise { const secret = env.STRIPE_WEBHOOK_SECRET?.trim(); if (!secret) throw new StripeError('Stripe webhook secret is not configured.', 503); if (!signatureHeader) throw new StripeError('Missing Stripe-Signature header.', 400); const parts = signatureHeader.split(',').map((part) => part.trim()); const timestamp = parts.find((part) => part.startsWith('t='))?.slice(2); const signatures = parts.filter((part) => part.startsWith('v1=')).map((part) => part.slice(3)); const timestampNumber = Number(timestamp); if (!timestamp || !Number.isFinite(timestampNumber) || signatures.length === 0) { throw new StripeError('Invalid Stripe webhook signature.', 400); } const ageSeconds = Math.abs(Date.now() / 1000 - timestampNumber); if (ageSeconds > toleranceSeconds) throw new StripeError('Expired Stripe webhook signature.', 400); const expected = await hmacSha256Hex(secret, `${timestamp}.${rawBody}`); if (!signatures.some((signature) => constantTimeEqual(signature, expected))) { throw new StripeError('Invalid Stripe webhook signature.', 400); } try { return JSON.parse(rawBody) as StripeEvent; } catch { throw new StripeError('Invalid Stripe webhook payload.', 400); } } export async function createStripeRefund( env: Env, paymentIntentId: string, bookingId: string, ): Promise { if (!/^pi_[A-Za-z0-9_]+$/.test(paymentIntentId)) { throw new StripeError('Invalid Stripe PaymentIntent id.', 400); } const params = new URLSearchParams(); params.set('payment_intent', paymentIntentId); params.set('metadata[booking_id]', bookingId); params.set('metadata[source]', 'slot_booking_cancellation'); return stripeRequest(env, '/v1/refunds', { method: 'POST', body: params.toString(), headers: { 'Idempotency-Key': `slot-refund-${bookingId}` }, }); } export async function stripeReceiptUrl(env: Env, session: StripeCheckoutSession): Promise { const paymentIntentId = stripePaymentIntentId(session); if (!paymentIntentId || !/^pi_[A-Za-z0-9_]+$/.test(paymentIntentId)) return null; const intent = await stripeRequest( env, `/v1/payment_intents/${encodeURIComponent(paymentIntentId)}?expand%5B%5D=latest_charge`, ); if (intent.latest_charge && typeof intent.latest_charge !== 'string') { return intent.latest_charge.receipt_url ?? null; } if (typeof intent.latest_charge === 'string' && /^ch_[A-Za-z0-9_]+$/.test(intent.latest_charge)) { const charge = await stripeRequest(env, `/v1/charges/${encodeURIComponent(intent.latest_charge)}`); return charge.receipt_url ?? null; } return null; } export function stripePaymentIntentId(session: StripeCheckoutSession): string | null { if (typeof session.payment_intent === 'string') return session.payment_intent; return session.payment_intent?.id ?? null; }