feat(release): prepare 1.1.0 candidate

This commit is contained in:
Yun Chan 2026-08-29 18:33:45 +09:00
parent 5a34f66981
commit 5205dcdfa9
736 changed files with 115667 additions and 12203 deletions

View file

@ -1,137 +1,202 @@
// server/supabase/functions/stripe-checkout/index.ts
// Stripe Checkout Session 생성 — 사용자가 업그레이드 버튼 클릭 시 호출.
// 응답: { url: 'https://checkout.stripe.com/...' } → 클라이언트가 redirect.
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts'
import { createServiceRoleClient } from '../_shared/quota.ts'
interface CheckoutRequest {
tier: 'pro' | 'team'
success_url: string
cancel_url: string
tier?: unknown
success_url?: unknown
cancel_url?: unknown
idempotency_key?: unknown
}
// Stripe Price ID는 환경변수로 주입 (대시보드에서 생성한 product의 price ID)
// 실제 운영 시:
// supabase secrets set STRIPE_PRICE_PRO=price_xxx
// supabase secrets set STRIPE_PRICE_TEAM=price_yyy
// supabase secrets set STRIPE_SECRET_KEY=sk_live_xxx
interface OperationReservation {
created?: boolean
operation_id?: string
state?: string
reason?: string
}
function jsonResponse(body: Record<string, unknown>, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
}
function normalizeTier(value: unknown): 'pro' | 'pro_plus' | null {
if (value === 'pro') return 'pro'
// team is accepted only as an input compatibility alias. It is never stored.
if (value === 'pro_plus' || value === 'team') return 'pro_plus'
return null
}
function parseReturnUrls(success: unknown, cancel: unknown): { success: string; cancel: string } | null {
if (typeof success !== 'string' || typeof cancel !== 'string') return null
try {
const successUrl = new URL(success)
const cancelUrl = new URL(cancel)
const validProtocol = (url: URL) => url.protocol === 'https:'
|| (url.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(url.hostname))
if (
!validProtocol(successUrl)
|| !validProtocol(cancelUrl)
|| successUrl.origin !== cancelUrl.origin
|| successUrl.username
|| successUrl.password
|| cancelUrl.username
|| cancelUrl.password
) return null
return { success: successUrl.toString(), cancel: cancelUrl.toString() }
} catch {
return null
}
}
function stripeHeaders(secretKey: string, idempotencyKey?: string): HeadersInit {
return {
Authorization: `Bearer ${secretKey}`,
'Content-Type': 'application/x-www-form-urlencoded',
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
}
}
// @ts-expect-error — Deno 런타임 전역
Deno.serve(async (req: Request) => {
const preflight = handleCorsPreflightRequest(req)
if (preflight) return preflight
if (req.method !== 'POST') return jsonResponse({ error: 'method_not_allowed' }, 405)
if (req.method !== 'POST') {
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
status: 405,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
})
}
const serviceClient = createServiceRoleClient()
let operationId: string | null = null
try {
const user = await requireUser(req)
const body = (await req.json()) as CheckoutRequest
// @ts-expect-error — Deno.env
const stripeKey = Deno.env.get('STRIPE_SECRET_KEY') ?? ''
// @ts-expect-error — Deno.env
const priceMap: Record<string, string> = {
pro: Deno.env.get('STRIPE_PRICE_PRO') ?? '',
team: Deno.env.get('STRIPE_PRICE_TEAM') ?? ''
const body = await req.json() as CheckoutRequest
const tier = normalizeTier(body.tier)
const urls = parseReturnUrls(body.success_url, body.cancel_url)
if (
!tier
|| !urls
|| (body.idempotency_key !== undefined
&& (typeof body.idempotency_key !== 'string'
|| !/^[A-Za-z0-9._:-]{12,160}$/.test(body.idempotency_key)))
) {
return jsonResponse({ error: 'invalid_request' }, 400)
}
const priceId = priceMap[body.tier]
const stripeKey = Deno.env.get('STRIPE_SECRET_KEY')?.trim() ?? ''
const priceMap = {
pro: Deno.env.get('STRIPE_PRICE_PRO')?.trim() ?? '',
pro_plus: (Deno.env.get('STRIPE_PRICE_PRO_PLUS')
?? Deno.env.get('STRIPE_PRICE_TEAM'))?.trim() ?? '',
}
const priceId = priceMap[tier]
if (!stripeKey || !priceId) {
return new Response(
JSON.stringify({
error: 'stripe_not_configured',
message: 'STRIPE_SECRET_KEY 또는 STRIPE_PRICE_* 환경변수가 설정되지 않았습니다.'
}),
{
status: 503,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
}
)
return jsonResponse({ error: 'stripe_not_configured' }, 503)
}
// 기존 customer_id 조회 (subscriptions 테이블에서)
const serviceClient = createServiceRoleClient()
const { data: sub } = await serviceClient
const idempotencyKey = typeof body.idempotency_key === 'string'
? body.idempotency_key
: `stripe-checkout:${crypto.randomUUID()}`
const providerOrderId = `STRIPE-${crypto.randomUUID()}`
const { data: reservationData, error: reservationError } = await serviceClient.rpc(
'reserve_payment_provider_operation',
{
p_user_id: user.id,
p_provider: 'stripe',
p_operation_type: 'checkout',
p_requested_tier: tier,
p_idempotency_key: idempotencyKey,
p_provider_order_id: providerOrderId,
p_provider_resource_id: null,
},
)
if (reservationError) throw new Error('payment_reservation_failed')
const reservation = reservationData as OperationReservation | null
if (!reservation?.created || typeof reservation.operation_id !== 'string') {
return jsonResponse({
error: reservation?.reason ?? 'payment_operation_in_progress',
state: reservation?.state ?? 'rejected',
}, 409)
}
operationId = reservation.operation_id
const { data: subscription, error: subscriptionError } = await serviceClient
.from('subscriptions')
.select('stripe_customer_id')
.eq('user_id', user.id)
.maybeSingle()
.single()
if (subscriptionError) throw new Error('subscription_lookup_failed')
let customerId = typeof subscription?.stripe_customer_id === 'string'
? subscription.stripe_customer_id
: null
let customerId = (sub?.stripe_customer_id as string | null | undefined) ?? null
// 없으면 새 customer 생성
if (!customerId) {
const customerResp = await fetch('https://api.stripe.com/v1/customers', {
const customerResponse = await fetch('https://api.stripe.com/v1/customers', {
method: 'POST',
headers: {
Authorization: `Bearer ${stripeKey}`,
'Content-Type': 'application/x-www-form-urlencoded'
},
headers: stripeHeaders(stripeKey, `customer-${operationId}`),
body: new URLSearchParams({
email: user.email ?? '',
'metadata[user_id]': user.id
})
'metadata[user_id]': user.id,
}),
})
if (!customerResp.ok) {
const errText = await customerResp.text()
throw new Error(`Stripe customer 생성 실패: ${errText}`)
if (!customerResponse.ok) throw new Error('stripe_customer_creation_failed')
const customer = await customerResponse.json() as { id?: unknown }
if (typeof customer.id !== 'string' || !customer.id.startsWith('cus_')) {
throw new Error('stripe_customer_response_invalid')
}
const customerData = (await customerResp.json()) as { id: string }
customerId = customerData.id
// subscriptions에 저장
await serviceClient
.from('subscriptions')
.upsert({ user_id: user.id, stripe_customer_id: customerId, tier: 'free' })
customerId = customer.id
}
// Checkout session 생성
const sessionResp = await fetch('https://api.stripe.com/v1/checkout/sessions', {
const sessionResponse = await fetch('https://api.stripe.com/v1/checkout/sessions', {
method: 'POST',
headers: {
Authorization: `Bearer ${stripeKey}`,
'Content-Type': 'application/x-www-form-urlencoded'
},
headers: stripeHeaders(stripeKey, `checkout-${operationId}`),
body: new URLSearchParams({
customer: customerId,
mode: 'subscription',
'line_items[0][price]': priceId,
'line_items[0][quantity]': '1',
success_url: body.success_url,
cancel_url: body.cancel_url,
success_url: urls.success,
cancel_url: urls.cancel,
client_reference_id: user.id,
'metadata[user_id]': user.id,
'metadata[tier]': body.tier
'metadata[tier]': tier,
'metadata[operation_id]': operationId,
'subscription_data[metadata][user_id]': user.id,
'subscription_data[metadata][tier]': tier,
'subscription_data[metadata][operation_id]': operationId,
}),
})
if (!sessionResponse.ok) throw new Error('stripe_checkout_creation_failed')
const session = await sessionResponse.json() as { id?: unknown; url?: unknown }
if (
typeof session.id !== 'string'
|| !session.id.startsWith('cs_')
|| typeof session.url !== 'string'
|| !session.url.startsWith('https://checkout.stripe.com/')
) {
throw new Error('stripe_checkout_response_invalid')
}
const { error: operationError } = await serviceClient.rpc('mark_payment_provider_operation', {
p_operation_id: operationId,
p_state: 'external_created',
p_external_reference: session.id,
p_error_code: null,
})
if (operationError) throw new Error('payment_operation_update_failed')
return jsonResponse({ url: session.url })
} catch (error) {
if (operationId) {
await serviceClient.rpc('mark_payment_provider_operation', {
p_operation_id: operationId,
p_state: 'failed',
p_external_reference: null,
p_error_code: 'stripe_checkout_failed',
})
})
if (!sessionResp.ok) {
const errText = await sessionResp.text()
throw new Error(`Stripe checkout 생성 실패: ${errText}`)
}
const sessionData = (await sessionResp.json()) as { url: string }
return new Response(JSON.stringify({ url: sessionData.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)
if (error && typeof error === 'object' && 'status' in error && 'message' in error) {
return authErrorResponse(error as AuthError, corsHeaders)
}
const message = err instanceof Error ? err.message : 'Unknown error'
return new Response(JSON.stringify({ error: message }), {
status: 500,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
})
return jsonResponse({ error: 'stripe_checkout_failed' }, 502)
}
})