202 lines
7.2 KiB
TypeScript
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)
|
|
}
|
|
})
|