d3ro-voice/server/supabase/functions/stripe-portal/index.ts
2026-08-29 18:33:45 +09:00

114 lines
3.8 KiB
TypeScript

// 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' }
})
}
})