114 lines
3.8 KiB
TypeScript
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' }
|
|
})
|
|
}
|
|
})
|