import { corsHeaders } from '../_shared/cors.ts' import { createServiceRoleClient } from '../_shared/quota.ts' import { generateOrderId, getPaypleConfig, PaypleConfigurationError, sha256Text, } from '../_shared/payple.ts' import { createPaypleRenewalGateway, createSupabaseRenewalStore } from './adapters.ts' import { normalizeRenewalCandidate, type RenewableTier, type RenewalDeps, type RenewalResult, renewSubscription, } from './renewal.ts' import { latestPaidTierByUser, withPaidTier } from './paid-tier.ts' type ServiceClient = ReturnType function jsonResponse(body: Record, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, }) } function isAuthorizedCron(req: Request): boolean { const token = (req.headers.get('authorization') ?? '').replace(/^Bearer\s+/i, '') const cronSecret = Deno.env.get('CRON_SECRET')?.trim() ?? '' if (!cronSecret || token.length !== cronSecret.length) return false let mismatch = 0 for (let index = 0; index < token.length; index += 1) { mismatch |= token.charCodeAt(index) ^ cronSecret.charCodeAt(index) } return mismatch === 0 } /** End-of-term cancellations are revoked through the same serialized RPC. */ async function expireEndedSubscriptions(serviceClient: ServiceClient, now: Date): Promise { const { data: endingSubscriptions, error: endingError } = await serviceClient .from('subscriptions') .select('user_id, tier, provider_resource_id, current_period_start, current_period_end') .eq('provider', 'payple') .eq('auto_renewing', false) .not('provider_resource_id', 'is', null) .lte('current_period_end', now.toISOString()) if (endingError) throw new Error('ending_subscription_query_failed') for (const subscription of endingSubscriptions ?? []) { if ( typeof subscription.user_id !== 'string' || typeof subscription.provider_resource_id !== 'string' ) continue const eventId = `scheduled-expire:${subscription.provider_resource_id}:${subscription.current_period_end}` const { error: expireError } = await serviceClient.rpc('apply_payment_provider_event', { p_user_id: subscription.user_id, p_provider: 'payple', p_event_id: eventId.slice(0, 255), p_event_created_at: now.toISOString(), p_event_type: 'subscription.scheduled_expiry', p_payload_digest: await sha256Text(eventId), p_provider_resource_id: subscription.provider_resource_id, p_tier: 'free', p_status: 'expired', p_entitled: false, p_current_period_start: subscription.current_period_start, p_current_period_end: subscription.current_period_end, p_cancel_at: subscription.current_period_end, p_auto_renewing: false, p_provider_customer_id: null, p_provider_order_id: null, p_store_product_id: null, p_store_purchase_id: null, p_operation_id: null, }) if (expireError) throw new Error('scheduled_expiry_failed') } return endingSubscriptions?.length ?? 0 } async function selectDueRenewals( serviceClient: ServiceClient, now: Date, ): Promise[]> { const { data, error } = await serviceClient .from('subscriptions') .select( 'user_id, tier, payple_payer_id, provider_resource_id, current_period_end, renewal_failures', ) .eq('provider', 'payple') .eq('auto_renewing', true) .in('status', ['active', 'past_due']) .not('payple_payer_id', 'is', null) .lte('current_period_end', now.toISOString()) if (error) throw new Error('renewal_query_failed') return (data ?? []) as Record[] } const PAID_TIER_QUERY_CHUNK = 100 /** * 갱신 대상 사용자별로 마지막으로 반영된 Payple 결제 등급을 읽는다. * 청구 금액의 정본은 이 원장이다 — subscriptions.tier는 청구 근거로 쓰지 않는다. */ async function loadLastPaidTiers( serviceClient: ServiceClient, userIds: readonly string[], ): Promise> { const rows: Record[] = [] for (let offset = 0; offset < userIds.length; offset += PAID_TIER_QUERY_CHUNK) { const { data, error } = await serviceClient .from('payment_provider_operations') .select('user_id, requested_tier, updated_at, created_at') .eq('provider', 'payple') .eq('state', 'applied') .in('operation_type', ['checkout', 'renewal']) .not('requested_tier', 'is', null) .in('user_id', userIds.slice(offset, offset + PAID_TIER_QUERY_CHUNK)) if (error) throw new Error('renewal_paid_tier_query_failed') rows.push(...((data ?? []) as Record[])) } return latestPaidTierByUser(rows) } export async function paypleRenewHandler(req: Request): Promise { if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders }) if (req.method !== 'POST') return jsonResponse({ error: 'method_not_allowed' }, 405) if (!isAuthorizedCron(req)) return jsonResponse({ error: 'unauthorized' }, 401) const serviceClient = createServiceRoleClient() const now = new Date() const results: RenewalResult[] = [] try { const expired = await expireEndedSubscriptions(serviceClient, now) const dueRenewals = await selectDueRenewals(serviceClient, now) if (!dueRenewals.length) { return jsonResponse({ renewed: 0, failed: 0, expired, results }) } const userIds = dueRenewals .map((row) => row.user_id) .filter((userId): userId is string => typeof userId === 'string') const paidTiers = await loadLastPaidTiers(serviceClient, userIds) const deps: RenewalDeps = { store: createSupabaseRenewalStore(serviceClient), gateway: await createPaypleRenewalGateway(getPaypleConfig()), now: () => new Date(), newOrderId: (userId) => generateOrderId(userId), } for (const row of dueRenewals) { const candidate = normalizeRenewalCandidate(withPaidTier(row, paidTiers)) if (!candidate) { results.push({ userId: typeof row.user_id === 'string' ? row.user_id : '', tier: String(row.tier), success: false, error: 'invalid_subscription', }) continue } results.push(await renewSubscription(deps, candidate)) } return jsonResponse({ renewed: results.filter((result) => result.success).length, failed: results.filter((result) => !result.success).length, expired, results, }) } catch (error) { if (error instanceof PaypleConfigurationError) { return jsonResponse({ error: error.code }, 503) } return jsonResponse({ error: 'payple_renewal_processing_failed' }, 500) } } if (import.meta.main) Deno.serve(paypleRenewHandler)