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) }
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue