fix(payple-renew): reconcile renewals whose charge outcome was left unknown
This commit is contained in:
parent
06e96c58f4
commit
f4724ddf53
4 changed files with 980 additions and 234 deletions
185
server/supabase/functions/payple-renew/adapters.ts
Normal file
185
server/supabase/functions/payple-renew/adapters.ts
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
// server/supabase/functions/payple-renew/adapters.ts
|
||||||
|
// renewal.ts 포트의 구현 — Supabase 서비스 롤 RPC 저장소와 Payple HTTP 게이트웨이.
|
||||||
|
|
||||||
|
import type { createServiceRoleClient } from '../_shared/quota.ts'
|
||||||
|
import {
|
||||||
|
type PaypleAuthResult,
|
||||||
|
paypleAuth,
|
||||||
|
paypleBilling,
|
||||||
|
PaypleBillingError,
|
||||||
|
type PaypleConfig,
|
||||||
|
paypleLookupPayment,
|
||||||
|
payplePaymentEventDigest,
|
||||||
|
payplePaymentEventId,
|
||||||
|
resolvePaypleOrderDate,
|
||||||
|
sha256Text,
|
||||||
|
TIER_PRICE,
|
||||||
|
} from '../_shared/payple.ts'
|
||||||
|
import {
|
||||||
|
BILLING_TIMEOUT_MS,
|
||||||
|
type ChargeReceipt,
|
||||||
|
type ChargeRequest,
|
||||||
|
classifyLookupFailure,
|
||||||
|
interpretLookup,
|
||||||
|
LOOKUP_TIMEOUT_MS,
|
||||||
|
type LookupOutcome,
|
||||||
|
MAX_RENEWAL_FAILURES,
|
||||||
|
type RenewalOperation,
|
||||||
|
type RenewalPaymentApplication,
|
||||||
|
type RenewalPaymentGateway,
|
||||||
|
type RenewalReservation,
|
||||||
|
type RenewalStore,
|
||||||
|
withTimeout,
|
||||||
|
} from './renewal.ts'
|
||||||
|
|
||||||
|
type ServiceClient = ReturnType<typeof createServiceRoleClient>
|
||||||
|
|
||||||
|
interface OperationReservationRow {
|
||||||
|
created?: boolean
|
||||||
|
operation_id?: string
|
||||||
|
reason?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toRenewalReservation(data: unknown): RenewalReservation {
|
||||||
|
const row = (data ?? null) as OperationReservationRow | null
|
||||||
|
const operationId = typeof row?.operation_id === 'string' ? row.operation_id : null
|
||||||
|
if (row?.created && operationId) return { kind: 'created', operationId }
|
||||||
|
if (row?.reason === 'idempotent_replay' && operationId) return { kind: 'replay', operationId }
|
||||||
|
return { kind: 'rejected', reason: row?.reason ?? 'renewal_operation_in_progress' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSupabaseRenewalStore(client: ServiceClient): RenewalStore {
|
||||||
|
return {
|
||||||
|
async reserveRenewal(candidate, idempotencyKey, orderId) {
|
||||||
|
const { data, error } = await client.rpc('reserve_payment_provider_operation', {
|
||||||
|
p_user_id: candidate.userId,
|
||||||
|
p_provider: 'payple',
|
||||||
|
p_operation_type: 'renewal',
|
||||||
|
p_requested_tier: candidate.tier,
|
||||||
|
p_idempotency_key: idempotencyKey,
|
||||||
|
p_provider_order_id: orderId,
|
||||||
|
p_provider_resource_id: candidate.resourceId,
|
||||||
|
})
|
||||||
|
if (error) throw new Error('renewal_reservation_failed')
|
||||||
|
return toRenewalReservation(data)
|
||||||
|
},
|
||||||
|
|
||||||
|
async getOperation(operationId): Promise<RenewalOperation | null> {
|
||||||
|
const { data, error } = await client
|
||||||
|
.from('payment_provider_operations')
|
||||||
|
.select('id, state, provider_order_id, expires_at')
|
||||||
|
.eq('id', operationId)
|
||||||
|
.eq('provider', 'payple')
|
||||||
|
.maybeSingle()
|
||||||
|
if (error) throw new Error('renewal_operation_lookup_failed')
|
||||||
|
const row = data as Record<string, unknown> | null
|
||||||
|
if (!row || typeof row.id !== 'string' || typeof row.state !== 'string') return null
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
state: row.state,
|
||||||
|
providerOrderId: typeof row.provider_order_id === 'string' ? row.provider_order_id : null,
|
||||||
|
expiresAt: typeof row.expires_at === 'string' ? row.expires_at : null,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async markOperation(operationId, state, externalReference, errorCode) {
|
||||||
|
const { error } = await client.rpc('mark_payment_provider_operation', {
|
||||||
|
p_operation_id: operationId,
|
||||||
|
p_state: state,
|
||||||
|
p_external_reference: externalReference,
|
||||||
|
p_error_code: errorCode,
|
||||||
|
})
|
||||||
|
return !error
|
||||||
|
},
|
||||||
|
|
||||||
|
async recordRenewalFailure(candidate, operationId, at) {
|
||||||
|
const { error } = await client.rpc('record_payment_provider_renewal_failure', {
|
||||||
|
p_user_id: candidate.userId,
|
||||||
|
p_provider: 'payple',
|
||||||
|
p_event_id: `renewal-failed:${operationId}`,
|
||||||
|
p_event_created_at: at.toISOString(),
|
||||||
|
p_payload_digest: await sha256Text(`${operationId}:payple_renewal_failed`),
|
||||||
|
p_provider_resource_id: candidate.resourceId,
|
||||||
|
p_error_code: 'payple_renewal_failed',
|
||||||
|
p_failure_threshold: MAX_RENEWAL_FAILURES,
|
||||||
|
p_operation_id: operationId,
|
||||||
|
})
|
||||||
|
return !error
|
||||||
|
},
|
||||||
|
|
||||||
|
async applyRenewalPayment(application: RenewalPaymentApplication) {
|
||||||
|
const { candidate, orderId } = application
|
||||||
|
const { data, error } = await client.rpc('apply_payment_provider_event', {
|
||||||
|
p_user_id: candidate.userId,
|
||||||
|
p_provider: 'payple',
|
||||||
|
p_event_id: payplePaymentEventId(orderId),
|
||||||
|
p_event_created_at: application.eventTime.toISOString(),
|
||||||
|
p_event_type: 'payment.completed',
|
||||||
|
p_payload_digest: await payplePaymentEventDigest({
|
||||||
|
orderId,
|
||||||
|
payerId: candidate.payerId,
|
||||||
|
payType: 'card',
|
||||||
|
amount: TIER_PRICE[candidate.tier],
|
||||||
|
}),
|
||||||
|
p_provider_resource_id: candidate.resourceId,
|
||||||
|
p_tier: candidate.tier,
|
||||||
|
p_status: 'active',
|
||||||
|
p_entitled: true,
|
||||||
|
p_current_period_start: application.periodStart,
|
||||||
|
p_current_period_end: application.periodEnd,
|
||||||
|
p_cancel_at: null,
|
||||||
|
p_auto_renewing: true,
|
||||||
|
p_provider_customer_id: candidate.payerId,
|
||||||
|
p_provider_order_id: orderId,
|
||||||
|
p_store_product_id: null,
|
||||||
|
p_store_purchase_id: null,
|
||||||
|
p_operation_id: application.operationId,
|
||||||
|
})
|
||||||
|
if (error) throw new Error('renewal_entitlement_apply_failed')
|
||||||
|
const result = data as { applied?: boolean; duplicate?: boolean } | null
|
||||||
|
return { applied: result?.applied === true, duplicate: result?.duplicate === true }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payple 게이트웨이를 만든다. 빌링 인증은 기존과 같이 실행 시작 시 한 번 받고
|
||||||
|
* (실패하면 실행 전체가 실패), 결과조회 인증은 재조정이 필요할 때만 받는다.
|
||||||
|
*/
|
||||||
|
export async function createPaypleRenewalGateway(
|
||||||
|
config: PaypleConfig,
|
||||||
|
): Promise<RenewalPaymentGateway> {
|
||||||
|
const billingAuth = await paypleAuth(config, { simpleFlag: true })
|
||||||
|
let lookupAuth: PaypleAuthResult | null = null
|
||||||
|
|
||||||
|
return {
|
||||||
|
async charge(request: ChargeRequest): Promise<ChargeReceipt> {
|
||||||
|
const billing = await withTimeout(
|
||||||
|
paypleBilling(config, billingAuth, request),
|
||||||
|
BILLING_TIMEOUT_MS,
|
||||||
|
() => new PaypleBillingError('payple_billing_timeout_unknown', false),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
orderId: billing.PCD_PAY_OID,
|
||||||
|
total: billing.PCD_PAY_TOTAL,
|
||||||
|
payerId: billing.PCD_PAYER_ID,
|
||||||
|
payTime: billing.PCD_PAY_TIME,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async lookup(orderId: string): Promise<LookupOutcome> {
|
||||||
|
try {
|
||||||
|
const payDate = resolvePaypleOrderDate(orderId)
|
||||||
|
lookupAuth ??= await paypleAuth(config, { payCheckFlag: true })
|
||||||
|
const result = await withTimeout(
|
||||||
|
paypleLookupPayment(config, lookupAuth, { orderId, payType: 'card', payDate }),
|
||||||
|
LOOKUP_TIMEOUT_MS,
|
||||||
|
() => new Error('payple_lookup_timeout'),
|
||||||
|
)
|
||||||
|
return interpretLookup(result)
|
||||||
|
} catch (error) {
|
||||||
|
return { kind: classifyLookupFailure(error) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,36 +1,20 @@
|
||||||
import { corsHeaders } from '../_shared/cors.ts'
|
import { corsHeaders } from '../_shared/cors.ts'
|
||||||
import { createServiceRoleClient } from '../_shared/quota.ts'
|
import { createServiceRoleClient } from '../_shared/quota.ts'
|
||||||
import {
|
import {
|
||||||
calcSubscriptionPeriod,
|
|
||||||
generateOrderId,
|
generateOrderId,
|
||||||
getPaypleConfig,
|
getPaypleConfig,
|
||||||
parsePaypleTimestamp,
|
|
||||||
paypleAuth,
|
|
||||||
paypleBilling,
|
|
||||||
PaypleBillingError,
|
|
||||||
PaypleConfigurationError,
|
PaypleConfigurationError,
|
||||||
payplePaymentEventDigest,
|
|
||||||
payplePaymentEventId,
|
|
||||||
sha256Text,
|
sha256Text,
|
||||||
TIER_GOODS_NAME,
|
|
||||||
TIER_PRICE,
|
|
||||||
} from '../_shared/payple.ts'
|
} from '../_shared/payple.ts'
|
||||||
|
import { createPaypleRenewalGateway, createSupabaseRenewalStore } from './adapters.ts'
|
||||||
|
import {
|
||||||
|
normalizeRenewalCandidate,
|
||||||
|
type RenewalDeps,
|
||||||
|
type RenewalResult,
|
||||||
|
renewSubscription,
|
||||||
|
} from './renewal.ts'
|
||||||
|
|
||||||
const MAX_RENEWAL_FAILURES = 3
|
type ServiceClient = ReturnType<typeof createServiceRoleClient>
|
||||||
|
|
||||||
interface RenewalResult {
|
|
||||||
userId: string
|
|
||||||
tier: string
|
|
||||||
success: boolean
|
|
||||||
orderId?: string
|
|
||||||
error?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OperationReservation {
|
|
||||||
created?: boolean
|
|
||||||
operation_id?: string
|
|
||||||
reason?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function jsonResponse(body: Record<string, unknown>, status = 200): Response {
|
function jsonResponse(body: Record<string, unknown>, status = 200): Response {
|
||||||
return new Response(JSON.stringify(body), {
|
return new Response(JSON.stringify(body), {
|
||||||
|
|
@ -39,26 +23,19 @@ function jsonResponse(body: Record<string, unknown>, status = 200): Response {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
Deno.serve(async (req: Request) => {
|
function isAuthorizedCron(req: Request): boolean {
|
||||||
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
|
|
||||||
if (req.method !== 'POST') return jsonResponse({ error: 'method_not_allowed' }, 405)
|
|
||||||
|
|
||||||
const token = (req.headers.get('authorization') ?? '').replace(/^Bearer\s+/i, '')
|
const token = (req.headers.get('authorization') ?? '').replace(/^Bearer\s+/i, '')
|
||||||
const cronSecret = Deno.env.get('CRON_SECRET')?.trim() ?? ''
|
const cronSecret = Deno.env.get('CRON_SECRET')?.trim() ?? ''
|
||||||
if (!cronSecret || token.length !== cronSecret.length) {
|
if (!cronSecret || token.length !== cronSecret.length) return false
|
||||||
return jsonResponse({ error: 'unauthorized' }, 401)
|
|
||||||
}
|
|
||||||
let mismatch = 0
|
let mismatch = 0
|
||||||
for (let index = 0; index < token.length; index += 1) {
|
for (let index = 0; index < token.length; index += 1) {
|
||||||
mismatch |= token.charCodeAt(index) ^ cronSecret.charCodeAt(index)
|
mismatch |= token.charCodeAt(index) ^ cronSecret.charCodeAt(index)
|
||||||
}
|
}
|
||||||
if (mismatch !== 0) return jsonResponse({ error: 'unauthorized' }, 401)
|
return mismatch === 0
|
||||||
|
}
|
||||||
|
|
||||||
const serviceClient = createServiceRoleClient()
|
/** End-of-term cancellations are revoked through the same serialized RPC. */
|
||||||
const now = new Date()
|
async function expireEndedSubscriptions(serviceClient: ServiceClient, now: Date): Promise<number> {
|
||||||
const results: RenewalResult[] = []
|
|
||||||
try {
|
|
||||||
// End-of-term cancellations are revoked through the same serialized RPC.
|
|
||||||
const { data: endingSubscriptions, error: endingError } = await serviceClient
|
const { data: endingSubscriptions, error: endingError } = await serviceClient
|
||||||
.from('subscriptions')
|
.from('subscriptions')
|
||||||
.select('user_id, tier, provider_resource_id, current_period_start, current_period_end')
|
.select('user_id, tier, provider_resource_id, current_period_start, current_period_end')
|
||||||
|
|
@ -96,8 +73,14 @@ Deno.serve(async (req: Request) => {
|
||||||
})
|
})
|
||||||
if (expireError) throw new Error('scheduled_expiry_failed')
|
if (expireError) throw new Error('scheduled_expiry_failed')
|
||||||
}
|
}
|
||||||
|
return endingSubscriptions?.length ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
const { data: expiredSubscriptions, error: queryError } = await serviceClient
|
async function selectDueRenewals(
|
||||||
|
serviceClient: ServiceClient,
|
||||||
|
now: Date,
|
||||||
|
): Promise<Record<string, unknown>[]> {
|
||||||
|
const { data, error } = await serviceClient
|
||||||
.from('subscriptions')
|
.from('subscriptions')
|
||||||
.select(
|
.select(
|
||||||
'user_id, tier, payple_payer_id, provider_resource_id, current_period_end, renewal_failures',
|
'user_id, tier, payple_payer_id, provider_resource_id, current_period_end, renewal_failures',
|
||||||
|
|
@ -107,169 +90,50 @@ Deno.serve(async (req: Request) => {
|
||||||
.in('status', ['active', 'past_due'])
|
.in('status', ['active', 'past_due'])
|
||||||
.not('payple_payer_id', 'is', null)
|
.not('payple_payer_id', 'is', null)
|
||||||
.lte('current_period_end', now.toISOString())
|
.lte('current_period_end', now.toISOString())
|
||||||
if (queryError) throw new Error('renewal_query_failed')
|
if (error) throw new Error('renewal_query_failed')
|
||||||
if (!expiredSubscriptions?.length) {
|
return (data ?? []) as Record<string, unknown>[]
|
||||||
return jsonResponse({ renewed: 0, failed: 0, expired: endingSubscriptions?.length ?? 0, results })
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const config = getPaypleConfig()
|
export async function paypleRenewHandler(req: Request): Promise<Response> {
|
||||||
const auth = await paypleAuth(config, { simpleFlag: true })
|
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
|
||||||
|
if (req.method !== 'POST') return jsonResponse({ error: 'method_not_allowed' }, 405)
|
||||||
|
if (!isAuthorizedCron(req)) return jsonResponse({ error: 'unauthorized' }, 401)
|
||||||
|
|
||||||
for (const subscription of expiredSubscriptions) {
|
const serviceClient = createServiceRoleClient()
|
||||||
const userId = typeof subscription.user_id === 'string' ? subscription.user_id : ''
|
const now = new Date()
|
||||||
const tier = subscription.tier === 'pro' || subscription.tier === 'pro_plus'
|
const results: RenewalResult[] = []
|
||||||
? 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 {
|
try {
|
||||||
const { data: reservationData, error: reservationError } = await serviceClient.rpc(
|
const expired = await expireEndedSubscriptions(serviceClient, now)
|
||||||
'reserve_payment_provider_operation',
|
const dueRenewals = await selectDueRenewals(serviceClient, now)
|
||||||
{
|
if (!dueRenewals.length) {
|
||||||
p_user_id: userId,
|
return jsonResponse({ renewed: 0, failed: 0, expired, results })
|
||||||
p_provider: 'payple',
|
}
|
||||||
p_operation_type: 'renewal',
|
|
||||||
p_requested_tier: tier,
|
const deps: RenewalDeps = {
|
||||||
p_idempotency_key: idempotencyKey,
|
store: createSupabaseRenewalStore(serviceClient),
|
||||||
p_provider_order_id: orderId,
|
gateway: await createPaypleRenewalGateway(getPaypleConfig()),
|
||||||
p_provider_resource_id: resourceId,
|
now: () => new Date(),
|
||||||
},
|
newOrderId: (userId) => generateOrderId(userId),
|
||||||
)
|
}
|
||||||
if (reservationError) throw new Error('renewal_reservation_failed')
|
|
||||||
const reservation = reservationData as OperationReservation | null
|
for (const row of dueRenewals) {
|
||||||
if (!reservation?.created || typeof reservation.operation_id !== 'string') {
|
const candidate = normalizeRenewalCandidate(row)
|
||||||
|
if (!candidate) {
|
||||||
results.push({
|
results.push({
|
||||||
userId,
|
userId: typeof row.user_id === 'string' ? row.user_id : '',
|
||||||
tier,
|
tier: String(row.tier),
|
||||||
success: false,
|
success: false,
|
||||||
error: reservation?.reason ?? 'renewal_operation_in_progress',
|
error: 'invalid_subscription',
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
operationId = reservation.operation_id
|
results.push(await renewSubscription(deps, candidate))
|
||||||
|
|
||||||
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,
|
|
||||||
tier,
|
|
||||||
success: false,
|
|
||||||
error: charged || chargeOutcomeUnknown
|
|
||||||
? 'renewal_requires_reconciliation'
|
|
||||||
: 'payple_renewal_failed',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
renewed: results.filter((result) => result.success).length,
|
renewed: results.filter((result) => result.success).length,
|
||||||
failed: results.filter((result) => !result.success).length,
|
failed: results.filter((result) => !result.success).length,
|
||||||
expired: endingSubscriptions?.length ?? 0,
|
expired,
|
||||||
results,
|
results,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -278,4 +142,6 @@ Deno.serve(async (req: Request) => {
|
||||||
}
|
}
|
||||||
return jsonResponse({ error: 'payple_renewal_processing_failed' }, 500)
|
return jsonResponse({ error: 'payple_renewal_processing_failed' }, 500)
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
|
if (import.meta.main) Deno.serve(paypleRenewHandler)
|
||||||
|
|
|
||||||
318
server/supabase/functions/payple-renew/renewal.test.ts
Normal file
318
server/supabase/functions/payple-renew/renewal.test.ts
Normal file
|
|
@ -0,0 +1,318 @@
|
||||||
|
import {
|
||||||
|
classifyLookupFailure,
|
||||||
|
decideReplay,
|
||||||
|
interpretLookup,
|
||||||
|
type LookupOutcome,
|
||||||
|
normalizeRenewalCandidate,
|
||||||
|
type RenewalCandidate,
|
||||||
|
type RenewalDeps,
|
||||||
|
type RenewalOperation,
|
||||||
|
type RenewalPaymentApplication,
|
||||||
|
type RenewalReservation,
|
||||||
|
renewalIdempotencyKey,
|
||||||
|
renewSubscription,
|
||||||
|
withTimeout,
|
||||||
|
} from './renewal.ts'
|
||||||
|
import { toRenewalReservation } from './adapters.ts'
|
||||||
|
import { PaypleBillingError, PaypleVerificationError, TIER_PRICE } from '../_shared/payple.ts'
|
||||||
|
|
||||||
|
function assert(condition: boolean, message: string): asserts condition {
|
||||||
|
if (!condition) throw new Error(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOW = new Date('2026-09-28T01:00:00.000Z')
|
||||||
|
const ORIGINAL_ORDER = 'D3RO-20260927100000-user0001-aaaaaaaa'
|
||||||
|
const NEW_ORDER = 'D3RO-20260928100000-user0001-bbbbbbbb'
|
||||||
|
|
||||||
|
const candidate: RenewalCandidate = {
|
||||||
|
userId: 'user0001-0000-0000-0000-000000000000',
|
||||||
|
tier: 'pro',
|
||||||
|
payerId: 'payer-1',
|
||||||
|
resourceId: 'payer-1',
|
||||||
|
currentPeriodEnd: '2026-09-27T00:00:00.000Z',
|
||||||
|
renewalFailures: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Harness {
|
||||||
|
deps: RenewalDeps
|
||||||
|
calls: {
|
||||||
|
reserved: string[]
|
||||||
|
charges: string[]
|
||||||
|
lookups: string[]
|
||||||
|
marks: Array<{ id: string; state: string }>
|
||||||
|
failures: string[]
|
||||||
|
applied: RenewalPaymentApplication[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function harness(options: {
|
||||||
|
reservation: RenewalReservation
|
||||||
|
operation?: RenewalOperation | null
|
||||||
|
lookup?: LookupOutcome
|
||||||
|
charge?: () => Promise<{ orderId: string; total: string; payerId?: string; payTime?: string }>
|
||||||
|
}): Harness {
|
||||||
|
const calls: Harness['calls'] = {
|
||||||
|
reserved: [],
|
||||||
|
charges: [],
|
||||||
|
lookups: [],
|
||||||
|
marks: [],
|
||||||
|
failures: [],
|
||||||
|
applied: [],
|
||||||
|
}
|
||||||
|
const deps: RenewalDeps = {
|
||||||
|
now: () => NOW,
|
||||||
|
newOrderId: () => NEW_ORDER,
|
||||||
|
store: {
|
||||||
|
reserveRenewal: (_c, key) => {
|
||||||
|
calls.reserved.push(key)
|
||||||
|
return Promise.resolve(options.reservation)
|
||||||
|
},
|
||||||
|
getOperation: () => Promise.resolve(options.operation ?? null),
|
||||||
|
markOperation: (id, state) => {
|
||||||
|
calls.marks.push({ id, state })
|
||||||
|
return Promise.resolve(true)
|
||||||
|
},
|
||||||
|
recordRenewalFailure: (_c, operationId) => {
|
||||||
|
calls.failures.push(operationId)
|
||||||
|
return Promise.resolve(true)
|
||||||
|
},
|
||||||
|
applyRenewalPayment: (application) => {
|
||||||
|
calls.applied.push(application)
|
||||||
|
return Promise.resolve({ applied: true, duplicate: false })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
gateway: {
|
||||||
|
charge: (request) => {
|
||||||
|
calls.charges.push(request.orderId)
|
||||||
|
return options.charge
|
||||||
|
? options.charge()
|
||||||
|
: Promise.resolve({ orderId: request.orderId, total: String(request.amount) })
|
||||||
|
},
|
||||||
|
lookup: (orderId) => {
|
||||||
|
calls.lookups.push(orderId)
|
||||||
|
return Promise.resolve(options.lookup ?? { kind: 'unknown' })
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return { deps, calls }
|
||||||
|
}
|
||||||
|
|
||||||
|
function parkedOperation(state: string, expiresAt: string): RenewalOperation {
|
||||||
|
return { id: 'op-parked', state, providerOrderId: ORIGINAL_ORDER, expiresAt }
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXPIRED = '2026-09-27T01:15:00.000Z'
|
||||||
|
const LIVE = '2026-09-28T01:10:00.000Z'
|
||||||
|
|
||||||
|
// ── 회귀: 결과 불명 작업이 영구히 건너뛰어지던 버그 ─────────────
|
||||||
|
|
||||||
|
Deno.test('replayed external_created renewal past its lease records a failure when Payple has no charge', async () => {
|
||||||
|
const { deps, calls } = harness({
|
||||||
|
reservation: { kind: 'replay', operationId: 'op-parked' },
|
||||||
|
operation: parkedOperation('external_created', EXPIRED),
|
||||||
|
lookup: { kind: 'not_charged' },
|
||||||
|
})
|
||||||
|
const result = await renewSubscription(deps, candidate)
|
||||||
|
assert(calls.lookups.length === 1 && calls.lookups[0] === ORIGINAL_ORDER, 'looks up the original order')
|
||||||
|
assert(calls.failures.length === 1 && calls.failures[0] === 'op-parked', 'counts a renewal failure')
|
||||||
|
assert(calls.charges.length === 0, 'never charges again inside a replay')
|
||||||
|
assert(calls.applied.length === 0, 'grants nothing')
|
||||||
|
assert(!result.success && result.error === 'payple_renewal_failed', 'reports the failure')
|
||||||
|
})
|
||||||
|
|
||||||
|
Deno.test('replayed reserved renewal killed mid-run applies the charge Payple confirms', async () => {
|
||||||
|
const { deps, calls } = harness({
|
||||||
|
reservation: { kind: 'replay', operationId: 'op-parked' },
|
||||||
|
operation: parkedOperation('reserved', EXPIRED),
|
||||||
|
lookup: {
|
||||||
|
kind: 'charged',
|
||||||
|
payment: {
|
||||||
|
orderId: ORIGINAL_ORDER,
|
||||||
|
total: String(TIER_PRICE.pro),
|
||||||
|
payerId: 'payer-1',
|
||||||
|
payTime: '20260927100005',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const result = await renewSubscription(deps, candidate)
|
||||||
|
assert(result.success && result.orderId === ORIGINAL_ORDER, 'renewal succeeds with the original order')
|
||||||
|
assert(calls.applied.length === 1, 'applies once')
|
||||||
|
assert(calls.applied[0].operationId === 'op-parked', 'closes the parked operation')
|
||||||
|
assert(calls.applied[0].orderId === ORIGINAL_ORDER, 'uses the charged order, not a fresh one')
|
||||||
|
assert(calls.failures.length === 0, 'no failure recorded')
|
||||||
|
})
|
||||||
|
|
||||||
|
Deno.test('replayed failed renewal is reconciled instead of skipped forever', async () => {
|
||||||
|
const { deps, calls } = harness({
|
||||||
|
reservation: { kind: 'replay', operationId: 'op-parked' },
|
||||||
|
operation: parkedOperation('failed', EXPIRED),
|
||||||
|
lookup: { kind: 'not_charged' },
|
||||||
|
})
|
||||||
|
const result = await renewSubscription(deps, candidate)
|
||||||
|
assert(calls.failures.length === 1, 'failure counted so the attempt key advances')
|
||||||
|
assert(result.error === 'payple_renewal_failed', 'reported')
|
||||||
|
})
|
||||||
|
|
||||||
|
Deno.test('replay with a live lease is left to the run that owns it', async () => {
|
||||||
|
const { deps, calls } = harness({
|
||||||
|
reservation: { kind: 'replay', operationId: 'op-parked' },
|
||||||
|
operation: parkedOperation('external_created', LIVE),
|
||||||
|
lookup: { kind: 'not_charged' },
|
||||||
|
})
|
||||||
|
const result = await renewSubscription(deps, candidate)
|
||||||
|
assert(calls.lookups.length === 0 && calls.failures.length === 0, 'untouched')
|
||||||
|
assert(result.error === 'renewal_operation_in_progress', 'in progress')
|
||||||
|
})
|
||||||
|
|
||||||
|
Deno.test('replay whose lookup is still inconclusive changes nothing', async () => {
|
||||||
|
const { deps, calls } = harness({
|
||||||
|
reservation: { kind: 'replay', operationId: 'op-parked' },
|
||||||
|
operation: parkedOperation('external_created', EXPIRED),
|
||||||
|
lookup: { kind: 'unknown' },
|
||||||
|
})
|
||||||
|
const result = await renewSubscription(deps, candidate)
|
||||||
|
assert(calls.failures.length === 0 && calls.applied.length === 0, 'no state change')
|
||||||
|
assert(result.error === 'renewal_requires_reconciliation', 'flagged')
|
||||||
|
})
|
||||||
|
|
||||||
|
Deno.test('replay confirmed with a wrong amount is not auto-applied', async () => {
|
||||||
|
const { deps, calls } = harness({
|
||||||
|
reservation: { kind: 'replay', operationId: 'op-parked' },
|
||||||
|
operation: parkedOperation('external_created', EXPIRED),
|
||||||
|
lookup: { kind: 'charged', payment: { orderId: ORIGINAL_ORDER, total: '1' } },
|
||||||
|
})
|
||||||
|
const result = await renewSubscription(deps, candidate)
|
||||||
|
assert(calls.applied.length === 0 && calls.failures.length === 0, 'left for manual review')
|
||||||
|
assert(result.error === 'renewal_requires_reconciliation', 'flagged')
|
||||||
|
})
|
||||||
|
|
||||||
|
Deno.test('replay of an applied operation is a no-op', async () => {
|
||||||
|
const { deps, calls } = harness({
|
||||||
|
reservation: { kind: 'replay', operationId: 'op-parked' },
|
||||||
|
operation: parkedOperation('applied', EXPIRED),
|
||||||
|
})
|
||||||
|
const result = await renewSubscription(deps, candidate)
|
||||||
|
assert(calls.lookups.length === 0 && calls.applied.length === 0, 'untouched')
|
||||||
|
assert(result.error === 'idempotent_replay', 'reported as replay')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 기존 동작 보존 ─────────────────────────────────────
|
||||||
|
|
||||||
|
Deno.test('fresh renewal charges and applies once', async () => {
|
||||||
|
const { deps, calls } = harness({ reservation: { kind: 'created', operationId: 'op-new' } })
|
||||||
|
const result = await renewSubscription(deps, candidate)
|
||||||
|
assert(result.success && result.orderId === NEW_ORDER, 'success')
|
||||||
|
assert(calls.reserved[0] === renewalIdempotencyKey(candidate), 'uses the attempt key')
|
||||||
|
assert(calls.marks.some((mark) => mark.state === 'charged'), 'marked charged')
|
||||||
|
assert(calls.applied.length === 1 && calls.applied[0].operationId === 'op-new', 'applied')
|
||||||
|
})
|
||||||
|
|
||||||
|
Deno.test('unknown charge outcome parks the operation without counting a failure', async () => {
|
||||||
|
const { deps, calls } = harness({
|
||||||
|
reservation: { kind: 'created', operationId: 'op-new' },
|
||||||
|
charge: () => Promise.reject(new PaypleBillingError('payple_billing_transport_unknown', false)),
|
||||||
|
})
|
||||||
|
const result = await renewSubscription(deps, candidate)
|
||||||
|
assert(calls.marks.length === 1 && calls.marks[0].state === 'external_created', 'parked')
|
||||||
|
assert(calls.failures.length === 0, 'not counted yet')
|
||||||
|
assert(result.error === 'renewal_requires_reconciliation', 'flagged')
|
||||||
|
})
|
||||||
|
|
||||||
|
Deno.test('definitive decline records a renewal failure', async () => {
|
||||||
|
const { deps, calls } = harness({
|
||||||
|
reservation: { kind: 'created', operationId: 'op-new' },
|
||||||
|
charge: () => Promise.reject(new PaypleBillingError('payple_billing_declined', true)),
|
||||||
|
})
|
||||||
|
const result = await renewSubscription(deps, candidate)
|
||||||
|
assert(calls.failures.length === 1 && calls.failures[0] === 'op-new', 'counted')
|
||||||
|
assert(result.error === 'payple_renewal_failed', 'reported')
|
||||||
|
})
|
||||||
|
|
||||||
|
Deno.test('rejected reservation reports its reason', async () => {
|
||||||
|
const { deps, calls } = harness({
|
||||||
|
reservation: { kind: 'rejected', reason: 'payment_operation_in_progress' },
|
||||||
|
})
|
||||||
|
const result = await renewSubscription(deps, candidate)
|
||||||
|
assert(calls.charges.length === 0, 'no charge')
|
||||||
|
assert(result.error === 'payment_operation_in_progress', 'reason passed through')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 순수 정책 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
Deno.test('reservation rows map replays to reconciliation', () => {
|
||||||
|
const replay = toRenewalReservation({
|
||||||
|
created: false, operation_id: 'op-1', state: 'external_created', reason: 'idempotent_replay',
|
||||||
|
})
|
||||||
|
assert(replay.kind === 'replay' && replay.operationId === 'op-1', 'replay')
|
||||||
|
const created = toRenewalReservation({ created: true, operation_id: 'op-2' })
|
||||||
|
assert(created.kind === 'created', 'created')
|
||||||
|
const rejected = toRenewalReservation({ created: false, reason: 'provider_not_owner' })
|
||||||
|
assert(rejected.kind === 'rejected' && rejected.reason === 'provider_not_owner', 'rejected')
|
||||||
|
const empty = toRenewalReservation(null)
|
||||||
|
assert(empty.kind === 'rejected' && empty.reason === 'renewal_operation_in_progress', 'default')
|
||||||
|
})
|
||||||
|
|
||||||
|
Deno.test('decideReplay only waits on live in-flight leases', () => {
|
||||||
|
const op = (state: string, expiresAt: string | null): RenewalOperation => ({
|
||||||
|
id: 'op', state, providerOrderId: ORIGINAL_ORDER, expiresAt,
|
||||||
|
})
|
||||||
|
assert(decideReplay(op('reserved', LIVE), NOW) === 'in_progress', 'live reserved')
|
||||||
|
assert(decideReplay(op('charged', LIVE), NOW) === 'in_progress', 'live charged')
|
||||||
|
assert(decideReplay(op('reserved', EXPIRED), NOW) === 'reconcile', 'expired reserved')
|
||||||
|
assert(decideReplay(op('external_created', null), NOW) === 'reconcile', 'no lease')
|
||||||
|
assert(decideReplay(op('failed', LIVE), NOW) === 'reconcile', 'failed')
|
||||||
|
assert(decideReplay(op('applied', EXPIRED), NOW) === 'already_applied', 'applied')
|
||||||
|
})
|
||||||
|
|
||||||
|
Deno.test('lookup classification treats only an unconfirmed transaction as not charged', () => {
|
||||||
|
assert(
|
||||||
|
classifyLookupFailure(new PaypleVerificationError('payple_lookup_mismatch')) === 'not_charged',
|
||||||
|
'mismatch',
|
||||||
|
)
|
||||||
|
assert(
|
||||||
|
classifyLookupFailure(new PaypleVerificationError('payple_lookup_http_502')) === 'unknown',
|
||||||
|
'http',
|
||||||
|
)
|
||||||
|
assert(classifyLookupFailure(new TypeError('network')) === 'unknown', 'transport')
|
||||||
|
assert(
|
||||||
|
interpretLookup({ PCD_PAY_OID: ORIGINAL_ORDER, PCD_PAY_STATE: '승인취소완료' }).kind === 'not_charged',
|
||||||
|
'canceled',
|
||||||
|
)
|
||||||
|
assert(interpretLookup({ PCD_PAY_OID: ORIGINAL_ORDER, PCD_PAY_TOTAL: '9900' }).kind === 'charged', 'paid')
|
||||||
|
})
|
||||||
|
|
||||||
|
Deno.test('attempt key advances only with recorded failures', () => {
|
||||||
|
const first = renewalIdempotencyKey(candidate)
|
||||||
|
assert(first === renewalIdempotencyKey({ ...candidate }), 'stable')
|
||||||
|
assert(first !== renewalIdempotencyKey({ ...candidate, renewalFailures: 1 }), 'advances')
|
||||||
|
assert(first.endsWith(':attempt1'), 'attempt number')
|
||||||
|
})
|
||||||
|
|
||||||
|
Deno.test('candidate normalization rejects incomplete rows', () => {
|
||||||
|
const row = {
|
||||||
|
user_id: candidate.userId,
|
||||||
|
tier: 'pro',
|
||||||
|
payple_payer_id: 'payer-1',
|
||||||
|
provider_resource_id: 'payer-1',
|
||||||
|
current_period_end: candidate.currentPeriodEnd,
|
||||||
|
renewal_failures: 2,
|
||||||
|
}
|
||||||
|
assert(normalizeRenewalCandidate(row)?.renewalFailures === 2, 'valid')
|
||||||
|
assert(normalizeRenewalCandidate({ ...row, tier: 'free' }) === null, 'free tier')
|
||||||
|
assert(normalizeRenewalCandidate({ ...row, payple_payer_id: null }) === null, 'no payer')
|
||||||
|
})
|
||||||
|
|
||||||
|
Deno.test('withTimeout turns a hung call into the supplied error', async () => {
|
||||||
|
let error: unknown
|
||||||
|
try {
|
||||||
|
await withTimeout(
|
||||||
|
new Promise<never>(() => {}),
|
||||||
|
5,
|
||||||
|
() => new PaypleBillingError('payple_billing_timeout_unknown', false),
|
||||||
|
)
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught
|
||||||
|
}
|
||||||
|
assert(error instanceof PaypleBillingError && !error.definitive, 'non-definitive timeout')
|
||||||
|
assert(await withTimeout(Promise.resolve(7), 1_000, () => new Error('x')) === 7, 'passes through')
|
||||||
|
})
|
||||||
377
server/supabase/functions/payple-renew/renewal.ts
Normal file
377
server/supabase/functions/payple-renew/renewal.ts
Normal file
|
|
@ -0,0 +1,377 @@
|
||||||
|
// server/supabase/functions/payple-renew/renewal.ts
|
||||||
|
// Payple 정기 갱신 유스케이스 — 순수 정책 + 포트(RenewalStore, RenewalPaymentGateway).
|
||||||
|
// IO(Supabase RPC, Payple HTTP)는 adapters.ts가 구현하고, 이 모듈은 결정만 내린다.
|
||||||
|
|
||||||
|
import {
|
||||||
|
calcSubscriptionPeriod,
|
||||||
|
parsePaypleTimestamp,
|
||||||
|
PaypleBillingError,
|
||||||
|
PaypleVerificationError,
|
||||||
|
TIER_GOODS_NAME,
|
||||||
|
TIER_PRICE,
|
||||||
|
} from '../_shared/payple.ts'
|
||||||
|
|
||||||
|
export const MAX_RENEWAL_FAILURES = 3
|
||||||
|
/** Payple 빌링 호출 상한. 멈춘 호출이 크론 전체 실행 시간을 잡아먹지 않게 한다. */
|
||||||
|
export const BILLING_TIMEOUT_MS = 30_000
|
||||||
|
/** Payple 결과조회(PayChkAct) 호출 상한. */
|
||||||
|
export const LOOKUP_TIMEOUT_MS = 30_000
|
||||||
|
|
||||||
|
// ── 도메인 타입 ─────────────────────────────────────────
|
||||||
|
|
||||||
|
export type RenewableTier = 'pro' | 'pro_plus'
|
||||||
|
|
||||||
|
export interface RenewalCandidate {
|
||||||
|
userId: string
|
||||||
|
tier: RenewableTier
|
||||||
|
payerId: string
|
||||||
|
resourceId: string
|
||||||
|
currentPeriodEnd: string
|
||||||
|
renewalFailures: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenewalResult {
|
||||||
|
userId: string
|
||||||
|
tier: string
|
||||||
|
success: boolean
|
||||||
|
orderId?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RenewalOperationState =
|
||||||
|
| 'reserved'
|
||||||
|
| 'external_created'
|
||||||
|
| 'charged'
|
||||||
|
| 'applied'
|
||||||
|
| 'failed'
|
||||||
|
|
||||||
|
export interface RenewalOperation {
|
||||||
|
id: string
|
||||||
|
state: string
|
||||||
|
providerOrderId: string | null
|
||||||
|
expiresAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RenewalReservation =
|
||||||
|
| { kind: 'created'; operationId: string }
|
||||||
|
| { kind: 'replay'; operationId: string }
|
||||||
|
| { kind: 'rejected'; reason: string }
|
||||||
|
|
||||||
|
export interface RenewalPaymentApplication {
|
||||||
|
candidate: RenewalCandidate
|
||||||
|
orderId: string
|
||||||
|
operationId: string
|
||||||
|
eventTime: Date
|
||||||
|
periodStart: string
|
||||||
|
periodEnd: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChargeRequest {
|
||||||
|
payerId: string
|
||||||
|
amount: number
|
||||||
|
orderId: string
|
||||||
|
goodsName: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChargeReceipt {
|
||||||
|
orderId: string
|
||||||
|
total: string
|
||||||
|
payerId?: string
|
||||||
|
payTime?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LookupOutcome =
|
||||||
|
| { kind: 'charged'; payment: ChargeReceipt }
|
||||||
|
| { kind: 'not_charged' }
|
||||||
|
| { kind: 'unknown' }
|
||||||
|
|
||||||
|
// ── 포트 ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** 결제 운영 원장 + 구독 엔타이틀먼트 저장소 포트. */
|
||||||
|
export interface RenewalStore {
|
||||||
|
reserveRenewal(
|
||||||
|
candidate: RenewalCandidate,
|
||||||
|
idempotencyKey: string,
|
||||||
|
orderId: string,
|
||||||
|
): Promise<RenewalReservation>
|
||||||
|
getOperation(operationId: string): Promise<RenewalOperation | null>
|
||||||
|
/** false면 저장소가 상태 전이를 거부했거나 실패했다. */
|
||||||
|
markOperation(
|
||||||
|
operationId: string,
|
||||||
|
state: 'external_created' | 'charged' | 'failed',
|
||||||
|
externalReference: string | null,
|
||||||
|
errorCode: string | null,
|
||||||
|
): Promise<boolean>
|
||||||
|
/** 갱신 실패를 한 번 기록한다(실패 카운터 증가, 3회 누적 시 회수). false면 기록되지 않았다. */
|
||||||
|
recordRenewalFailure(
|
||||||
|
candidate: RenewalCandidate,
|
||||||
|
operationId: string,
|
||||||
|
at: Date,
|
||||||
|
): Promise<boolean>
|
||||||
|
applyRenewalPayment(
|
||||||
|
application: RenewalPaymentApplication,
|
||||||
|
): Promise<{ applied: boolean; duplicate: boolean }>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Payple 빌링/결과조회 포트. */
|
||||||
|
export interface RenewalPaymentGateway {
|
||||||
|
/** 결과가 불명확하면 PaypleBillingError(definitive=false)를 던진다. */
|
||||||
|
charge(request: ChargeRequest): Promise<ChargeReceipt>
|
||||||
|
lookup(orderId: string): Promise<LookupOutcome>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenewalDeps {
|
||||||
|
store: RenewalStore
|
||||||
|
gateway: RenewalPaymentGateway
|
||||||
|
now: () => Date
|
||||||
|
newOrderId: (userId: string) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 순수 정책 ──────────────────────────────────────────
|
||||||
|
|
||||||
|
export function normalizeRenewalCandidate(row: Record<string, unknown>): RenewalCandidate | null {
|
||||||
|
const userId = typeof row.user_id === 'string' ? row.user_id : ''
|
||||||
|
const tier = row.tier === 'pro' || row.tier === 'pro_plus' ? row.tier : null
|
||||||
|
const payerId = typeof row.payple_payer_id === 'string' ? row.payple_payer_id : null
|
||||||
|
const resourceId = typeof row.provider_resource_id === 'string' ? row.provider_resource_id : null
|
||||||
|
const currentPeriodEnd = typeof row.current_period_end === 'string' ? row.current_period_end : null
|
||||||
|
if (!userId || !tier || !payerId || !resourceId || !currentPeriodEnd) return null
|
||||||
|
return {
|
||||||
|
userId,
|
||||||
|
tier,
|
||||||
|
payerId,
|
||||||
|
resourceId,
|
||||||
|
currentPeriodEnd,
|
||||||
|
renewalFailures: Number(row.renewal_failures ?? 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 같은 기간·같은 시도 번호면 같은 키가 나온다. 시도 번호는 기록된 실패 횟수로만
|
||||||
|
* 전진하므로, 결과 불명 작업은 재조정(reconcileReplay)으로 반드시 결론을 내야 한다.
|
||||||
|
*/
|
||||||
|
export function renewalIdempotencyKey(candidate: RenewalCandidate): string {
|
||||||
|
const attempt = candidate.renewalFailures + 1
|
||||||
|
const periodKey = new Date(candidate.currentPeriodEnd).getTime()
|
||||||
|
return `payple-renew:${candidate.userId}:${periodKey}:attempt${attempt}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const IN_FLIGHT_STATES: ReadonlySet<string> = new Set(['reserved', 'external_created', 'charged'])
|
||||||
|
|
||||||
|
export type ReplayDecision = 'already_applied' | 'in_progress' | 'reconcile'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 멱등 재생된 작업을 어떻게 다룰지 결정한다.
|
||||||
|
* - applied: 이미 반영됨 → 건드리지 않는다.
|
||||||
|
* - 진행 중 상태 + 리스 유효: 다른 실행이 처리 중일 수 있다 → 건너뛴다.
|
||||||
|
* - 그 외(리스 만료, failed): Payple 결과조회로 결론을 낸다.
|
||||||
|
*/
|
||||||
|
export function decideReplay(operation: RenewalOperation, now: Date): ReplayDecision {
|
||||||
|
if (operation.state === 'applied') return 'already_applied'
|
||||||
|
if (IN_FLIGHT_STATES.has(operation.state)) {
|
||||||
|
const expiresAt = operation.expiresAt ? new Date(operation.expiresAt).getTime() : Number.NaN
|
||||||
|
if (Number.isFinite(expiresAt) && expiresAt > now.getTime()) return 'in_progress'
|
||||||
|
}
|
||||||
|
return 'reconcile'
|
||||||
|
}
|
||||||
|
|
||||||
|
const CANCELED_PAY_STATES: ReadonlySet<string> = new Set(['승인취소완료', 'canceled'])
|
||||||
|
|
||||||
|
/** 결과조회 성공 응답을 결론으로 바꾼다. 취소된 거래는 청구되지 않은 것으로 본다. */
|
||||||
|
export function interpretLookup(result: {
|
||||||
|
PCD_PAY_OID: string
|
||||||
|
PCD_PAY_TOTAL?: string
|
||||||
|
PCD_PAYER_ID?: string
|
||||||
|
PCD_PAY_TIME?: string
|
||||||
|
PCD_PAY_STATE?: string
|
||||||
|
}): LookupOutcome {
|
||||||
|
if (result.PCD_PAY_STATE && CANCELED_PAY_STATES.has(result.PCD_PAY_STATE)) {
|
||||||
|
return { kind: 'not_charged' }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: 'charged',
|
||||||
|
payment: {
|
||||||
|
orderId: result.PCD_PAY_OID,
|
||||||
|
total: result.PCD_PAY_TOTAL ?? '',
|
||||||
|
payerId: result.PCD_PAYER_ID,
|
||||||
|
payTime: result.PCD_PAY_TIME,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 결과조회 실패를 분류한다. Payple이 거래를 성공으로 확인해 주지 않은 경우
|
||||||
|
* (payple_lookup_mismatch)만 "청구 없음"으로 확정한다 — 웹훅도 같은 결과로는
|
||||||
|
* 엔타이틀먼트를 주지 않는다. 전송/HTTP/파싱 오류는 다음 실행에서 다시 본다.
|
||||||
|
*/
|
||||||
|
export function classifyLookupFailure(error: unknown): 'not_charged' | 'unknown' {
|
||||||
|
if (error instanceof PaypleVerificationError && error.code === 'payple_lookup_mismatch') {
|
||||||
|
return 'not_charged'
|
||||||
|
}
|
||||||
|
return 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function receiptMatchesCandidate(
|
||||||
|
receipt: ChargeReceipt,
|
||||||
|
orderId: string,
|
||||||
|
candidate: RenewalCandidate,
|
||||||
|
): boolean {
|
||||||
|
return receipt.orderId === orderId
|
||||||
|
&& receipt.total === String(TIER_PRICE[candidate.tier])
|
||||||
|
&& (!receipt.payerId || receipt.payerId === candidate.payerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function withTimeout<T>(
|
||||||
|
task: Promise<T>,
|
||||||
|
timeoutMs: number,
|
||||||
|
onTimeout: () => Error,
|
||||||
|
): Promise<T> {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
const timeout = new Promise<never>((_, reject) => {
|
||||||
|
timer = setTimeout(() => reject(onTimeout()), timeoutMs)
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
return await Promise.race([task, timeout])
|
||||||
|
} finally {
|
||||||
|
if (timer !== undefined) clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 유스케이스 ─────────────────────────────────────────
|
||||||
|
|
||||||
|
function buildApplication(
|
||||||
|
candidate: RenewalCandidate,
|
||||||
|
orderId: string,
|
||||||
|
operationId: string,
|
||||||
|
receipt: ChargeReceipt,
|
||||||
|
now: Date,
|
||||||
|
): RenewalPaymentApplication {
|
||||||
|
const eventTime = receipt.payTime ? parsePaypleTimestamp(receipt.payTime) : now
|
||||||
|
const { start, end } = calcSubscriptionPeriod(eventTime)
|
||||||
|
return { candidate, orderId, operationId, eventTime, periodStart: start, periodEnd: end }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyConfirmedCharge(
|
||||||
|
deps: RenewalDeps,
|
||||||
|
candidate: RenewalCandidate,
|
||||||
|
orderId: string,
|
||||||
|
operationId: string,
|
||||||
|
receipt: ChargeReceipt,
|
||||||
|
): Promise<RenewalResult> {
|
||||||
|
const applied = await deps.store.applyRenewalPayment(
|
||||||
|
buildApplication(candidate, orderId, operationId, receipt, deps.now()),
|
||||||
|
)
|
||||||
|
if (!applied.applied && !applied.duplicate) {
|
||||||
|
throw new Error('renewal_requires_reconciliation')
|
||||||
|
}
|
||||||
|
return { userId: candidate.userId, tier: candidate.tier, success: true, orderId }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 멱등 재생된 갱신 작업에 결론을 낸다. 결과가 불명확한 채로 남은 작업
|
||||||
|
* (external_created/reserved 리스 만료, failed)은 Payple 결과조회로 확인해
|
||||||
|
* 청구됐으면 반영하고, 청구되지 않았으면 실패를 기록해 시도 번호를 전진시킨다.
|
||||||
|
*/
|
||||||
|
export async function reconcileReplay(
|
||||||
|
deps: RenewalDeps,
|
||||||
|
candidate: RenewalCandidate,
|
||||||
|
operationId: string,
|
||||||
|
): Promise<RenewalResult> {
|
||||||
|
const base = { userId: candidate.userId, tier: candidate.tier }
|
||||||
|
const operation = await deps.store.getOperation(operationId)
|
||||||
|
if (!operation) return { ...base, success: false, error: 'renewal_requires_reconciliation' }
|
||||||
|
|
||||||
|
const decision = decideReplay(operation, deps.now())
|
||||||
|
if (decision === 'already_applied') return { ...base, success: false, error: 'idempotent_replay' }
|
||||||
|
if (decision === 'in_progress') {
|
||||||
|
return { ...base, success: false, error: 'renewal_operation_in_progress' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderId = operation.providerOrderId
|
||||||
|
const outcome: LookupOutcome = orderId
|
||||||
|
? await deps.gateway.lookup(orderId)
|
||||||
|
: { kind: 'not_charged' }
|
||||||
|
|
||||||
|
if (outcome.kind === 'unknown') {
|
||||||
|
return { ...base, success: false, error: 'renewal_requires_reconciliation' }
|
||||||
|
}
|
||||||
|
if (outcome.kind === 'not_charged') {
|
||||||
|
const recorded = await deps.store.recordRenewalFailure(candidate, operation.id, deps.now())
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
success: false,
|
||||||
|
error: recorded ? 'payple_renewal_failed' : 'renewal_requires_reconciliation',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 청구가 확인됐다. 금액·주문·결제자가 어긋나면 자동 반영하지 않는다.
|
||||||
|
if (!orderId || !receiptMatchesCandidate(outcome.payment, orderId, candidate)) {
|
||||||
|
return { ...base, success: false, error: 'renewal_requires_reconciliation' }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await applyConfirmedCharge(deps, candidate, orderId, operation.id, outcome.payment)
|
||||||
|
} catch {
|
||||||
|
return { ...base, success: false, error: 'renewal_requires_reconciliation' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 구독 한 건을 갱신한다. 새 작업이면 청구하고, 재생된 작업이면 재조정한다. */
|
||||||
|
export async function renewSubscription(
|
||||||
|
deps: RenewalDeps,
|
||||||
|
candidate: RenewalCandidate,
|
||||||
|
): Promise<RenewalResult> {
|
||||||
|
const { userId, tier, payerId } = candidate
|
||||||
|
const idempotencyKey = renewalIdempotencyKey(candidate)
|
||||||
|
const orderId = deps.newOrderId(userId)
|
||||||
|
let operationId: string | null = null
|
||||||
|
let charged = false
|
||||||
|
try {
|
||||||
|
const reservation = await deps.store.reserveRenewal(candidate, idempotencyKey, orderId)
|
||||||
|
if (reservation.kind === 'replay') {
|
||||||
|
return await reconcileReplay(deps, candidate, reservation.operationId)
|
||||||
|
}
|
||||||
|
if (reservation.kind === 'rejected') {
|
||||||
|
return { userId, tier, success: false, error: reservation.reason }
|
||||||
|
}
|
||||||
|
operationId = reservation.operationId
|
||||||
|
|
||||||
|
const receipt = await deps.gateway.charge({
|
||||||
|
payerId,
|
||||||
|
amount: TIER_PRICE[tier],
|
||||||
|
orderId,
|
||||||
|
goodsName: TIER_GOODS_NAME[tier],
|
||||||
|
})
|
||||||
|
charged = true
|
||||||
|
const marked = await deps.store.markOperation(
|
||||||
|
operationId,
|
||||||
|
'charged',
|
||||||
|
receipt.orderId || orderId,
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
if (!marked) throw new Error('renewal_operation_update_failed')
|
||||||
|
if (!receiptMatchesCandidate(receipt, orderId, candidate)) {
|
||||||
|
throw new Error('renewal_response_mismatch')
|
||||||
|
}
|
||||||
|
return await applyConfirmedCharge(deps, candidate, orderId, operationId, receipt)
|
||||||
|
} catch (error) {
|
||||||
|
const chargeOutcomeUnknown = error instanceof PaypleBillingError && !error.definitive
|
||||||
|
if (operationId && !charged) {
|
||||||
|
if (chargeOutcomeUnknown) {
|
||||||
|
// 결과 불명: 실패로 세지 않고 남겨 둔다. 다음 실행에서 멱등 재생 → reconcileReplay가 결론을 낸다.
|
||||||
|
await deps.store.markOperation(operationId, 'external_created', orderId, null)
|
||||||
|
} else if (error instanceof PaypleBillingError && error.definitive) {
|
||||||
|
await deps.store.recordRenewalFailure(candidate, operationId, deps.now())
|
||||||
|
} else {
|
||||||
|
await deps.store.markOperation(operationId, 'failed', null, 'payple_renewal_failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
userId,
|
||||||
|
tier,
|
||||||
|
success: false,
|
||||||
|
error: charged || chargeOutcomeUnknown
|
||||||
|
? 'renewal_requires_reconciliation'
|
||||||
|
: 'payple_renewal_failed',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue