first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,213 @@
# @suaveplan/payment-providers - Slim universal foundation for the @suaveplan/payment-provider-* sibling packages. Ships ProviderRegistry, definePaymentProvider factory, shared httpRequest/composeAbortSignal helpers, FetchLike, the ApiKeyConfig / OAuthConfig / MerchantKeyConfig / HttpOptions / ProviderResponse types, and the PAYMENT_PROVIDER_CODES + PaymentProviderError error model. Concrete Stripe / PayPal / Square / Mollie / Adyen adapters ship as separate @suaveplan/payment-provider-<name> packages that depend on this foundation.
Universal **foundation** for the `@suaveplan/payment-provider-*`
sibling-package family. This package ships only the shared abstractions
every concrete adapter needs — the `ProviderRegistry`, the
`definePaymentProvider` factory, the `httpRequest` +
`composeAbortSignal` HTTP helpers, the `FetchLike` type, the shared
config shapes (`ApiKeyConfig`, `OAuthConfig`, `MerchantKeyConfig`,
`HttpOptions`, `ProviderResponse`), and the `PAYMENT_PROVIDER_CODES`
registry + `PaymentProviderError` class. Concrete adapters
(Stripe, PayPal, Square, Mollie, Adyen) live in **sibling packages** so
consumers pay only for the providers they install.
<!-- begin:auto-docs -->
## Documentation tree
- [error-codes](./src/error-codes/error-codes.md) — Stable error registry and typed error class for `@suaveplan/payment-providers`. Every production failure path — non-ok…
- [error-codes](./src/error-codes/error-codes.md) — Stable error registry and typed error class for `@suaveplan/payment-providers`. Every production failure path — non-ok…
- [http](./src/http/http.md) — Universal HTTP helper used by every provider to issue typed, cancellable JSON requests. The helper is a thin wrapper ov…
- [http](./src/http/http.md) — Universal HTTP helper used by every provider to issue typed, cancellable JSON requests. The helper is a thin wrapper ov…
- [registry](./src/registry/registry.md) — Process-local, synchronous registry that indexes `PaymentProvider` instances (from `@suaveplan/payment`) by their `name…
- [define](./src/registry/define.md) — Factory helper for sibling `@suaveplan/payment-provider-*` packages that want to expose a `PaymentProvider` (from `@sua…
- [registry](./src/registry/registry.md) — Process-local, synchronous registry that indexes `PaymentProvider` instances (from `@suaveplan/payment`) by their `name…
- [types](./src/types/provider.md) — Shared configuration interfaces and injectable seams consumed by every sibling `@suaveplan/payment-provider-*` adapter…
- [provider](./src/types/provider.md) — Shared configuration interfaces and injectable seams consumed by every sibling `@suaveplan/payment-provider-*` adapter…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/payment-providers @suaveplan/payment
# plus one or more of:
bun add @suaveplan/payment-provider-stripe
bun add @suaveplan/payment-provider-paypal
bun add @suaveplan/payment-provider-square
bun add @suaveplan/payment-provider-mollie
bun add @suaveplan/payment-provider-adyen
```
## Quick start
```ts
import { ProviderRegistry } from "@suaveplan/payment-providers";
import { StripeProvider } from "@suaveplan/payment-provider-stripe";
import { PaymentEngine } from "@suaveplan/payment";
const registry = new ProviderRegistry();
registry.register(new StripeProvider({
apiKey: process.env.STRIPE_SECRET_KEY ?? "",
}));
const engine = new PaymentEngine({ provider: registry.get("stripe") });
const intent = await engine.createIntent({
amount: { amount: 5000n, currency: "USD" },
captureMethod: "automatic",
});
```
## Features
- **`ProviderRegistry`** — process-local, synchronous,
insertion-ordered registry of `PaymentProvider` instances keyed by
`name`; strict `get` with typed miss, duplicate-safe `register`,
idempotent `unregister`, frozen `list()` snapshot
- **`definePaymentProvider<TName>(spec)`** — factory that returns a
frozen, literal-typed `PaymentProvider` for sibling packages that
prefer a closure to a class
- **`httpRequest<T>(options)`** — universal `FetchLike` wrapper with
per-call timeout, JSON serialisation, custom-header passthrough,
typed errors, empty-body handling, and external-`AbortSignal`
composition
- **`composeAbortSignal(internal, external?)`** — reusable
abort-signal composer (extracted from `httpRequest` so sibling
packages can compose their own out-of-band HTTP calls with
caller-supplied cancellation)
- **`PAYMENT_PROVIDER_CODES`** + **`PaymentProviderError`** — 6-code
registry (`HTTP_ERROR`, `TIMEOUT`, `PARSE_ERROR`,
`OAUTH_TOKEN_FAILURE`, `PROVIDER_NOT_REGISTERED`,
`PROVIDER_ALREADY_REGISTERED`) with optional `provider` /
`httpStatus` / `url` / `bodyExcerpt` discriminants
- **Shared config shapes** — `ApiKeyConfig`, `OAuthConfig`,
`MerchantKeyConfig`, `HttpOptions`, `ProviderResponse`,
`ProviderCommonOptions`, plus `NowFn` / `RandomIdFn` /
`Base64EncodeFn` injectable seams
- **Universal tier** — zero Node, browser, React, or `node:crypto`
imports; runs unchanged on Node, Bun, browsers, and Web Workers
- **Tree-shakeable** — `sideEffects: false` plus per-feature subpath
exports
- **100% test coverage** (Istanbul) + 100% docs coverage (sibling
`.md` per implementation file)
## Sibling provider packages
The five concrete provider adapters that used to ship in this package
now live in dedicated sibling packages, each depending on this
foundation:
- `@suaveplan/payment-provider-stripe` — `StripeProvider`
- `@suaveplan/payment-provider-paypal` — `PayPalProvider`
- `@suaveplan/payment-provider-square` — `SquareProvider` + the
Square-specific `SquareConfig` type
- `@suaveplan/payment-provider-mollie` — `MollieProvider`
- `@suaveplan/payment-provider-adyen` — `AdyenProvider`
Install one or more alongside this foundation. Every sibling exports
the same class name + `PaymentProvider`-interface contract as before
the slim-down, so migration from a bundled import is a one-line
package swap (see `## Migration` below).
## Migration
If you were importing a concrete provider from
`@suaveplan/payment-providers`, switch to the matching sibling
package. The class names are preserved verbatim:
```ts
// Before
import { StripeProvider } from "@suaveplan/payment-providers";
// After
import { StripeProvider } from "@suaveplan/payment-provider-stripe";
```
All shared types (`ApiKeyConfig`, `OAuthConfig`, `MerchantKeyConfig`,
`HttpOptions`, `ProviderResponse`, `NowFn`, `RandomIdFn`,
`Base64EncodeFn`), `httpRequest`, `composeAbortSignal`, the
`PaymentProviderError` class, and the `PAYMENT_PROVIDER_CODES`
registry continue to be exported from
`@suaveplan/payment-providers` unchanged. The Square-specific
`SquareConfig` type now ships with
`@suaveplan/payment-provider-square`.
## Modules
- [`src/types/provider.md`](./src/types/provider.md) — `ApiKeyConfig`,
`OAuthConfig`, `MerchantKeyConfig`, `HttpOptions`,
`ProviderResponse`, plus `NowFn` / `RandomIdFn` / `Base64EncodeFn`
- [`src/error-codes/error-codes.md`](./src/error-codes/error-codes.md) —
`PAYMENT_PROVIDER_CODES`, `PaymentProviderError`, factories
(`httpError`, `timeoutError`, `parseError`, `oauthTokenFailure`,
`providerNotRegistered`, `providerAlreadyRegistered`)
- [`src/http/http.md`](./src/http/http.md) — `httpRequest` +
`composeAbortSignal` + `FetchLike`
- [`src/registry/registry.md`](./src/registry/registry.md) —
`ProviderRegistry`
- [`src/registry/define.md`](./src/registry/define.md) —
`definePaymentProvider` factory
## Building a custom provider
Sibling provider packages — and any consumer adding a non-standard
provider — implement the `PaymentProvider` interface from
`@suaveplan/payment` and use `httpRequest` from this foundation for
every HTTP call:
```ts
import {
type ApiKeyConfig,
httpRequest,
PaymentProviderError,
} from "@suaveplan/payment-providers";
import type {
CreatePaymentIntentInput,
PaymentIntent,
PaymentProvider,
} from "@suaveplan/payment";
export class CustomProvider implements PaymentProvider {
public readonly name = "custom";
private readonly config: ApiKeyConfig;
public constructor(config: ApiKeyConfig) {
this.config = config;
}
public async createIntent(
input: CreatePaymentIntentInput
): Promise<PaymentIntent> {
const { data } = await httpRequest<{ id: string }>({
method: "POST",
url: `${this.config.baseUrl ?? "https://api.custom.com"}/intents`,
headers: { "X-API-Key": this.config.apiKey },
body: { amount: Number(input.amount.amount) },
fetch: this.config.fetch,
signal: this.config.signal,
timeoutMs: this.config.timeoutMs,
provider: this.name,
});
return mapToPaymentIntent(data);
}
// ...implement remaining PaymentProvider methods
}
function mapToPaymentIntent(_raw: unknown): PaymentIntent {
throw new PaymentProviderError(
"PAYMENT_PROVIDER.PARSE_ERROR" as never,
"implement mapping"
);
}
```
## Related
- `@suaveplan/payment` — `PaymentEngine`, `WebhookVerifier`,
`PaymentProvider` interface
- `@suaveplan/money` — currency-aware arithmetic
- `@suaveplan/error` — base error class + `defineCodes` registry helper
## License
MIT

