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:
parent
36031acda9
commit
d3c2a4348d
5 changed files with 216 additions and 0 deletions
178
server/supabase/functions/payple-renew/index.ts
Normal file
178
server/supabase/functions/payple-renew/index.ts
Normal 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' } }
|
||||
)
|
||||
}
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue