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