feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
|
|
@ -1,27 +1,40 @@
|
|||
// server/supabase/functions/payple-checkout/index.ts
|
||||
// Payple 빌링키 결제 처리 — 웹 결제 페이지에서 카드 등록 후 호출.
|
||||
// 1) 클라이언트가 Payple JS SDK로 카드 등록 → PCD_PAYER_ID(빌링키) 획득
|
||||
// 2) 이 함수에 payer_id + tier 전달 → 파트너 인증 → 빌링 결제 → DB 업데이트
|
||||
// verify_jwt = false (requireUser로 직접 인증)
|
||||
|
||||
import { corsHeaders, handleCorsPreflightRequest } from '../_shared/cors.ts'
|
||||
import { requireUser, authErrorResponse, type AuthError } from '../_shared/auth.ts'
|
||||
import { createServiceRoleClient } from '../_shared/quota.ts'
|
||||
import {
|
||||
calcSubscriptionPeriod,
|
||||
generateOrderId,
|
||||
getPaypleConfig,
|
||||
parsePaypleTimestamp,
|
||||
paypleAuth,
|
||||
paypleBilling,
|
||||
generateOrderId,
|
||||
calcSubscriptionPeriod,
|
||||
TIER_PRICE,
|
||||
PaypleBillingError,
|
||||
paypleLookupBillingKey,
|
||||
payplePaymentEventDigest,
|
||||
payplePaymentEventId,
|
||||
payplePayerNumber,
|
||||
PaypleConfigurationError,
|
||||
TIER_GOODS_NAME,
|
||||
TIER_PRICE,
|
||||
} from '../_shared/payple.ts'
|
||||
|
||||
interface CheckoutRequest {
|
||||
payer_id: string // PCD_PAYER_ID (빌링키)
|
||||
tier: 'pro' | 'pro_plus'
|
||||
pcd_pay_cardname?: string // 카드사명 (표시용)
|
||||
pcd_pay_cardnum?: string // 카드번호 마스킹 (표시용)
|
||||
payer_id?: unknown
|
||||
tier?: unknown
|
||||
idempotency_key?: unknown
|
||||
}
|
||||
|
||||
interface OperationReservation {
|
||||
created?: boolean
|
||||
operation_id?: string
|
||||
state?: string
|
||||
reason?: string
|
||||
}
|
||||
|
||||
interface ApplyResult {
|
||||
applied?: boolean
|
||||
duplicate?: boolean
|
||||
reason?: string
|
||||
}
|
||||
|
||||
function jsonResponse(body: Record<string, unknown>, status = 200): Response {
|
||||
|
|
@ -31,83 +44,181 @@ function jsonResponse(body: Record<string, unknown>, status = 200): Response {
|
|||
})
|
||||
}
|
||||
|
||||
// @ts-expect-error — Deno 런타임 전역
|
||||
function validIdempotencyKey(value: unknown): value is string {
|
||||
return typeof value === 'string' && /^[A-Za-z0-9._:-]{12,160}$/.test(value)
|
||||
}
|
||||
|
||||
Deno.serve(async (req: Request) => {
|
||||
const preflight = handleCorsPreflightRequest(req)
|
||||
if (preflight) return preflight
|
||||
if (req.method !== 'POST') return jsonResponse({ error: 'method_not_allowed' }, 405)
|
||||
|
||||
if (req.method !== 'POST') {
|
||||
return jsonResponse({ error: 'Method not allowed' }, 405)
|
||||
}
|
||||
let operationId: string | null = null
|
||||
let providerOrderId: string | null = null
|
||||
let externalChargeCompleted = false
|
||||
const serviceClient = createServiceRoleClient()
|
||||
|
||||
try {
|
||||
const user = await requireUser(req)
|
||||
const body = (await req.json()) as CheckoutRequest
|
||||
|
||||
if (!body.payer_id || !body.tier) {
|
||||
return jsonResponse({ error: 'payer_id and tier are required' }, 400)
|
||||
const body = await req.json() as CheckoutRequest
|
||||
if (
|
||||
typeof body.payer_id !== 'string'
|
||||
|| body.payer_id.length < 8
|
||||
|| body.payer_id.length > 255
|
||||
|| (body.tier !== 'pro' && body.tier !== 'pro_plus')
|
||||
|| (body.idempotency_key !== undefined && !validIdempotencyKey(body.idempotency_key))
|
||||
) {
|
||||
return jsonResponse({ error: 'invalid_request' }, 400)
|
||||
}
|
||||
|
||||
if (body.tier !== 'pro' && body.tier !== 'pro_plus') {
|
||||
return jsonResponse({ error: 'Invalid tier. Must be pro or pro_plus' }, 400)
|
||||
// Configuration is validated before reserving state or contacting Payple.
|
||||
// There are deliberately no bundled/test credential fallbacks.
|
||||
const config = getPaypleConfig()
|
||||
const idempotencyKey = body.idempotency_key
|
||||
?? `payple-checkout:${crypto.randomUUID()}`
|
||||
const orderId = generateOrderId(user.id)
|
||||
providerOrderId = orderId
|
||||
const { data: reservationData, error: reservationError } = await serviceClient.rpc(
|
||||
'reserve_payment_provider_operation',
|
||||
{
|
||||
p_user_id: user.id,
|
||||
p_provider: 'payple',
|
||||
p_operation_type: 'checkout',
|
||||
p_requested_tier: body.tier,
|
||||
p_idempotency_key: idempotencyKey,
|
||||
p_provider_order_id: orderId,
|
||||
// Bind the reserved order to this billing key before any charge. The
|
||||
// PUSERINFO lookup below still verifies its authenticated user owner.
|
||||
p_provider_resource_id: body.payer_id,
|
||||
},
|
||||
)
|
||||
if (reservationError) throw new Error('payment_reservation_failed')
|
||||
const reservation = reservationData as OperationReservation | null
|
||||
if (!reservation?.created || typeof reservation.operation_id !== 'string') {
|
||||
return jsonResponse({
|
||||
error: reservation?.reason ?? 'payment_operation_in_progress',
|
||||
state: reservation?.state ?? 'rejected',
|
||||
}, 409)
|
||||
}
|
||||
operationId = reservation.operation_id
|
||||
|
||||
const price = TIER_PRICE[body.tier]
|
||||
const goodsName = TIER_GOODS_NAME[body.tier]
|
||||
|
||||
if (!price || !goodsName) {
|
||||
return jsonResponse({ error: 'Unknown tier' }, 400)
|
||||
const billingKeyAuth = await paypleAuth(config, { payWork: 'PUSERINFO' })
|
||||
const billingKey = await paypleLookupBillingKey(config, billingKeyAuth, body.payer_id)
|
||||
const expectedPayerNumbers = [user.id, await payplePayerNumber(user.id)]
|
||||
if (
|
||||
billingKey.PCD_PAY_RST !== 'success'
|
||||
|| billingKey.PCD_PAYER_ID !== body.payer_id
|
||||
|| !billingKey.PCD_PAYER_NO
|
||||
|| !expectedPayerNumbers.includes(billingKey.PCD_PAYER_NO)
|
||||
) {
|
||||
throw new Error('payple_billing_key_owner_mismatch')
|
||||
}
|
||||
|
||||
// 1. Payple 파트너 인증 (simple flag — 빌링 결제용)
|
||||
const config = getPaypleConfig()
|
||||
const auth = await paypleAuth(config, { simpleFlag: true })
|
||||
|
||||
// 2. 빌링키로 첫 결제 실행
|
||||
const orderId = generateOrderId(user.id)
|
||||
const billingResult = await paypleBilling(config, auth, {
|
||||
payerId: body.payer_id,
|
||||
amount: price,
|
||||
orderId,
|
||||
goodsName,
|
||||
})
|
||||
externalChargeCompleted = true
|
||||
|
||||
// 3. 결제 성공 → DB 업데이트
|
||||
const { start, end } = calcSubscriptionPeriod()
|
||||
const serviceClient = createServiceRoleClient()
|
||||
const { error: chargedError } = await serviceClient.rpc('mark_payment_provider_operation', {
|
||||
p_operation_id: operationId,
|
||||
p_state: 'charged',
|
||||
p_external_reference: billingResult.PCD_PAY_OID || orderId,
|
||||
p_error_code: null,
|
||||
})
|
||||
if (chargedError) throw new Error('payment_operation_update_failed')
|
||||
|
||||
await serviceClient
|
||||
.from('subscriptions')
|
||||
.update({
|
||||
tier: body.tier,
|
||||
status: 'active',
|
||||
payment_provider: 'payple',
|
||||
payple_payer_id: body.payer_id,
|
||||
payple_pay_oid: billingResult.PCD_PAY_OID || orderId,
|
||||
current_period_start: start,
|
||||
current_period_end: end,
|
||||
cancel_at: null,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('user_id', user.id)
|
||||
if (
|
||||
billingResult.PCD_PAY_OID !== orderId
|
||||
|| billingResult.PCD_PAY_TOTAL !== String(price)
|
||||
|| (billingResult.PCD_PAYER_ID && billingResult.PCD_PAYER_ID !== body.payer_id)
|
||||
) {
|
||||
throw new Error('payple_charge_response_mismatch')
|
||||
}
|
||||
|
||||
// profiles.tier도 동기화
|
||||
await serviceClient
|
||||
.from('profiles')
|
||||
.update({ tier: body.tier, updated_at: new Date().toISOString() })
|
||||
.eq('id', user.id)
|
||||
const eventDate = billingResult.PCD_PAY_TIME
|
||||
? parsePaypleTimestamp(billingResult.PCD_PAY_TIME)
|
||||
: new Date()
|
||||
const { start, end } = calcSubscriptionPeriod(eventDate)
|
||||
|
||||
const { data: applyData, error: applyError } = await serviceClient.rpc(
|
||||
'apply_payment_provider_event',
|
||||
{
|
||||
p_user_id: user.id,
|
||||
p_provider: 'payple',
|
||||
p_event_id: payplePaymentEventId(orderId),
|
||||
p_event_created_at: eventDate.toISOString(),
|
||||
p_event_type: 'payment.completed',
|
||||
p_payload_digest: await payplePaymentEventDigest({
|
||||
orderId,
|
||||
payerId: body.payer_id,
|
||||
payType: 'card',
|
||||
amount: price,
|
||||
}),
|
||||
p_provider_resource_id: body.payer_id,
|
||||
p_tier: body.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: body.payer_id,
|
||||
p_provider_order_id: orderId,
|
||||
p_store_product_id: null,
|
||||
p_store_purchase_id: null,
|
||||
p_operation_id: operationId,
|
||||
},
|
||||
)
|
||||
if (applyError) throw new Error('entitlement_persistence_failed')
|
||||
const applied = applyData as ApplyResult | null
|
||||
if (!applied?.applied && !applied?.duplicate) {
|
||||
return jsonResponse({
|
||||
error: 'payment_requires_reconciliation',
|
||||
reason: applied?.reason ?? 'entitlement_not_applied',
|
||||
order_id: orderId,
|
||||
}, 409)
|
||||
}
|
||||
|
||||
return jsonResponse({
|
||||
success: true,
|
||||
tier: body.tier,
|
||||
order_id: billingResult.PCD_PAY_OID || orderId,
|
||||
order_id: orderId,
|
||||
amount: price,
|
||||
})
|
||||
} catch (err) {
|
||||
if (err && typeof err === 'object' && 'status' in err && 'message' in err) {
|
||||
return authErrorResponse(err as AuthError, corsHeaders)
|
||||
} catch (error) {
|
||||
const chargeOutcomeUnknown = error instanceof PaypleBillingError && !error.definitive
|
||||
if (operationId && chargeOutcomeUnknown) {
|
||||
await serviceClient.rpc('mark_payment_provider_operation', {
|
||||
p_operation_id: operationId,
|
||||
p_state: 'external_created',
|
||||
p_external_reference: providerOrderId,
|
||||
p_error_code: null,
|
||||
})
|
||||
} else if (operationId && !externalChargeCompleted) {
|
||||
await serviceClient.rpc('mark_payment_provider_operation', {
|
||||
p_operation_id: operationId,
|
||||
p_state: 'failed',
|
||||
p_external_reference: null,
|
||||
p_error_code: error instanceof PaypleConfigurationError
|
||||
? 'payple_not_configured'
|
||||
: 'payple_checkout_failed',
|
||||
})
|
||||
}
|
||||
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||
return jsonResponse({ error: message }, 500)
|
||||
if (error && typeof error === 'object' && 'status' in error && 'message' in error) {
|
||||
return authErrorResponse(error as AuthError, corsHeaders)
|
||||
}
|
||||
if (error instanceof PaypleConfigurationError) {
|
||||
return jsonResponse({ error: error.code }, 503)
|
||||
}
|
||||
return jsonResponse({
|
||||
error: externalChargeCompleted || chargeOutcomeUnknown
|
||||
? 'payment_requires_reconciliation'
|
||||
: 'payple_checkout_failed',
|
||||
}, externalChargeCompleted || chargeOutcomeUnknown ? 409 : 502)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue