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`.
|
||||
Reference in New Issue
Block a user