// 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 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 { 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 | 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 { const billingAuth = await paypleAuth(config, { simpleFlag: true }) let lookupAuth: PaypleAuthResult | null = null return { async charge(request: ChargeRequest): Promise { 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 { 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) } } }, } }