// server/supabase/functions/payple-renew/index.ts // Payple 정기 결제 갱신 — 외부 스케줄러(GitHub Actions 등)에서 매일 호출 // 만료된 active 구독을 찾아 빌링키로 재결제 + 기간 갱신 import { corsHeaders } from '../_shared/cors.ts' import { createServiceRoleClient } from '../_shared/quota.ts' import { getPaypleConfig, paypleAuth, paypleBilling, generateOrderId, calcSubscriptionPeriod, TIER_PRICE, TIER_GOODS_NAME, } from '../_shared/payple.ts' const MAX_RENEWAL_FAILURES = 3 interface RenewalResult { userId: string tier: string success: boolean orderId?: string error?: string } // @ts-expect-error — Deno.serve Deno.serve(async (req: Request) => { if (req.method === 'OPTIONS') { return new Response('ok', { headers: corsHeaders }) } // CRON_SECRET 검증 — 외부 스케줄러만 호출 가능 const authHeader = req.headers.get('authorization') ?? '' const token = authHeader.replace('Bearer ', '') // @ts-expect-error — Deno.env const cronSecret = Deno.env.get('CRON_SECRET') ?? '' if (!cronSecret || token !== cronSecret) { return new Response( JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } const serviceClient = createServiceRoleClient() const results: RenewalResult[] = [] try { // 만료된 active Payple 구독 조회 const { data: expiredSubs, error: queryError } = await serviceClient .from('subscriptions') .select('id, user_id, tier, payple_payer_id, renewal_failures') .eq('status', 'active') .eq('payment_provider', 'payple') .not('payple_payer_id', 'is', null) .is('cancel_at', null) .lte('current_period_end', new Date().toISOString()) if (queryError) { return new Response( JSON.stringify({ error: `Query failed: ${queryError.message}` }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } if (!expiredSubs || expiredSubs.length === 0) { return new Response( JSON.stringify({ renewed: 0, failed: 0, results: [] }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } // Payple 파트너 인증 (한 번만) const config = getPaypleConfig() const auth = await paypleAuth(config, { simpleFlag: true }) // 각 구독에 대해 재결제 시도 for (const sub of expiredSubs) { const tier = sub.tier as string const price = TIER_PRICE[tier] const goodsName = TIER_GOODS_NAME[tier] if (!price || !goodsName || !sub.payple_payer_id) { results.push({ userId: sub.user_id as string, tier, success: false, error: 'Invalid tier or missing payer_id', }) continue } try { const orderId = generateOrderId(sub.user_id as string) const billingResult = await paypleBilling(config, auth, { payerId: sub.payple_payer_id as string, amount: price, orderId, goodsName, }) if (billingResult.PCD_PAY_RST !== 'success') { throw new Error(billingResult.PCD_PAY_MSG || 'Billing failed') } // 결제 성공 → 기간 갱신 const { start, end } = calcSubscriptionPeriod() await serviceClient .from('subscriptions') .update({ current_period_start: start, current_period_end: end, payple_pay_oid: billingResult.PCD_PAY_OID || orderId, renewal_failures: 0, updated_at: new Date().toISOString(), }) .eq('id', sub.id) results.push({ userId: sub.user_id as string, tier, success: true, orderId: billingResult.PCD_PAY_OID || orderId, }) } catch (err) { const failures = ((sub.renewal_failures as number) ?? 0) + 1 const errorMsg = err instanceof Error ? err.message : String(err) if (failures >= MAX_RENEWAL_FAILURES) { // 3회 초과 실패 → 다운그레이드 await serviceClient .from('subscriptions') .update({ status: 'expired', renewal_failures: failures, updated_at: new Date().toISOString(), }) .eq('id', sub.id) await serviceClient .from('profiles') .update({ tier: 'free', updated_at: new Date().toISOString() }) .eq('id', sub.user_id) } else { // 실패 카운트 증가 await serviceClient .from('subscriptions') .update({ renewal_failures: failures, updated_at: new Date().toISOString(), }) .eq('id', sub.id) } results.push({ userId: sub.user_id as string, tier, success: false, error: `${errorMsg} (failure ${failures}/${MAX_RENEWAL_FAILURES})`, }) } } const renewed = results.filter((r) => r.success).length const failed = results.filter((r) => !r.success).length return new Response( JSON.stringify({ renewed, failed, results }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } catch (err) { return new Response( JSON.stringify({ error: err instanceof Error ? err.message : String(err) }), { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } })