Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View 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`).