320 lines
12 KiB
TypeScript
320 lines
12 KiB
TypeScript
import { createServiceRoleClient } from '../_shared/quota.ts'
|
|
|
|
interface StripeEvent {
|
|
id: string
|
|
type: string
|
|
created: number
|
|
data: { object: Record<string, unknown> }
|
|
}
|
|
|
|
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<boolean> {
|
|
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<string> {
|
|
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<string, unknown>,
|
|
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<string, unknown>, 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<string, unknown> | 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<Response> {
|
|
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<string, unknown>
|
|
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)
|