Initial Commit
This commit is contained in:
273
openspec/archive/2026-05-17-slim-payment-providers/design.md
Normal file
273
openspec/archive/2026-05-17-slim-payment-providers/design.md
Normal file
@@ -0,0 +1,273 @@
|
||||
# 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`).
|
||||
Reference in New Issue
Block a user