# Design: slim-payment-providers (foundation-with-sibling-plugins) This design doc is the **canonical cross-cutting architecture document for the `@suaveplan/payment-provider*` family**. Per `openspec/AGENTS.md` §0c (Multi-package / cross-cutting feature sets), the foundation package's `design.md` carries the shared model and each sibling package's `design.md` references this file by relative path rather than duplicating the shape. ## Why a foundation-with-sibling-plugins layout The just-shipped `2026-05-17-add-payment-providers` migration packed five concrete adapters (Stripe, PayPal, Square, Mollie, Adyen) into a single `@suaveplan/payment-providers` bundle. Consumers that only need Stripe still install — and tree-shake around — PayPal's OAuth2 token cache, Adyen's merchant-context wiring, and three unused HTTP wrappers. The bundle also gates the next provider onboarding (Razorpay, Braintree, Klarna, etc.) behind a change to a single-publish unit that already has five tenants. Genesis solves this elsewhere with a **foundation-with-sibling-plugins** shape: - `@suaveplan/network` ships the `NetworkPlugin` interface, `PluginManager`, and shared bus/protocol primitives. Concrete transports live in sibling `@suaveplan/network-plugin-*` packages. - `@suaveplan/drive` ships the VFS core + `DrivePlugin` slot. Concrete storage transports / chunk schemes / sharing logic live in sibling `@suaveplan/drive-plugin-*`, `drive-transport-*`, `drive-sharing` packages. This change retrofits the same shape onto `@suaveplan/payment-providers`: ```text @suaveplan/payment # interface owner (PaymentProvider) └── @suaveplan/payment-providers # FOUNDATION: shared types + http + registry + errors ├── @suaveplan/payment-provider-stripe # sibling: StripeProvider ├── @suaveplan/payment-provider-paypal # sibling: PayPalProvider ├── @suaveplan/payment-provider-square # sibling: SquareProvider ├── @suaveplan/payment-provider-mollie # sibling: MollieProvider └── @suaveplan/payment-provider-adyen # sibling: AdyenProvider ``` The dep graph is **acyclic** by construction: every sibling depends on the foundation; nothing depends on a sibling except the consumer that chose to install it. ## What lives in the foundation After this slim-down, `@suaveplan/payment-providers` contains exactly: ```text src/ types/ provider.ts # ApiKeyConfig, OAuthConfig, MerchantKeyConfig, # HttpOptions, ProviderResponse, # ProviderCommonOptions, NowFn, RandomIdFn, # Base64EncodeFn provider.md index.ts http/ http.ts # httpRequest, FetchLike, composeAbortSignal http.md http.test.ts index.ts error-codes/ error-codes.ts # PAYMENT_PROVIDER_CODES (6 codes), # PaymentProviderError, factories # (httpError, timeoutError, parseError, # oauthTokenFailure, providerNotRegistered, # providerAlreadyRegistered) error-codes.md error-codes.test.ts index.ts registry/ registry.ts # ProviderRegistry class registry.md registry.test.ts define.ts # definePaymentProvider factory define.md define.test.ts index.ts index.ts # explicit named barrel (rule 6a) tests/ dep-graph.test.ts # universal-tier purity grep setup/polyfills.ts ``` What is **gone**: the entire `src/providers/{stripe,paypal,square,mollie,adyen}/` tree (5 dirs × ~5 files each), and the matching 5 subpath exports in `package.json`. ## ProviderRegistry shape ```ts export class ProviderRegistry { public register(provider: PaymentProvider): void; public unregister(name: string): boolean; public get(name: string): PaymentProvider; // throws on miss public has(name: string): boolean; public list(): readonly PaymentProvider[]; public readonly size: number; } ``` Semantics: - `register(provider)` indexes the instance by `provider.name`. Re-registering the same name throws `PaymentProviderError(PROVIDER_ALREADY_REGISTERED)`. - `unregister(name)` returns `true` if a provider was removed, `false` otherwise. Idempotent for the absent case (no throw). - `get(name)` throws `PaymentProviderError(PROVIDER_NOT_REGISTERED)` when the name is absent — strict mode keeps consumer code from silently routing to `undefined`. - `has(name)` is the soft-lookup counterpart for callers that prefer flow control over exception handling. - `list()` returns a frozen snapshot in insertion order so callers can enumerate without mutating the registry. - `size` is a numeric getter, not a method, mirroring `Map`/`Set`. The registry is **process-local** and **synchronous**. There is no persistence, no auto-discovery, no plugin-loader scan. Consumers explicitly `import` + `register` the providers they intend to route to. ## definePaymentProvider factory ```ts export function definePaymentProvider( spec: PaymentProviderSpec ): PaymentProvider & { readonly name: TName }; ``` The spec captures the same six methods as `PaymentProvider` (`createIntent`, `confirmIntent`, `captureIntent`, `cancelIntent`, `refund`, `getIntent`) plus a literal-typed `name`. The factory: 1. Builds a plain object with the supplied methods bound to the spec (no `this` rebinding — sibling packages can keep methods as arrow functions referencing closed-over config). 2. `Object.freeze`s the result so accidental mutation downstream throws in strict mode. 3. Returns the result narrowed by the literal `TName` so consumers can discriminate at the type level (`provider.name === "stripe"` narrows `provider` to the Stripe shape). The factory is **opt-in**: sibling packages MAY return a plain class instance from their `new XProvider(...)` constructor instead — both satisfy the structural `PaymentProvider` contract and both register identically into `ProviderRegistry`. The factory exists so providers without per-instance state (rare but possible — e.g. a deterministic test provider) can avoid the class boilerplate. ## composeAbortSignal — extracted public helper `httpRequest` already composed a caller-supplied `signal` with its internal timeout signal. The slim-down extracts that composition into a named exported helper so sibling provider packages can reuse it when they build their own HTTP wrappers (e.g. PayPal's OAuth token fetch sits outside the per-call `httpRequest` and needs the same composition). ```ts export function composeAbortSignal( internal: AbortSignal, external: AbortSignal | undefined ): AbortSignal; ``` Behaviour is byte-identical to the previously-private helper — extraction is purely a public-surface change. ## Sibling-package contract Every `@suaveplan/payment-provider-` sibling MUST: 1. Declare `@suaveplan/payment-providers` and `@suaveplan/payment` as `workspace:^` deps (catalog externals for `@suaveplan/error`, `tslib`). 2. Export exactly one class (`Provider`) implementing the `PaymentProvider` interface from `@suaveplan/payment`. 3. Construct via an `Config` extending one of `ApiKeyConfig`, `OAuthConfig`, or `MerchantKeyConfig` from this foundation. Provider- specific config fields (`webhookSecret`, `merchantAccount`, `randomId`, etc.) extend the base. 4. Use `httpRequest` from this foundation for every HTTP call. 5. Raise only `PaymentProviderError` (no `throw new Error(...)`). 6. Set `provider.name` to a literal string matching the package's short name (`"stripe"`, `"paypal"`, etc.). 7. Ship at the universal tier (`"sideEffects": false`, no Node/browser/ React imports). A sibling package MAY: - Extend `PAYMENT_PROVIDER_CODES` with provider-specific codes (e.g. `STRIPE_API_VERSION_MISMATCH`) via its own `defineCodes("STRIPE", ...)` registry. - Re-export the foundation's `ProviderRegistry` for downstream convenience, but MUST NOT instantiate a singleton registry at module load. - Declare additional injectable seams in its config beyond `fetch`, `now`, `randomId`, `base64Encode`, `signal`, `timeoutMs`. A sibling package MUST NOT: - Re-export concrete adapter classes from sibling packages (rule 4 — no re-exports of external deps for convenience). - Mutate the `PAYMENT_PROVIDER_CODES` registry from this foundation. - Depend on another `@suaveplan/payment-provider-*` sibling. ## Public-surface contract that sibling packages consume Every sibling package will import the following from `@suaveplan/payment-providers`: ```ts import { // registry ProviderRegistry, definePaymentProvider, // http httpRequest, composeAbortSignal, type FetchLike, // types type ApiKeyConfig, type OAuthConfig, type MerchantKeyConfig, type ProviderCommonOptions, type HttpOptions, type ProviderResponse, type NowFn, type RandomIdFn, type Base64EncodeFn, // errors PaymentProviderError, PAYMENT_PROVIDER_CODES, type PaymentProviderErrorCode, type PaymentProviderErrorOptions, httpError, timeoutError, parseError, oauthTokenFailure, providerNotRegistered, providerAlreadyRegistered, } from "@suaveplan/payment-providers"; import type { PaymentProvider } from "@suaveplan/payment"; ``` This is the closed contract — the foundation will not grow new symbols without a follow-up OpenSpec change naming the sibling-package impact. ## File-layout decisions - `registry/` is a single feature dir with two files (`registry.ts` and `define.ts`). Both belong to the same conceptual surface (constructing - indexing providers) and both are small enough that a separate dir per file would inflate the manifest. The dir has one `index.ts` barrel that explicitly re-exports both surfaces. - The Square-specific `SquareConfig` type is moved into the `@suaveplan/payment-provider-square` package — the foundation must not carry provider-specific shapes (every config field in the foundation applies to every provider, otherwise it doesn't belong here). - Removed providers are deleted in full (not stub-replaced) so the gate scans (§13 step 4, 4a, 5) stay clean. ## Out of scope - Auto-discovery via the `package.json` `peerDependencies` graph (deferred — explicit registration is clearer for now). - A `loadProvider(name: string): Promise` dynamic- import helper (deferred until consumers ask for it). - OTel spans / counters on `ProviderRegistry.{register,get}` (deferred — aligns with the parent migration's deferral). - A separate `payment-providers-react` browser-tier package for hook- based consumption (not required — registry is universal-tier; React consumers wrap it in a `useMemo`).