fix(payple): reject checkout while a paid Payple period is still active

This commit is contained in:
Yun Chan 2026-09-28 02:16:21 +09:00
parent f456d737c4
commit e69fe0335d
5 changed files with 717 additions and 71 deletions

View file

@ -0,0 +1,172 @@
import {
chargeMatchesOrder,
decideReservation,
isBillingKeyOwnedBy,
isValidIdempotencyKey,
parseCheckoutRequest,
planCheckoutFailure,
} from './checkout-policy.ts'
function assert(condition: boolean, message: string): asserts condition {
if (!condition) throw new Error(message)
}
function assertEquals(actual: unknown, expected: unknown, message: string): void {
const a = JSON.stringify(actual)
const e = JSON.stringify(expected)
if (a !== e) throw new Error(`${message}: expected ${e}, got ${a}`)
}
Deno.test('parseCheckoutRequest accepts a valid request with or without idempotency key', () => {
assertEquals(
parseCheckoutRequest({ payer_id: 'payer-12345', tier: 'pro' }),
{ payerId: 'payer-12345', tier: 'pro', idempotencyKey: undefined },
'without key',
)
assertEquals(
parseCheckoutRequest({
payer_id: 'payer-12345',
tier: 'pro_plus',
idempotency_key: 'payple-checkout:abc-123',
}),
{ payerId: 'payer-12345', tier: 'pro_plus', idempotencyKey: 'payple-checkout:abc-123' },
'with key',
)
})
Deno.test('parseCheckoutRequest rejects malformed bodies', () => {
const invalid: unknown[] = [
null,
'string',
{},
{ payer_id: 'short', tier: 'pro' },
{ payer_id: 'x'.repeat(256), tier: 'pro' },
{ payer_id: 'payer-12345', tier: 'free' },
{ payer_id: 12345678, tier: 'pro' },
{ payer_id: 'payer-12345', tier: 'pro', idempotency_key: 'short' },
{ payer_id: 'payer-12345', tier: 'pro', idempotency_key: 'has spaces in it!' },
{ payer_id: 'payer-12345', tier: 'pro', idempotency_key: null },
]
for (const body of invalid) {
assert(parseCheckoutRequest(body) === null, `must reject ${JSON.stringify(body)}`)
}
})
Deno.test('isValidIdempotencyKey enforces the database key format', () => {
assert(isValidIdempotencyKey('payple-checkout:0123'), 'valid key')
assert(!isValidIdempotencyKey('a'.repeat(11)), 'too short')
assert(!isValidIdempotencyKey('a'.repeat(161)), 'too long')
assert(!isValidIdempotencyKey(42), 'not a string')
})
Deno.test('decideReservation proceeds only for a freshly created operation', () => {
assertEquals(
decideReservation({ created: true, operation_id: 'op-1', state: 'reserved' }),
{ kind: 'proceed', operationId: 'op-1' },
'created',
)
assertEquals(
decideReservation({ created: false, operation_id: 'op-1', state: 'applied', reason: 'idempotent_replay' }),
{ kind: 'reject', status: 409, body: { error: 'idempotent_replay', state: 'applied' } },
'replay never charges again',
)
assertEquals(
decideReservation(null),
{ kind: 'reject', status: 409, body: { error: 'payment_operation_in_progress', state: 'rejected' } },
'missing reservation',
)
assertEquals(
decideReservation({ created: true }),
{ kind: 'reject', status: 409, body: { error: 'payment_operation_in_progress', state: 'rejected' } },
'created without operation id',
)
})
Deno.test('decideReservation surfaces an active paid period without charging (stale-tab double checkout)', () => {
const decision = decideReservation({
created: false,
state: 'rejected',
reason: 'subscription_already_active',
owner_provider: 'payple',
tier: 'pro',
current_period_end: '2026-10-28T00:00:00.000Z',
})
assertEquals(
decision,
{ kind: 'reject', status: 409, body: { error: 'subscription_already_active', state: 'rejected' } },
'active subscriber is rejected before Payple is contacted',
)
})
Deno.test('isBillingKeyOwnedBy requires a live key registered to the caller', () => {
const owners = ['user-1', 'hashed-user-1']
assert(
isBillingKeyOwnedBy({ PCD_PAY_RST: 'success', PCD_PAYER_ID: 'payer-1', PCD_PAYER_NO: 'hashed-user-1' }, 'payer-1', owners),
'owner accepted',
)
assert(
!isBillingKeyOwnedBy({ PCD_PAY_RST: 'error', PCD_PAYER_ID: 'payer-1', PCD_PAYER_NO: 'user-1' }, 'payer-1', owners),
'failed lookup rejected',
)
assert(
!isBillingKeyOwnedBy({ PCD_PAY_RST: 'success', PCD_PAYER_ID: 'payer-2', PCD_PAYER_NO: 'user-1' }, 'payer-1', owners),
'different billing key rejected',
)
assert(
!isBillingKeyOwnedBy({ PCD_PAY_RST: 'success', PCD_PAYER_ID: 'payer-1', PCD_PAYER_NO: '' }, 'payer-1', owners),
'missing payer number rejected',
)
assert(
!isBillingKeyOwnedBy({ PCD_PAY_RST: 'success', PCD_PAYER_ID: 'payer-1', PCD_PAYER_NO: 'user-2' }, 'payer-1', owners),
'other user rejected',
)
})
Deno.test('chargeMatchesOrder binds the charge to the reserved order', () => {
const expected = { orderId: 'D3RO-1', amount: 9900, payerId: 'payer-1' }
assert(chargeMatchesOrder({ PCD_PAY_OID: 'D3RO-1', PCD_PAY_TOTAL: '9900' }, expected), 'no payer echo')
assert(
chargeMatchesOrder({ PCD_PAY_OID: 'D3RO-1', PCD_PAY_TOTAL: '9900', PCD_PAYER_ID: 'payer-1' }, expected),
'matching payer echo',
)
assert(!chargeMatchesOrder({ PCD_PAY_OID: 'D3RO-2', PCD_PAY_TOTAL: '9900' }, expected), 'order mismatch')
assert(!chargeMatchesOrder({ PCD_PAY_OID: 'D3RO-1', PCD_PAY_TOTAL: '100' }, expected), 'amount mismatch')
assert(
!chargeMatchesOrder({ PCD_PAY_OID: 'D3RO-1', PCD_PAY_TOTAL: '9900', PCD_PAYER_ID: 'payer-x' }, expected),
'payer mismatch',
)
})
Deno.test('planCheckoutFailure keeps unknown charges reconcilable and releases safe failures', () => {
const base = {
hasOperation: true,
externalChargeCompleted: false,
chargeOutcomeUnknown: false,
configurationError: false,
}
assertEquals(
planCheckoutFailure({ ...base, chargeOutcomeUnknown: true }, 'payple_not_configured'),
{ mark: { state: 'external_created' }, status: 409, error: 'payment_requires_reconciliation' },
'ambiguous charge',
)
assertEquals(
planCheckoutFailure(base, 'payple_not_configured'),
{ mark: { state: 'failed', errorCode: 'payple_checkout_failed' }, status: 502, error: 'payple_checkout_failed' },
'failure before charge',
)
assertEquals(
planCheckoutFailure({ ...base, externalChargeCompleted: true }, 'payple_not_configured'),
{ mark: null, status: 409, error: 'payment_requires_reconciliation' },
'failure after charge leaves the operation charged',
)
assertEquals(
planCheckoutFailure({ ...base, hasOperation: false, configurationError: true }, 'payple_not_configured'),
{ mark: null, status: 503, error: 'payple_not_configured' },
'configuration error',
)
assertEquals(
planCheckoutFailure({ ...base, configurationError: true }, 'payple_not_configured'),
{ mark: { state: 'failed', errorCode: 'payple_not_configured' }, status: 503, error: 'payple_not_configured' },
'configuration error with operation',
)
})

