import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts' import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts' import { createServiceRoleClient } from '../_shared/quota.ts' interface CheckoutRequest { tier?: unknown success_url?: unknown cancel_url?: unknown idempotency_key?: unknown } interface OperationReservation { created?: boolean operation_id?: string state?: string reason?: string } function jsonResponse(body: Record, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }) } function normalizeTier(value: unknown): 'pro' | 'pro_plus' | null { if (value === 'pro') return 'pro' // team is accepted only as an input compatibility alias. It is never stored. if (value === 'pro_plus' || value === 'team') return 'pro_plus' return null } function parseReturnUrls(success: unknown, cancel: unknown): { success: string; cancel: string } | null { if (typeof success !== 'string' || typeof cancel !== 'string') return null try { const successUrl = new URL(success) const cancelUrl = new URL(cancel) const validProtocol = (url: URL) => url.protocol === 'https:' || (url.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(url.hostname)) if ( !validProtocol(successUrl) || !validProtocol(cancelUrl) || successUrl.origin !== cancelUrl.origin || successUrl.username || successUrl.password || cancelUrl.username || cancelUrl.password ) return null return { success: successUrl.toString(), cancel: cancelUrl.toString() } } catch { return null } } function stripeHeaders(secretKey: string, idempotencyKey?: string): HeadersInit { return { Authorization: `Bearer ${secretKey}`, 'Content-Type': 'application/x-www-form-urlencoded', ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}), } } Deno.serve(async (req: Request) => { const preflight = handleCorsPreflightRequest(req) if (preflight) return preflight if (req.method !== 'POST') return jsonResponse({ error: 'method_not_allowed' }, 405) const serviceClient = createServiceRoleClient() let operationId: string | null = null try { const user = await requireUser(req) const body = await req.json() as CheckoutRequest const tier = normalizeTier(body.tier) const urls = parseReturnUrls(body.success_url, body.cancel_url) if ( !tier || !urls || (body.idempotency_key !== undefined && (typeof body.idempotency_key !== 'string' || !/^[A-Za-z0-9._:-]{12,160}$/.test(body.idempotency_key))) ) { return jsonResponse({ error: 'invalid_request' }, 400) } const stripeKey = Deno.env.get('STRIPE_SECRET_KEY')?.trim() ?? '' const priceMap = { pro: Deno.env.get('STRIPE_PRICE_PRO')?.trim() ?? '', pro_plus: (Deno.env.get('STRIPE_PRICE_PRO_PLUS') ?? Deno.env.get('STRIPE_PRICE_TEAM'))?.trim() ?? '', } const priceId = priceMap[tier] if (!stripeKey || !priceId) { return jsonResponse({ error: 'stripe_not_configured' }, 503) } const idempotencyKey = typeof body.idempotency_key === 'string' ? body.idempotency_key : `stripe-checkout:${crypto.randomUUID()}` const providerOrderId = `STRIPE-${crypto.randomUUID()}` const { data: reservationData, error: reservationError } = await serviceClient.rpc( 'reserve_payment_provider_operation', { p_user_id: user.id, p_provider: 'stripe', p_operation_type: 'checkout', p_requested_tier: tier, p_idempotency_key: idempotencyKey, p_provider_order_id: providerOrderId, p_provider_resource_id: null, }, ) if (reservationError) throw new Error('payment_reservation_failed') const reservation = reservationData as OperationReservation | null if (!reservation?.created || typeof reservation.operation_id !== 'string') { return jsonResponse({ error: reservation?.reason ?? 'payment_operation_in_progress', state: reservation?.state ?? 'rejected', }, 409) } operationId = reservation.operation_id const { data: subscription, error: subscriptionError } = await serviceClient .from('subscriptions') .select('stripe_customer_id') .eq('user_id', user.id) .single() if (subscriptionError) throw new Error('subscription_lookup_failed') let customerId = typeof subscription?.stripe_customer_id === 'string' ? subscription.stripe_customer_id : null if (!customerId) { const customerResponse = await fetch('https://api.stripe.com/v1/customers', { method: 'POST', headers: stripeHeaders(stripeKey, `customer-${operationId}`), body: new URLSearchParams({ email: user.email ?? '', 'metadata[user_id]': user.id, }), }) if (!customerResponse.ok) throw new Error('stripe_customer_creation_failed') const customer = await customerResponse.json() as { id?: unknown } if (typeof customer.id !== 'string' || !customer.id.startsWith('cus_')) { throw new Error('stripe_customer_response_invalid') } customerId = customer.id } const sessionResponse = await fetch('https://api.stripe.com/v1/checkout/sessions', { method: 'POST', headers: stripeHeaders(stripeKey, `checkout-${operationId}`), body: new URLSearchParams({ customer: customerId, mode: 'subscription', 'line_items[0][price]': priceId, 'line_items[0][quantity]': '1', success_url: urls.success, cancel_url: urls.cancel, client_reference_id: user.id, 'metadata[user_id]': user.id, 'metadata[tier]': tier, 'metadata[operation_id]': operationId, 'subscription_data[metadata][user_id]': user.id, 'subscription_data[metadata][tier]': tier, 'subscription_data[metadata][operation_id]': operationId, }), }) if (!sessionResponse.ok) throw new Error('stripe_checkout_creation_failed') const session = await sessionResponse.json() as { id?: unknown; url?: unknown } if ( typeof session.id !== 'string' || !session.id.startsWith('cs_') || typeof session.url !== 'string' || !session.url.startsWith('https://checkout.stripe.com/') ) { throw new Error('stripe_checkout_response_invalid') } const { error: operationError } = await serviceClient.rpc('mark_payment_provider_operation', { p_operation_id: operationId, p_state: 'external_created', p_external_reference: session.id, p_error_code: null, }) if (operationError) throw new Error('payment_operation_update_failed') return jsonResponse({ url: session.url }) } catch (error) { if (operationId) { await serviceClient.rpc('mark_payment_provider_operation', { p_operation_id: operationId, p_state: 'failed', p_external_reference: null, p_error_code: 'stripe_checkout_failed', }) } if (error && typeof error === 'object' && 'status' in error && 'message' in error) { return authErrorResponse(error as AuthError, corsHeaders) } return jsonResponse({ error: 'stripe_checkout_failed' }, 502) } })