# 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` 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` 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 `PaymentError`s 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` 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 `` 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>` 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"`