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

202 lines
7.2 KiB
TypeScript

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?: unknown
success_url?: unknown
cancel_url?: unknown
idempotency_key?: unknown
}
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 } : {}),
}
}
Deno.serve(async (req: Request) => {
const preflight = handleCorsPreflightRequest(req)
if (preflight) return preflight
if (req.method !== 'POST') return jsonResponse({ error: 'method_not_allowed' }, 405)
const serviceClient = createServiceRoleClient()
let operationId: string | null = null
try {
const user = await requireUser(req)
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 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 jsonResponse({ error: 'stripe_not_configured' }, 503)
}
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)
.single()
if (subscriptionError) throw new Error('subscription_lookup_failed')
let customerId = typeof subscription?.stripe_customer_id === 'string'
? subscription.stripe_customer_id
: null
if (!customerId) {
const customerResponse = await fetch('https://api.stripe.com/v1/customers', {
method: 'POST',
headers: stripeHeaders(stripeKey, `customer-${operationId}`),
body: new URLSearchParams({
email: user.email ?? '',
'metadata[user_id]': user.id,
}),
})
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')
}
customerId = customer.id
}
const sessionResponse = await fetch('https://api.stripe.com/v1/checkout/sessions', {
method: 'POST',
headers: stripeHeaders(stripeKey, `checkout-${operationId}`),
body: new URLSearchParams({
customer: customerId,
mode: 'subscription',
'line_items[0][price]': priceId,
'line_items[0][quantity]': '1',
success_url: urls.success,
cancel_url: urls.cancel,
client_reference_id: user.id,
'metadata[user_id]': user.id,
'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 (error && typeof error === 'object' && 'status' in error && 'message' in error) {
return authErrorResponse(error as AuthError, corsHeaders)
}
return jsonResponse({ error: 'stripe_checkout_failed' }, 502)
}
})