274 lines
11 KiB
Markdown
274 lines
11 KiB
Markdown
# 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<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.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-<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`:
|
||
|
||
```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<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`).
|