View File

@@ -0,0 +1,99 @@
# Payment-provider error codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Stable error registry and typed error class for
`@suaveplan/payment-providers`. Every production failure path — non-ok
HTTP response, request timeout, malformed JSON body, OAuth
token-acquisition failure, unknown-name lookup against the registry,
duplicate registration against the registry — raises a
`PaymentProviderError` carrying one of the namespaced codes declared
via `defineCodes`. Consumers programmatically dispatch on `error.code`
(e.g. `"PAYMENT_PROVIDER.HTTP_ERROR"`) or on the optional discriminants
`provider`, `httpStatus`, `url`, and `bodyExcerpt` rather than parsing
free-form `error.message` strings.
## Features
- 6-code registry via `defineCodes("PAYMENT_PROVIDER", […])`
- `PaymentProviderError extends SuaveplanError<PaymentProviderErrorCode>`
- Discriminants: `provider`, `httpStatus`, `url`, `bodyExcerpt`
(capped at 256 chars + ellipsis)
- Cause-preserving factories for parse and OAuth failures
- Registry-aware factories `providerNotRegistered` /
`providerAlreadyRegistered` raised by `ProviderRegistry`
- Custom message override on `httpError` for callers that have a more
user-friendly diagnostic than the default `HTTP {status} from {provider}`
## Usage: Basic
```ts
import {
httpError,
PAYMENT_PROVIDER_CODES,
PaymentProviderError,
} from "@suaveplan/payment-providers";
const err = httpError("stripe", 404, "https://api.stripe.com/v1/x", "missing");
console.log(err.code); // "PAYMENT_PROVIDER.HTTP_ERROR"
console.log(err.httpStatus); // 404
console.log(err.provider); // "stripe"
if (err instanceof PaymentProviderError) {
// narrow on code below
}
if (err.code === PAYMENT_PROVIDER_CODES.HTTP_ERROR) {
// typed branch
}
```
## Usage: Advanced
```ts
import { parseError, oauthTokenFailure } from "@suaveplan/payment-providers";
try {
const json = JSON.parse("{not json");
void json;
} catch (cause) {
// Preserve the original parse failure as the .cause so OTel and
// log appenders can walk the chain.
throw parseError("stripe", "https://api.stripe.com/v1/x", "{not json", cause);
}
try {
await fetchPayPalToken();
} catch (cause) {
throw oauthTokenFailure("paypal", "https://api.paypal.com/v1/oauth2/token", cause);
}
```
## API Reference
| Symbol | Kind | Signature |
|---|---|---|
| `PAYMENT_PROVIDER_CODES` | `const` | `{ HTTP_ERROR, TIMEOUT, PARSE_ERROR, OAUTH_TOKEN_FAILURE, PROVIDER_NOT_REGISTERED, PROVIDER_ALREADY_REGISTERED }` (each value is `"PAYMENT_PROVIDER.<NAME>"`) |
| `PaymentProviderErrorCode` | `type` | union of registry values |
| `PaymentProviderError` | `class` | extends `SuaveplanError<PaymentProviderErrorCode>` |
| `PaymentProviderErrorOptions` | `interface` | `{ provider?, httpStatus?, url?, bodyExcerpt? } & SuaveplanErrorOptions` |
| `httpError(provider, status, url, body, message?)` | `function` | returns `PaymentProviderError(HTTP_ERROR)` |
| `timeoutError(provider, url, timeoutMs, cause?)` | `function` | returns `PaymentProviderError(TIMEOUT)` |
| `parseError(provider, url, body, cause)` | `function` | returns `PaymentProviderError(PARSE_ERROR)`, preserves cause |
| `oauthTokenFailure(provider, url, cause)` | `function` | returns `PaymentProviderError(OAUTH_TOKEN_FAILURE)`, preserves cause |
| `providerNotRegistered(name)` | `function` | returns `PaymentProviderError(PROVIDER_NOT_REGISTERED)` carrying `name` on `provider` |
| `providerAlreadyRegistered(name)` | `function` | returns `PaymentProviderError(PROVIDER_ALREADY_REGISTERED)` carrying `name` on `provider` |
## Implementation Notes
`bodyExcerpt` is truncated at 256 characters with a trailing `…` so the
error is safe to log without flooding sinks. The codes are registered
at module-load time via `defineCodes`, which freezes the registry
object — runtime mutations are silently ignored under strict mode and
fail loudly with `TypeError` under non-strict. The class itself
deliberately does not expose any extra serialisation beyond what
`SuaveplanError` provides; consumers wanting wire format should call
the base-class `.toJSON()` which already encodes `code`, `message`,
`cause` chain, and the discriminant fields.

