185 lines
6.6 KiB
TypeScript
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) }
|
|
}
|
|
},
|
|
}
|
|
}
|