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

View File

@@ -0,0 +1,231 @@
# Slim @suaveplan/payment-providers — extract concrete provider modules into sibling packages
**Package**: `@suaveplan/payment-providers`
**Status**: PROPOSED
**Type**: refactor (modification of the just-shipped migration `2026-05-17-add-payment-providers`)
- **Design**: ./design.md
- **Spec delta**: ./specs/payment-providers/spec.md
## Why
The just-shipped migration (`openspec/archive/2026-05-17-add-payment-providers/`)
folded five concrete provider adapters (Stripe, PayPal, Square, Mollie, Adyen)
into a single `@suaveplan/payment-providers` package. That bundle wired the
shared shape (`PaymentProviderError`, `httpRequest`, `FetchLike`,
`ApiKeyConfig`, `OAuthConfig`, `MerchantKeyConfig`) and the five adapter
classes into one publish unit.
Consumers that only need Stripe pay for PayPal's OAuth machinery, Adyen's
merchant-context wiring, and three more provider trees they never call.
`@suaveplan/payment` already depends on this package transitively for its
`PaymentProvider` interface; growing it with every new provider compounds the
problem.
The genesis pattern for foundation-with-sibling-plugins (see
`@suaveplan/network` + `network-plugin-*`, `@suaveplan/drive` +
`drive-plugin-*`) is to ship the core abstraction + registry in the
foundation package and the concrete plugins in sibling packages. This
change retrofits `@suaveplan/payment-providers` to match that pattern:
1. **Slim the core** — `@suaveplan/payment-providers` keeps only the shared
types (`ApiKeyConfig`, `OAuthConfig`, `MerchantKeyConfig`, `HttpOptions`,
`ProviderResponse`, `ProviderCommonOptions`, `NowFn`, `RandomIdFn`,
`Base64EncodeFn`, `FetchLike`), the `httpRequest` helper, the
`PaymentProviderError` + `PAYMENT_PROVIDER_CODES` registry, plus the
new `ProviderRegistry` + `definePaymentProvider` factory.
2. **Add `ProviderRegistry`** — a thin in-memory registry that stores
`PaymentProvider` instances by name with `register`/`get`/`has`/`list`/
`unregister`, raising `PaymentProviderError(PROVIDER_NOT_REGISTERED)`
on unknown lookups.
3. **Add `definePaymentProvider`** — a factory helper that returns a
`PaymentProvider`-shaped object with a stable identity so sibling
provider packages can build adapters without duplicating boilerplate.
4. **Remove the five concrete provider trees** (Stripe / PayPal / Square /
Mollie / Adyen). They re-land as 5 sibling packages
(`@suaveplan/payment-provider-stripe`, `-paypal`, `-square`, `-mollie`,
`-adyen`) in a follow-up wave, each depending on this slimmed core.
## What changes
### REMOVED
- `StripeProvider` (entire `src/providers/stripe/` tree, including tests +
`.md`). Re-lands as `@suaveplan/payment-provider-stripe.StripeProvider`.
- `PayPalProvider` (entire `src/providers/paypal/` tree). Re-lands as
`@suaveplan/payment-provider-paypal.PayPalProvider`.
- `SquareProvider` (entire `src/providers/square/` tree). Re-lands as
`@suaveplan/payment-provider-square.SquareProvider`.
- `MollieProvider` (entire `src/providers/mollie/` tree). Re-lands as
`@suaveplan/payment-provider-mollie.MollieProvider`.
- `AdyenProvider` (entire `src/providers/adyen/` tree). Re-lands as
`@suaveplan/payment-provider-adyen.AdyenProvider`.
- Subpath exports `./providers/{stripe,paypal,square,mollie,adyen}/*`
from `package.json#exports`.
- `SquareConfig` type alias (Square-specific extension of `ApiKeyConfig`
with `randomId`). The sibling package re-declares it locally — the core
ships only provider-agnostic config shapes.
### ADDED
- `ProviderRegistry` class with `register(provider)`, `get(name)`,
`has(name)`, `list()`, `unregister(name)`, and `size`. Lookup miss
throws `PaymentProviderError(PROVIDER_NOT_REGISTERED)` from `get`;
duplicate registration throws `PROVIDER_ALREADY_REGISTERED`.
- `definePaymentProvider<TName extends string>(spec)` factory returning a
`PaymentProvider & { readonly name: TName }`. Preserves caller-supplied
method identities; freezes the resulting object so sibling packages can
hand the value straight to `registry.register`.
- `PROVIDER_NOT_REGISTERED` and `PROVIDER_ALREADY_REGISTERED` codes added
to `PAYMENT_PROVIDER_CODES`.
- `composeAbortSignal(internal, external)` extracted from `httpRequest`
and re-exported as a public surface so sibling provider packages can
compose their own signals without re-implementing the helper.
### MODIFIED
- `package.json#exports` shrinks from 9 entries (5 providers + 4 core) to
5 entries (`.`, `./types/provider`, `./error-codes/error-codes`,
`./http/http`, `./registry/registry`).
- `README.md` rewritten to describe the slim core + sibling package
ecosystem instead of the 5-adapter bundle.
- `package.json#description` rewritten to reflect the slim shape.
- `vite.config.ts#rollupOptions.external` stays unchanged
(`@suaveplan/error`, `@suaveplan/payment`, `tslib`); no new externals
are introduced.
## Migration
Consumers that imported a concrete provider from
`@suaveplan/payment-providers` MUST switch to the matching sibling package.
The provider class names, constructors, and `PaymentProvider` interface
contract are preserved verbatim — only the package name changes.
Search command:
```bash
grep -rln "from ['\"]@suaveplan/payment-providers['\"]" packages/ \
| xargs grep -l "StripeProvider\|PayPalProvider\|SquareProvider\|MollieProvider\|AdyenProvider"
```
Replacement table:
| Old (pre-slim) | New (post-slim) |
|---|---|
| `import { StripeProvider } from "@suaveplan/payment-providers"` | `import { StripeProvider } from "@suaveplan/payment-provider-stripe"` |
| `import { PayPalProvider } from "@suaveplan/payment-providers"` | `import { PayPalProvider } from "@suaveplan/payment-provider-paypal"` |
| `import { SquareProvider } from "@suaveplan/payment-providers"` | `import { SquareProvider } from "@suaveplan/payment-provider-square"` |
| `import { MollieProvider } from "@suaveplan/payment-providers"` | `import { MollieProvider } from "@suaveplan/payment-provider-mollie"` |
| `import { AdyenProvider } from "@suaveplan/payment-providers"` | `import { AdyenProvider } from "@suaveplan/payment-provider-adyen"` |
| `import type { SquareConfig } from "@suaveplan/payment-providers"` | `import type { SquareConfig } from "@suaveplan/payment-provider-square"` |
`ApiKeyConfig`, `OAuthConfig`, `MerchantKeyConfig`, `HttpOptions`,
`ProviderResponse`, `ProviderCommonOptions`, `NowFn`, `RandomIdFn`,
`Base64EncodeFn`, `FetchLike`, `httpRequest`, `composeAbortSignal`,
`PaymentProviderError`, `PAYMENT_PROVIDER_CODES`, `httpError`,
`timeoutError`, `parseError`, `oauthTokenFailure` continue to be exported
from `@suaveplan/payment-providers` unchanged.
Consumers that want a name-keyed lookup of multiple providers SHOULD now
build a `ProviderRegistry`:
```ts
import { ProviderRegistry } from "@suaveplan/payment-providers";
import { StripeProvider } from "@suaveplan/payment-provider-stripe";
import { PayPalProvider } from "@suaveplan/payment-provider-paypal";
const registry = new ProviderRegistry();
registry.register(new StripeProvider({ apiKey: STRIPE_KEY }));
registry.register(new PayPalProvider({ clientId, clientSecret }));
const stripe = registry.get("stripe"); // PaymentProvider
```
## Source mapping
Kept-core requirements derive from `../suaveplan/enterprise/payment-providers.md`
and the just-shipped `openspec/archive/2026-05-17-add-payment-providers/`
delta. Removed-provider requirements derive from the same archived delta:
| Surface | Source | Status |
|---|---|---|
| Universal-tier purity | `enterprise/payment-providers.md` L37 | kept |
| `PAYMENT_PROVIDER_CODES` registry + `PaymentProviderError` | `enterprise/payment-providers.md` L42, L61 | kept; codes extended with `PROVIDER_NOT_REGISTERED`, `PROVIDER_ALREADY_REGISTERED` |
| `httpRequest` + `FetchLike` + timeout/abort | `archive/2026-05-17-add-payment-providers/specs/payment-providers/spec.md` Req "httpRequest helper..." | kept; `composeAbortSignal` now publicly exported |
| `ApiKeyConfig` / `OAuthConfig` / `MerchantKeyConfig` / `HttpOptions` / `ProviderResponse` / `NowFn` / `RandomIdFn` / `Base64EncodeFn` | `archive/...` spec L62-L96 | kept |
| `StripeProvider` full lifecycle | `archive/...` spec Req L100-L138 | REMOVED — re-lands in `@suaveplan/payment-provider-stripe` |
| `PayPalProvider` OAuth + lifecycle | `archive/...` spec Req L142-L187 | REMOVED — re-lands in `@suaveplan/payment-provider-paypal` |
| `SquareProvider` + injectable `randomId` | `archive/...` spec Req L191-L224 | REMOVED — re-lands in `@suaveplan/payment-provider-square` |
| `MollieProvider` + string-amount marshalling | `archive/...` spec Req L228-L258 | REMOVED — re-lands in `@suaveplan/payment-provider-mollie` |
| `AdyenProvider` + merchant context | `archive/...` spec Req L262-L293 | REMOVED — re-lands in `@suaveplan/payment-provider-adyen` |
| `ProviderRegistry` | new genesis-originated | ADDED |
| `definePaymentProvider` factory | new genesis-originated | ADDED |
| `composeAbortSignal` public export | extracted from internal `http.ts` | ADDED |
## Out of scope
- The 5 sibling provider packages themselves (`payment-provider-stripe`,
`-paypal`, `-square`, `-mollie`, `-adyen`) — those are a follow-up wave,
one OpenSpec change per package, each depending on this slimmed core.
- Auto-discovery / dynamic loading of provider packages. Consumers
explicitly install + register the providers they need.
- `OpenTelemetry` spans / counters on the registry — same deferral as the
parent migration (align with `@suaveplan/payment`).
## Impact
### Affected dependents
Run `bunx turbo ls --filter=...@suaveplan/payment-providers` to enumerate.
- `@suaveplan/payment` — [ ] tests run / [ ] docs reviewed / [x] no
action needed (reason: imports only the `PaymentProvider` interface from
itself; does NOT import any concrete adapter or shared type from
`@suaveplan/payment-providers`. Verified by
`grep -rn "@suaveplan/payment-providers" packages/universal/payment/`
→ empty result).
Future dependents (the 5 sibling provider packages) will each carry their
own OpenSpec change naming this slimmed core as a peer/runtime dep.
| Future sibling package | Will depend on this core for |
|---|---|
| `@suaveplan/payment-provider-stripe` | `httpRequest`, `composeAbortSignal`, `ApiKeyConfig`, `PaymentProviderError`, `PAYMENT_PROVIDER_CODES`, `definePaymentProvider`, optionally `ProviderRegistry` |
| `@suaveplan/payment-provider-paypal` | `httpRequest`, `composeAbortSignal`, `OAuthConfig`, `NowFn`, `Base64EncodeFn`, `PaymentProviderError`, `oauthTokenFailure`, `definePaymentProvider` |
| `@suaveplan/payment-provider-square` | `httpRequest`, `ApiKeyConfig`, `RandomIdFn`, `PaymentProviderError`, `definePaymentProvider` |
| `@suaveplan/payment-provider-mollie` | `httpRequest`, `ApiKeyConfig`, `PaymentProviderError`, `definePaymentProvider` |
| `@suaveplan/payment-provider-adyen` | `httpRequest`, `MerchantKeyConfig`, `RandomIdFn`, `PaymentProviderError`, `definePaymentProvider` |
## Parity Checklist
This is a slim-down refactor, not a fresh migration, so the parity bar is
against the just-shipped `2026-05-17-add-payment-providers` delta — not an
upstream library:
| Capability | Prior shape | Slimmed shape | Notes |
|---|---|---|---|
| `httpRequest` + `FetchLike` + timeout/abort | yes | yes | unchanged behaviour; `composeAbortSignal` extracted as public export |
| `PaymentProviderError` + 4-code registry | yes | yes (+ 2 codes) | adds `PROVIDER_NOT_REGISTERED`, `PROVIDER_ALREADY_REGISTERED` |
| `ApiKeyConfig` / `OAuthConfig` / `MerchantKeyConfig` / `HttpOptions` / `ProviderResponse` / `ProviderCommonOptions` / `NowFn` / `RandomIdFn` / `Base64EncodeFn` | yes | yes | verbatim |
| `SquareConfig` | yes | 🚫 removed | Square-specific; sibling package re-declares locally so the core has no provider-specific shapes |
| 5 concrete provider classes | yes | 🚫 removed | re-land in 5 sibling packages (`payment-provider-{stripe,paypal,square,mollie,adyen}`) per the follow-up wave |
| `ProviderRegistry` + `definePaymentProvider` | 🚫 absent | ✅ added | new foundation API for the sibling-plugin pattern |
## Verification approach
- `src/registry/registry.test.ts` — exercises every `ProviderRegistry`
method (`register`, `get`, `has`, `list`, `unregister`, `size`),
duplicate-registration error, unknown-name error, both new error codes
reachable from real call sites.
- `src/registry/define.test.ts` — exercises `definePaymentProvider`,
confirms it returns a frozen `PaymentProvider`, preserves method
identities, and the result registers cleanly into `ProviderRegistry`.
- `src/error-codes/error-codes.test.ts` — extended to cover both new
codes and the two new factories (`providerNotRegistered`,
`providerAlreadyRegistered`).
- `src/http/http.test.ts` — extended to cover the now-public
`composeAbortSignal` helper end-to-end.
- `tests/dep-graph.test.ts` — unchanged; still asserts the package has
no Node / browser / runtime-specific imports.
- `src/index.ts` barrel + `package.json#exports` — manually verified to
contain only the slim core symbols / paths.

