18 KiB
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:
- Typed
PaymentErrorextendingSuaveplanError<PaymentErrorCode>replaces the upstreamBaseErrorand the side-effect-importedErrorCodes.registerManymechanism. ThePAYMENT_CODESregistry is declared viadefineCodes("PAYMENT", [...])(13 codes), and every formerthrow new Error(...)boundary upgrades to a typed factory. - Universal-tier purity — the upstream webhook verifier imported
node:crypto; we route HMAC through@suaveplan/crypto-hashand constant-time compare through@suaveplan/crypto-utils. The card tokenizer keeps usingSubtleCrypto(already universal). The PayPal adapter accepts injectedbtoato keep the universal contract on runtimes that name it differently. - Injectable boundaries — every
Date.now()/Math.random()/fetch/setTimeoutuse is hoisted to a config option so tests are deterministic withoutvi.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 aPaymentProvider,EventBus<PaymentEventMap>-backed lifecycle events (intent.created/confirmed/captured/canceled/failed,refund.created/succeeded/failed,dispute.opened/closed),VALID_TRANSITIONSenforcement, refund-balance guard, hook pipeline. ExplicitmarkFailed/openDispute/closeDisputeemitters so the closed event map is fully reachable. - ADDED:
WebhookVerifier— HMAC-SHA-{256,384,512} overtimestamp.body, constant-time signature compare via@suaveplan/crypto-utils, replay window via configurabletoleranceMs, injectednow()clock. - ADDED:
ReceiptGenerator+resetReceiptCounter— structuredReceiptrecords + plain-text renderer with injectednow()andnextId. - ADDED:
PaymentGatewayinterface +GatewayChargeStatus/GatewayResult/GatewayCharge/ValidationResult/ChargeOptionstypes. - ADDED:
TestGateway+resetChargeCounter— in-memory adapter with recorded operations log, configurable failure modes, injectednow(). - ADDED:
StripeGateway— Payment Intents API via injectedFetchLike; defaultsapiVersionto2024-12-18.acacia; constant-time-safe. - ADDED:
PayPalGateway— Orders API v2 via injectedFetchLike; token cache uses injectednow(); injectedbtoafor universal Base-64. - ADDED:
FraudDetector— velocity / amount / customer / new-method heuristics with configurable thresholds + injectednow(). - ADDED:
RefundManager+resetRefundCounter— per-intent refund ledger raising typedPaymentError(PAYMENT.REFUND_INTENT_NOT_REGISTERED)orPaymentError(PAYMENT.REFUND_EXCEEDS_CAPTURED). - ADDED:
PaymentRetrier— exponential backoff + jitter, non-retryable code blocklist, injectedrandom()+sleep(). - ADDED:
TaxCalculator— region → country → default fallback, inclusive- exclusive pricing, exempt items.
- ADDED:
CardTokenizer— Luhn + brand detection + HMAC-SHA-256 over injectedSubtleCrypto; typedPaymentError(PAYMENT.INVALID_CARD)on validation failure. - ADDED:
PaymentReconciliation— internal-vs-gateway matcher with normalised status comparison, orphan detection on both sides, injectednow(). - ADDED:
paymentHookssingleton +Hooks<TMap>class +onPaymentProcessingshortcut — package-local replacement for the upstream@suaveplan/events/hooksimport (the genesis events package does not yet exposeHooks; reusing the contract avoids a follow-up rewrite when it does). - ADDED:
PAYMENT_CODESregistry viadefineCodes("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 optionalintentId/currentStatus/targetStatus/ageMsdiscriminants 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/idempotencyintegration. The enterprise audit calls for idempotency-key enforcement around every state transition; the upstream library accepts and passes throughidempotencyKeybut does not enforce dedup. The migration preserves the upstream contract; a follow-upadd-payment-idempotencychange wires@suaveplan/idempotencyinto the engine's mutating paths.@suaveplan/schema-registryZod 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. TheWebhookPayload<T>generic remains unchanged.@suaveplan/feature-flagsrollouts for risky provider behaviour. Upstream does not use them; additive new feature deferred.@suaveplan/opentelemetryinstrumentation. Upstream emits zero OTel spans; a futureenterprise-paymentchange adds spans, metrics, and counters around every entry point.@suaveplan/retryconsumer migration. The upstreamPaymentRetrieris a private re-implementation; replacing it with@suaveplan/retrywould change the public surface (different option field names, differentRetryResultshape). 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 atindex.tsload time; the genesis@suaveplan/errorpackage exposesdefineCodes(...)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 PaymentErrors 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.freezed |
| 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 PaymentErrors; 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.mdtouchingpayment— none beyondenterprise-paymentitself
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, discriminantssrc/hooks/hooks.test.ts— generic class + singleton + prioritysrc/engine/engine.test.ts— every lifecycle path + transition guard + provider-error wrap (Error + non-Error throws) + out-of-band emitterssrc/webhook/webhook.test.ts— signature determinism, tolerance, parse, algorithm matrix, fallback clocksrc/receipt/receipt.test.ts— full render + minimal render + injectednextId+taxLabelfallbacksrc/gateway/test.test.ts— happy + failure mid-stream + validationsrc/gateway/stripe.test.ts— create + capture + refund + get + validate via injectedFetchLike; success / cancelled / unknown / HTTP-error pathssrc/gateway/paypal.test.ts— auth + token cache + token refresh + capture + refund (with capture id) + refund (no capture) + status mapsrc/fraud/fraud.test.ts— every flag, threshold boundary, reset, capsrc/refund/refund.test.ts— every error code reachable, summary, by-reason filter, predicate, injected nextIdsrc/retry/retry.test.ts— success / retry-success / non-retryable result / non-retryable thrown / exhaustion / non-Error wrap / calculateDelay matrix + default sleepsrc/tax/tax.test.ts— registry, region override, exclusive, inclusive, exempt, empty inputsrc/tokenization/tokenization.test.ts— tokenize / Luhn (positive + length + checksum) / brand (every supported pattern) / typed-error pathssrc/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 thePendinglist and adopts this package when migrated.
Migration impact
None — additive only. No existing genesis package imports
@suaveplan/payment.