Files
suaveplan/openspec/specs/payment/spec.md
2026-05-27 23:29:53 +01:00

423 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 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/payment/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 -->