- payple-renew: 만료 구독 조회 → paypleBilling → 기간 갱신 - 3회 연속 실패 시 tier=free, status=expired 다운그레이드 - migration: subscriptions.renewal_failures 컬럼 추가 - payple-webhook: 결제 완료 시 renewal_failures=0 리셋 - GitHub Actions: 매일 01:00 UTC 스케줄
178 lines
5.5 KiB
TypeScript
178 lines
5.5 KiB
TypeScript
// 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' } }
|
|
)
|
|
}
|
|
})
|