import { createServiceRoleClient } from '../_shared/quota.ts' interface StripeEvent { id: string type: string created: number data: { object: Record } } interface StripeMetadata { user_id?: string tier?: string operation_id?: string } interface ProviderApplyResult { applied?: boolean duplicate?: boolean reason?: string } export function constantTimeEqual(a: string, b: string): boolean { if (a.length !== b.length) return false let mismatch = 0 for (let index = 0; index < a.length; index += 1) { mismatch |= a.charCodeAt(index) ^ b.charCodeAt(index) } return mismatch === 0 } export async function verifyStripeSignature( payload: string, signatureHeader: string, secret: string, toleranceSec = 300, nowMs = Date.now(), ): Promise { if (!signatureHeader || !secret || !payload || toleranceSec <= 0) return false const parts = signatureHeader.split(',').map((part) => part.trim()) const timestampParts = parts.filter((part) => part.startsWith('t=')) const signatures = parts .filter((part) => part.startsWith('v1=')) .map((part) => part.slice(3).toLowerCase()) .filter((part) => /^[0-9a-f]{64}$/.test(part)) if (timestampParts.length !== 1 || signatures.length === 0) return false const timestamp = Number(timestampParts[0].slice(2)) if (!Number.isInteger(timestamp) || timestamp <= 0) return false const nowSeconds = Math.floor(nowMs / 1000) if (Math.abs(nowSeconds - timestamp) > toleranceSec) return false const key = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'], ) const signature = await crypto.subtle.sign( 'HMAC', key, new TextEncoder().encode(`${timestamp}.${payload}`), ) const expected = Array.from(new Uint8Array(signature)) .map((part) => part.toString(16).padStart(2, '0')) .join('') return signatures.some((candidate) => constantTimeEqual(candidate, expected)) } export async function sha256Payload(payload: string): Promise { const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(payload)) return Array.from(new Uint8Array(digest)) .map((part) => part.toString(16).padStart(2, '0')) .join('') } export function normalizeStripeTier(value: unknown): 'pro' | 'pro_plus' | null { if (value === 'pro') return 'pro' if (value === 'pro_plus' || value === 'team') return 'pro_plus' return null } export function tierFromStripeSubscriptionPrice( subscription: Record, prices: { pro: string; pro_plus: string }, ): 'pro' | 'pro_plus' | null { const items = subscription.items as { data?: unknown } | undefined if (!Array.isArray(items?.data) || items.data.length !== 1) return null const item = items.data[0] as { price?: { id?: unknown } } | undefined const priceId = asString(item?.price?.id) if (priceId && priceId === prices.pro) return 'pro' if (priceId && priceId === prices.pro_plus) return 'pro_plus' return null } function asString(value: unknown): string | null { return typeof value === 'string' && value.length > 0 ? value : null } function epochToIso(value: unknown): string | null { if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return null return new Date(value * 1000).toISOString() } function stripePeriodValue(subscription: Record, field: 'current_period_start' | 'current_period_end'): unknown { if (subscription[field] !== undefined) return subscription[field] const items = subscription.items as { data?: unknown } | undefined if (!Array.isArray(items?.data) || items.data.length !== 1) return undefined return (items.data[0] as Record | undefined)?.[field] } function stripeSubscriptionEntitled(status: string): boolean { return ['active', 'trialing', 'past_due'].includes(status) } function isUuid(value: unknown): value is string { return typeof value === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value) } export async function stripeWebhookHandler(req: Request): Promise { if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 }) const webhookSecret = Deno.env.get('STRIPE_WEBHOOK_SECRET')?.trim() ?? '' const stripeSecret = Deno.env.get('STRIPE_SECRET_KEY')?.trim() ?? '' if (!webhookSecret || !stripeSecret) { return new Response('Stripe webhook not configured', { status: 503 }) } const payload = await req.text() const validSignature = await verifyStripeSignature( payload, req.headers.get('stripe-signature') ?? '', webhookSecret, ) if (!validSignature) return new Response('Invalid signature', { status: 400 }) let event: StripeEvent try { event = JSON.parse(payload) as StripeEvent } catch { return new Response('Invalid JSON', { status: 400 }) } if ( !/^evt_[A-Za-z0-9]+$/.test(event.id ?? '') || typeof event.type !== 'string' || !Number.isInteger(event.created) || event.created <= 0 || !event.data?.object ) { return new Response('Invalid event', { status: 400 }) } const serviceClient = createServiceRoleClient() const payloadDigest = await sha256Payload(payload) try { if (event.type === 'checkout.session.completed') { const session = event.data.object const metadata = (session.metadata ?? {}) as StripeMetadata const userId = asString(metadata.user_id) ?? asString(session.client_reference_id) const subscriptionId = asString(session.subscription) if (!userId || !subscriptionId) { return new Response(JSON.stringify({ received: true, ignored: 'missing_correlation' }), { headers: { 'Content-Type': 'application/json' }, }) } const { error: observationError } = await serviceClient.rpc( 'record_payment_provider_observation', { p_user_id: userId, p_provider: 'stripe', p_event_id: event.id, p_event_created_at: new Date(event.created * 1000).toISOString(), p_event_type: event.type, p_payload_digest: payloadDigest, p_provider_resource_id: subscriptionId, }, ) if (observationError) throw new Error('stripe_observation_failed') if (isUuid(metadata.operation_id)) { const { error: operationError } = await serviceClient.rpc( 'mark_payment_provider_operation', { p_operation_id: metadata.operation_id, p_state: 'external_created', p_external_reference: asString(session.id) ?? subscriptionId, p_error_code: null, }, ) if (operationError) throw new Error('stripe_operation_update_failed') } return new Response(JSON.stringify({ received: true }), { headers: { 'Content-Type': 'application/json' }, }) } if (![ 'customer.subscription.created', 'customer.subscription.updated', 'customer.subscription.deleted', ].includes(event.type)) { return new Response(JSON.stringify({ received: true, ignored: true }), { headers: { 'Content-Type': 'application/json' }, }) } let subscription = event.data.object const subscriptionId = asString(subscription.id) if (!subscriptionId) return new Response('Invalid subscription', { status: 400 }) // Stripe signs each delivery but does not guarantee delivery order. For // non-deletion events, retrieve the subscription's current authoritative // state so a late event cannot resurrect an older state from its payload. if (event.type !== 'customer.subscription.deleted') { const currentResponse = await fetch( `https://api.stripe.com/v1/subscriptions/${encodeURIComponent(subscriptionId)}`, { headers: { Authorization: `Bearer ${stripeSecret}` } }, ) if (!currentResponse.ok) throw new Error('stripe_subscription_verification_failed') const current = await currentResponse.json() as Record if (asString(current.id) !== subscriptionId) { throw new Error('stripe_subscription_verification_mismatch') } subscription = current } const customerId = asString(subscription.customer) const metadata = (subscription.metadata ?? {}) as StripeMetadata if (!customerId) return new Response('Invalid subscription', { status: 400 }) const prices = { pro: Deno.env.get('STRIPE_PRICE_PRO')?.trim() ?? '', pro_plus: (Deno.env.get('STRIPE_PRICE_PRO_PLUS') ?? Deno.env.get('STRIPE_PRICE_TEAM'))?.trim() ?? '', } if (!prices.pro && !prices.pro_plus) { return new Response('Stripe prices not configured', { status: 503 }) } const priceTier = tierFromStripeSubscriptionPrice(subscription, prices) const metadataTier = normalizeStripeTier(metadata.tier) if (!priceTier || (metadataTier && metadataTier !== priceTier)) { return new Response('Subscription price mismatch', { status: 422 }) } let userId = asString(metadata.user_id) const tier: 'pro' | 'pro_plus' = priceTier if (!userId) { const { data: existing, error: lookupError } = await serviceClient .from('subscriptions') .select('user_id, tier') .eq('stripe_subscription_id', subscriptionId) .maybeSingle() if (lookupError) throw new Error('stripe_subscription_lookup_failed') userId ??= asString(existing?.user_id) const existingTier = normalizeStripeTier(existing?.tier) if (existingTier && existingTier !== priceTier) { return new Response('Stored subscription price mismatch', { status: 422 }) } } if (!userId) return new Response('Missing subscription owner', { status: 422 }) const status = event.type === 'customer.subscription.deleted' ? 'canceled' : asString(subscription.status) if (!status || ![ 'active', 'trialing', 'past_due', 'canceled', 'unpaid', 'incomplete', 'incomplete_expired', 'paused', ].includes(status)) { return new Response('Invalid subscription status', { status: 400 }) } const entitled = stripeSubscriptionEntitled(status) const cancelAtPeriodEnd = subscription.cancel_at_period_end === true const periodEnd = epochToIso(stripePeriodValue(subscription, 'current_period_end')) const cancelAt = epochToIso(subscription.cancel_at) ?? (cancelAtPeriodEnd ? periodEnd : null) const { data: applyData, error: applyError } = await serviceClient.rpc( 'apply_payment_provider_event', { p_user_id: userId, p_provider: 'stripe', p_event_id: event.id, p_event_created_at: new Date(event.created * 1000).toISOString(), p_event_type: event.type, p_payload_digest: payloadDigest, p_provider_resource_id: subscriptionId, p_tier: entitled ? tier : 'free', p_status: status, p_entitled: entitled, p_current_period_start: epochToIso(stripePeriodValue(subscription, 'current_period_start')), p_current_period_end: periodEnd, p_cancel_at: cancelAt, p_auto_renewing: entitled && !cancelAtPeriodEnd, p_provider_customer_id: customerId, p_provider_order_id: null, p_store_product_id: null, p_store_purchase_id: null, p_operation_id: isUuid(metadata.operation_id) ? metadata.operation_id : null, }, ) if (applyError) throw new Error('stripe_entitlement_apply_failed') const result = applyData as ProviderApplyResult | null return new Response(JSON.stringify({ received: true, applied: result?.applied ?? false, duplicate: result?.duplicate ?? false, reason: result?.reason, }), { headers: { 'Content-Type': 'application/json' }, }) } catch { return new Response(JSON.stringify({ error: 'stripe_webhook_processing_failed' }), { status: 500, headers: { 'Content-Type': 'application/json' }, }) } } if (import.meta.main) Deno.serve(stripeWebhookHandler)