first commit
This commit is contained in:
213
docs/money/payment/payment-providers/README.md
Normal file
213
docs/money/payment/payment-providers/README.md
Normal 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
|
||||
|
||||
@@ -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.
|
||||
105
docs/money/payment/payment-providers/src/http/http.md
Normal file
105
docs/money/payment/payment-providers/src/http/http.md
Normal 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.
|
||||
119
docs/money/payment/payment-providers/src/registry/define.md
Normal file
119
docs/money/payment/payment-providers/src/registry/define.md
Normal 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.
|
||||
117
docs/money/payment/payment-providers/src/registry/registry.md
Normal file
117
docs/money/payment/payment-providers/src/registry/registry.md
Normal 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.
|
||||
102
docs/money/payment/payment-providers/src/types/provider.md
Normal file
102
docs/money/payment/payment-providers/src/types/provider.md
Normal 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.
|
||||
Reference in New Issue
Block a user