Files
2026-05-27 23:29:53 +01:00

26 KiB
Raw Permalink Blame History

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 PaymentError whose code matches the expected PAYMENT.* constant

Scenario: cause-bearing factories preserve the underlying error

  • WHEN providerError, webhookParseError, or gatewayError is invoked with a cause
  • THEN the resulting PaymentError's cause is 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 refunded and the caller invokes cancelIntent(id)
  • THEN the engine throws PaymentError(PAYMENT.INVALID_TRANSITION) and the provider's cancelIntent is never called

Scenario: canTransition reflects the table

  • WHEN canTransition is queried for known legal and illegal pairs
  • THEN the predicate matches the VALID_TRANSITIONS lookup

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 Refund whose status is succeeded / failed / pending
  • THEN the engine emits refund.succeeded / refund.failed / refund.created respectively

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 on err.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 tags metadata
  • THEN the engine's intent.created listener 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 toleranceMs away from now() in either direction
  • THEN verify throws PaymentError(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 verify throws PaymentError(PAYMENT.WEBHOOK_INVALID_SIGNATURE)

Scenario: invalid JSON throws parse error

  • WHEN the body passes the signature check but is not valid JSON
  • THEN verify throws PaymentError(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 tax is not supplied and amountRefunded is 0n
  • THEN toPlainText does 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), or failed respectively

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: false with errorCode: "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 velocityMaxAttempts calls within velocityWindowMs
  • THEN the next check raises the velocity flag and the recommendation reaches block

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_method flag

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 createRefund is 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" } and extractCode extracts the code
  • THEN the retrier reports success: false, attempts: 1 and 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 detectBrand is 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 -1 and 0
  • THEN the priority -1 handler runs first and its output is fed to the priority 0 handler

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 PaymentStatus is iterated and passed as the from argument to canTransition
  • THEN no key lookup throws

Verified by: src/engine/engine.test.ts::"declares every status key"