fix(payple): reject checkout while a paid Payple period is still active
This commit is contained in:
parent
f456d737c4
commit
e69fe0335d
5 changed files with 717 additions and 71 deletions
|
|
@ -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',
|
||||
)
|
||||
})
|
||||
145
server/supabase/functions/payple-checkout/checkout-policy.ts
Normal file
145
server/supabase/functions/payple-checkout/checkout-policy.ts
Normal 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',
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,211 @@
|
|||
-- ============================================================================
|
||||
-- Payple checkout: reject a second checkout while a paid period is running
|
||||
--
|
||||
-- reserve_payment_provider_operation previously allowed a checkout whenever
|
||||
-- subscriptions.provider was 'none' or the requesting provider. An active
|
||||
-- Payple subscriber on a stale tab (or a direct API call with a fresh
|
||||
-- idempotency key) could therefore reserve a new checkout, get charged the
|
||||
-- full price again, and apply_payment_provider_event then restarted the paid
|
||||
-- period at now()..now()+1 month, dropping the unused remainder.
|
||||
--
|
||||
-- The reservation is the serialization point (per-user advisory lock), so the
|
||||
-- guard lives here: a same-provider checkout is rejected with
|
||||
-- 'subscription_already_active' while the row is paid (tier <> 'free') and its
|
||||
-- current period has not ended. Upgrades are not modelled as checkouts; an
|
||||
-- upgrade must be a separate prorating/extending operation.
|
||||
--
|
||||
-- Only the checkout branch changes; renewal and cancellation keep their
|
||||
-- existing ownership checks. The body is otherwise identical to
|
||||
-- 20260821000003_payment_provider_serialization.sql.
|
||||
-- ============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.reserve_payment_provider_operation(
|
||||
p_user_id uuid,
|
||||
p_provider text,
|
||||
p_operation_type text,
|
||||
p_requested_tier text,
|
||||
p_idempotency_key text,
|
||||
p_provider_order_id text DEFAULT NULL,
|
||||
p_provider_resource_id text DEFAULT NULL
|
||||
) RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public, pg_temp
|
||||
AS $$
|
||||
DECLARE
|
||||
v_existing public.payment_provider_operations%ROWTYPE;
|
||||
v_subscription public.subscriptions%ROWTYPE;
|
||||
v_operation public.payment_provider_operations%ROWTYPE;
|
||||
BEGIN
|
||||
IF p_user_id IS NULL OR NOT EXISTS (SELECT 1 FROM auth.users WHERE id = p_user_id) THEN
|
||||
RAISE EXCEPTION 'unknown_user';
|
||||
END IF;
|
||||
IF p_provider NOT IN ('stripe', 'payple', 'google_play', 'app_store') THEN
|
||||
RAISE EXCEPTION 'invalid_provider';
|
||||
END IF;
|
||||
IF p_operation_type NOT IN ('checkout', 'renewal', 'cancellation') THEN
|
||||
RAISE EXCEPTION 'invalid_operation_type';
|
||||
END IF;
|
||||
IF p_operation_type IN ('checkout', 'renewal') AND p_requested_tier NOT IN ('pro', 'pro_plus') THEN
|
||||
RAISE EXCEPTION 'invalid_tier';
|
||||
END IF;
|
||||
IF p_operation_type = 'cancellation' AND p_requested_tier IS NOT NULL THEN
|
||||
RAISE EXCEPTION 'cancellation_tier_must_be_null';
|
||||
END IF;
|
||||
IF p_idempotency_key IS NULL
|
||||
OR p_idempotency_key !~ '^[A-Za-z0-9._:-]{12,160}$' THEN
|
||||
RAISE EXCEPTION 'invalid_idempotency_key';
|
||||
END IF;
|
||||
IF p_provider_order_id IS NOT NULL
|
||||
AND p_provider_order_id !~ '^[A-Za-z0-9._-]{8,64}$' THEN
|
||||
RAISE EXCEPTION 'invalid_provider_order_id';
|
||||
END IF;
|
||||
IF p_provider_resource_id IS NOT NULL
|
||||
AND length(trim(p_provider_resource_id)) NOT BETWEEN 1 AND 255 THEN
|
||||
RAISE EXCEPTION 'invalid_provider_resource_id';
|
||||
END IF;
|
||||
|
||||
PERFORM pg_advisory_xact_lock(hashtextextended(p_user_id::text, 73031));
|
||||
|
||||
SELECT *
|
||||
INTO v_existing
|
||||
FROM public.payment_provider_operations
|
||||
WHERE user_id = p_user_id
|
||||
AND provider = p_provider
|
||||
AND idempotency_key = p_idempotency_key
|
||||
FOR UPDATE;
|
||||
|
||||
IF v_existing.id IS NOT NULL THEN
|
||||
IF v_existing.operation_type <> p_operation_type
|
||||
OR v_existing.requested_tier IS DISTINCT FROM p_requested_tier
|
||||
OR v_existing.provider_resource_id IS DISTINCT FROM p_provider_resource_id THEN
|
||||
RAISE EXCEPTION 'idempotency_key_payload_mismatch';
|
||||
END IF;
|
||||
RETURN jsonb_build_object(
|
||||
'created', false,
|
||||
'operation_id', v_existing.id,
|
||||
'state', v_existing.state,
|
||||
'provider_order_id', v_existing.provider_order_id,
|
||||
'external_reference', v_existing.external_reference,
|
||||
'reason', 'idempotent_replay'
|
||||
);
|
||||
END IF;
|
||||
|
||||
UPDATE public.payment_provider_operations
|
||||
SET state = 'failed',
|
||||
error_code = 'operation_lease_expired',
|
||||
updated_at = now()
|
||||
WHERE user_id = p_user_id
|
||||
AND state IN ('reserved', 'external_created', 'charged')
|
||||
AND expires_at <= now();
|
||||
|
||||
SELECT *
|
||||
INTO v_subscription
|
||||
FROM public.subscriptions
|
||||
WHERE user_id = p_user_id
|
||||
FOR UPDATE;
|
||||
|
||||
IF v_subscription.id IS NULL THEN
|
||||
INSERT INTO public.subscriptions (user_id, tier, status, provider, payment_provider)
|
||||
VALUES (p_user_id, 'free', 'active', 'none', 'none')
|
||||
RETURNING * INTO v_subscription;
|
||||
END IF;
|
||||
|
||||
IF p_operation_type = 'checkout' THEN
|
||||
IF v_subscription.provider NOT IN ('none', p_provider) THEN
|
||||
RETURN jsonb_build_object(
|
||||
'created', false,
|
||||
'state', 'rejected',
|
||||
'reason', 'active_subscription_other_provider',
|
||||
'owner_provider', v_subscription.provider
|
||||
);
|
||||
END IF;
|
||||
-- Same provider, still paid and inside its period: a new checkout would
|
||||
-- charge again and restart the period. A missing period end on a paid
|
||||
-- row is treated as still running.
|
||||
IF v_subscription.provider = p_provider
|
||||
AND v_subscription.tier <> 'free'
|
||||
AND coalesce(v_subscription.current_period_end, 'infinity'::timestamptz) > now() THEN
|
||||
RETURN jsonb_build_object(
|
||||
'created', false,
|
||||
'state', 'rejected',
|
||||
'reason', 'subscription_already_active',
|
||||
'owner_provider', v_subscription.provider,
|
||||
'tier', v_subscription.tier,
|
||||
'current_period_end', v_subscription.current_period_end
|
||||
);
|
||||
END IF;
|
||||
ELSE
|
||||
IF v_subscription.provider <> p_provider THEN
|
||||
RETURN jsonb_build_object(
|
||||
'created', false,
|
||||
'state', 'rejected',
|
||||
'reason', 'provider_not_owner',
|
||||
'owner_provider', v_subscription.provider
|
||||
);
|
||||
END IF;
|
||||
IF p_provider_resource_id IS NOT NULL
|
||||
AND v_subscription.provider_resource_id IS DISTINCT FROM p_provider_resource_id THEN
|
||||
RETURN jsonb_build_object(
|
||||
'created', false,
|
||||
'state', 'rejected',
|
||||
'reason', 'provider_resource_not_owner'
|
||||
);
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM public.payment_provider_operations
|
||||
WHERE user_id = p_user_id
|
||||
AND state IN ('reserved', 'external_created', 'charged')
|
||||
AND expires_at > now()
|
||||
) THEN
|
||||
RETURN jsonb_build_object(
|
||||
'created', false,
|
||||
'state', 'rejected',
|
||||
'reason', 'payment_operation_in_progress'
|
||||
);
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.payment_provider_operations (
|
||||
user_id,
|
||||
provider,
|
||||
operation_type,
|
||||
idempotency_key,
|
||||
requested_tier,
|
||||
provider_order_id,
|
||||
provider_resource_id,
|
||||
expires_at
|
||||
) VALUES (
|
||||
p_user_id,
|
||||
p_provider,
|
||||
p_operation_type,
|
||||
p_idempotency_key,
|
||||
p_requested_tier,
|
||||
nullif(trim(p_provider_order_id), ''),
|
||||
nullif(trim(p_provider_resource_id), ''),
|
||||
now() + interval '15 minutes'
|
||||
)
|
||||
RETURNING * INTO v_operation;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'created', true,
|
||||
'operation_id', v_operation.id,
|
||||
'state', v_operation.state,
|
||||
'provider_order_id', v_operation.provider_order_id,
|
||||
'expires_at', v_operation.expires_at
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.reserve_payment_provider_operation(
|
||||
uuid, text, text, text, text, text, text
|
||||
) FROM PUBLIC, anon, authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.reserve_payment_provider_operation(
|
||||
uuid, text, text, text, text, text, text
|
||||
) TO service_role;
|
||||
|
||||
COMMIT;
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
\set ON_ERROR_STOP on
|
||||
|
||||
-- Regression: a second Payple checkout (stale tab, direct API call) must not
|
||||
-- charge an active Payple subscriber again and restart the paid period.
|
||||
-- Covers reserve_payment_provider_operation from
|
||||
-- 20260928200000_payple_checkout_active_subscription_guard.sql.
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE OR REPLACE FUNCTION pg_temp.assert_true(condition boolean, message text)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
IF condition IS NOT TRUE THEN
|
||||
RAISE EXCEPTION 'assertion_failed: %', message;
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
INSERT INTO auth.users (
|
||||
id, aud, role, email, encrypted_password, email_confirmed_at,
|
||||
raw_app_meta_data, raw_user_meta_data, created_at, updated_at
|
||||
) VALUES (
|
||||
'20000000-0000-4000-8000-000000000020', 'authenticated', 'authenticated',
|
||||
'payple-active-guard@example.invalid', crypt('fixture-password', gen_salt('bf')), now(),
|
||||
'{"provider":"email","providers":["email"]}'::jsonb, '{}'::jsonb, now(), now()
|
||||
);
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
c_user constant uuid := '20000000-0000-4000-8000-000000000020';
|
||||
v_first jsonb;
|
||||
v_apply jsonb;
|
||||
v_second jsonb;
|
||||
v_upgrade jsonb;
|
||||
v_cancel jsonb;
|
||||
v_lapsed jsonb;
|
||||
v_period_end timestamptz;
|
||||
v_before public.subscriptions%ROWTYPE;
|
||||
v_after public.subscriptions%ROWTYPE;
|
||||
v_operations integer;
|
||||
BEGIN
|
||||
-- First tab: a free user subscribes to Pro through Payple.
|
||||
v_first := public.reserve_payment_provider_operation(
|
||||
c_user, 'payple', 'checkout', 'pro',
|
||||
'payple-checkout:active-guard-first', 'D3RO-20260928030000-guard01',
|
||||
'payer-active-guard'
|
||||
);
|
||||
PERFORM pg_temp.assert_true((v_first->>'created')::boolean, 'free user can reserve checkout');
|
||||
|
||||
v_period_end := now() + interval '1 month';
|
||||
v_apply := public.apply_payment_provider_event(
|
||||
p_user_id => c_user,
|
||||
p_provider => 'payple',
|
||||
p_event_id => 'payple-payment:active-guard-first',
|
||||
p_event_created_at => now() - interval '1 minute',
|
||||
p_event_type => 'payment.completed',
|
||||
p_payload_digest => repeat('a', 64),
|
||||
p_provider_resource_id => 'payer-active-guard',
|
||||
p_tier => 'pro',
|
||||
p_status => 'active',
|
||||
p_entitled => true,
|
||||
p_current_period_start => now() - interval '1 minute',
|
||||
p_current_period_end => v_period_end,
|
||||
p_cancel_at => NULL,
|
||||
p_auto_renewing => true,
|
||||
p_provider_customer_id => 'payer-active-guard',
|
||||
p_provider_order_id => 'D3RO-20260928030000-guard01',
|
||||
p_operation_id => (v_first->>'operation_id')::uuid
|
||||
);
|
||||
PERFORM pg_temp.assert_true((v_apply->>'applied')::boolean, 'first checkout applied');
|
||||
|
||||
SELECT * INTO v_before FROM public.subscriptions WHERE user_id = c_user;
|
||||
|
||||
-- Second (stale) tab: fresh idempotency key, same provider, same tier.
|
||||
v_second := public.reserve_payment_provider_operation(
|
||||
c_user, 'payple', 'checkout', 'pro',
|
||||
'payple-checkout:active-guard-second', 'D3RO-20260928030500-guard02',
|
||||
'payer-active-guard'
|
||||
);
|
||||
PERFORM pg_temp.assert_true(
|
||||
NOT coalesce((v_second->>'created')::boolean, false),
|
||||
'active Payple subscriber must not reserve a second checkout'
|
||||
);
|
||||
PERFORM pg_temp.assert_true(
|
||||
v_second->>'reason' = 'subscription_already_active',
|
||||
'second checkout is rejected as subscription_already_active, got '
|
||||
|| coalesce(v_second->>'reason', '<null>')
|
||||
);
|
||||
|
||||
-- An unprorated full-price "upgrade" through checkout is rejected too.
|
||||
v_upgrade := public.reserve_payment_provider_operation(
|
||||
c_user, 'payple', 'checkout', 'pro_plus',
|
||||
'payple-checkout:active-guard-upgrade', 'D3RO-20260928031000-guard03',
|
||||
'payer-active-guard'
|
||||
);
|
||||
PERFORM pg_temp.assert_true(
|
||||
v_upgrade->>'reason' = 'subscription_already_active',
|
||||
'checkout cannot be used as an unprorated upgrade'
|
||||
);
|
||||
|
||||
SELECT count(*) INTO v_operations
|
||||
FROM public.payment_provider_operations
|
||||
WHERE user_id = c_user AND operation_type = 'checkout';
|
||||
PERFORM pg_temp.assert_true(v_operations = 1, 'rejected checkouts create no operation');
|
||||
|
||||
SELECT * INTO v_after FROM public.subscriptions WHERE user_id = c_user;
|
||||
PERFORM pg_temp.assert_true(
|
||||
v_after.current_period_start = v_before.current_period_start
|
||||
AND v_after.current_period_end = v_before.current_period_end
|
||||
AND v_after.current_period_end = v_period_end
|
||||
AND v_after.tier = 'pro',
|
||||
'paid period is preserved after the rejected checkout'
|
||||
);
|
||||
|
||||
-- Cancellation of the live entitlement is still possible.
|
||||
v_cancel := public.reserve_payment_provider_operation(
|
||||
c_user, 'payple', 'cancellation', NULL,
|
||||
'payple-manage:active-guard-cancel', NULL,
|
||||
'payer-active-guard'
|
||||
);
|
||||
PERFORM pg_temp.assert_true((v_cancel->>'created')::boolean, 'active subscriber can still cancel');
|
||||
PERFORM public.mark_payment_provider_operation(
|
||||
(v_cancel->>'operation_id')::uuid, 'failed', NULL, 'fixture_released'
|
||||
);
|
||||
|
||||
-- Once the paid period has lapsed, a new checkout is allowed again.
|
||||
UPDATE public.subscriptions
|
||||
SET current_period_end = now() - interval '1 second'
|
||||
WHERE user_id = c_user;
|
||||
v_lapsed := public.reserve_payment_provider_operation(
|
||||
c_user, 'payple', 'checkout', 'pro',
|
||||
'payple-checkout:active-guard-lapsed', 'D3RO-20260928031500-guard04',
|
||||
'payer-active-guard'
|
||||
);
|
||||
PERFORM pg_temp.assert_true(
|
||||
(v_lapsed->>'created')::boolean,
|
||||
'checkout is allowed after the paid Payple period ended'
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
ROLLBACK;
|
||||
|
||||
SELECT 'payple_checkout_active_subscription_ok' AS result;
|
||||
Loading…
Add table
Add a link
Reference in a new issue