Initial Commit
This commit is contained in:
280
openspec/archive/2026-05-17-add-payment/proposal.md
Normal file
280
openspec/archive/2026-05-17-add-payment/proposal.md
Normal file
@@ -0,0 +1,280 @@
|
||||
# Add @suaveplan/payment — universal-tier payment intent lifecycle
|
||||
|
||||
**Package**: `@suaveplan/payment`
|
||||
**Status**: PROPOSED
|
||||
**Type**: migration (upstream `../suaveplan/packages/universal/payment`)
|
||||
|
||||
- **Spec delta**: ./specs/payment/spec.md
|
||||
|
||||
## Why
|
||||
|
||||
`STATUS.md` line 132 lists `@suaveplan/payment` as Pending. The upstream
|
||||
package owns the entire payment-intent lifecycle: the 9-state machine, the
|
||||
typed event emissions for each transition, webhook signature verification
|
||||
with replay protection, structured receipt generation, fraud scoring,
|
||||
refund-balance tracking, jurisdiction-aware tax calculation, retry
|
||||
orchestration with exponential backoff, gateway adapters for Stripe and
|
||||
PayPal (plus an in-memory test gateway), PCI-conscious card tokenisation,
|
||||
and gateway reconciliation. With `@suaveplan/money` shipping in the same
|
||||
wave, this is the next domino — every commerce-tier downstream
|
||||
(`@suaveplan/payment-providers`, `@suaveplan/api-plugin-payment`,
|
||||
`@suaveplan/api-plugin-banking`, `@suaveplan/booking`) depends on the
|
||||
`PaymentEngine`, `PaymentProvider` interface, `PaymentIntent` shape, and
|
||||
the typed `PaymentError` registry.
|
||||
|
||||
The migration preserves the entire upstream public surface verbatim
|
||||
(`PaymentEngine`, `WebhookVerifier`, `ReceiptGenerator`, `TestGateway`,
|
||||
`StripeGateway`, `PayPalGateway`, `FraudDetector`, `RefundManager`,
|
||||
`PaymentRetrier`, `TaxCalculator`, `CardTokenizer`,
|
||||
`PaymentReconciliation`, `paymentHooks` / `onPaymentProcessing`, every type
|
||||
and constant) while upgrading three foundational concerns to the genesis
|
||||
quality bar:
|
||||
|
||||
1. **Typed `PaymentError`** extending `SuaveplanError<PaymentErrorCode>`
|
||||
replaces the upstream `BaseError` and the side-effect-imported
|
||||
`ErrorCodes.registerMany` mechanism. The `PAYMENT_CODES` registry is
|
||||
declared via `defineCodes("PAYMENT", [...])` (13 codes), and every
|
||||
former `throw new Error(...)` boundary upgrades to a typed factory.
|
||||
2. **Universal-tier purity** — the upstream webhook verifier imported
|
||||
`node:crypto`; we route HMAC through `@suaveplan/crypto-hash` and
|
||||
constant-time compare through `@suaveplan/crypto-utils`. The card
|
||||
tokenizer keeps using `SubtleCrypto` (already universal). The PayPal
|
||||
adapter accepts injected `btoa` to keep the universal contract on
|
||||
runtimes that name it differently.
|
||||
3. **Injectable boundaries** — every `Date.now()` / `Math.random()` /
|
||||
`fetch` / `setTimeout` use is hoisted to a config option so tests are
|
||||
deterministic without `vi.mock`. This also enables the no-mocks rule
|
||||
while keeping 100% coverage tractable.
|
||||
|
||||
The 18 upstream source files collapse to 16 (the upstream
|
||||
`errors/error-codes.ts` + `errors/payment-errors.ts` pair merges into one
|
||||
`error-codes/error-codes.ts`; the upstream `gateway/gateway.types.ts` +
|
||||
`gateway/test-gateway.ts` / `stripe-gateway.ts` / `paypal-gateway.ts`
|
||||
keep their separate files but live under `gateway/` with no `*-gateway`
|
||||
suffix per the no-redundant-naming rule). One sibling `.md` per
|
||||
implementation file lands at genesis quality (≥200 words, basic +
|
||||
advanced examples, full API reference).
|
||||
|
||||
## What changes
|
||||
|
||||
- ADDED: `PaymentEngine` — orchestrator over a `PaymentProvider`,
|
||||
`EventBus<PaymentEventMap>`-backed lifecycle events
|
||||
(`intent.created`/`confirmed`/`captured`/`canceled`/`failed`,
|
||||
`refund.created`/`succeeded`/`failed`, `dispute.opened`/`closed`),
|
||||
`VALID_TRANSITIONS` enforcement, refund-balance guard, hook pipeline.
|
||||
Explicit `markFailed` / `openDispute` / `closeDispute` emitters so the
|
||||
closed event map is fully reachable.
|
||||
- ADDED: `WebhookVerifier` — HMAC-SHA-{256,384,512} over
|
||||
`timestamp.body`, constant-time signature compare via
|
||||
`@suaveplan/crypto-utils`, replay window via configurable `toleranceMs`,
|
||||
injected `now()` clock.
|
||||
- ADDED: `ReceiptGenerator` + `resetReceiptCounter` — structured `Receipt`
|
||||
records + plain-text renderer with injected `now()` and `nextId`.
|
||||
- ADDED: `PaymentGateway` interface + `GatewayChargeStatus` /
|
||||
`GatewayResult` / `GatewayCharge` / `ValidationResult` / `ChargeOptions`
|
||||
types.
|
||||
- ADDED: `TestGateway` + `resetChargeCounter` — in-memory adapter with
|
||||
recorded operations log, configurable failure modes, injected `now()`.
|
||||
- ADDED: `StripeGateway` — Payment Intents API via injected `FetchLike`;
|
||||
defaults `apiVersion` to `2024-12-18.acacia`; constant-time-safe.
|
||||
- ADDED: `PayPalGateway` — Orders API v2 via injected `FetchLike`; token
|
||||
cache uses injected `now()`; injected `btoa` for universal Base-64.
|
||||
- ADDED: `FraudDetector` — velocity / amount / customer / new-method
|
||||
heuristics with configurable thresholds + injected `now()`.
|
||||
- ADDED: `RefundManager` + `resetRefundCounter` — per-intent refund
|
||||
ledger raising typed `PaymentError(PAYMENT.REFUND_INTENT_NOT_REGISTERED)`
|
||||
or `PaymentError(PAYMENT.REFUND_EXCEEDS_CAPTURED)`.
|
||||
- ADDED: `PaymentRetrier` — exponential backoff + jitter, non-retryable
|
||||
code blocklist, injected `random()` + `sleep()`.
|
||||
- ADDED: `TaxCalculator` — region → country → default fallback, inclusive
|
||||
+ exclusive pricing, exempt items.
|
||||
- ADDED: `CardTokenizer` — Luhn + brand detection + HMAC-SHA-256 over
|
||||
injected `SubtleCrypto`; typed `PaymentError(PAYMENT.INVALID_CARD)` on
|
||||
validation failure.
|
||||
- ADDED: `PaymentReconciliation` — internal-vs-gateway matcher with
|
||||
normalised status comparison, orphan detection on both sides,
|
||||
injected `now()`.
|
||||
- ADDED: `paymentHooks` singleton + `Hooks<TMap>` class + `onPaymentProcessing`
|
||||
shortcut — package-local replacement for the upstream
|
||||
`@suaveplan/events/hooks` import (the genesis events package does not
|
||||
yet expose `Hooks`; reusing the contract avoids a follow-up rewrite
|
||||
when it does).
|
||||
- ADDED: `PAYMENT_CODES` registry via `defineCodes("PAYMENT", [...])` —
|
||||
13 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`).
|
||||
- ADDED: `PaymentError extends SuaveplanError<PaymentErrorCode>` with
|
||||
optional `intentId` / `currentStatus` / `targetStatus` / `ageMs`
|
||||
discriminants and 12 factory functions matching the upstream surface
|
||||
plus two genesis-only additions (`alreadyRefunded`, `invalidCard`,
|
||||
`gatewayError`, `refundIntentNotRegistered`).
|
||||
- ADDED: Subpath exports per public module — 16 entries covering every
|
||||
feature; tree-shakeable.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **`@suaveplan/idempotency` integration.** The enterprise audit calls
|
||||
for idempotency-key enforcement around every state transition; the
|
||||
upstream library accepts and passes through `idempotencyKey` but does
|
||||
not enforce dedup. The migration preserves the upstream contract;
|
||||
a follow-up `add-payment-idempotency` change wires
|
||||
`@suaveplan/idempotency` into the engine's mutating paths.
|
||||
- **`@suaveplan/schema-registry` Zod boundaries.** The enterprise audit
|
||||
asks for Zod schemas on every intent, webhook envelope, and receipt.
|
||||
Adding them would change the public types' runtime behaviour (validate
|
||||
at the boundary, throw on parse fail); deferred to a follow-up so the
|
||||
migration baseline preserves behaviour bit-for-bit. The
|
||||
`WebhookPayload<T>` generic remains unchanged.
|
||||
- **`@suaveplan/feature-flags` rollouts for risky provider behaviour.**
|
||||
Upstream does not use them; additive new feature deferred.
|
||||
- **`@suaveplan/opentelemetry` instrumentation.** Upstream emits zero
|
||||
OTel spans; a future `enterprise-payment` change adds spans, metrics,
|
||||
and counters around every entry point.
|
||||
- **`@suaveplan/retry` consumer migration.** The upstream `PaymentRetrier`
|
||||
is a private re-implementation; replacing it with
|
||||
`@suaveplan/retry` would change the public surface (different option
|
||||
field names, different `RetryResult` shape). Deferred.
|
||||
- **Property-based tests (fast-check).** The deterministic suite hits
|
||||
100% — fast-check folds in once that baseline is green, mirroring the
|
||||
money migration.
|
||||
- **Side-effect error-code registration in `ErrorCodes.registerMany`.**
|
||||
Upstream side-effect-imports the registration at `index.ts` load
|
||||
time; the genesis `@suaveplan/error` package exposes
|
||||
`defineCodes(...)` directly without a process-wide singleton, so the
|
||||
registration ceremony goes away. The discrimination contract
|
||||
(`err.code === PAYMENT_CODES.NOT_FOUND`) is preserved.
|
||||
- **Consumer migration.** No genesis consumer exists yet; downstream
|
||||
packages adopt the new contract when migrated.
|
||||
|
||||
## Source mapping
|
||||
|
||||
This change derives from `../suaveplan/enterprise/payment.md` and the
|
||||
upstream source under `../suaveplan/packages/universal/payment/src/`.
|
||||
|
||||
| Requirement | Source lines | Notes |
|
||||
|---|---|---|
|
||||
| Universal-tier purity (zero browser/Node deps) | enterprise/payment.md L37 | Reinforced; upstream webhook used `node:crypto` — replaced with `@suaveplan/crypto-hash` |
|
||||
| `PaymentIntent` shape + 9-state lifecycle | enterprise/payment.md L12, L40; upstream `src/types/payment.types.ts` L1-L166 | Verbatim |
|
||||
| `VALID_TRANSITIONS` state-machine table | upstream `src/types/payment.types.ts` L151-L165 | Verbatim |
|
||||
| `PaymentEngine` with `createIntent`/`confirmIntent`/`captureIntent`/`cancelIntent`/`refund`/`getIntent` | enterprise/payment.md L40; upstream `src/engine/payment-engine.ts` L1-L169 | Verbatim |
|
||||
| State-transition enforcement → typed `PAYMENT.INVALID_TRANSITION` | enterprise/payment.md L40, L46; upstream `src/engine/payment-engine.ts` L154-L167 | Verbatim |
|
||||
| Refund-balance guard → typed `PAYMENT.REFUND_EXCEEDS_CAPTURED` | enterprise/payment.md L46; upstream `src/engine/payment-engine.ts` L121-L127 | Verbatim |
|
||||
| Lifecycle event emission | enterprise/payment.md L12; upstream `src/engine/payment-engine.ts` L53/L68/L86/L101/L130-L135 | Verbatim (with `EventBus.emitAsync`) |
|
||||
| Webhook verification with HMAC + replay window | enterprise/payment.md L41, L43-L44; upstream `src/webhook/webhook-verifier.ts` L1-L88 | Verbatim; HMAC routed through `@suaveplan/crypto-hash` |
|
||||
| Webhook constant-time signature compare | enterprise/payment.md L41, L43; upstream `src/webhook/webhook-verifier.ts` L74-L77 | Replaced with `@suaveplan/crypto-utils.constantTimeEqual` |
|
||||
| Receipt generation | enterprise/payment.md L12; upstream `src/receipt/receipt-generator.ts` L1-L165 | Verbatim with injected `now`/`nextId` |
|
||||
| `PaymentGateway` abstract contract | upstream `src/gateway/gateway.types.ts` L1-L70 | Verbatim |
|
||||
| `TestGateway` in-memory adapter | upstream `src/gateway/test-gateway.ts` L1-L267 | Verbatim with injected `now()` |
|
||||
| `StripeGateway` Payment Intents API | upstream `src/gateway/stripe-gateway.ts` L1-L303 | Verbatim with injected `FetchLike` |
|
||||
| `PayPalGateway` Orders API v2 + token cache | upstream `src/gateway/paypal-gateway.ts` L1-L328 | Verbatim with injected `FetchLike`/`now`/`btoa` |
|
||||
| `FraudDetector` velocity / amount / customer / new-method heuristics | upstream `src/fraud/fraud-detector.ts` L1-L185 | Verbatim with injected `now()` |
|
||||
| `RefundManager` per-intent ledger | upstream `src/refund/refund-manager.ts` L1-L147 | Verbatim, throws typed `PaymentError`s instead of `Error` |
|
||||
| `PaymentRetrier` exponential backoff | upstream `src/retry/payment-retry.ts` L1-L157 | Verbatim with injected `random()`/`sleep()` |
|
||||
| `TaxCalculator` jurisdiction registry | upstream `src/tax/tax-calculator.ts` L1-L183 | Verbatim |
|
||||
| `CardTokenizer` PCI-conscious hashing | enterprise/payment.md L42; upstream `src/tokenization/card-tokenizer.ts` L1-L156 | Verbatim, throws typed `PaymentError(INVALID_CARD)`, injected `SubtleCrypto` |
|
||||
| `PaymentReconciliation` matcher | upstream `src/reconciliation/reconciliation.ts` L1-L158 | Verbatim |
|
||||
| `paymentHooks` + `onPaymentProcessing` | upstream `src/hooks.ts` L1-L60 | Re-implemented locally — genesis `@suaveplan/events` does not expose `Hooks` yet; surface preserved verbatim |
|
||||
| Stable error-code registry | enterprise/payment.md L46; upstream `src/errors/error-codes.ts` + `src/errors/payment-errors.ts` | Merged + upgraded to `defineCodes("PAYMENT", [...])`; expanded to 13 codes |
|
||||
|
||||
### Requirements with no source line
|
||||
|
||||
| Requirement | Justification |
|
||||
|---|---|
|
||||
| 100% test coverage (istanbul, all four axes) | Genesis-wide policy (CLAUDE.md §7) — upstream was at ~99% |
|
||||
| Sibling `.md` per implementation file (≥200 words, basic + advanced examples) | Genesis-wide policy (CLAUDE.md §4) — upstream had partial docs |
|
||||
| Test files import runner primitives from `@suaveplan/testing/runner` | Genesis-wide policy (AGENTS.md §7) — upstream imported `vitest` directly |
|
||||
| Explicit named re-exports in every feature-module barrel (no `export *`) | Genesis-wide policy (AGENTS.md §6a) |
|
||||
| Package version starts at `0.1.0` | Genesis-wide policy (CLAUDE.md "Editing") — upstream was `1.2.6` |
|
||||
| Injected `now()` / `random()` / `sleep()` / `fetch` / `btoa` / `SubtleCrypto` on every IO + clock boundary | Required to satisfy the no-mocks rule at 100% coverage without `vi.mock` |
|
||||
| `markFailed` / `openDispute` / `closeDispute` explicit emitters | Required so the closed `PaymentEventMap` has a production code path for every key (engine alone never emits `intent.failed`/`dispute.*`); upstream relied on the open-ended `[key: string]: unknown[]` escape hatch which `EventBus<TMap>` does not allow |
|
||||
| 13 error codes (vs upstream's 10) | Three genesis-only codes (`INVALID_CARD`, `GATEWAY_ERROR`, `REFUND_INTENT_NOT_REGISTERED`) replace upstream's raw `throw new Error(...)` at the tokeniser, gateway-adapter, and refund-manager boundaries respectively |
|
||||
|
||||
### Parity Checklist
|
||||
|
||||
| Capability | Upstream | Migrated | Notes |
|
||||
|---|---|---|---|
|
||||
| `PaymentEngine` full lifecycle surface | ✅ | ✅ | Every method preserved |
|
||||
| `VALID_TRANSITIONS` state machine | ✅ | ✅ | Verbatim, now `Object.freeze`d |
|
||||
| Typed lifecycle events | ✅ | ⬆️ Enhanced | Closed map (no `[key: string]` escape hatch); explicit emitters for failed/disputed states |
|
||||
| `WebhookVerifier` HMAC + replay window | ✅ | ⬆️ Enhanced | Universal-tier (uses `@suaveplan/crypto-hash`); injectable `now()` |
|
||||
| `ReceiptGenerator` + plain-text renderer | ✅ | ⬆️ Enhanced | Injectable `now()` + `nextId` |
|
||||
| `PaymentGateway` contract | ✅ | ✅ | Verbatim |
|
||||
| `TestGateway` | ✅ | ⬆️ Enhanced | Injectable `now()` |
|
||||
| `StripeGateway` | ✅ | ⬆️ Enhanced | Injectable `FetchLike` for tests; typed errors |
|
||||
| `PayPalGateway` | ✅ | ⬆️ Enhanced | Injectable `FetchLike`/`now`/`btoa`; typed errors |
|
||||
| `FraudDetector` | ✅ | ⬆️ Enhanced | Injectable `now()` |
|
||||
| `RefundManager` | ✅ | ⬆️ Enhanced | Throws typed `PaymentError`s; injectable `now()`/`nextId` |
|
||||
| `PaymentRetrier` | ✅ | ⬆️ Enhanced | Injectable `random()`/`sleep()` |
|
||||
| `TaxCalculator` | ✅ | ✅ | Verbatim |
|
||||
| `CardTokenizer` | ✅ | ⬆️ Enhanced | Throws typed `PaymentError(INVALID_CARD)`; injectable `SubtleCrypto`/`now()` |
|
||||
| `PaymentReconciliation` | ✅ | ⬆️ Enhanced | Injectable `now()` |
|
||||
| `paymentHooks` / `onPaymentProcessing` | ✅ | ✅ | Re-implemented locally (genesis `@suaveplan/events` lacks `Hooks`); surface preserved |
|
||||
| `PaymentError` factory functions | ✅ | ⬆️ Enhanced | Now extends `SuaveplanError<PaymentErrorCode>`; 13 codes (vs upstream 10) |
|
||||
| Subpath exports | ✅ | ⬆️ Enhanced | 16 entries (vs upstream's 12) |
|
||||
| Idempotency-key enforcement | 🚫 upstream (audit asks) | 🚫 deferred | `@suaveplan/idempotency` integration in follow-up |
|
||||
| Zod schemas at boundaries | 🚫 upstream (audit asks) | 🚫 deferred | Behaviour-preserving baseline first |
|
||||
| OTel spans / counters / histograms | 🚫 upstream (audit asks) | 🚫 deferred | `enterprise-payment` follow-up |
|
||||
| Feature flags for provider rollouts | 🚫 upstream (audit asks) | 🚫 deferred | Additive new feature |
|
||||
| Property-based tests (fast-check) | 🚫 upstream (audit asks) | 🚫 deferred | Deterministic suite hits 100% first |
|
||||
| `@suaveplan/retry` consumer migration | 🚫 upstream (audit asks) | 🚫 deferred | Changes public retry surface |
|
||||
|
||||
### Reference docs consulted
|
||||
|
||||
- ✅ `../suaveplan/packages/universal/payment/src/` — all 18 implementation files read
|
||||
- ✅ `../suaveplan/packages/universal/payment/README.md`
|
||||
- ✅ `../suaveplan/packages/universal/payment/package.json`
|
||||
- ✅ `../suaveplan/enterprise/payment.md` — full
|
||||
- 🚫 `../suaveplan/openspec/specs/payment/spec.md` — does not exist
|
||||
- 🚫 `../suaveplan/openspec/changes/*/proposal.md` touching `payment` — none beyond `enterprise-payment` itself
|
||||
|
||||
## Verification approach
|
||||
|
||||
Every requirement in the spec delta has a `**Verified by:**` line naming
|
||||
the test files that pin its scenarios. The test layout mirrors the source
|
||||
layout — one `<feature>.test.ts` per `<feature>.ts`:
|
||||
|
||||
- `src/error-codes/error-codes.test.ts` — registry, factories, discriminants
|
||||
- `src/hooks/hooks.test.ts` — generic class + singleton + priority
|
||||
- `src/engine/engine.test.ts` — every lifecycle path + transition guard +
|
||||
provider-error wrap (Error + non-Error throws) + out-of-band emitters
|
||||
- `src/webhook/webhook.test.ts` — signature determinism, tolerance, parse,
|
||||
algorithm matrix, fallback clock
|
||||
- `src/receipt/receipt.test.ts` — full render + minimal render + injected
|
||||
`nextId` + `taxLabel` fallback
|
||||
- `src/gateway/test.test.ts` — happy + failure mid-stream + validation
|
||||
- `src/gateway/stripe.test.ts` — create + capture + refund + get +
|
||||
validate via injected `FetchLike`; success / cancelled / unknown /
|
||||
HTTP-error paths
|
||||
- `src/gateway/paypal.test.ts` — auth + token cache + token refresh +
|
||||
capture + refund (with capture id) + refund (no capture) + status map
|
||||
- `src/fraud/fraud.test.ts` — every flag, threshold boundary, reset, cap
|
||||
- `src/refund/refund.test.ts` — every error code reachable, summary,
|
||||
by-reason filter, predicate, injected nextId
|
||||
- `src/retry/retry.test.ts` — success / retry-success / non-retryable
|
||||
result / non-retryable thrown / exhaustion / non-Error wrap /
|
||||
calculateDelay matrix + default sleep
|
||||
- `src/tax/tax.test.ts` — registry, region override, exclusive,
|
||||
inclusive, exempt, empty input
|
||||
- `src/tokenization/tokenization.test.ts` — tokenize / Luhn (positive +
|
||||
length + checksum) / brand (every supported pattern) / typed-error paths
|
||||
- `src/reconciliation/reconciliation.test.ts` — match / discrepancy /
|
||||
unmatched both sides / status normalisation / clock injection
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
Run `bunx turbo ls --filter=...@suaveplan/payment` to enumerate.
|
||||
|
||||
- No dependents — this is a new universal-tier package on genesis. Every
|
||||
potential downstream (`@suaveplan/payment-providers`,
|
||||
`@suaveplan/api-plugin-payment`, `@suaveplan/api-plugin-banking`,
|
||||
`@suaveplan/booking`) is still in the `Pending` list and adopts this
|
||||
package when migrated.
|
||||
|
||||
## Migration impact
|
||||
|
||||
None — additive only. No existing genesis package imports
|
||||
`@suaveplan/payment`.
|
||||
422
openspec/archive/2026-05-17-add-payment/specs/payment/spec.md
Normal file
422
openspec/archive/2026-05-17-add-payment/specs/payment/spec.md
Normal 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 -->
|
||||
88
openspec/archive/2026-05-17-add-payment/tasks.md
Normal file
88
openspec/archive/2026-05-17-add-payment/tasks.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# Tasks — add @suaveplan/payment
|
||||
|
||||
> Every checkbox MUST reflect work actually done in this change. Retroactive ticking is forbidden.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [x] 1.1 Read `../suaveplan/enterprise/payment.md` end-to-end.
|
||||
- [x] 1.2 Read upstream source at `../suaveplan/packages/universal/payment/src/` end-to-end.
|
||||
- [x] 1.3 Read `openspec/AGENTS.md` §13 + §14.
|
||||
- [x] 1.4 Draft `proposal.md` from template.
|
||||
- [x] 1.5 Fill `## Source mapping` table — every requirement cites line range or appears in the "no source line" table with justification.
|
||||
- [x] 1.6 Draft `specs/payment/spec.md` from template.
|
||||
- [x] 1.7 Every requirement carries a `**Verified by:**` line at draft time.
|
||||
|
||||
## 2. Implementation
|
||||
|
||||
- [x] 2.1 `src/types/payment.ts` — `PaymentStatus`, `PaymentMethod{,Type}`, `CardBrand`, `PaymentIntent`, `PaymentProvider`, `PaymentEventMap`, `Refund{,Status,Reason,Input}`, `Dispute{,Evidence}`, `CreatePaymentIntentInput`, `VALID_TRANSITIONS` (frozen).
|
||||
- [x] 2.2 `src/error-codes/error-codes.ts` — `PAYMENT_CODES` (13 codes) via `defineCodes`; `PaymentError extends SuaveplanError<PaymentErrorCode>` with `intentId`/`currentStatus`/`targetStatus`/`ageMs` discriminants; 12 factory functions.
|
||||
- [x] 2.3 `src/hooks/hooks.ts` — generic `Hooks<TMap>` + `paymentHooks` singleton + `onPaymentProcessing` shortcut.
|
||||
- [x] 2.4 `src/engine/engine.ts` — `PaymentEngine` over `EventBus<PaymentEventMap>`, state-machine enforcement, refund-balance guard, hook pipeline, explicit `markFailed`/`openDispute`/`closeDispute` emitters.
|
||||
- [x] 2.5 `src/webhook/webhook.ts` — `WebhookVerifier` using `@suaveplan/crypto-hash` HMAC + `@suaveplan/crypto-utils` `constantTimeEqual`; injectable `now()`.
|
||||
- [x] 2.6 `src/receipt/receipt.ts` — `ReceiptGenerator` + `resetReceiptCounter` + plain-text renderer; injectable `now()`/`nextId`.
|
||||
- [x] 2.7 `src/gateway/types.ts` — `PaymentGateway` + `GatewayChargeStatus` + `GatewayResult` + `GatewayCharge` + `ValidationResult` + `ChargeOptions`.
|
||||
- [x] 2.8 `src/gateway/test.ts` — `TestGateway` + `resetChargeCounter`; injectable `now()`.
|
||||
- [x] 2.9 `src/gateway/stripe.ts` — `StripeGateway` over injected `FetchLike`; typed-error wrap; status map.
|
||||
- [x] 2.10 `src/gateway/paypal.ts` — `PayPalGateway` over injected `FetchLike`/`now`/`btoa`; OAuth token cache.
|
||||
- [x] 2.11 `src/fraud/fraud.ts` — `FraudDetector`; injectable `now()`.
|
||||
- [x] 2.12 `src/refund/refund.ts` — `RefundManager` + `resetRefundCounter`; typed `PaymentError`s; injectable `now()`/`nextId`.
|
||||
- [x] 2.13 `src/retry/retry.ts` — `PaymentRetrier`; injectable `random()`/`sleep()`.
|
||||
- [x] 2.14 `src/tax/tax.ts` — `TaxCalculator`.
|
||||
- [x] 2.15 `src/tokenization/tokenization.ts` — `CardTokenizer`; typed `PaymentError(INVALID_CARD)`; injectable `SubtleCrypto`/`now()`.
|
||||
- [x] 2.16 `src/reconciliation/reconciliation.ts` — `PaymentReconciliation`; injectable `now()`.
|
||||
- [x] 2.17 `src/index.ts` — explicit named root barrel re-exporting every public symbol.
|
||||
- [x] 2.18 Per-feature `index.ts` barrels — explicit named re-exports only.
|
||||
- [x] 2.19 `package.json` at `0.1.0` with `workspace:^` for `@suaveplan/{error,events,money,crypto-hash,crypto-utils}` and `catalog:build` for `tslib`; 16 subpath exports; `sideEffects: false`.
|
||||
- [x] 2.20 `vite.config.ts` + `tsconfig{,.lib}.json` + `vitest.config.ts` + `tests/setup/polyfills.ts`.
|
||||
|
||||
## 3. Tests
|
||||
|
||||
- [x] 3.1 `src/error-codes/error-codes.test.ts` — registry shape; every code reachable from a factory; discriminants set / unset; cause passthrough on the three cause-bearing factories.
|
||||
- [x] 3.2 `src/hooks/hooks.test.ts` — no-op pass-through; priority ordering; async handler support; unsubscribe idempotence; clear / count; singleton + shortcut.
|
||||
- [x] 3.3 `src/engine/engine.test.ts` — every lifecycle method, every transition guard, every provider-error wrap (Error + non-Error throws), out-of-band emitters, `canTransition` matrix.
|
||||
- [x] 3.4 `src/webhook/webhook.test.ts` — sign determinism; verify valid + expired + future + bad-sig + bad-length-sig + non-hex-sig + odd-length-sig + tampered-body + invalid-JSON + custom tolerance + SHA-512 + Date.now fallback.
|
||||
- [x] 3.5 `src/receipt/receipt.test.ts` — structured generate, injected nextId + taxLabel, plain-text full + minimal render.
|
||||
- [x] 3.6 `src/gateway/test.test.ts` — happy + delay + failure + missing charge + shouldFail mid-stream for capture / refund + validate matrix.
|
||||
- [x] 3.7 `src/gateway/stripe.test.ts` — success / canceled / unknown / HTTP-error paths + capture (with/without amount) + refund (success + fallback) + get (with/without charges array) + validate matrix + baseUrl normalisation.
|
||||
- [x] 3.8 `src/gateway/paypal.test.ts` — auth + create + token cache + token refresh + auth failure + order failure + custom baseUrl + capture (with/without amount) + refund (success + no-capture + PENDING) + get (aggregation + empty units + unknown status) + validate matrix + empty body.
|
||||
- [x] 3.9 `src/fraud/fraud.test.ts` — clean allow, missing-customer flag, high-amount flag, velocity → block, between-thresholds review, score cap, reset, early-return when no customerId.
|
||||
- [x] 3.10 `src/refund/refund.test.ts` — every typed error reachable, summary balances, fullyRefunded flag, byReason filter, canRefund matrix, injected nextId.
|
||||
- [x] 3.11 `src/retry/retry.test.ts` — success / retry-success / non-retryable result / non-retryable thrown / exhaustion / non-Error wrap / default sleep / calculateDelay matrix (no-jitter / cap / +jitter / -jitter never-negative).
|
||||
- [x] 3.12 `src/tax/tax.test.ts` — fallback default, region override, removeRate, exclusive, inclusive, exempt, empty input.
|
||||
- [x] 3.13 `src/tokenization/tokenization.test.ts` — tokenize + custom prefix + bad Luhn + bad month + expired + Luhn matrix + brand matrix (8 brands + unknown).
|
||||
- [x] 3.14 `src/reconciliation/reconciliation.test.ts` — matched + discrepancy + unmatched (both sides) + status normalisation + reconciledAt clock.
|
||||
- [x] 3.15 Every `it(...)` carries `{ timeout: 5000 }`.
|
||||
|
||||
## 4. Documentation
|
||||
|
||||
- [x] 4.1 Sibling `.md` for every implementation `.ts` (16 modules) — ≥200 words, basic + advanced examples, full API reference.
|
||||
- [x] 4.2 `README.md` linking every co-located `.md` under `## Modules`.
|
||||
|
||||
## 5. Pre-archive verification (the 5-gate triple)
|
||||
|
||||
- [ ] 5.1 `bun run check` — Biome 0/0/0 across the monorepo.
|
||||
- [ ] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/payment` — green.
|
||||
- [ ] 5.3 `bunx turbo test:coverage --filter=@suaveplan/payment` — 100/100/100/100.
|
||||
- [ ] 5.4 `bunx turbo build --filter=@suaveplan/payment` — green.
|
||||
- [ ] 5.5 Banned-pattern scan (`@deprecated`/`TODO`/`FIXME`/`ts-ignore`/`ts-expect-error`/`.skip`/`.only`) — zero output.
|
||||
|
||||
## 6. Pre-archive 9-row checklist (AGENTS.md §13)
|
||||
|
||||
- [ ] 6.1 Generics threaded; no `any`.
|
||||
- [ ] 6.2 Zod schemas wired at every declared boundary — N/A (deferred per `## Out of scope`).
|
||||
- [ ] 6.3 Every error code in `PAYMENT_CODES` thrown by a production path.
|
||||
- [ ] 6.4 Strict-mode behaviours tested both on and off — N/A (no strict-mode toggle).
|
||||
- [ ] 6.5 OTel instruments emitted from production paths — N/A (deferred per `## Out of scope`).
|
||||
- [ ] 6.6 Lifecycle dedup tested where claimed — N/A (idempotency deferred).
|
||||
- [ ] 6.7 No retroactive tick.
|
||||
- [ ] 6.8 No `export *` in any feature-module barrel.
|
||||
- [ ] 6.9 No direct `vitest` / `bun:test` imports in test files.
|
||||
|
||||
## 7. Archive
|
||||
|
||||
- [ ] 7.1 Fold spec delta into `openspec/specs/payment/spec.md`.
|
||||
- [ ] 7.2 Move `openspec/changes/add-payment/` → `openspec/archive/2026-05-17-add-payment/`.
|
||||
- [ ] 7.3 Refresh `MANIFEST.md` via `bun scripts/folder-reorg/generate-manifest.ts`.
|
||||
- [ ] 7.4 Update `STATUS.md` to flip `@suaveplan/payment` from `–` → `✅`.
|
||||
- [ ] 7.5 Update `SUMMARY.md` with the shipped surface.
|
||||
- [ ] 7.6 `bunx markdownlint-cli2 --fix` across touched markdown.
|
||||
Reference in New Issue
Block a user