View File

@@ -0,0 +1,105 @@
# `httpRequest` helper
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Universal HTTP helper used by every provider to issue typed, cancellable
JSON requests. The helper is a thin wrapper over an injectable
`FetchLike` (defaulting to `globalThis.fetch`) that JSON-serialises
non-string bodies, applies a per-call timeout via `AbortSignal`,
composes a caller-supplied `signal` with the internal timeout signal,
and converts every failure path into a typed `PaymentProviderError` so
callers can branch on `error.code` instead of parsing strings.
## Features
- Injectable `FetchLike` (`fetch?: FetchLike`) — defaults to
`globalThis.fetch`
- JSON serialisation of object bodies (string bodies pass through
unchanged, enabling `application/x-www-form-urlencoded` flows)
- Per-call `timeoutMs` (default `30_000`) via internal `AbortController`
- External `signal?: AbortSignal` composed with the internal timeout
- Typed errors: `HTTP_ERROR`, `TIMEOUT`, `PARSE_ERROR`
- Empty-body handling: 204 / empty responses resolve to `data: {}`
- `provider?: string` discriminant surfaced on every raised error
- `composeAbortSignal(internal, external)` public helper — reusable by
sibling provider packages that issue out-of-band HTTP calls (OAuth
token fetches, webhook signature fetches) and want the same
caller-signal composition without re-implementing the wiring
## Usage: Basic
```ts
import { httpRequest } from "@suaveplan/payment-providers";
const { status, data } = await httpRequest<{ id: string }>({
method: "POST",
url: "https://api.example.com/charges",
headers: { Authorization: "Bearer secret" },
body: { amount: 1000 },
timeoutMs: 10_000,
provider: "example",
});
```
## Usage: Advanced
```ts
import { type FetchLike, httpRequest } from "@suaveplan/payment-providers";
// Inject a recording FetchLike for tests — no globalThis monkey-patching.
const calls: Array<{ url: string; init?: RequestInit }> = [];
const fetch: FetchLike = async (url, init) => {
calls.push({ url, init });
return new Response(JSON.stringify({ ok: true }), { status: 200 });
};
const external = new AbortController();
const promise = httpRequest({
method: "GET",
url: "https://api.example.com/intent/1",
fetch,
signal: external.signal,
timeoutMs: 5_000,
});
// Cancel externally — either signal firing aborts the request.
external.abort();
await promise.catch((err) => console.log(err.code));
```
## API Reference
| Symbol | Kind | Description |
|---|---|---|
| `FetchLike` | `type` | `(input: string, init?: RequestInit) => Promise<Response>` |
| `httpRequest<T>(options: HttpOptions): Promise<ProviderResponse<T>>` | `function` | issues the HTTP call, returns parsed JSON |
| `composeAbortSignal(internal, external?): AbortSignal` | `function` | returns `internal` when `external` is undefined; otherwise returns a fresh signal that aborts when either input aborts, with already-aborted external propagating synchronously |
### `HttpOptions`
| Field | Type | Default | Description |
|---|---|---|---|
| `method` | `"GET" \| "POST" \| "DELETE" \| "PATCH"` | — | HTTP verb |
| `url` | `string` | — | request URL |
| `headers` | `Record<string, string>` | `{}` | extra headers (override the JSON defaults if set) |
| `body` | `unknown` | — | JSON-serialised unless already a `string` |
| `timeoutMs` | `number` | `30_000` | per-call timeout |
| `fetch` | `FetchLike` | `globalThis.fetch` | injectable HTTP client |
| `signal` | `AbortSignal` | — | external cancellation |
| `provider` | `string` | `"unknown"` | discriminant placed on raised errors |
## Implementation Notes
The internal `AbortController` always wins on timeout — its `abort()` is
called from a `setTimeout` whose handle is cleared in `finally`, so no
listener leaks past the call. The composed `AbortSignal` is created
inline via a third `AbortController`; both source signals
(`timeoutController.signal` and the caller's `signal`) propagate to it
via `addEventListener("abort", …, { once: true })`, plus an upfront
check for the already-aborted case. Non-`Error` throws from the
injected `FetchLike` (e.g. raw strings) become `HTTP_ERROR` with a
stringified body excerpt — the helper never lets an untyped failure
escape.

View File

@@ -0,0 +1,119 @@
# `definePaymentProvider`
## Purpose
Factory helper for sibling `@suaveplan/payment-provider-*` packages
that want to expose a `PaymentProvider` (from `@suaveplan/payment`)
without authoring a full class. The factory returns a frozen object
that satisfies the structural `PaymentProvider` contract and carries
the caller-supplied method references verbatim — closures are
preserved, no `this` rebinding happens. Sibling packages MAY use this
factory or return a class instance from their `new XProvider(...)`
constructor; both register identically into
{@link ProviderRegistry}. The factory exists so providers without
per-instance state (deterministic test providers, in-memory mocks,
thin wrappers around an HTTP closure) can skip the class boilerplate.
## Features
- Literal-typed `name` — the result narrows to
`PaymentProvider & { readonly name: TName }` so consumers can
discriminate at the type level (`provider.name === "stripe"` narrows
the rest of the object)
- Frozen output — accidental mutation downstream throws in strict mode
- Method-identity preservation — the function passed in is the function
dispatched at call time (useful for spies / instrumentation that
identify methods by reference)
- Zero runtime cost beyond a single `Object.freeze`
- Universal — no Node, browser, or React imports
## Usage: Basic
```ts
import { definePaymentProvider, ProviderRegistry } from "@suaveplan/payment-providers";
const stub = definePaymentProvider({
name: "stub",
createIntent: async (input) => ({
id: "intent_1",
provider: "stub",
amount: input.amount,
currency: input.amount.currency,
status: "created",
captureMethod: input.captureMethod ?? "automatic",
refunds: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}),
confirmIntent: async () => { throw new Error("not used"); },
captureIntent: async () => { throw new Error("not used"); },
cancelIntent: async () => { throw new Error("not used"); },
refund: async () => { throw new Error("not used"); },
getIntent: async () => { throw new Error("not used"); },
});
const registry = new ProviderRegistry();
registry.register(stub);
```
## Usage: Advanced
```ts
import {
definePaymentProvider,
ProviderRegistry,
} from "@suaveplan/payment-providers";
// A deterministic test provider that records every call by reference
// — note that `recordedCreate` IS the function exposed on
// `provider.createIntent`, so spies / assertions can identify it by
// equality.
const calls: string[] = [];
const recordedCreate = async (input: never): Promise<never> => {
calls.push("create");
void input;
throw new Error("test-only stub");
};
const provider = definePaymentProvider({
name: "test",
createIntent: recordedCreate,
confirmIntent: recordedCreate,
captureIntent: recordedCreate,
cancelIntent: recordedCreate,
refund: recordedCreate,
getIntent: recordedCreate,
});
// Type narrowing on the literal name.
if (provider.name === "test") {
// `provider` here is narrowed to `{ name: "test", ... }`
}
// The factory result is frozen.
expect(Object.isFrozen(provider)).toBe(true);
expect(provider.createIntent).toBe(recordedCreate);
const registry = new ProviderRegistry();
registry.register(provider);
expect(registry.get("test")).toBe(provider);
```
## API Reference
| Symbol | Kind | Signature |
|---|---|---|
| `PaymentProviderSpec<TName>` | `interface` | `{ name: TName; createIntent; confirmIntent; captureIntent; cancelIntent; refund; getIntent }` — every method type matches the `PaymentProvider` interface from `@suaveplan/payment` |
| `definePaymentProvider<TName>(spec)` | `function` | returns `PaymentProvider & { readonly name: TName }`, frozen |
## Implementation Notes
`definePaymentProvider` is intentionally minimal — it does not
register the result anywhere, does not wrap methods in instrumentation,
and does not perform validation beyond TypeScript's structural checks.
Validation belongs to the layer that decides what to do with a
provider (the `ProviderRegistry`, the `PaymentEngine`, etc.); the
factory's only job is to package a spec into a frozen, literal-typed
`PaymentProvider`. The `Object.freeze` call freezes the top-level
object only; the methods themselves remain mutable function objects so
sibling packages can attach their own metadata if needed.

View File

@@ -0,0 +1,117 @@
# `ProviderRegistry`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Process-local, synchronous registry that indexes `PaymentProvider`
instances (from `@suaveplan/payment`) by their `name` field. The
registry is the foundation contract that sibling
`@suaveplan/payment-provider-*` packages plug into — consumers
explicitly construct + register the provider instances they want to
route to and look them up by string name. The registry has no
persistence layer, no auto-discovery, and no async lifecycle so it can
ship and run unchanged on every JavaScript runtime.
## Features
- O(1) `register`, `get`, `has`, `unregister` via an internal
`Map<string, PaymentProvider>`
- Insertion-ordered enumeration via `list()` and a frozen array
snapshot to prevent external mutation
- Strict `get` semantics — throws
`PaymentProviderError(PAYMENT_PROVIDER.PROVIDER_NOT_REGISTERED)` on
miss so callers never silently route through `undefined`
- Duplicate-safe `register` — throws
`PaymentProviderError(PAYMENT_PROVIDER.PROVIDER_ALREADY_REGISTERED)`
when the same `name` is supplied twice
- Idempotent `unregister` — returns `true` on hit and `false` on miss,
never throws
- `size` getter mirrors `Map`/`Set` semantics (numeric property, not a
method)
- Provider-agnostic — the registry knows nothing about Stripe / PayPal /
Square specifics; it only sees the `PaymentProvider` interface from
`@suaveplan/payment`
## Usage: Basic
```ts
import { ProviderRegistry } from "@suaveplan/payment-providers";
import { StripeProvider } from "@suaveplan/payment-provider-stripe";
const registry = new ProviderRegistry();
registry.register(new StripeProvider({ apiKey: process.env.STRIPE_KEY ?? "" }));
const stripe = registry.get("stripe");
const intent = await stripe.createIntent({
amount: { amount: 5000n, currency: "USD" },
captureMethod: "automatic",
});
```
## Usage: Advanced
```ts
import {
PAYMENT_PROVIDER_CODES,
PaymentProviderError,
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: PP_ID, clientSecret: PP_SECRET }));
// Route by name with strict semantics.
function route(name: string) {
try {
return registry.get(name);
} catch (err) {
if (
err instanceof PaymentProviderError &&
err.code === PAYMENT_PROVIDER_CODES.PROVIDER_NOT_REGISTERED
) {
// Fall back to a soft default — but only after observing the
// strict miss, so logs distinguish "unconfigured" from
// "configured but failed".
return registry.get("stripe");
}
throw err;
}
}
// Enumerate for diagnostics — the result is frozen so this code can
// hand the array straight to a logger without worrying about callers
// mutating registry state.
for (const provider of registry.list()) {
console.log(`provider=${provider.name}`);
}
```
## API Reference
| Member | Kind | Signature | Description |
|---|---|---|---|
| `register(provider)` | method | `(provider: PaymentProvider) => void` | Index by `provider.name`. Throws `PROVIDER_ALREADY_REGISTERED` on duplicate. |
| `unregister(name)` | method | `(name: string) => boolean` | Remove by name; returns `true` if present. |
| `get(name)` | method | `(name: string) => PaymentProvider` | Strict lookup. Throws `PROVIDER_NOT_REGISTERED` on miss. |
| `has(name)` | method | `(name: string) => boolean` | Soft membership check. |
| `list()` | method | `() => readonly PaymentProvider[]` | Frozen insertion-ordered snapshot. |
| `size` | getter | `number` | Number of registered providers. |
## Implementation Notes
The registry is backed by an `ES Map` keyed by `provider.name` to keep
operations O(1) and to inherit `Map`'s insertion-order iteration
guarantee. `list()` returns a frozen `Array.from` snapshot — copying
on read keeps the public surface immutable without giving up the
`Map` storage. The two registry-related error codes
(`PROVIDER_NOT_REGISTERED`, `PROVIDER_ALREADY_REGISTERED`) live with
the other `PAYMENT_PROVIDER_CODES` so consumers do a single
`switch (err.code)` over the whole namespace. The class is deliberately
**not** a singleton — applications that need multiple registries (e.g.
test isolation, multi-tenant routing) construct multiple instances.

View File

@@ -0,0 +1,102 @@
# Provider configuration types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Shared configuration interfaces and injectable seams consumed by every
sibling `@suaveplan/payment-provider-*` adapter package. These types
live in the slim foundation so each sibling can declare its concrete
config (`StripeConfig`, `PayPalConfig`, `SquareConfig`,
`AdyenConfig`, …) by extending one of the three base shapes here. They
are universal — no runtime code, no browser or Node imports, no
dependencies beyond a type-only `FetchLike` re-import from the sibling
`http/http.ts` module.
## Features
- `ApiKeyConfig` — Bearer-auth providers (Stripe, Mollie, Square via
the sibling package)
- `OAuthConfig` — client-credentials providers (PayPal via the sibling
package) with injectable `now` and `base64Encode`
- `MerchantKeyConfig` — header-keyed providers carrying a merchant
account (Adyen via the sibling package) with injectable `randomId`
- `ProviderResponse<T>` — generic HTTP response wrapper returned by
`httpRequest`
- `HttpOptions` — input shape of `httpRequest`
- `ProviderCommonOptions` — shared injectable seams (`fetch`,
`timeoutMs`, `signal`)
- `NowFn` / `RandomIdFn` / `Base64EncodeFn` — function-type aliases for
the injectable seams
## Usage: Basic
```ts
import type { ApiKeyConfig } from "@suaveplan/payment-providers/types/provider";
const config: ApiKeyConfig = {
apiKey: process.env.STRIPE_SECRET_KEY ?? "",
baseUrl: "https://api.stripe.com/v1",
timeoutMs: 10_000,
};
```
## Usage: Advanced
```ts
import type {
MerchantKeyConfig,
OAuthConfig,
} from "@suaveplan/payment-providers/types/provider";
// OAuth provider (PayPal sibling): inject a deterministic clock so the
// token-cache TTL is reproducible in tests, and a Base-64 encoder that
// works on every runtime (Node, Bun, browsers, Web Workers).
const oauth: OAuthConfig = {
clientId: "id",
clientSecret: "secret",
now: () => 0,
base64Encode: (s) => Buffer.from(s, "utf8").toString("base64"),
timeoutMs: 5_000,
};
// Merchant-keyed provider (Adyen sibling): inject a deterministic id
// generator so auto-generated `reference` values are predictable in
// tests.
const merchant: MerchantKeyConfig = {
apiKey: "k",
merchantAccount: "TestMerchant",
randomId: () => "deterministic-id",
};
```
## API Reference
| Symbol | Kind | Description |
|---|---|---|
| `ApiKeyConfig` | `interface` | `{ apiKey, baseUrl?, webhookSecret?, fetch?, timeoutMs?, signal? }` |
| `OAuthConfig` | `interface` | adds `clientId`, `clientSecret`, `now?`, `base64Encode?` |
| `MerchantKeyConfig` | `interface` | `{ apiKey, merchantAccount, baseUrl?, webhookHmacKey?, randomId?, …common }` |
| `ProviderResponse<T>` | `interface` | `{ status: number; data: T }` |
| `HttpOptions` | `interface` | `{ method, url, headers?, body?, provider?, …common }` |
| `ProviderCommonOptions` | `interface` | `{ fetch?, timeoutMs?, signal? }` |
| `NowFn` | `type` | `() => number` |
| `RandomIdFn` | `type` | `() => string` |
| `Base64EncodeFn` | `type` | `(input: string) => string` |
## Implementation Notes
`fetch`, `now`, `randomId`, and `base64Encode` default at the sibling
package level to `globalThis.fetch`, `Date.now`,
`globalThis.crypto.randomUUID`, and `globalThis.btoa` respectively.
Each sibling provider resolves its seams at construction time rather
than at call time so tests can inject a single deterministic fixture
per instance. `signal` composes with the per-call internal
`AbortSignal` driven by `timeoutMs` via the foundation's
`composeAbortSignal` helper, so callers cancelling a request from
outside cancel the in-flight `fetch` and free the `setTimeout`.
Provider-specific shapes (`StripeConfig`, `SquareConfig`, `PayPalConfig`,
etc.) live in their respective sibling packages — the foundation only
ships shapes that apply to every provider class.