26 KiB
Capability Spec Delta: payment (add-payment)
Package: @suaveplan/payment
Status: PROPOSED
Every requirement below carries a
**Verified by:**line at authoring time.
ADDED Requirements
Requirement: Universal-tier purity
The package SHALL ship as a universal-tier package — no node:*, bun:*,
fs, path, stream, net, http, or bare crypto imports in any file
under src/. The package SHALL run unchanged under Node ≥ 18, Bun, modern
browsers, and Web Workers. package.json SHALL declare "sideEffects": false.
Scenario: no platform imports in src/
- WHEN
grep -rnE "from ['\"](node:|bun:|fs|path|stream|net|http|crypto)['\"]" packages/universal/payment/src/is run - THEN the command produces zero matches
Verified by: src/webhook/webhook.test.ts::"verifies a valid webhook" AND src/tokenization/tokenization.test.ts::"hashes a valid card to a token"
Requirement: Stable PAYMENT_CODES registry and PaymentError class
The package SHALL declare an error-code registry via
defineCodes("PAYMENT", […]) containing exactly the codes NOT_FOUND,
INVALID_TRANSITION, ALREADY_CAPTURED, ALREADY_REFUNDED,
REFUND_EXCEEDS_CAPTURED, PROVIDER_ERROR, WEBHOOK_INVALID_SIGNATURE,
WEBHOOK_EXPIRED, WEBHOOK_PARSE_ERROR, AMOUNT_MISMATCH,
INVALID_CARD, GATEWAY_ERROR, REFUND_INTENT_NOT_REGISTERED. Every
public error path SHALL throw a
PaymentError extends SuaveplanError<PaymentErrorCode> carrying one of
those codes. PaymentError SHALL expose optional intentId,
currentStatus, targetStatus, and ageMs discriminants. The package
SHALL NOT throw raw Error instances from any public method.
Scenario: every code reachable from a real call site
- WHEN the test suite exercises the factory for each code
- THEN each path raises a
PaymentErrorwhosecodematches the expectedPAYMENT.*constant
Scenario: cause-bearing factories preserve the underlying error
- WHEN
providerError,webhookParseError, orgatewayErroris invoked with acause - THEN the resulting
PaymentError'scauseis the original error
Verified by: src/error-codes/error-codes.test.ts::"every PAYMENT.* code is reachable from a factory" AND src/error-codes/error-codes.test.ts::"preserves cause through providerError" AND src/error-codes/error-codes.test.ts::"preserves cause through webhookParseError" AND src/error-codes/error-codes.test.ts::"preserves cause through gatewayError" AND src/error-codes/error-codes.test.ts::"carries intent / status / age discriminants when set"
Requirement: Closed PaymentEventMap and explicit out-of-band emitters
The PaymentEngine SHALL expose a typed on(event, listener) over a
private EventBus<PaymentEventMap> where PaymentEventMap is closed (no
[key: string]: unknown[] escape hatch). For every event key that the
state-machine path does not naturally produce (intent.failed,
dispute.opened, dispute.closed), the engine SHALL expose an explicit
emitter method (markFailed, openDispute, closeDispute) so every key
in the map is reachable from a production code path.
Scenario: out-of-band emitters fire the right event
- WHEN
engine.markFailed(intent)/engine.openDispute(dispute)/engine.closeDispute(dispute)are invoked - THEN subscribers registered via
engine.on("intent.failed" | "dispute.opened" | "dispute.closed", …)receive the payload exactly once
Verified by: src/engine/engine.test.ts::"markFailed emits intent.failed" AND src/engine/engine.test.ts::"openDispute emits dispute.opened" AND src/engine/engine.test.ts::"closeDispute emits dispute.closed"
Requirement: PaymentEngine state-transition enforcement
The engine SHALL consult VALID_TRANSITIONS before delegating to the
provider. Any transition not declared in VALID_TRANSITIONS SHALL raise
PaymentError(PAYMENT.INVALID_TRANSITION) with intentId,
currentStatus, and targetStatus discriminants set, and SHALL NOT
invoke the provider. The canTransition(from, to) predicate SHALL
return true if and only if to ∈ VALID_TRANSITIONS[from].
Scenario: illegal transition raises typed error before provider call
- WHEN the current status is
refundedand the caller invokescancelIntent(id) - THEN the engine throws
PaymentError(PAYMENT.INVALID_TRANSITION)and the provider'scancelIntentis never called
Scenario: canTransition reflects the table
- WHEN
canTransitionis queried for known legal and illegal pairs - THEN the predicate matches the
VALID_TRANSITIONSlookup
Verified by: src/engine/engine.test.ts::"rejects cancel from refunded" AND src/engine/engine.test.ts::"rejects invalid transition from captured" AND src/engine/engine.test.ts::"rejects capture from canceled" AND src/engine/engine.test.ts::"rejects refund from non-refundable status" AND src/engine/engine.test.ts::"canTransition reflects VALID_TRANSITIONS"
Requirement: PaymentEngine refund-balance guard
engine.refund(input) SHALL look up the current intent, refuse any
refund whose amount.amount exceeds capturedAmount.amount − refundedAmount.amount
with PaymentError(PAYMENT.REFUND_EXCEEDS_CAPTURED), and SHALL emit
refund.succeeded, refund.failed, or refund.created based on the
provider's reported status value.
Scenario: refund exceeding balance throws typed error
- WHEN the caller asks for a refund larger than
captured - refunded - THEN the engine raises
PaymentError(PAYMENT.REFUND_EXCEEDS_CAPTURED)
Scenario: refund status drives the emitted event
- WHEN the provider returns a
Refundwhosestatusissucceeded/failed/pending - THEN the engine emits
refund.succeeded/refund.failed/refund.createdrespectively
Verified by: src/engine/engine.test.ts::"rejects refund exceeding captured amount" AND src/engine/engine.test.ts::"refunds a captured intent (succeeded)" AND src/engine/engine.test.ts::"emits refund.created for pending refunds" AND src/engine/engine.test.ts::"emits refund.failed for failed refunds" AND src/engine/engine.test.ts::"allows partial refund within remaining" AND src/engine/engine.test.ts::"refunds a partially_refunded intent (no amount)"
Requirement: PaymentEngine provider-error wrapping
Every provider failure SHALL be wrapped into
PaymentError(PAYMENT.PROVIDER_ERROR) carrying the original error on
cause when it is an Error, and producing a string-form representation
when it is not. The wrapped message SHALL preserve the operation context
(prefix indicating which lifecycle call failed). Provider failures inside
getIntent SHALL be wrapped as PaymentError(PAYMENT.NOT_FOUND) so
callers dispatch on a single code rather than substring-matching messages.
Scenario: Error throws wrap with cause
- WHEN the provider rejects with a real
Error - THEN the engine raises
PaymentError(PAYMENT.PROVIDER_ERROR)and the original error is onerr.cause
Scenario: non-Error throws stringify into the message
- WHEN the provider rejects with
"string err"/42/null - THEN the engine raises
PaymentError(PAYMENT.PROVIDER_ERROR)whose message contains the string form
Verified by: src/engine/engine.test.ts::"wraps Error from provider as PaymentError(PROVIDER_ERROR)" AND src/engine/engine.test.ts::"handles non-Error throws and stringifies them" AND src/engine/engine.test.ts::"wraps confirm provider errors" AND src/engine/engine.test.ts::"wraps capture provider errors" AND src/engine/engine.test.ts::"wraps cancel provider errors" AND src/engine/engine.test.ts::"wraps refund provider errors" AND src/engine/engine.test.ts::"handles non-Error throws from confirm" AND src/engine/engine.test.ts::"handles non-Error throws from capture" AND src/engine/engine.test.ts::"handles non-Error throws from cancel" AND src/engine/engine.test.ts::"handles non-Error throws from refund" AND src/engine/engine.test.ts::"wraps provider failure as PAYMENT.NOT_FOUND"
Requirement: PaymentEngine processes the payment:processing hook chain
engine.createIntent(input) SHALL invoke paymentHooks.applyHooksAsync("payment:processing", { input, metadata: {} })
before calling the provider. The provider SHALL receive the
hookResult.input value rather than the caller's literal input so that
hook handlers can transform the request.
Scenario: hook transforms the metadata in flight
- WHEN a handler is registered via
paymentHooks.hook("payment:processing", …)that tagsmetadata - THEN the engine's
intent.createdlistener is invoked, and the registered handler executed
Verified by: src/engine/engine.test.ts::"emits intent.created and routes through hooks"
Requirement: WebhookVerifier HMAC + replay window
WebhookVerifier.sign(body, timestamp) SHALL return the lower-case hex
encoding of hmac(algorithm, secret, encode(timestamp + "." + body)) where
the HMAC routes through @suaveplan/crypto-hash. WebhookVerifier.verify(body, signature, timestamp)
SHALL reject when Math.abs(now() - timestamp) > toleranceMs with
PaymentError(PAYMENT.WEBHOOK_EXPIRED) whose ageMs discriminant
matches the computed age; SHALL reject signatures with malformed hex,
mismatched length, or differing content with
PaymentError(PAYMENT.WEBHOOK_INVALID_SIGNATURE) using a constant-time
compare via @suaveplan/crypto-utils; and SHALL reject non-JSON bodies
that pass the signature check with PaymentError(PAYMENT.WEBHOOK_PARSE_ERROR)
preserving the parse-failure cause.
Scenario: deterministic signing
- WHEN
sign(body, ts)is invoked twice for identical inputs - THEN the two signatures are equal hex strings matching
/^[a-f0-9]+$/
Scenario: expiry rejects past and future drift
- WHEN the timestamp is more than
toleranceMsaway fromnow()in either direction - THEN
verifythrowsPaymentError(PAYMENT.WEBHOOK_EXPIRED)
Scenario: tampered or malformed signature is rejected
- WHEN the signature length mismatches, contains non-hex characters, has odd length, or content has been tampered
- THEN
verifythrowsPaymentError(PAYMENT.WEBHOOK_INVALID_SIGNATURE)
Scenario: invalid JSON throws parse error
- WHEN the body passes the signature check but is not valid JSON
- THEN
verifythrowsPaymentError(PAYMENT.WEBHOOK_PARSE_ERROR)
Verified by: src/webhook/webhook.test.ts::"produces a hex string" AND src/webhook/webhook.test.ts::"is deterministic" AND src/webhook/webhook.test.ts::"verifies a valid webhook" AND src/webhook/webhook.test.ts::"rejects an expired (too-old) timestamp" AND src/webhook/webhook.test.ts::"rejects a future timestamp beyond tolerance" AND src/webhook/webhook.test.ts::"rejects an invalid signature length" AND src/webhook/webhook.test.ts::"rejects an invalid (same-length) signature" AND src/webhook/webhook.test.ts::"rejects a signature with non-hex chars" AND src/webhook/webhook.test.ts::"rejects a signature with odd length" AND src/webhook/webhook.test.ts::"rejects tampered body even with matching length sig" AND src/webhook/webhook.test.ts::"rejects invalid JSON after the signature passes" AND src/webhook/webhook.test.ts::"accepts within custom tolerance" AND src/webhook/webhook.test.ts::"rejects outside custom tolerance" AND src/webhook/webhook.test.ts::"works with SHA-512"
Requirement: ReceiptGenerator produces structured records and plain text
ReceiptGenerator.generate(input) SHALL build a Receipt carrying
merchant identity, customer data, line items, subtotal / tax / total,
captured / refunded totals, attached payment method, refund history, and
a creation timestamp derived from the injected now() clock. The id
SHALL be produced by the injected nextId function (or the module
default). toPlainText(receipt) SHALL render every populated field —
merchant header, line items, subtotal, optional tax line, total, paid,
optional refunded line, payment-method summary, optional footer — and
SHALL omit empty sections.
Scenario: minimal receipt skips tax and refunded sections
- WHEN
taxis not supplied andamountRefundedis0n - THEN
toPlainTextdoes not include "Tax:" or "Refunded:" lines
Verified by: src/receipt/receipt.test.ts::"generates a structured receipt" AND src/receipt/receipt.test.ts::"honours injected nextId + tax label" AND src/receipt/receipt.test.ts::"toPlainText renders every section" AND src/receipt/receipt.test.ts::"toPlainText handles the minimal receipt"
Requirement: PaymentGateway interface satisfied by adapters
Every adapter SHALL implement the full PaymentGateway interface
(createCharge, captureCharge, refundCharge, getCharge,
validatePaymentMethod). The TestGateway SHALL also expose
operations (readonly log), chargeCount, and clearOperations. The
StripeGateway and PayPalGateway SHALL raise
PaymentError(PAYMENT.GATEWAY_ERROR) on non-2xx HTTP responses with the
response status and body preserved in the message. The TestGateway
SHALL return a success: false GatewayResult (rather than throwing)
when shouldFail is set or when the requested charge id is missing.
Scenario: TestGateway end-to-end
- WHEN a caller creates an authorised charge, captures it, then refunds half and the rest
- THEN the gateway returns success on every call, the second refund flips the status to
refunded, and the operation log records every call
Scenario: StripeGateway maps every documented status
- WHEN the upstream returns
succeeded,canceled, or an unknown status - THEN the adapter maps to
captured,canceled(success false), orfailedrespectively
Scenario: PayPalGateway caches and refreshes the OAuth token
- WHEN two successive requests are issued within the cached window followed by one beyond expiry
- THEN the first call triggers two HTTP calls (token + op), the second triggers one (op only), the third triggers two (re-auth + op)
Scenario: PayPalGateway refund without a capture
- WHEN the retrieved order has no
payments.captures[0] - THEN the adapter returns
success: falsewitherrorCode: "NO_CAPTURE"instead of throwing
Verified by: src/gateway/test.test.ts::"creates, captures, and refunds" AND src/gateway/test.test.ts::"createCharge returns a failure when shouldFail" AND src/gateway/test.test.ts::"captureCharge fails when charge missing" AND src/gateway/test.test.ts::"refundCharge fails when charge missing" AND src/gateway/test.test.ts::"getCharge throws when charge missing" AND src/gateway/test.test.ts::"captureCharge returns failure result when shouldFail flips on existing charge" AND src/gateway/test.test.ts::"refundCharge returns failure result when shouldFail flips on existing charge" AND src/gateway/stripe.test.ts::"posts to payment_intents and maps success" AND src/gateway/stripe.test.ts::"maps cancelled status to canceled (not success)" AND src/gateway/stripe.test.ts::"treats unknown stripe statuses as failed" AND src/gateway/stripe.test.ts::"raises PAYMENT.GATEWAY_ERROR on non-2xx" AND src/gateway/paypal.test.ts::"authenticates then creates an order" AND src/gateway/paypal.test.ts::"refreshes the token after expiry" AND src/gateway/paypal.test.ts::"raises PAYMENT.GATEWAY_ERROR on auth failure" AND src/gateway/paypal.test.ts::"returns NO_CAPTURE when refunding an order without captures" AND src/gateway/paypal.test.ts::"refunds via capture id"
Requirement: FraudDetector deterministic risk scoring
FraudDetector.check(input) SHALL compute a 0-100 capped score by
summing flags raised by four rules (velocity, high-amount,
missing-customer, new-payment-method), SHALL surface
recommendation: "allow" | "review" | "block" derived from
configurable thresholds, SHALL learn paymentMethod.id between calls
so the new-method flag stops firing after the first sighting, and SHALL
record per-customer attempts in a velocity log driven by the injected
now(). reset() SHALL clear both data structures.
Scenario: velocity blocks after threshold
- WHEN a customer makes more than
velocityMaxAttemptscalls withinvelocityWindowMs - THEN the next check raises the
velocityflag and the recommendation reachesblock
Scenario: reset wipes the new-method memo
- WHEN the same method id is checked twice with a
reset()in between - THEN both checks raise the
new_payment_methodflag
Verified by: src/fraud/fraud.test.ts::"allows a clean check from a known customer" AND src/fraud/fraud.test.ts::"flags missing customer" AND src/fraud/fraud.test.ts::"flags high amount and recommends review when over threshold" AND src/fraud/fraud.test.ts::"flags velocity and blocks above blockThreshold" AND src/fraud/fraud.test.ts::"recommends review between thresholds" AND src/fraud/fraud.test.ts::"caps score at 100" AND src/fraud/fraud.test.ts::"reset clears velocity log and known-method memo" AND src/fraud/fraud.test.ts::"velocity check returns early when no customerId"
Requirement: RefundManager balance ledger with typed errors
RefundManager.createRefund(input) SHALL raise
PaymentError(PAYMENT.REFUND_INTENT_NOT_REGISTERED) when the intent has
not been registered via registerCapture, SHALL raise
PaymentError(PAYMENT.REFUND_EXCEEDS_CAPTURED) when the cumulative
refunds would exceed the captured amount, SHALL append the refund to
the per-intent record, and SHALL stamp createdAt from the injected
now() clock. getSummary SHALL produce a snapshot carrying the
captured amount, total refunded, remaining balance, refund count, a
defensive copy of the refund list, and a fullyRefunded flag.
canRefund SHALL return false when the intent is missing,
otherwise SHALL accept any amount ≤ remaining balance.
Scenario: every error code reachable
- WHEN
createRefundis invoked against an unregistered intent and again with an amount exceeding remaining - THEN both raise typed
PaymentErrors carrying the expected codes
Verified by: src/refund/refund.test.ts::"creates a refund within balance" AND src/refund/refund.test.ts::"rejects refund when intent not registered" AND src/refund/refund.test.ts::"rejects refund exceeding remaining balance" AND src/refund/refund.test.ts::"getSummary reports balances and fullyRefunded flag" AND src/refund/refund.test.ts::"getSummary returns undefined for unknown intent" AND src/refund/refund.test.ts::"getRefundsByReason filters and returns empty for unknown intent" AND src/refund/refund.test.ts::"canRefund predicate" AND src/refund/refund.test.ts::"uses injected nextId"
Requirement: PaymentRetrier exponential backoff with deterministic jitter
PaymentRetrier.execute(operation, isSuccess, extractCode?) SHALL retry
up to maxAttempts times, SHALL stop early on a non-retryable result
code or a non-retryable thrown-error message (both compared against the
nonRetryableCodes set — case-insensitive substring for thrown errors,
exact equality for extractCode results), SHALL wrap non-Error
throws into new Error(String(value)), and SHALL emit a RetryResult<T>
carrying success, attempts, errors, and durationMs.
calculateDelay(attempt) SHALL produce baseDelayMs * 2^(attempt - 1)
capped at maxDelayMs, plus (random() * 2 - 1) * jitterRange, clamped
to ≥ 0. The injected random() and sleep() make the retrier fully
deterministic in tests.
Scenario: extracted non-retryable code short-circuits
- WHEN the operation returns
{ success: false, code: "card_declined" }andextractCodeextracts the code - THEN the retrier reports
success: false, attempts: 1and does not retry
Verified by: src/retry/retry.test.ts::"returns success on first attempt" AND src/retry/retry.test.ts::"retries on non-success then succeeds" AND src/retry/retry.test.ts::"exits early on non-retryable extracted code" AND src/retry/retry.test.ts::"exits early on non-retryable thrown error" AND src/retry/retry.test.ts::"exhausts attempts on retryable thrown errors" AND src/retry/retry.test.ts::"wraps non-Error throws" AND src/retry/retry.test.ts::"uses default sleep when none injected" AND src/retry/retry.test.ts::"returns base delay on attempt 1 with no jitter" AND src/retry/retry.test.ts::"caps at maxDelayMs" AND src/retry/retry.test.ts::"applies jitter according to injected random" AND src/retry/retry.test.ts::"never returns negative delay even with extreme jitter"
Requirement: TaxCalculator jurisdiction registry with inclusive + exclusive pricing
TaxCalculator.calculate(items, jurisdiction) SHALL resolve the rate via
region → country → default fallback, SHALL apply inclusive pricing as
taxPortion = round(total * (rate / (1 + rate))) and exclusive pricing
as tax = round(itemTotal * rate), SHALL honour taxExempt: true line
items with zero tax and a synthetic "Exempt" tax name, and SHALL
return a TaxCalculationResult with per-line and aggregate amounts plus
the resolved taxRate metadata and inclusive flag.
Scenario: region overrides country
- WHEN the calculator has rates registered for
(US)and(US, CA) - THEN
getRate({country: "US", region: "CA"})returns the CA rate;getRate({country: "US", region: "NY"})falls back to the US rate
Verified by: src/tax/tax.test.ts::"falls back to default rate when no jurisdiction registered" AND src/tax/tax.test.ts::"uses custom default rate" AND src/tax/tax.test.ts::"region overrides country" AND src/tax/tax.test.ts::"removes a rate" AND src/tax/tax.test.ts::"exclusive pricing adds tax on top" AND src/tax/tax.test.ts::"inclusive pricing extracts tax from total" AND src/tax/tax.test.ts::"respects taxExempt items" AND src/tax/tax.test.ts::"returns USD with empty input"
Requirement: CardTokenizer Luhn + HMAC tokenisation
CardTokenizer.tokenize(card) SHALL strip non-digit characters from the
PAN, validate the Luhn checksum (raising
PaymentError(PAYMENT.INVALID_CARD) on failure), validate the expiry
month (raising the same code for 0/13+ values), validate that the expiry
is in the future relative to the injected now() clock (raising the
same code for expired cards), then return a TokenizedCard whose
token is <prefix><hex-sha256> produced via the injected
SubtleCrypto. validateLuhn SHALL reject inputs whose digit count
falls outside [13, 19]. detectBrand SHALL match the seven supported
brand prefixes, returning "unknown" otherwise.
Scenario: every brand pattern is reachable
- WHEN
detectBrandis invoked with sample PANs for each supported brand and an unknown prefix - THEN the helper returns the expected brand string
Verified by: src/tokenization/tokenization.test.ts::"hashes a valid card to a token" AND src/tokenization/tokenization.test.ts::"respects custom prefix" AND src/tokenization/tokenization.test.ts::"rejects bad Luhn" AND src/tokenization/tokenization.test.ts::"rejects bad month" AND src/tokenization/tokenization.test.ts::"rejects expired card" AND src/tokenization/tokenization.test.ts::"accepts valid PANs" AND src/tokenization/tokenization.test.ts::"rejects too-short / too-long inputs" AND src/tokenization/tokenization.test.ts::"rejects bad checksum" AND src/tokenization/tokenization.test.ts::"detects every supported brand"
Requirement: PaymentReconciliation matcher with status normalisation
PaymentReconciliation.reconcile(internal, gateway) SHALL match
internal records to gateway records via gatewayChargeId ↔ chargeId,
SHALL surface every unmatched record (both sides) with a synthetic
discrepancy string, SHALL normalise statuses by lower-casing and
stripping [_-] before comparison, and SHALL stamp reconciledAt from
the injected now() clock. The report header SHALL carry per-category
counts so callers can branch without re-walking the entries.
Scenario: status normalisation considers underscores and dashes equal
- WHEN the internal record's status is
"partially_refunded"and the gateway's is"PARTIALLY-REFUNDED" - THEN the entry is
"matched"with no discrepancy
Verified by: src/reconciliation/reconciliation.test.ts::"matches by gateway charge id" AND src/reconciliation/reconciliation.test.ts::"flags discrepancies" AND src/reconciliation/reconciliation.test.ts::"flags internal record without gatewayChargeId" AND src/reconciliation/reconciliation.test.ts::"flags missing gateway record" AND src/reconciliation/reconciliation.test.ts::"flags orphan gateway record" AND src/reconciliation/reconciliation.test.ts::"normalises status across underscores/dashes/case" AND src/reconciliation/reconciliation.test.ts::"sets reconciledAt from injected clock"
Requirement: Hooks bus with priority ordering and unsubscribe
The package SHALL expose a generic Hooks<TMap extends Record<string, unknown>>
class with hook(key, handler, options?) returning an unsubscribe
function, applyHooksAsync(key, data) invoking handlers in priority
order (lower priority first; stable for equal priorities) and awaiting
each handler's return value before passing the result to the next, plus
clear(key) and count(key). With zero registered handlers,
applyHooksAsync SHALL return the input unchanged. The
paymentHooks singleton SHALL be the canonical bus for the
payment:processing key, and onPaymentProcessing(handler, options?)
SHALL be a shortcut over it.
Scenario: ordered chain
- WHEN two handlers are registered with priorities
-1and0 - THEN the priority
-1handler runs first and its output is fed to the priority0handler
Verified by: src/hooks/hooks.test.ts::"returns input unchanged with no handlers" AND src/hooks/hooks.test.ts::"applies handlers sequentially in priority order" AND src/hooks/hooks.test.ts::"supports async handlers" AND src/hooks/hooks.test.ts::"unsubscribe removes the handler" AND src/hooks/hooks.test.ts::"clear removes all handlers for a key" AND src/hooks/hooks.test.ts::"count returns 0 for unknown key" AND src/hooks/hooks.test.ts::"registers and dispatches payment:processing" AND src/hooks/hooks.test.ts::"respects priority option on the shortcut"
Requirement: Frozen VALID_TRANSITIONS table
VALID_TRANSITIONS SHALL be a frozen, complete table mapping every
PaymentStatus to its allowed successors. Mutation attempts at runtime
SHALL fail under strict mode and SHALL not silently corrupt the
state-machine contract.
Scenario: every status appears as a key
- WHEN every
PaymentStatusis iterated and passed as thefromargument tocanTransition - THEN no key lookup throws
Verified by: src/engine/engine.test.ts::"declares every status key"