feat(release): prepare 1.1.0 candidate

This commit is contained in:
Yun Chan 2026-08-29 18:33:45 +09:00
parent 5a34f66981
commit 5205dcdfa9
736 changed files with 115667 additions and 12203 deletions

View file

@ -1,17 +1,19 @@
// server/supabase/functions/payple-renew/index.ts
// Payple 정기 결제 갱신 — 외부 스케줄러(GitHub Actions 등)에서 매일 호출
// 만료된 active 구독을 찾아 빌링키로 재결제 + 기간 갱신
import { corsHeaders } from '../_shared/cors.ts'
import { createServiceRoleClient } from '../_shared/quota.ts'
import {
calcSubscriptionPeriod,
generateOrderId,
getPaypleConfig,
parsePaypleTimestamp,
paypleAuth,
paypleBilling,
generateOrderId,
calcSubscriptionPeriod,
TIER_PRICE,
PaypleBillingError,
PaypleConfigurationError,
payplePaymentEventDigest,
payplePaymentEventId,
sha256Text,
TIER_GOODS_NAME,
TIER_PRICE,
} from '../_shared/payple.ts'
const MAX_RENEWAL_FAILURES = 3
@ -24,155 +26,256 @@ interface RenewalResult {
error?: string
}
// @ts-expect-error — Deno.serve
interface OperationReservation {
created?: boolean
operation_id?: string
reason?: string
}
function jsonResponse(body: Record<string, unknown>, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
}
Deno.serve(async (req: Request) => {
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders })
}
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
if (req.method !== 'POST') return jsonResponse({ error: 'method_not_allowed' }, 405)
// 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 token = (req.headers.get('authorization') ?? '').replace(/^Bearer\s+/i, '')
const cronSecret = Deno.env.get('CRON_SECRET')?.trim() ?? ''
if (!cronSecret || token.length !== cronSecret.length) {
return jsonResponse({ error: 'unauthorized' }, 401)
}
let mismatch = 0
for (let index = 0; index < token.length; index += 1) {
mismatch |= token.charCodeAt(index) ^ cronSecret.charCodeAt(index)
}
if (mismatch !== 0) return jsonResponse({ error: 'unauthorized' }, 401)
const serviceClient = createServiceRoleClient()
const now = new Date()
const results: RenewalResult[] = []
try {
// 만료된 active Payple 구독 조회
const { data: expiredSubs, error: queryError } = await serviceClient
// End-of-term cancellations are revoked through the same serialized RPC.
const { data: endingSubscriptions, error: endingError } = await serviceClient
.from('subscriptions')
.select('id, user_id, tier, payple_payer_id, renewal_failures')
.eq('status', 'active')
.eq('payment_provider', 'payple')
.select('user_id, tier, provider_resource_id, current_period_start, current_period_end')
.eq('provider', 'payple')
.eq('auto_renewing', false)
.not('provider_resource_id', 'is', null)
.lte('current_period_end', now.toISOString())
if (endingError) throw new Error('ending_subscription_query_failed')
for (const subscription of endingSubscriptions ?? []) {
if (
typeof subscription.user_id !== 'string'
|| typeof subscription.provider_resource_id !== 'string'
) continue
const eventId = `scheduled-expire:${subscription.provider_resource_id}:${subscription.current_period_end}`
const { error: expireError } = await serviceClient.rpc('apply_payment_provider_event', {
p_user_id: subscription.user_id,
p_provider: 'payple',
p_event_id: eventId.slice(0, 255),
p_event_created_at: now.toISOString(),
p_event_type: 'subscription.scheduled_expiry',
p_payload_digest: await sha256Text(eventId),
p_provider_resource_id: subscription.provider_resource_id,
p_tier: 'free',
p_status: 'expired',
p_entitled: false,
p_current_period_start: subscription.current_period_start,
p_current_period_end: subscription.current_period_end,
p_cancel_at: subscription.current_period_end,
p_auto_renewing: false,
p_provider_customer_id: null,
p_provider_order_id: null,
p_store_product_id: null,
p_store_purchase_id: null,
p_operation_id: null,
})
if (expireError) throw new Error('scheduled_expiry_failed')
}
const { data: expiredSubscriptions, error: queryError } = await serviceClient
.from('subscriptions')
.select(
'user_id, tier, payple_payer_id, provider_resource_id, current_period_end, renewal_failures',
)
.eq('provider', 'payple')
.eq('auto_renewing', true)
.in('status', ['active', 'past_due'])
.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' } }
)
.lte('current_period_end', now.toISOString())
if (queryError) throw new Error('renewal_query_failed')
if (!expiredSubscriptions?.length) {
return jsonResponse({ renewed: 0, failed: 0, expired: endingSubscriptions?.length ?? 0, results })
}
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',
})
for (const subscription of expiredSubscriptions) {
const userId = typeof subscription.user_id === 'string' ? subscription.user_id : ''
const tier = subscription.tier === 'pro' || subscription.tier === 'pro_plus'
? subscription.tier
: null
const payerId = typeof subscription.payple_payer_id === 'string'
? subscription.payple_payer_id
: null
const resourceId = typeof subscription.provider_resource_id === 'string'
? subscription.provider_resource_id
: null
if (!userId || !tier || !payerId || !resourceId) {
results.push({ userId, tier: String(subscription.tier), success: false, error: 'invalid_subscription' })
continue
}
const attempt = Number(subscription.renewal_failures ?? 0) + 1
const periodKey = new Date(subscription.current_period_end as string).getTime()
const idempotencyKey = `payple-renew:${userId}:${periodKey}:attempt${attempt}`
const orderId = generateOrderId(userId)
let operationId: string | null = null
let charged = false
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(),
const { data: reservationData, error: reservationError } = await serviceClient.rpc(
'reserve_payment_provider_operation',
{
p_user_id: userId,
p_provider: 'payple',
p_operation_type: 'renewal',
p_requested_tier: tier,
p_idempotency_key: idempotencyKey,
p_provider_order_id: orderId,
p_provider_resource_id: resourceId,
},
)
if (reservationError) throw new Error('renewal_reservation_failed')
const reservation = reservationData as OperationReservation | null
if (!reservation?.created || typeof reservation.operation_id !== 'string') {
results.push({
userId,
tier,
success: false,
error: reservation?.reason ?? 'renewal_operation_in_progress',
})
.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)
continue
}
operationId = reservation.operation_id
const billing = await paypleBilling(config, auth, {
payerId,
amount: TIER_PRICE[tier],
orderId,
goodsName: TIER_GOODS_NAME[tier],
})
charged = true
const { error: chargedError } = await serviceClient.rpc('mark_payment_provider_operation', {
p_operation_id: operationId,
p_state: 'charged',
p_external_reference: billing.PCD_PAY_OID || orderId,
p_error_code: null,
})
if (chargedError) throw new Error('renewal_operation_update_failed')
if (
billing.PCD_PAY_OID !== orderId
|| billing.PCD_PAY_TOTAL !== String(TIER_PRICE[tier])
|| (billing.PCD_PAYER_ID && billing.PCD_PAYER_ID !== payerId)
) throw new Error('renewal_response_mismatch')
const eventTime = billing.PCD_PAY_TIME
? parsePaypleTimestamp(billing.PCD_PAY_TIME)
: new Date()
const { start, end } = calcSubscriptionPeriod(eventTime)
const { data: applyData, error: applyError } = await serviceClient.rpc(
'apply_payment_provider_event',
{
p_user_id: userId,
p_provider: 'payple',
p_event_id: payplePaymentEventId(orderId),
p_event_created_at: eventTime.toISOString(),
p_event_type: 'payment.completed',
p_payload_digest: await payplePaymentEventDigest({
orderId,
payerId,
payType: 'card',
amount: TIER_PRICE[tier],
}),
p_provider_resource_id: resourceId,
p_tier: tier,
p_status: 'active',
p_entitled: true,
p_current_period_start: start,
p_current_period_end: end,
p_cancel_at: null,
p_auto_renewing: true,
p_provider_customer_id: payerId,
p_provider_order_id: orderId,
p_store_product_id: null,
p_store_purchase_id: null,
p_operation_id: operationId,
},
)
if (applyError) throw new Error('renewal_entitlement_apply_failed')
const applied = applyData as { applied?: boolean; duplicate?: boolean; reason?: string } | null
if (!applied?.applied && !applied?.duplicate) {
throw new Error('renewal_requires_reconciliation')
}
results.push({ userId, tier, success: true, orderId })
} catch (error) {
const chargeOutcomeUnknown = error instanceof PaypleBillingError && !error.definitive
if (operationId && !charged) {
if (chargeOutcomeUnknown) {
await serviceClient.rpc('mark_payment_provider_operation', {
p_operation_id: operationId,
p_state: 'external_created',
p_external_reference: orderId,
p_error_code: null,
})
} else if (error instanceof PaypleBillingError && error.definitive) {
const eventTime = new Date()
await serviceClient.rpc('record_payment_provider_renewal_failure', {
p_user_id: userId,
p_provider: 'payple',
p_event_id: `renewal-failed:${operationId}`,
p_event_created_at: eventTime.toISOString(),
p_payload_digest: await sha256Text(`${operationId}:payple_renewal_failed`),
p_provider_resource_id: resourceId,
p_error_code: 'payple_renewal_failed',
p_failure_threshold: MAX_RENEWAL_FAILURES,
p_operation_id: operationId,
})
} else {
await serviceClient.rpc('mark_payment_provider_operation', {
p_operation_id: operationId,
p_state: 'failed',
p_external_reference: null,
p_error_code: 'payple_renewal_failed',
})
}
}
results.push({
userId: sub.user_id as string,
userId,
tier,
success: false,
error: `${errorMsg} (failure ${failures}/${MAX_RENEWAL_FAILURES})`,
error: charged || chargeOutcomeUnknown
? 'renewal_requires_reconciliation'
: 'payple_renewal_failed',
})
}
}
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' } }
)
return jsonResponse({
renewed: results.filter((result) => result.success).length,
failed: results.filter((result) => !result.success).length,
expired: endingSubscriptions?.length ?? 0,
results,
})
} catch (error) {
if (error instanceof PaypleConfigurationError) {
return jsonResponse({ error: error.code }, 503)
}
return jsonResponse({ error: 'payple_renewal_processing_failed' }, 500)
}
})