From d3c2a4348dfafde2119dc40a758233454cd851ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=B0=AC?= Date: Sun, 12 Apr 2026 20:18:11 +0900 Subject: [PATCH] =?UTF-8?q?feat(server):=20Payple=20=EC=A0=95=EA=B8=B0=20?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0=20=ED=81=AC=EB=A1=A0=20=E2=80=94=20payple-re?= =?UTF-8?q?new=20Edge=20Function=20+=20GitHub=20Actions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - payple-renew: 만료 구독 조회 → paypleBilling → 기간 갱신 - 3회 연속 실패 시 tier=free, status=expired 다운그레이드 - migration: subscriptions.renewal_failures 컬럼 추가 - payple-webhook: 결제 완료 시 renewal_failures=0 리셋 - GitHub Actions: 매일 01:00 UTC 스케줄 --- .github/workflows/payple-renew.yml | 29 +++ server/supabase/config.toml | 3 + .../supabase/functions/payple-renew/index.ts | 178 ++++++++++++++++++ .../functions/payple-webhook/index.ts | 1 + .../20260413000001_payple_renew.sql | 5 + 5 files changed, 216 insertions(+) create mode 100644 .github/workflows/payple-renew.yml create mode 100644 server/supabase/functions/payple-renew/index.ts create mode 100644 server/supabase/migrations/20260413000001_payple_renew.sql diff --git a/.github/workflows/payple-renew.yml b/.github/workflows/payple-renew.yml new file mode 100644 index 0000000..5313371 --- /dev/null +++ b/.github/workflows/payple-renew.yml @@ -0,0 +1,29 @@ +name: Payple Subscription Renewal + +on: + schedule: + # 매일 01:00 UTC (KST 10:00) + - cron: '0 1 * * *' + workflow_dispatch: {} + +jobs: + renew: + runs-on: ubuntu-latest + steps: + - name: Trigger payple-renew Edge Function + run: | + response=$(curl -s -w "\n%{http_code}" -X POST \ + "${{ secrets.SUPABASE_URL }}/functions/v1/payple-renew" \ + -H "Authorization: Bearer ${{ secrets.CRON_SECRET }}" \ + -H "Content-Type: application/json") + + http_code=$(echo "$response" | tail -1) + body=$(echo "$response" | head -n -1) + + echo "HTTP $http_code" + echo "$body" | jq . 2>/dev/null || echo "$body" + + if [ "$http_code" -ge 400 ]; then + echo "::error::Renewal failed with HTTP $http_code" + exit 1 + fi diff --git a/server/supabase/config.toml b/server/supabase/config.toml index 950c441..a14f627 100644 --- a/server/supabase/config.toml +++ b/server/supabase/config.toml @@ -103,6 +103,9 @@ verify_jwt = false [functions.payple-manage] verify_jwt = false +[functions.payple-renew] +verify_jwt = false + [functions.team-invite] verify_jwt = true diff --git a/server/supabase/functions/payple-renew/index.ts b/server/supabase/functions/payple-renew/index.ts new file mode 100644 index 0000000..845e02d --- /dev/null +++ b/server/supabase/functions/payple-renew/index.ts @@ -0,0 +1,178 @@ +// 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' } } + ) + } +}) diff --git a/server/supabase/functions/payple-webhook/index.ts b/server/supabase/functions/payple-webhook/index.ts index 4d99f47..7b0fada 100644 --- a/server/supabase/functions/payple-webhook/index.ts +++ b/server/supabase/functions/payple-webhook/index.ts @@ -95,6 +95,7 @@ Deno.serve(async (req: Request) => { status: 'active', current_period_start: now.toISOString(), current_period_end: end.toISOString(), + renewal_failures: 0, updated_at: now.toISOString(), }) .eq('user_id', sub.user_id) diff --git a/server/supabase/migrations/20260413000001_payple_renew.sql b/server/supabase/migrations/20260413000001_payple_renew.sql new file mode 100644 index 0000000..6ef09ca --- /dev/null +++ b/server/supabase/migrations/20260413000001_payple_renew.sql @@ -0,0 +1,5 @@ +-- Phase 3.2-B: Payple 정기 갱신 실패 카운터 +-- payple-renew Edge Function이 갱신 실패 시 카운트, 3회 초과 시 다운그레이드 + +ALTER TABLE public.subscriptions + ADD COLUMN IF NOT EXISTS renewal_failures integer NOT NULL DEFAULT 0;