// server/supabase/functions/stripe-portal/index.ts // Stripe Customer Portal 세션 생성 — 로그인한 사용자가 자신의 구독을 관리할 수 있도록. // 응답: { url } → 클라이언트가 redirect. import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts' import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts' import { createServiceRoleClient } from '../_shared/quota.ts' interface PortalRequest { return_url?: unknown } function validReturnUrl(value: unknown): string | null { if (typeof value !== 'string') return null try { const url = new URL(value) if (url.username || url.password) return null if (url.protocol === 'https:') return url.toString() if (url.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(url.hostname)) { return url.toString() } return null } catch { return null } } Deno.serve(async (req: Request) => { const preflight = handleCorsPreflightRequest(req) if (preflight) return preflight if (req.method !== 'POST') { return new Response(JSON.stringify({ error: 'Method not allowed' }), { status: 405, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }) } try { const user = await requireUser(req) const body = (await req.json()) as PortalRequest const returnUrl = validReturnUrl(body.return_url) if (!returnUrl) { return new Response(JSON.stringify({ error: 'invalid_return_url' }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }) } const stripeKey = Deno.env.get('STRIPE_SECRET_KEY') ?? '' if (!stripeKey) { return new Response( JSON.stringify({ error: 'stripe_not_configured' }), { status: 503, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } // 기존 customer_id 조회 const serviceClient = createServiceRoleClient() const { data: sub } = await serviceClient .from('subscriptions') .select('stripe_customer_id') .eq('user_id', user.id) .maybeSingle() const customerId = (sub?.stripe_customer_id as string | null | undefined) ?? null if (!customerId?.startsWith('cus_')) { return new Response( JSON.stringify({ error: 'no_customer', message: '활성 구독이 없습니다. 먼저 업그레이드하세요.' }), { status: 404, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } // Portal session 생성 const portalResp = await fetch('https://api.stripe.com/v1/billing_portal/sessions', { method: 'POST', headers: { Authorization: `Bearer ${stripeKey}`, 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ customer: customerId, return_url: returnUrl }) }) if (!portalResp.ok) { throw new Error('stripe_portal_creation_failed') } const data = (await portalResp.json()) as { url?: unknown } if (typeof data.url !== 'string') throw new Error('stripe_portal_response_invalid') const portalUrl = new URL(data.url) if (portalUrl.protocol !== 'https:' || !portalUrl.hostname.endsWith('.stripe.com')) { throw new Error('stripe_portal_response_invalid') } return new Response(JSON.stringify({ url: data.url }), { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }) } catch (err) { if (err && typeof err === 'object' && 'status' in err && 'message' in err) { return authErrorResponse(err as AuthError, corsHeaders) } return new Response(JSON.stringify({ error: 'stripe_portal_failed' }), { status: 502, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }) } })