11 KiB
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/networkships theNetworkPlugininterface,PluginManager, and shared bus/protocol primitives. Concrete transports live in sibling@suaveplan/network-plugin-*packages.@suaveplan/driveships the VFS core +DrivePluginslot. Concrete storage transports / chunk schemes / sharing logic live in sibling@suaveplan/drive-plugin-*,drive-transport-*,drive-sharingpackages.
This change retrofits the same shape onto @suaveplan/payment-providers:
@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:
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
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 byprovider.name. Re-registering the same name throwsPaymentProviderError(PROVIDER_ALREADY_REGISTERED).unregister(name)returnstrueif a provider was removed,falseotherwise. Idempotent for the absent case (no throw).get(name)throwsPaymentProviderError(PROVIDER_NOT_REGISTERED)when the name is absent — strict mode keeps consumer code from silently routing toundefined.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.sizeis a numeric getter, not a method, mirroringMap/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
export function definePaymentProvider<TName extends string>(
spec: PaymentProviderSpec<TName>
): 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:
- Builds a plain object with the supplied methods bound to the spec
(no
thisrebinding — sibling packages can keep methods as arrow functions referencing closed-over config). Object.freezes the result so accidental mutation downstream throws in strict mode.- Returns the result narrowed by the literal
TNameso consumers can discriminate at the type level (provider.name === "stripe"narrowsproviderto 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).
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-<name> sibling MUST:
- Declare
@suaveplan/payment-providersand@suaveplan/paymentasworkspace:^deps (catalog externals for@suaveplan/error,tslib). - Export exactly one class (
<Name>Provider) implementing thePaymentProviderinterface from@suaveplan/payment. - Construct via an
<Name>Configextending one ofApiKeyConfig,OAuthConfig, orMerchantKeyConfigfrom this foundation. Provider- specific config fields (webhookSecret,merchantAccount,randomId, etc.) extend the base. - Use
httpRequestfrom this foundation for every HTTP call. - Raise only
PaymentProviderError(nothrow new Error(...)). - Set
provider.nameto a literal string matching the package's short name ("stripe","paypal", etc.). - Ship at the universal tier (
"sideEffects": false, no Node/browser/ React imports).
A sibling package MAY:
- Extend
PAYMENT_PROVIDER_CODESwith provider-specific codes (e.g.STRIPE_API_VERSION_MISMATCH) via its owndefineCodes("STRIPE", ...)registry. - Re-export the foundation's
ProviderRegistryfor 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_CODESregistry 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:
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.tsanddefine.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.tsbarrel that explicitly re-exports both surfaces.
- indexing providers) and both are small enough that a separate dir
per file would inflate the manifest. The dir has one
- The Square-specific
SquareConfigtype is moved into the@suaveplan/payment-provider-squarepackage — 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.jsonpeerDependenciesgraph (deferred — explicit registration is clearer for now). - A
loadProvider(name: string): Promise<PaymentProvider>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-reactbrowser-tier package for hook- based consumption (not required — registry is universal-tier; React consumers wrap it in auseMemo).