Files
2026-05-27 23:29:53 +01:00

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:

  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 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.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.