d3ro-voice/server/supabase/functions/payple-renew/adapters.ts

185 lines
6.6 KiB
TypeScript

// 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) }
}
},
}
}