281 lines
18 KiB
Markdown
281 lines
18 KiB
Markdown
# 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`.
|