feat(server): Payple 정기 갱신 크론 — payple-renew Edge Function + GitHub Actions

- payple-renew: 만료 구독 조회 → paypleBilling → 기간 갱신
- 3회 연속 실패 시 tier=free, status=expired 다운그레이드
- migration: subscriptions.renewal_failures 컬럼 추가
- payple-webhook: 결제 완료 시 renewal_failures=0 리셋
- GitHub Actions: 매일 01:00 UTC 스케줄
This commit is contained in:
윤찬 2026-04-12 20:18:11 +09:00
parent 36031acda9
commit d3c2a4348d
5 changed files with 216 additions and 0 deletions

29
.github/workflows/payple-renew.yml vendored Normal file
View file

@ -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

View file

@ -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

View file

@ -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' } }
)
}
})

View file

@ -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)

View file

@ -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;