View file

@ -0,0 +1,145 @@
// Pure decision rules for the Payple checkout edge function. No IO lives
// here: index.ts owns Supabase/Payple calls and asks these functions what to
// do with each result, so the rules are unit-testable without a network.
export type CheckoutTier = 'pro' | 'pro_plus'
export interface CheckoutInput {
payerId: string
tier: CheckoutTier
idempotencyKey: string | undefined
}
export function isValidIdempotencyKey(value: unknown): value is string {
return typeof value === 'string' && /^[A-Za-z0-9._:-]{12,160}$/.test(value)
}
/** Returns null when the request body is not a valid checkout request. */
export function parseCheckoutRequest(body: unknown): CheckoutInput | null {
if (!body || typeof body !== 'object') return null
const record = body as Record<string, unknown>
const payerId = record.payer_id
const tier = record.tier
const idempotencyKey = record.idempotency_key
if (
typeof payerId !== 'string'
|| payerId.length < 8
|| payerId.length > 255
|| (tier !== 'pro' && tier !== 'pro_plus')
|| (idempotencyKey !== undefined && !isValidIdempotencyKey(idempotencyKey))
) {
return null
}
return { payerId, tier, idempotencyKey }
}
export type ReservationDecision =
| { kind: 'proceed'; operationId: string }
| { kind: 'reject'; status: 409; body: { error: string; state: string } }
/**
* Interprets the reserve_payment_provider_operation result. Only a freshly
* created operation may lead to an external charge; every other outcome
* (idempotent replay, in-flight operation, other-provider ownership, or an
* already active paid period — 'subscription_already_active') is a 409 with
* the database reason passed through unchanged.
*/
export function decideReservation(data: unknown): ReservationDecision {
const reservation = data && typeof data === 'object'
? data as Record<string, unknown>
: null
if (reservation?.created === true && typeof reservation.operation_id === 'string') {
return { kind: 'proceed', operationId: reservation.operation_id }
}
return {
kind: 'reject',
status: 409,
body: {
error: typeof reservation?.reason === 'string'
? reservation.reason
: 'payment_operation_in_progress',
state: typeof reservation?.state === 'string' ? reservation.state : 'rejected',
},
}
}
export interface BillingKeyOwnerEvidence {
PCD_PAY_RST: string
PCD_PAYER_ID?: string
PCD_PAYER_NO?: string
}
/** The billing key must be live and registered to the authenticated user. */
export function isBillingKeyOwnedBy(
billingKey: BillingKeyOwnerEvidence,
payerId: string,
expectedPayerNumbers: readonly string[],
): boolean {
return billingKey.PCD_PAY_RST === 'success'
&& billingKey.PCD_PAYER_ID === payerId
&& typeof billingKey.PCD_PAYER_NO === 'string'
&& billingKey.PCD_PAYER_NO.length > 0
&& expectedPayerNumbers.includes(billingKey.PCD_PAYER_NO)
}
export interface ChargeEvidence {
PCD_PAY_OID: string
PCD_PAY_TOTAL: string
PCD_PAYER_ID?: string
}
/** The synchronous charge response must describe exactly the reserved order. */
export function chargeMatchesOrder(
charge: ChargeEvidence,
expected: { orderId: string; amount: number; payerId: string },
): boolean {
return charge.PCD_PAY_OID === expected.orderId
&& charge.PCD_PAY_TOTAL === String(expected.amount)
&& (!charge.PCD_PAYER_ID || charge.PCD_PAYER_ID === expected.payerId)
}
export interface CheckoutFailureFacts {
/** A reservation exists for this request. */
hasOperation: boolean
/** Payple accepted the charge before the failure. */
externalChargeCompleted: boolean
/** The charge call failed in a way that does not prove it was not taken. */
chargeOutcomeUnknown: boolean
/** Payple is not configured on this deployment. */
configurationError: boolean
}
export interface CheckoutFailurePlan {
/** How to mark the reserved operation, or null to leave it untouched. */
mark:
| { state: 'external_created' }
| { state: 'failed'; errorCode: 'payple_not_configured' | 'payple_checkout_failed' }
| null
/** HTTP status and error code returned to the caller (auth errors excepted). */
status: 409 | 502 | 503
error: string
}
export function planCheckoutFailure(
facts: CheckoutFailureFacts,
configurationErrorCode: string,
): CheckoutFailurePlan {
let mark: CheckoutFailurePlan['mark'] = null
if (facts.hasOperation && facts.chargeOutcomeUnknown) {
mark = { state: 'external_created' }
} else if (facts.hasOperation && !facts.externalChargeCompleted) {
mark = {
state: 'failed',
errorCode: facts.configurationError ? 'payple_not_configured' : 'payple_checkout_failed',
}
}
if (facts.configurationError) {
return { mark, status: 503, error: configurationErrorCode }
}
const needsReconciliation = facts.externalChargeCompleted || facts.chargeOutcomeUnknown
return {
mark,
status: needsReconciliation ? 409 : 502,
error: needsReconciliation ? 'payment_requires_reconciliation' : 'payple_checkout_failed',
}
}

