feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue