Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,422 @@
<!-- markdownlint-disable MD024 -->
# 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 `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<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"`
<!-- markdownlint-enable MD024 -->