View file

@ -17,19 +17,13 @@ import {
TIER_GOODS_NAME,
TIER_PRICE,
} from '../_shared/payple.ts'
interface CheckoutRequest {
payer_id?: unknown
tier?: unknown
idempotency_key?: unknown
}
interface OperationReservation {
created?: boolean
operation_id?: string
state?: string
reason?: string
}
import {
chargeMatchesOrder,
decideReservation,
isBillingKeyOwnedBy,
parseCheckoutRequest,
planCheckoutFailure,
} from './checkout-policy.ts'
interface ApplyResult {
applied?: boolean
@ -44,10 +38,6 @@ function jsonResponse(body: Record<string, unknown>, status = 200): Response {
})
}
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
@ -60,21 +50,13 @@ Deno.serve(async (req: Request) => {
try {
const user = await requireUser(req)
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)
}
const input = parseCheckoutRequest(await req.json())
if (!input) return jsonResponse({ error: 'invalid_request' }, 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
const idempotencyKey = input.idempotencyKey
?? `payple-checkout:${crypto.randomUUID()}`
const orderId = generateOrderId(user.id)
providerOrderId = orderId
@ -84,40 +66,35 @@ Deno.serve(async (req: Request) => {
p_user_id: user.id,
p_provider: 'payple',
p_operation_type: 'checkout',
p_requested_tier: body.tier,
p_requested_tier: input.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,
p_provider_resource_id: input.payerId,
},
)
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)
// The reservation is the single serialization point: it rejects an
// in-flight operation, another provider's ownership, and an active paid
// Payple period ('subscription_already_active') before any charge.
const reservation = decideReservation(reservationData)
if (reservation.kind === 'reject') {
return jsonResponse(reservation.body, reservation.status)
}
operationId = reservation.operation_id
operationId = reservation.operationId
const price = TIER_PRICE[body.tier]
const goodsName = TIER_GOODS_NAME[body.tier]
const price = TIER_PRICE[input.tier]
const goodsName = TIER_GOODS_NAME[input.tier]
const billingKeyAuth = await paypleAuth(config, { payWork: 'PUSERINFO' })
const billingKey = await paypleLookupBillingKey(config, billingKeyAuth, body.payer_id)
const billingKey = await paypleLookupBillingKey(config, billingKeyAuth, input.payerId)
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)
) {
if (!isBillingKeyOwnedBy(billingKey, input.payerId, expectedPayerNumbers)) {
throw new Error('payple_billing_key_owner_mismatch')
}
const auth = await paypleAuth(config, { simpleFlag: true })
const billingResult = await paypleBilling(config, auth, {
payerId: body.payer_id,
payerId: input.payerId,
amount: price,
orderId,
goodsName,
@ -132,11 +109,7 @@ Deno.serve(async (req: Request) => {
})
if (chargedError) throw new Error('payment_operation_update_failed')
if (
billingResult.PCD_PAY_OID !== orderId
|| billingResult.PCD_PAY_TOTAL !== String(price)
|| (billingResult.PCD_PAYER_ID && billingResult.PCD_PAYER_ID !== body.payer_id)
) {
if (!chargeMatchesOrder(billingResult, { orderId, amount: price, payerId: input.payerId })) {
throw new Error('payple_charge_response_mismatch')
}
@ -155,19 +128,19 @@ Deno.serve(async (req: Request) => {
p_event_type: 'payment.completed',
p_payload_digest: await payplePaymentEventDigest({
orderId,
payerId: body.payer_id,
payerId: input.payerId,
payType: 'card',
amount: price,
}),
p_provider_resource_id: body.payer_id,
p_tier: body.tier,
p_provider_resource_id: input.payerId,
p_tier: input.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_customer_id: input.payerId,
p_provider_order_id: orderId,
p_store_product_id: null,
p_store_purchase_id: null,
@ -186,39 +159,38 @@ Deno.serve(async (req: Request) => {
return jsonResponse({
success: true,
tier: body.tier,
tier: input.tier,
order_id: orderId,
amount: price,
})
} catch (error) {
const chargeOutcomeUnknown = error instanceof PaypleBillingError && !error.definitive
if (operationId && chargeOutcomeUnknown) {
const plan = planCheckoutFailure(
{
hasOperation: operationId !== null,
externalChargeCompleted,
chargeOutcomeUnknown: error instanceof PaypleBillingError && !error.definitive,
configurationError: error instanceof PaypleConfigurationError,
},
error instanceof PaypleConfigurationError ? error.code : 'payple_not_configured',
)
if (operationId && plan.mark?.state === 'external_created') {
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) {
} else if (operationId && plan.mark?.state === 'failed') {
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',
p_error_code: plan.mark.errorCode,
})
}
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)
return jsonResponse({ error: plan.error }, plan.status)
}
})