View File

@@ -0,0 +1,195 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: payment-providers (slim-payment-providers)
**Package**: `@suaveplan/payment-providers`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at draft time.
> This delta MODIFIES the canonical spec produced by
> `2026-05-17-add-payment-providers`. The `## REMOVED Requirements`
> block strips the five concrete-provider requirements that will re-land
> in sibling packages; the `## ADDED Requirements` block introduces the
> registry surface that grows in their place.
## REMOVED Requirements
### Requirement: StripeProvider full lifecycle
The `StripeProvider` class and its entire requirement block (status
mapping, refund-reason mapping, idempotency-key forwarding,
`StripeProvider.name === "stripe"`) is removed from this package. The
same surface re-lands in `@suaveplan/payment-provider-stripe` with the
class and `**Verified by:**` test names preserved verbatim.
---
### Requirement: PayPalProvider OAuth token cache and order lifecycle
The `PayPalProvider` class and its entire requirement block (OAuth2
client-credentials flow, injectable `now()` cache TTL, injectable
`base64Encode`, idempotency-key forwarding, status mapping,
`PayPalProvider.name === "paypal"`, `OAUTH_TOKEN_FAILURE` wrapping) is
removed from this package. The same surface re-lands in
`@suaveplan/payment-provider-paypal` with the class and `**Verified by:**`
test names preserved verbatim. The `OAUTH_TOKEN_FAILURE` code and the
`oauthTokenFailure` factory remain in this foundation so the sibling
package can raise them.
---
### Requirement: SquareProvider lifecycle with injectable randomId
The `SquareProvider` class, its entire requirement block, and the
Square-specific `SquareConfig` type are removed from this package. The
class and config re-land in `@suaveplan/payment-provider-square` with
the `**Verified by:**` test names preserved verbatim.
---
### Requirement: MollieProvider lifecycle with string-amount marshalling
The `MollieProvider` class and its entire requirement block are removed
from this package. The class re-lands in
`@suaveplan/payment-provider-mollie` with the `**Verified by:**` test
names preserved verbatim.
---
### Requirement: AdyenProvider lifecycle with merchant-account context
The `AdyenProvider` class and its entire requirement block are removed
from this package. The class re-lands in
`@suaveplan/payment-provider-adyen` with the `**Verified by:**` test
names preserved verbatim.
---
## ADDED Requirements
### Requirement: ProviderRegistry indexes PaymentProvider instances by name
The package SHALL export a `ProviderRegistry` class that indexes
`PaymentProvider` instances (from `@suaveplan/payment`) by their `name`
field. The class SHALL expose `register(provider)`,
`unregister(name)`, `get(name)`, `has(name)`, `list()`, and a numeric
`size` getter. `register` SHALL throw
`PaymentProviderError(PAYMENT_PROVIDER.PROVIDER_ALREADY_REGISTERED)`
when a provider with the same name is already present. `get` SHALL
throw `PaymentProviderError(PAYMENT_PROVIDER.PROVIDER_NOT_REGISTERED)`
when the requested name is absent. `list()` SHALL return a frozen
snapshot in insertion order so callers cannot mutate the registry
through the returned array.
#### Scenario: register + get round-trips a provider
- **WHEN** a caller invokes `registry.register(stripe)` followed by `registry.get("stripe")`
- **THEN** the second call returns the same `PaymentProvider` reference
#### Scenario: get on an unknown name throws PROVIDER_NOT_REGISTERED
- **WHEN** `registry.get("nope")` is invoked on an empty registry
- **THEN** the call throws a `PaymentProviderError` whose `code === "PAYMENT_PROVIDER.PROVIDER_NOT_REGISTERED"` and whose `provider === "nope"`
#### Scenario: duplicate register throws PROVIDER_ALREADY_REGISTERED
- **WHEN** a provider with `name: "stripe"` is registered twice
- **THEN** the second call throws a `PaymentProviderError` whose `code === "PAYMENT_PROVIDER.PROVIDER_ALREADY_REGISTERED"` and whose `provider === "stripe"`
#### Scenario: unregister returns true on hit and false on miss
- **WHEN** `registry.unregister("stripe")` is invoked after a successful registration
- **THEN** the call returns `true`, a subsequent `registry.has("stripe")` returns `false`, and a second `registry.unregister("stripe")` returns `false`
#### Scenario: list returns a frozen snapshot in insertion order
- **WHEN** three providers are registered in order A, B, C and `registry.list()` is invoked
- **THEN** the returned array equals `[A, B, C]` and is frozen (`Object.isFrozen` is `true`)
**Verified by:** `src/registry/registry.test.ts::"register + get round-trips a provider"` AND `src/registry/registry.test.ts::"throws PROVIDER_NOT_REGISTERED on unknown name"` AND `src/registry/registry.test.ts::"throws PROVIDER_ALREADY_REGISTERED on duplicate name"` AND `src/registry/registry.test.ts::"unregister returns true on hit and false on miss"` AND `src/registry/registry.test.ts::"list returns a frozen snapshot in insertion order"` AND `src/registry/registry.test.ts::"size tracks register and unregister"` AND `src/registry/registry.test.ts::"has reports membership without throwing"`
---
### Requirement: definePaymentProvider factory returns a frozen literal-typed provider
The package SHALL export `definePaymentProvider<TName extends string>(spec)`
that returns a `PaymentProvider & { readonly name: TName }`. The factory
SHALL freeze the returned object (`Object.isFrozen(result) === true`)
and SHALL preserve method identity (the function instance passed in
`spec.createIntent` SHALL be the same reference exposed on
`result.createIntent`). The returned value SHALL be assignable to
`PaymentProvider` so it can be passed directly to
`ProviderRegistry.register`.
#### Scenario: factory result registers cleanly into ProviderRegistry
- **WHEN** `definePaymentProvider({ name: "test", createIntent: …, … })` is registered into a `ProviderRegistry`
- **THEN** `registry.get("test")` returns the same reference the factory produced
#### Scenario: returned object is frozen
- **WHEN** the caller attempts to assign to a method on the factory result (in strict mode)
- **THEN** `Object.isFrozen` is `true` for the result and the assignment throws
#### Scenario: method identity is preserved
- **WHEN** the same function `f` is passed as both `spec.createIntent` and `spec.getIntent`
- **THEN** `result.createIntent === f && result.getIntent === f`
**Verified by:** `src/registry/define.test.ts::"factory result is assignable to PaymentProvider"` AND `src/registry/define.test.ts::"factory result registers cleanly into ProviderRegistry"` AND `src/registry/define.test.ts::"returned object is frozen"` AND `src/registry/define.test.ts::"method identity is preserved"` AND `src/registry/define.test.ts::"literal name narrows the result type"`
---
### Requirement: PAYMENT_PROVIDER_CODES adds registry codes
`PAYMENT_PROVIDER_CODES` SHALL be extended with
`PROVIDER_NOT_REGISTERED` and `PROVIDER_ALREADY_REGISTERED`. The package
SHALL export `providerNotRegistered(name)` and
`providerAlreadyRegistered(name)` factories that return a
`PaymentProviderError` carrying the offending name on the `provider`
discriminant. Both codes SHALL be reachable from real call sites
exercised by the test suite (i.e. raised through `ProviderRegistry`
methods, not only from the factories in isolation).
#### Scenario: providerNotRegistered carries the name as provider discriminant
- **WHEN** `providerNotRegistered("stripe")` is invoked
- **THEN** the resulting `PaymentProviderError` has `code === "PAYMENT_PROVIDER.PROVIDER_NOT_REGISTERED"` and `provider === "stripe"`
#### Scenario: providerAlreadyRegistered carries the name as provider discriminant
- **WHEN** `providerAlreadyRegistered("stripe")` is invoked
- **THEN** the resulting `PaymentProviderError` has `code === "PAYMENT_PROVIDER.PROVIDER_ALREADY_REGISTERED"` and `provider === "stripe"`
**Verified by:** `src/error-codes/error-codes.test.ts::"providerNotRegistered carries the name as provider discriminant"` AND `src/error-codes/error-codes.test.ts::"providerAlreadyRegistered carries the name as provider discriminant"` AND `src/registry/registry.test.ts::"throws PROVIDER_NOT_REGISTERED on unknown name"` AND `src/registry/registry.test.ts::"throws PROVIDER_ALREADY_REGISTERED on duplicate name"`
---
### Requirement: composeAbortSignal is publicly exported
The package SHALL export `composeAbortSignal(internal, external)` from
`src/http/http.ts` and re-export it from `src/index.ts`. The function
SHALL return the internal signal when `external` is `undefined`, SHALL
return a fresh `AbortSignal` that aborts when either input aborts
otherwise, and SHALL abort immediately when `external` is already
aborted at call time. The reason of the aborting input SHALL propagate
to the composed signal's `reason`.
#### Scenario: external undefined returns internal signal directly
- **WHEN** `composeAbortSignal(internal, undefined)` is invoked
- **THEN** the returned signal is reference-equal to `internal`
#### Scenario: internal abort propagates to composed signal
- **WHEN** the internal `AbortController.abort("boom")` is invoked after composition
- **THEN** the composed signal is aborted and its `reason` is the propagated `AbortSignal`
#### Scenario: already-aborted external aborts the composed signal immediately
- **WHEN** the external signal is already aborted at the time of `composeAbortSignal` call
- **THEN** the composed signal is aborted before the function returns
**Verified by:** `src/http/http.test.ts::"composeAbortSignal returns internal when external is undefined"` AND `src/http/http.test.ts::"composeAbortSignal propagates internal abort"` AND `src/http/http.test.ts::"composeAbortSignal propagates external abort"` AND `src/http/http.test.ts::"composeAbortSignal aborts immediately when external is pre-aborted"` AND `src/http/http.test.ts::"composes caller AbortSignal with internal timeout"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,139 @@
# Tasks: slim-payment-providers
## 1. Remove concrete provider modules
- [ ] 1.1 Delete `packages/universal/payment-providers/src/providers/stripe/`.
- [ ] 1.2 Delete `packages/universal/payment-providers/src/providers/paypal/`.
- [ ] 1.3 Delete `packages/universal/payment-providers/src/providers/square/`.
- [ ] 1.4 Delete `packages/universal/payment-providers/src/providers/mollie/`.
- [ ] 1.5 Delete `packages/universal/payment-providers/src/providers/adyen/`.
- [ ] 1.6 Delete the now-empty `src/providers/` directory.
- [ ] 1.7 Drop the `SquareConfig` type from `src/types/provider.ts`
(it ships with the sibling Square package).
- [ ] 1.8 Remove the 5 `./providers/<name>/<name>` entries from
`package.json#exports`.
## 2. Extract composeAbortSignal as a public helper
- [ ] 2.1 Promote the internal `composeSignals` function in
`src/http/http.ts` to an exported `composeAbortSignal(internal, external)`.
- [ ] 2.2 Re-export `composeAbortSignal` from `src/http/index.ts` and
`src/index.ts`.
- [ ] 2.3 Extend `src/http/http.test.ts` to cover `composeAbortSignal`
directly (internal abort propagates, external abort propagates,
already-aborted external triggers immediately, undefined external
returns internal).
- [ ] 2.4 Update `src/http/http.md` to document `composeAbortSignal`.
## 3. Add PROVIDER_NOT_REGISTERED + PROVIDER_ALREADY_REGISTERED codes
- [ ] 3.1 Extend the `PAYMENT_PROVIDER_CODES` registry in
`src/error-codes/error-codes.ts` with `PROVIDER_NOT_REGISTERED` and
`PROVIDER_ALREADY_REGISTERED`.
- [ ] 3.2 Add `providerNotRegistered(name)` and
`providerAlreadyRegistered(name)` factories.
- [ ] 3.3 Extend `src/error-codes/error-codes.test.ts` to confirm both
new codes are reachable from their factories and carry the `provider`
discriminant when the registry surfaces them.
- [ ] 3.4 Update `src/error-codes/error-codes.md` to list all 6 codes.
## 4. Build ProviderRegistry
- [ ] 4.1 Create `src/registry/registry.ts` exporting a `ProviderRegistry`
class with `register`, `unregister`, `get`, `has`, `list`, `size`.
- [ ] 4.2 `get(name)` MUST throw `PaymentProviderError(PROVIDER_NOT_REGISTERED)`
on miss.
- [ ] 4.3 `register(provider)` MUST throw
`PaymentProviderError(PROVIDER_ALREADY_REGISTERED)` when the name is
already present.
- [ ] 4.4 `list()` MUST return a frozen array in insertion order.
- [ ] 4.5 Create `src/registry/registry.md` with the §4 mandatory
sections (Purpose, Features, Basic + Advanced usage, API Reference,
Implementation Notes).
- [ ] 4.6 Create `src/registry/registry.test.ts` covering every public
method, both error paths, and the insertion-order guarantee.
## 5. Add definePaymentProvider factory
- [ ] 5.1 Create `src/registry/define.ts` exporting
`definePaymentProvider<TName extends string>(spec)`.
- [ ] 5.2 Result MUST be frozen and MUST carry the literal `TName`.
- [ ] 5.3 Method identities MUST be preserved (the closure passed in
IS the closure dispatched at call time).
- [ ] 5.4 Create `src/registry/define.md` with the §4 mandatory sections.
- [ ] 5.5 Create `src/registry/define.test.ts` covering the
literal-type narrowing, frozen-shape guarantee, method-identity
preservation, and the round-trip through `ProviderRegistry.register`.
## 6. Update barrels + subpath exports
- [ ] 6.1 Create `src/registry/index.ts` re-exporting `ProviderRegistry`
and `definePaymentProvider` via explicit named exports (rule 6a).
- [ ] 6.2 Rewrite `src/index.ts` to drop the 5 provider class exports
and the `SquareConfig` type export, and add `ProviderRegistry`,
`definePaymentProvider`, `composeAbortSignal`, `providerNotRegistered`,
`providerAlreadyRegistered`.
- [ ] 6.3 Add `./registry/registry` to `package.json#exports`. Final
count: 5 entries (`.`, `./types/provider`, `./error-codes/error-codes`,
`./http/http`, `./registry/registry`).
- [ ] 6.4 Update `package.json#description` to describe the slim core.
## 7. Update README.md
- [ ] 7.1 Rewrite the one-paragraph description to reflect the slim
shape.
- [ ] 7.2 Rewrite the Quick Start example to use `ProviderRegistry` +
a sibling-imported `StripeProvider`.
- [ ] 7.3 Rewrite the Features list to drop the 5-adapter language and
describe the foundation surface.
- [ ] 7.4 Rewrite the Modules list — only `types/provider`,
`error-codes/error-codes`, `http/http`, `registry/registry`.
- [ ] 7.5 Add a "Sibling provider packages" section linking the 5
follow-up packages by name (note they are not yet published).
## 8. Verification gates (§13)
- [ ] 8.1 Step 0 — spec reconciliation: walk every `### Requirement`
in the delta, grep `src/` for the named symbols, confirm every
`**Verified by:**` test name appears in the matching test file.
- [ ] 8.2 Step 1 — `bunx turbo lint typecheck test --filter=@suaveplan/payment-providers`.
- [ ] 8.3 Step 2 — `bunx turbo test:coverage --filter=@suaveplan/payment-providers`
reports 100/100/100/100.
- [ ] 8.4 Step 3 — `bunx turbo build --filter=@suaveplan/payment-providers`.
- [ ] 8.5 Step 4 — banned-pattern scan returns zero output.
- [ ] 8.6 Step 4a — barrel-shape scan returns zero output (no
`export *` in any `<feature>/index.ts`).
- [ ] 8.7 Step 4b — test-runner import scan returns zero output (no
direct `vitest` / `bun:test` imports).
- [ ] 8.8 Step 5 — every `.ts` (excl. `.test.ts`, `.types.ts`,
`index.ts`) has a sibling `.md`.
- [ ] 8.9 Step 6 — `README.md` exists.
- [ ] 8.10 Step 7 — no bare versions in `package.json`.
- [ ] 8.11 Step 8 — subpath-export count matches the feature-module
count (4 features + root = 5 entries).
- [ ] 8.12 Step 9 — security scan returns zero output.
- [ ] 8.13 Step 10 — dependent sweep:
`bunx turbo test --filter=...@suaveplan/payment-providers` green
(currently only the package itself).
## 9. Root-doc refresh (§13b)
- [ ] 9.1 Run `bun scripts/folder-reorg/generate-manifest.ts` to refresh
`MANIFEST.md`.
- [ ] 9.2 Run `bun scripts/generate-missing.ts` to refresh `MISSING.yml`.
- [ ] 9.3 Hand-edit `STATUS.md` row for `@suaveplan/payment-providers`
to describe the slim shape (no row flip — package stays ✅).
- [ ] 9.4 Hand-edit `SUMMARY.md` paragraph for
`@suaveplan/payment-providers` to describe the slim shape.
- [ ] 9.5 `bunx markdownlint-cli2 --fix` across touched docs.
## 10. Fold + archive (§14)
- [ ] 10.1 Fold `specs/payment-providers/spec.md` into
`openspec/specs/payment-providers/spec.md` — apply the `## REMOVED`
block (delete the 5 provider requirements + drop `SquareConfig` from
the http/types requirement) and apply the `## ADDED` block (add the
`ProviderRegistry`, `definePaymentProvider`, `composeAbortSignal`, and
registry-code requirements).
- [ ] 10.2 Move `openspec/changes/slim-payment-providers/` →
`openspec/archive/2026-05-17-slim-payment-providers/`.