# Capability Spec: payment-providers **Package**: `@suaveplan/payment-providers` **Status**: SHIPPED > Every requirement below carries a `**Verified by:**` line. > > History: originally folded by `2026-05-17-add-payment-providers` as a > 5-adapter bundle; slimmed by `2026-05-17-slim-payment-providers` into > the present foundation-with-sibling-plugins shape. Concrete providers > (Stripe, PayPal, Square, Mollie, Adyen) ship as separate > `@suaveplan/payment-provider-` sibling packages. ## Requirements ### Requirement: Universal-tier purity The package SHALL ship as a universal-tier package — no `node:*`, `bun:*`, `fs`, `path`, `stream`, `net`, `http`, or bare `crypto` imports in any file under `src/`. The package SHALL run unchanged under Node ≥ 18, Bun, modern browsers, and Web Workers. `package.json` SHALL declare `"sideEffects": false`. Every HTTP call SHALL go through the injectable `FetchLike` so the package never reaches into a runtime- specific HTTP stack. #### Scenario: no platform imports in src/ - **WHEN** `grep -rnE "from ['\"](node:|bun:|fs|path|stream|net|http|crypto)['\"]" packages/payment/payment-providers/src/` is run - **THEN** the command produces zero matches **Verified by:** `tests/dep-graph.test.ts::"src contains no node/bun/fs/path/stream/net/http/crypto imports"` --- ### Requirement: Stable PAYMENT_PROVIDER_CODES registry and PaymentProviderError class The package SHALL declare an error-code registry via `defineCodes("PAYMENT_PROVIDER", […])` containing exactly the codes `HTTP_ERROR`, `TIMEOUT`, `PARSE_ERROR`, `OAUTH_TOKEN_FAILURE`, `PROVIDER_NOT_REGISTERED`, and `PROVIDER_ALREADY_REGISTERED`. Every public failure path triggered by network, parsing, OAuth, or registry errors SHALL throw a `PaymentProviderError extends SuaveplanError` carrying one of those codes. `PaymentProviderError` SHALL expose optional `provider`, `httpStatus`, `bodyExcerpt`, and `url` discriminants. The package SHALL NOT throw raw `Error` instances from any production code path under `src/`. #### Scenario: every code reachable from a real call site - **WHEN** the test suite exercises each factory (`httpError`, `timeoutError`, `parseError`, `oauthTokenFailure`, `providerNotRegistered`, `providerAlreadyRegistered`) - **THEN** each path produces a `PaymentProviderError` whose `code` matches the expected `PAYMENT_PROVIDER.*` constant #### Scenario: cause-bearing factories preserve the underlying error - **WHEN** `parseError` or `oauthTokenFailure` is invoked with a `cause` - **THEN** the resulting `PaymentProviderError`'s `cause` is the original error #### Scenario: HTTP error carries discriminants from the response - **WHEN** `httpError(provider, status, url, body)` is invoked - **THEN** the resulting error's `httpStatus`, `url`, `provider`, and `bodyExcerpt` fields are all populated #### Scenario: registry-aware factories carry the offending name - **WHEN** `providerNotRegistered("nope")` or `providerAlreadyRegistered("stripe")` is invoked - **THEN** the resulting `PaymentProviderError`'s `provider` discriminant equals the supplied name and the message contains the same name **Verified by:** `src/error-codes/error-codes.test.ts::"every PAYMENT_PROVIDER.* code is reachable from a factory"` AND `src/error-codes/error-codes.test.ts::"preserves cause through parseError"` AND `src/error-codes/error-codes.test.ts::"preserves cause through oauthTokenFailure"` AND `src/error-codes/error-codes.test.ts::"httpError carries provider/url/httpStatus/bodyExcerpt discriminants"` AND `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"` --- ### Requirement: httpRequest helper with injectable FetchLike and timeout The package SHALL export `httpRequest(options: HttpOptions): Promise>` that wraps an injectable `FetchLike` (defaulting to `globalThis.fetch`). The helper SHALL serialise `body` as JSON (string bodies pass through verbatim), set `Content-Type: application/json` and `Accept: application/json` headers (overridable by callers), enforce a per-call `timeoutMs` (default 30 000) via `AbortSignal` with a clean `clearTimeout` in the `finally` block, parse the response body as JSON (returning `{}` when the body is empty), and on a non-ok response throw a `PaymentProviderError(PAYMENT_PROVIDER.HTTP_ERROR)` carrying the HTTP status, URL, and a `bodyExcerpt` capped at 256 chars. A caller- supplied `signal` SHALL be composed with the internal timeout signal so external cancellation is honoured. #### Scenario: 200 response returns parsed body - **WHEN** the injected `FetchLike` resolves with `new Response("{\"ok\":true}", { status: 200 })` - **THEN** `httpRequest` resolves with `{ status: 200, data: { ok: true } }` #### Scenario: 204 response returns empty data - **WHEN** the injected `FetchLike` resolves with `new Response(null, { status: 204 })` - **THEN** `httpRequest` resolves with `{ status: 204, data: {} }` #### Scenario: non-ok response throws typed error - **WHEN** the injected `FetchLike` resolves with status 404 and body `{ "message": "Not Found" }` - **THEN** `httpRequest` rejects with a `PaymentProviderError` whose `code === "PAYMENT_PROVIDER.HTTP_ERROR"`, `httpStatus === 404`, and `bodyExcerpt` containing `"Not Found"` #### Scenario: timeout aborts via AbortSignal - **WHEN** the injected `FetchLike` never resolves and `timeoutMs` is set to 50 - **THEN** `httpRequest` rejects with a `PaymentProviderError` whose `code === "PAYMENT_PROVIDER.TIMEOUT"` #### Scenario: string body passes through unchanged - **WHEN** `httpRequest({ body: "grant_type=client_credentials", … })` is invoked - **THEN** the recording `FetchLike` observes a request whose `init.body` is the same string (not JSON-encoded) **Verified by:** `src/http/http.test.ts::"makes a GET request and parses JSON"` AND `src/http/http.test.ts::"sends JSON body on POST"` AND `src/http/http.test.ts::"includes custom headers"` AND `src/http/http.test.ts::"throws PaymentProviderError on non-ok response"` AND `src/http/http.test.ts::"throws HTTP_ERROR when no message in body"` AND `src/http/http.test.ts::"handles empty response body"` AND `src/http/http.test.ts::"throws on non-ok empty response"` AND `src/http/http.test.ts::"throws TIMEOUT when AbortSignal fires"` AND `src/http/http.test.ts::"supports DELETE and PATCH methods"` AND `src/http/http.test.ts::"passes through a string body without re-serialising as JSON"` AND `src/http/http.test.ts::"composes caller AbortSignal with internal timeout"` --- ### 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"` --- ### 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(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"`