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

11 KiB
Raw Permalink Blame History

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:

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

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:

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

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:

  1. Declare @suaveplan/payment-providers and @suaveplan/payment as workspace:^ deps (catalog externals for @suaveplan/error, tslib).
  2. Export exactly one class (<Name>Provider) implementing the PaymentProvider interface from @suaveplan/payment.
  3. Construct via an <Name>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:

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<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-react browser-tier package for hook- based consumption (not required — registry is universal-tier; React consumers wrap it in a useMemo).