// server/supabase/functions/stripe-webhook/index.ts // Stripe Webhook 핸들러 — checkout 완료, 구독 갱신/취소 등 이벤트 처리. // Supabase 대시보드 → Functions → stripe-webhook 의 verify_jwt를 false로 설정해야 함 // (Stripe는 JWT 없이 호출, 대신 signature로 검증). // // 환경변수: // STRIPE_SECRET_KEY // STRIPE_WEBHOOK_SECRET (Stripe 대시보드에서 발급) import { createServiceRoleClient } from '../_shared/quota.ts' interface StripeEvent { id: string type: string data: { object: Record } } async function verifyStripeSignature(payload: string, signature: string, secret: string): Promise { // 단순화: 본 스캐폴딩에서는 signature 검증 logic placeholder. // 정식 구현은 Stripe SDK의 stripe.webhooks.constructEvent 사용 또는 // HMAC-SHA256으로 직접 검증. // https://stripe.com/docs/webhooks/signatures return Boolean(signature && secret && payload) } // @ts-expect-error — Deno 런타임 전역 Deno.serve(async (req: Request) => { if (req.method !== 'POST') { return new Response('Method not allowed', { status: 405 }) } // @ts-expect-error — Deno.env const webhookSecret = Deno.env.get('STRIPE_WEBHOOK_SECRET') ?? '' if (!webhookSecret) { return new Response('Webhook secret not configured', { status: 503 }) } const signature = req.headers.get('stripe-signature') ?? '' const payload = await req.text() const valid = await verifyStripeSignature(payload, signature, webhookSecret) if (!valid) { 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 }) } const serviceClient = createServiceRoleClient() try { switch (event.type) { case 'checkout.session.completed': { const session = event.data.object as { customer: string subscription: string metadata?: { user_id?: string; tier?: string } } const userId = session.metadata?.user_id const tier = session.metadata?.tier if (userId && tier) { await serviceClient.from('subscriptions').upsert({ user_id: userId, stripe_customer_id: session.customer, stripe_subscription_id: session.subscription, tier, status: 'active' }) } break } case 'customer.subscription.updated': case 'customer.subscription.created': { const sub = event.data.object as { id: string customer: string status: string current_period_start?: number current_period_end?: number cancel_at?: number | null } await serviceClient .from('subscriptions') .update({ status: sub.status, current_period_start: sub.current_period_start ? new Date(sub.current_period_start * 1000).toISOString() : null, current_period_end: sub.current_period_end ? new Date(sub.current_period_end * 1000).toISOString() : null, cancel_at: sub.cancel_at ? new Date(sub.cancel_at * 1000).toISOString() : null }) .eq('stripe_subscription_id', sub.id) break } case 'customer.subscription.deleted': { const sub = event.data.object as { id: string } await serviceClient .from('subscriptions') .update({ tier: 'free', status: 'canceled' }) .eq('stripe_subscription_id', sub.id) break } default: // 기타 이벤트는 무시 break } return new Response(JSON.stringify({ received: true }), { status: 200, headers: { 'Content-Type': 'application/json' } }) } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error' return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } }) } })