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
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',
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue