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

274 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`).