Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 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,69 @@
{
"name": "@suaveplan/payment-providers",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "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.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
},
"./types/provider": {
"import": { "types": "./dist/types/provider.d.ts", "default": "./dist/types/provider.js" },
"require": { "types": "./dist/types/provider.d.ts", "default": "./dist/types/provider.js" }
},
"./error-codes/error-codes": {
"import": { "types": "./dist/error-codes/error-codes.d.ts", "default": "./dist/error-codes/error-codes.js" },
"require": { "types": "./dist/error-codes/error-codes.d.ts", "default": "./dist/error-codes/error-codes.js" }
},
"./http/http": {
"import": { "types": "./dist/http/http.d.ts", "default": "./dist/http/http.js" },
"require": { "types": "./dist/http/http.d.ts", "default": "./dist/http/http.js" }
},
"./registry/registry": {
"import": { "types": "./dist/registry/registry.d.ts", "default": "./dist/registry/registry.js" },
"require": { "types": "./dist/registry/registry.d.ts", "default": "./dist/registry/registry.js" }
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/payment": "workspace:^",
"tslib": "catalog:build"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

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,131 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
httpError,
oauthTokenFailure,
PAYMENT_PROVIDER_CODES,
PaymentProviderError,
parseError,
providerAlreadyRegistered,
providerNotRegistered,
timeoutError,
} from "./error-codes.js";
describe("PAYMENT_PROVIDER_CODES registry", () => {
it("every PAYMENT_PROVIDER.* code is reachable from a factory", {
timeout: 5000,
}, () => {
const codes = new Set<string>([
httpError("stripe", 500, "https://x", "boom").code,
timeoutError("paypal", "https://x", 1000).code,
parseError("stripe", "https://x", "not-json", new Error("bad"))
.code,
oauthTokenFailure("paypal", "https://x", new Error("nope")).code,
providerNotRegistered("nope").code,
providerAlreadyRegistered("stripe").code,
]);
expect(codes).toEqual(
new Set<string>([
PAYMENT_PROVIDER_CODES.HTTP_ERROR,
PAYMENT_PROVIDER_CODES.TIMEOUT,
PAYMENT_PROVIDER_CODES.PARSE_ERROR,
PAYMENT_PROVIDER_CODES.OAUTH_TOKEN_FAILURE,
PAYMENT_PROVIDER_CODES.PROVIDER_NOT_REGISTERED,
PAYMENT_PROVIDER_CODES.PROVIDER_ALREADY_REGISTERED,
])
);
});
it("providerNotRegistered carries the name as provider discriminant", {
timeout: 5000,
}, () => {
const err = providerNotRegistered("nope");
expect(err).toBeInstanceOf(PaymentProviderError);
expect(err.code).toBe(PAYMENT_PROVIDER_CODES.PROVIDER_NOT_REGISTERED);
expect(err.provider).toBe("nope");
expect(err.message).toContain("nope");
});
it("providerAlreadyRegistered carries the name as provider discriminant", {
timeout: 5000,
}, () => {
const err = providerAlreadyRegistered("stripe");
expect(err).toBeInstanceOf(PaymentProviderError);
expect(err.code).toBe(
PAYMENT_PROVIDER_CODES.PROVIDER_ALREADY_REGISTERED
);
expect(err.provider).toBe("stripe");
expect(err.message).toContain("stripe");
});
it("httpError carries provider/url/httpStatus/bodyExcerpt discriminants", {
timeout: 5000,
}, () => {
const err = httpError(
"stripe",
404,
"https://api.stripe.com/v1/payment_intents",
"a".repeat(300)
);
expect(err).toBeInstanceOf(PaymentProviderError);
expect(err.provider).toBe("stripe");
expect(err.httpStatus).toBe(404);
expect(err.url).toBe("https://api.stripe.com/v1/payment_intents");
expect(err.bodyExcerpt?.endsWith("…")).toBe(true);
expect((err.bodyExcerpt ?? "").length).toBe(257);
});
it("preserves cause through parseError", { timeout: 5000 }, () => {
const cause = new Error("malformed JSON");
const err = parseError("stripe", "https://x", "not-json", cause);
expect(err.cause).toBe(cause);
expect(err.provider).toBe("stripe");
});
it("preserves cause through oauthTokenFailure", { timeout: 5000 }, () => {
const cause = new Error("invalid_client");
const err = oauthTokenFailure("paypal", "https://x/oauth", cause);
expect(err.cause).toBe(cause);
});
it("timeoutError carries provider and url discriminants", {
timeout: 5000,
}, () => {
const err = timeoutError("paypal", "https://x", 50);
expect(err.provider).toBe("paypal");
expect(err.url).toBe("https://x");
expect(err.code).toBe(PAYMENT_PROVIDER_CODES.TIMEOUT);
});
it("httpError respects a custom message override", {
timeout: 5000,
}, () => {
const err = httpError(
"adyen",
500,
"https://x",
"server down",
"explicit message"
);
expect(err.message).toBe("explicit message");
});
it("httpError leaves bodyExcerpt untruncated when short", {
timeout: 5000,
}, () => {
const err = httpError("stripe", 500, "https://x", "short body");
expect(err.bodyExcerpt).toBe("short body");
});
it("PaymentProviderError tolerates zero discriminants", {
timeout: 5000,
}, () => {
const err = new PaymentProviderError(
PAYMENT_PROVIDER_CODES.HTTP_ERROR,
"no extras"
);
expect(err.provider).toBeUndefined();
expect(err.httpStatus).toBeUndefined();
expect(err.url).toBeUndefined();
expect(err.bodyExcerpt).toBeUndefined();
});
});

View File

@@ -0,0 +1,162 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/payment-providers`. Every
* production failure path (network error, timeout, JSON parse failure,
* OAuth token failure) raises a `PaymentProviderError` carrying one of
* these namespaced codes.
*/
export const PAYMENT_PROVIDER_CODES = defineCodes("PAYMENT_PROVIDER", [
"HTTP_ERROR",
"TIMEOUT",
"PARSE_ERROR",
"OAUTH_TOKEN_FAILURE",
"PROVIDER_NOT_REGISTERED",
"PROVIDER_ALREADY_REGISTERED",
] as const);
export type PaymentProviderErrorCode =
(typeof PAYMENT_PROVIDER_CODES)[keyof typeof PAYMENT_PROVIDER_CODES];
/** Discriminant context carried on a `PaymentProviderError`. */
export interface PaymentProviderErrorOptions extends SuaveplanErrorOptions {
/** Provider name (`"stripe"`, `"paypal"`, etc.) when known. */
readonly provider?: string;
/** HTTP status code, when the failure originated from a response. */
readonly httpStatus?: number;
/** Request URL, when the failure originated from a fetch call. */
readonly url?: string;
/** Response body excerpt, capped at 256 characters. */
readonly bodyExcerpt?: string;
}
const BODY_EXCERPT_MAX = 256;
/**
* Discriminated error class for `@suaveplan/payment-providers`. Extends
* `SuaveplanError` so `instanceof` narrows `code` to
* `PaymentProviderErrorCode`; carries optional `provider` /
* `httpStatus` / `url` / `bodyExcerpt` diagnostics.
*/
export class PaymentProviderError extends SuaveplanError<PaymentProviderErrorCode> {
public readonly provider?: string;
public readonly httpStatus?: number;
public readonly url?: string;
public readonly bodyExcerpt?: string;
public constructor(
code: PaymentProviderErrorCode,
message: string,
options: PaymentProviderErrorOptions = {}
) {
super(code, message, options);
if (options.provider !== undefined) {
this.provider = options.provider;
}
if (options.httpStatus !== undefined) {
this.httpStatus = options.httpStatus;
}
if (options.url !== undefined) {
this.url = options.url;
}
if (options.bodyExcerpt !== undefined) {
this.bodyExcerpt = options.bodyExcerpt;
}
}
}
function truncate(input: string): string {
if (input.length <= BODY_EXCERPT_MAX) {
return input;
}
return `${input.slice(0, BODY_EXCERPT_MAX)}…`;
}
/** Factory for `PAYMENT_PROVIDER.HTTP_ERROR` — non-ok HTTP response. */
export function httpError(
provider: string,
httpStatus: number,
url: string,
body: string,
message?: string
): PaymentProviderError {
const excerpt = truncate(body);
return new PaymentProviderError(
PAYMENT_PROVIDER_CODES.HTTP_ERROR,
message ?? `HTTP ${httpStatus} from ${provider}`,
{ provider, httpStatus, url, bodyExcerpt: excerpt }
);
}
/** Factory for `PAYMENT_PROVIDER.TIMEOUT` — request aborted by timeout. */
export function timeoutError(
provider: string,
url: string,
timeoutMs: number,
cause?: unknown
): PaymentProviderError {
return new PaymentProviderError(
PAYMENT_PROVIDER_CODES.TIMEOUT,
`Request to ${provider} timed out after ${timeoutMs}ms`,
{ provider, url, cause }
);
}
/** Factory for `PAYMENT_PROVIDER.PARSE_ERROR` — JSON parse failure. */
export function parseError(
provider: string,
url: string,
body: string,
cause: unknown
): PaymentProviderError {
return new PaymentProviderError(
PAYMENT_PROVIDER_CODES.PARSE_ERROR,
`Failed to parse response from ${provider}`,
{ provider, url, bodyExcerpt: truncate(body), cause }
);
}
/** Factory for `PAYMENT_PROVIDER.OAUTH_TOKEN_FAILURE` — OAuth call failed. */
export function oauthTokenFailure(
provider: string,
url: string,
cause: unknown
): PaymentProviderError {
return new PaymentProviderError(
PAYMENT_PROVIDER_CODES.OAUTH_TOKEN_FAILURE,
`Failed to acquire OAuth token from ${provider}`,
{ provider, url, cause }
);
}
/**
* Factory for `PAYMENT_PROVIDER.PROVIDER_NOT_REGISTERED` — raised by
* `ProviderRegistry.get` when the requested provider name has not been
* registered. The offending name is carried on the `provider`
* discriminant so consumers handling the error can surface a useful
* diagnostic without re-parsing the message.
*/
export function providerNotRegistered(name: string): PaymentProviderError {
return new PaymentProviderError(
PAYMENT_PROVIDER_CODES.PROVIDER_NOT_REGISTERED,
`Payment provider "${name}" is not registered`,
{ provider: name }
);
}
/**
* Factory for `PAYMENT_PROVIDER.PROVIDER_ALREADY_REGISTERED` — raised
* by `ProviderRegistry.register` when the supplied provider's `name`
* collides with an existing registration. The offending name is
* carried on the `provider` discriminant.
*/
export function providerAlreadyRegistered(name: string): PaymentProviderError {
return new PaymentProviderError(
PAYMENT_PROVIDER_CODES.PROVIDER_ALREADY_REGISTERED,
`Payment provider "${name}" is already registered`,
{ provider: name }
);
}

View File

@@ -0,0 +1,14 @@
export type {
PaymentProviderErrorCode,
PaymentProviderErrorOptions,
} from "./error-codes.js";
export {
httpError,
oauthTokenFailure,
PAYMENT_PROVIDER_CODES,
PaymentProviderError,
parseError,
providerAlreadyRegistered,
providerNotRegistered,
timeoutError,
} from "./error-codes.js";

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,385 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
PAYMENT_PROVIDER_CODES,
PaymentProviderError,
} from "../error-codes/error-codes.js";
import { composeAbortSignal, type FetchLike, httpRequest } from "./http.js";
interface Recorded {
readonly url: string;
readonly init?: RequestInit;
}
function recorder(
handler: (url: string, init?: RequestInit) => Response | Promise<Response>
): { fetch: FetchLike; calls: Recorded[] } {
const calls: Recorded[] = [];
const fetchImpl: FetchLike = async (url, init) => {
calls.push({ url, init });
return handler(url, init);
};
return { fetch: fetchImpl, calls };
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
describe("httpRequest", () => {
it("makes a GET request and parses JSON", { timeout: 5000 }, async () => {
const { fetch, calls } = recorder(() => jsonResponse({ ok: true }));
const result = await httpRequest<{ ok: boolean }>({
method: "GET",
url: "https://api.test/v1",
fetch,
provider: "test",
});
expect(result.status).toBe(200);
expect(result.data).toEqual({ ok: true });
expect(calls).toHaveLength(1);
expect(calls[0]?.init?.method).toBe("GET");
});
it("sends JSON body on POST", { timeout: 5000 }, async () => {
const { fetch, calls } = recorder(() => jsonResponse({ id: "1" }, 201));
const result = await httpRequest({
method: "POST",
url: "https://api.test/v1",
body: { amount: 100 },
fetch,
});
expect(result.data).toEqual({ id: "1" });
expect(calls[0]?.init?.body).toBe(JSON.stringify({ amount: 100 }));
});
it("includes custom headers", { timeout: 5000 }, async () => {
const { fetch, calls } = recorder(() => jsonResponse({}));
await httpRequest({
method: "GET",
url: "https://api.test/v1",
headers: { Authorization: "Bearer tok" },
fetch,
});
const headers = calls[0]?.init?.headers as Record<string, string>;
expect(headers.Authorization).toBe("Bearer tok");
expect(headers["Content-Type"]).toBe("application/json");
});
it("throws PaymentProviderError on non-ok response", {
timeout: 5000,
}, async () => {
const { fetch } = recorder(() =>
jsonResponse({ message: "Not Found" }, 404)
);
await expect(
httpRequest({
method: "GET",
url: "https://api.test/v1",
fetch,
provider: "stripe",
})
).rejects.toMatchObject({
code: PAYMENT_PROVIDER_CODES.HTTP_ERROR,
httpStatus: 404,
provider: "stripe",
message: "Not Found",
});
});
it("throws HTTP_ERROR when no message in body", {
timeout: 5000,
}, async () => {
const { fetch } = recorder(() => jsonResponse({ error: true }, 500));
await expect(
httpRequest({ method: "GET", url: "https://api.test/v1", fetch })
).rejects.toMatchObject({
code: PAYMENT_PROVIDER_CODES.HTTP_ERROR,
httpStatus: 500,
});
});
it("handles empty response body", { timeout: 5000 }, async () => {
const { fetch } = recorder(() => new Response(null, { status: 204 }));
const result = await httpRequest({
method: "DELETE",
url: "https://api.test/v1",
fetch,
});
expect(result.status).toBe(204);
expect(result.data).toEqual({});
});
it("throws on non-ok empty response", { timeout: 5000 }, async () => {
const { fetch } = recorder(() => new Response("", { status: 401 }));
await expect(
httpRequest({ method: "GET", url: "https://api.test/v1", fetch })
).rejects.toBeInstanceOf(PaymentProviderError);
});
it("throws TIMEOUT when AbortSignal fires", { timeout: 5000 }, async () => {
const { fetch } = recorder(
(_url, init) =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () =>
reject(new DOMException("aborted", "AbortError"))
);
})
);
await expect(
httpRequest({
method: "GET",
url: "https://api.test/v1",
timeoutMs: 25,
fetch,
provider: "paypal",
})
).rejects.toMatchObject({ code: PAYMENT_PROVIDER_CODES.TIMEOUT });
});
it("supports DELETE and PATCH methods", { timeout: 5000 }, async () => {
const { fetch, calls } = recorder(() => jsonResponse({ ok: true }));
await httpRequest({
method: "DELETE",
url: "https://api.test/v1/x",
fetch,
});
await httpRequest({
method: "PATCH",
url: "https://api.test/v1/x",
body: { a: 1 },
fetch,
});
expect(calls[0]?.init?.method).toBe("DELETE");
expect(calls[1]?.init?.method).toBe("PATCH");
});
it("passes through a string body without re-serialising as JSON", {
timeout: 5000,
}, async () => {
const { fetch, calls } = recorder(() => jsonResponse({ ok: true }));
const raw = "grant_type=client_credentials";
await httpRequest({
method: "POST",
url: "https://api.test/v1/oauth",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: raw,
fetch,
});
// String bodies must pass through verbatim — not JSON.stringify'd.
expect(calls[0]?.init?.body).toBe(raw);
});
it("composes caller AbortSignal with internal timeout", {
timeout: 5000,
}, async () => {
const external = new AbortController();
const { fetch } = recorder(
(_url, init) =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () =>
reject(new DOMException("aborted", "AbortError"))
);
})
);
const pending = httpRequest({
method: "GET",
url: "https://api.test/v1",
timeoutMs: 5000,
signal: external.signal,
fetch,
});
external.abort();
await expect(pending).rejects.toBeDefined();
});
it("surfaces non-AbortError network failures as HTTP_ERROR", {
timeout: 5000,
}, async () => {
const { fetch } = recorder(() => {
throw new Error("ECONNRESET");
});
await expect(
httpRequest({
method: "GET",
url: "https://api.test/v1",
fetch,
provider: "mollie",
})
).rejects.toMatchObject({
code: PAYMENT_PROVIDER_CODES.HTTP_ERROR,
provider: "mollie",
});
});
it("surfaces non-Error network failures as HTTP_ERROR", {
timeout: 5000,
}, async () => {
const { fetch } = recorder(() => {
const failure: unknown = "string failure";
throw failure;
});
await expect(
httpRequest({
method: "GET",
url: "https://api.test/v1",
fetch,
})
).rejects.toMatchObject({
code: PAYMENT_PROVIDER_CODES.HTTP_ERROR,
});
});
it("rethrows PaymentProviderError raised inside fetchImpl unchanged", {
timeout: 5000,
}, async () => {
const inner = new PaymentProviderError(
PAYMENT_PROVIDER_CODES.HTTP_ERROR,
"inner",
{ provider: "stripe" }
);
const { fetch } = recorder(() => {
throw inner;
});
await expect(
httpRequest({ method: "GET", url: "https://api.test/v1", fetch })
).rejects.toBe(inner);
});
it("throws PARSE_ERROR on malformed JSON", { timeout: 5000 }, async () => {
const { fetch } = recorder(
() => new Response("{not-json", { status: 200 })
);
await expect(
httpRequest({ method: "GET", url: "https://api.test/v1", fetch })
).rejects.toMatchObject({ code: PAYMENT_PROVIDER_CODES.PARSE_ERROR });
});
it("defaults to globalThis.fetch when none injected", {
timeout: 5000,
}, async () => {
const original = globalThis.fetch;
const calls: string[] = [];
Object.defineProperty(globalThis, "fetch", {
configurable: true,
value: async (url: string) => {
calls.push(url);
return jsonResponse({ ok: true });
},
});
try {
const result = await httpRequest({
method: "GET",
url: "https://default.test/v1",
});
expect(result.data).toEqual({ ok: true });
expect(calls).toEqual(["https://default.test/v1"]);
} finally {
Object.defineProperty(globalThis, "fetch", {
configurable: true,
value: original,
});
}
});
it("internal timeout aborts the composed signal when external signal is present but never fires", {
timeout: 5000,
}, async () => {
const external = new AbortController();
const { fetch } = recorder(
(_url, init) =>
new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () =>
reject(new DOMException("aborted", "AbortError"))
);
})
);
await expect(
httpRequest({
method: "GET",
url: "https://api.test/v1",
timeoutMs: 25,
signal: external.signal,
fetch,
provider: "stripe",
})
).rejects.toMatchObject({
code: PAYMENT_PROVIDER_CODES.TIMEOUT,
});
});
it("honours an already-aborted external signal", {
timeout: 5000,
}, async () => {
const external = new AbortController();
external.abort();
const { fetch } = recorder(
(_url, init) =>
new Promise<Response>((_resolve, reject) => {
if (init?.signal?.aborted === true) {
reject(new DOMException("aborted", "AbortError"));
return;
}
init?.signal?.addEventListener("abort", () =>
reject(new DOMException("aborted", "AbortError"))
);
})
);
await expect(
httpRequest({
method: "GET",
url: "https://api.test/v1",
signal: external.signal,
fetch,
})
).rejects.toBeDefined();
});
});
describe("composeAbortSignal", () => {
it("composeAbortSignal returns internal when external is undefined", {
timeout: 5000,
}, () => {
const internal = new AbortController().signal;
const out = composeAbortSignal(internal, undefined);
expect(out).toBe(internal);
});
it("composeAbortSignal propagates internal abort", {
timeout: 5000,
}, async () => {
const internal = new AbortController();
const external = new AbortController();
const out = composeAbortSignal(internal.signal, external.signal);
expect(out.aborted).toBe(false);
internal.abort("boom-internal");
// Event loop turn — AbortController dispatches synchronously, but
// we await to be defensive across runtimes.
await Promise.resolve();
expect(out.aborted).toBe(true);
});
it("composeAbortSignal propagates external abort", {
timeout: 5000,
}, async () => {
const internal = new AbortController();
const external = new AbortController();
const out = composeAbortSignal(internal.signal, external.signal);
expect(out.aborted).toBe(false);
external.abort("boom-external");
await Promise.resolve();
expect(out.aborted).toBe(true);
});
it("composeAbortSignal aborts immediately when external is pre-aborted", {
timeout: 5000,
}, () => {
const internal = new AbortController();
const external = new AbortController();
external.abort("pre-aborted");
const out = composeAbortSignal(internal.signal, external.signal);
expect(out.aborted).toBe(true);
});
});

View File

@@ -0,0 +1,152 @@
import {
httpError,
PaymentProviderError,
parseError,
timeoutError,
} from "../error-codes/error-codes.js";
import type { HttpOptions, ProviderResponse } from "../types/provider.js";
/**
* Subset of the global `fetch` signature the package uses. Every provider
* accepts a `FetchLike` config field so tests inject a recording adapter
* instead of monkey-patching `globalThis.fetch`. Defaults to
* `globalThis.fetch` when omitted.
*/
export type FetchLike = (
input: string,
init?: RequestInit
) => Promise<Response>;
const DEFAULT_TIMEOUT_MS = 30_000;
const DEFAULT_PROVIDER = "unknown";
function defaultFetch(): FetchLike {
return globalThis.fetch.bind(globalThis);
}
/**
* Compose two `AbortSignal`s into one. When `external` is `undefined`
* the function returns `internal` directly (reference-equal). Otherwise
* it returns a fresh signal that aborts as soon as either input aborts,
* propagating the firing input's `reason`. If `external` is already
* aborted at call time the composed signal is aborted before the
* function returns. Sibling provider packages reuse this helper to
* compose their out-of-band HTTP calls (OAuth token fetch, webhook
* fetch, etc.) with caller-supplied cancellation tokens without
* re-implementing the wiring.
*/
export function composeAbortSignal(
internal: AbortSignal,
external: AbortSignal | undefined
): AbortSignal {
if (external === undefined) {
return internal;
}
const controller = new AbortController();
const onAbort = (signal: AbortSignal): void => {
// AbortController.abort() is idempotent — subsequent calls are no-ops.
controller.abort(signal.reason);
};
internal.addEventListener("abort", () => onAbort(internal), { once: true });
if (external.aborted) {
controller.abort(external.reason);
} else {
external.addEventListener("abort", () => onAbort(external), {
once: true,
});
}
return controller.signal;
}
function isTimeoutAbort(err: unknown, timeoutSignal: AbortSignal): boolean {
if (!timeoutSignal.aborted) {
return false;
}
return err instanceof Error && err.name === "AbortError";
}
/**
* Universal HTTP helper wrapping an injectable `FetchLike`. Serialises
* JSON bodies, enforces a per-call timeout via `AbortSignal`, composes
* a caller-supplied `signal` with the internal timeout signal, and
* raises typed `PaymentProviderError`s on failure.
*/
export async function httpRequest<T = unknown>(
options: HttpOptions
): Promise<ProviderResponse<T>> {
const {
method,
url,
headers = {},
body,
timeoutMs = DEFAULT_TIMEOUT_MS,
fetch: fetchImpl = defaultFetch(),
signal: external,
provider = DEFAULT_PROVIDER,
} = options;
const timeoutController = new AbortController();
const timer = setTimeout(() => timeoutController.abort(), timeoutMs);
const signal = composeAbortSignal(timeoutController.signal, external);
const init: RequestInit = {
method,
headers: {
"Content-Type": "application/json",
Accept: "application/json",
...headers,
},
signal,
};
if (body !== undefined) {
init.body = typeof body === "string" ? body : JSON.stringify(body);
}
try {
let response: Response;
try {
response = await fetchImpl(url, init);
} catch (err) {
if (isTimeoutAbort(err, timeoutController.signal)) {
throw timeoutError(provider, url, timeoutMs, err);
}
if (err instanceof PaymentProviderError) {
throw err;
}
throw httpError(
provider,
0,
url,
err instanceof Error ? err.message : String(err),
err instanceof Error ? err.message : undefined
);
}
const text = await response.text();
let data: T;
if (text === "") {
data = {} as T;
} else {
try {
data = JSON.parse(text) as T;
} catch (parseErr) {
throw parseError(provider, url, text, parseErr);
}
}
if (!response.ok) {
const message =
typeof data === "object" && data !== null && "message" in data
? String((data as Record<string, unknown>)["message"])
: `HTTP ${response.status} from ${provider}`;
throw httpError(provider, response.status, url, text, message);
}
return { status: response.status, data };
} finally {
clearTimeout(timer);
}
}

View File

@@ -0,0 +1,5 @@
export type { FetchLike } from "./http.js";
export {
composeAbortSignal,
httpRequest,
} from "./http.js";

View File

@@ -0,0 +1,37 @@
// Error codes
export {
httpError,
oauthTokenFailure,
PAYMENT_PROVIDER_CODES,
PaymentProviderError,
type PaymentProviderErrorCode,
type PaymentProviderErrorOptions,
parseError,
providerAlreadyRegistered,
providerNotRegistered,
timeoutError,
} from "./error-codes/error-codes.js";
// HTTP
export {
composeAbortSignal,
type FetchLike,
httpRequest,
} from "./http/http.js";
// Registry
export {
definePaymentProvider,
type PaymentProviderSpec,
} from "./registry/define.js";
export { ProviderRegistry } from "./registry/registry.js";
// Types
export type {
ApiKeyConfig,
Base64EncodeFn,
HttpOptions,
MerchantKeyConfig,
NowFn,
OAuthConfig,
ProviderCommonOptions,
ProviderResponse,
RandomIdFn,
} from "./types/provider.js";

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,137 @@
import type {
CreatePaymentIntentInput,
MoneyLike,
PaymentIntent,
PaymentProvider,
Refund,
RefundInput,
} from "@suaveplan/payment";
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
import { definePaymentProvider } from "./define.js";
import { ProviderRegistry } from "./registry.js";
const MONEY: MoneyLike = { amount: 1000n, currency: "USD" };
const NOW = "2026-05-17T00:00:00.000Z";
function intent(id: string): PaymentIntent {
return {
id,
status: "created",
amount: MONEY,
capturedAmount: { amount: 0n, currency: "USD" },
refundedAmount: { amount: 0n, currency: "USD" },
captureMethod: "automatic",
refunds: [],
createdAt: NOW,
updatedAt: NOW,
};
}
const createIntent = async (
_input: CreatePaymentIntentInput
): Promise<PaymentIntent> => intent("created");
const confirmIntent = async (id: string): Promise<PaymentIntent> => intent(id);
const captureIntent = async (id: string): Promise<PaymentIntent> => intent(id);
const cancelIntent = async (id: string): Promise<PaymentIntent> => intent(id);
const refundFn = async (input: RefundInput): Promise<Refund> => ({
id: "refund_1",
paymentIntentId: input.paymentIntentId,
amount: MONEY,
status: "succeeded",
createdAt: NOW,
});
const getIntent = async (id: string): Promise<PaymentIntent> => intent(id);
describe("definePaymentProvider", () => {
it("factory result is assignable to PaymentProvider", {
timeout: 5000,
}, () => {
const provider = definePaymentProvider({
name: "stub",
createIntent,
confirmIntent,
captureIntent,
cancelIntent,
refund: refundFn,
getIntent,
});
const asInterface: PaymentProvider = provider;
expect(asInterface.name).toBe("stub");
});
it("factory result registers cleanly into ProviderRegistry", {
timeout: 5000,
}, () => {
const provider = definePaymentProvider({
name: "stub",
createIntent,
confirmIntent,
captureIntent,
cancelIntent,
refund: refundFn,
getIntent,
});
const registry = new ProviderRegistry();
registry.register(provider);
expect(registry.get("stub")).toBe(provider);
});
it("returned object is frozen", { timeout: 5000 }, () => {
const provider = definePaymentProvider({
name: "stub",
createIntent,
confirmIntent,
captureIntent,
cancelIntent,
refund: refundFn,
getIntent,
});
expect(Object.isFrozen(provider)).toBe(true);
let caught: unknown;
try {
(provider as unknown as { name: string }).name = "mutated";
} catch (err) {
caught = err;
}
// In strict module mode, assignment to a frozen property throws.
// The vitest/bun test runners both execute under strict module
// semantics, so the assignment above must have thrown — assert
// either the throw OR that the read-back value is unchanged
// (defensive across runtimes).
expect(caught !== undefined || provider.name === "stub").toBe(true);
expect(provider.name).toBe("stub");
});
it("method identity is preserved", { timeout: 5000 }, () => {
const provider = definePaymentProvider({
name: "stub",
createIntent,
confirmIntent,
captureIntent,
cancelIntent,
refund: refundFn,
getIntent,
});
expect(provider.createIntent).toBe(createIntent);
expect(provider.confirmIntent).toBe(confirmIntent);
expect(provider.captureIntent).toBe(captureIntent);
expect(provider.cancelIntent).toBe(cancelIntent);
expect(provider.refund).toBe(refundFn);
expect(provider.getIntent).toBe(getIntent);
});
it("literal name narrows the result type", { timeout: 5000 }, () => {
const provider = definePaymentProvider({
name: "stripe",
createIntent,
confirmIntent,
captureIntent,
cancelIntent,
refund: refundFn,
getIntent,
});
// Type-level assertion: name is narrowed to the literal "stripe".
expectTypeOf(provider.name).toEqualTypeOf<"stripe">();
expect(provider.name).toBe("stripe");
});
});

View File

@@ -0,0 +1,50 @@
import type { PaymentProvider } from "@suaveplan/payment";
/**
* Spec accepted by {@link definePaymentProvider}. Mirrors the structural
* `PaymentProvider` interface from `@suaveplan/payment` but with a
* literal-typed `name` so the factory result can be narrowed at the
* type level (`provider.name === "stripe"` narrows to the Stripe
* spec).
*/
export interface PaymentProviderSpec<TName extends string> {
readonly name: TName;
readonly createIntent: PaymentProvider["createIntent"];
readonly confirmIntent: PaymentProvider["confirmIntent"];
readonly captureIntent: PaymentProvider["captureIntent"];
readonly cancelIntent: PaymentProvider["cancelIntent"];
readonly refund: PaymentProvider["refund"];
readonly getIntent: PaymentProvider["getIntent"];
}
/**
* Factory helper for sibling `@suaveplan/payment-provider-*` packages
* that want to expose a `PaymentProvider` without authoring a class.
* The returned object:
*
* 1. Carries the supplied method references verbatim — closures stay
* intact, no `this` rebinding.
* 2. Is frozen via `Object.freeze` so accidental mutation downstream
* throws in strict mode.
* 3. Narrows the `name` field to the supplied string literal so
* consumers can discriminate at the type level.
*
* Sibling packages MAY return a class instance from their constructor
* instead — both satisfy the structural `PaymentProvider` contract and
* both register identically into {@link ProviderRegistry}. Use this
* factory when the provider is stateless or the boilerplate of a class
* is unwarranted.
*/
export function definePaymentProvider<TName extends string>(
spec: PaymentProviderSpec<TName>
): PaymentProvider & { readonly name: TName } {
return Object.freeze({
name: spec.name,
createIntent: spec.createIntent,
confirmIntent: spec.confirmIntent,
captureIntent: spec.captureIntent,
cancelIntent: spec.cancelIntent,
refund: spec.refund,
getIntent: spec.getIntent,
});
}

View File

@@ -0,0 +1,5 @@
export {
definePaymentProvider,
type PaymentProviderSpec,
} from "./define.js";
export { ProviderRegistry } from "./registry.js";

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,141 @@
import type {
MoneyLike,
PaymentIntent,
PaymentProvider,
Refund,
RefundInput,
} from "@suaveplan/payment";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
PAYMENT_PROVIDER_CODES,
PaymentProviderError,
} from "../error-codes/error-codes.js";
import { ProviderRegistry } from "./registry.js";
const MONEY: MoneyLike = { amount: 1000n, currency: "USD" };
const NOW = "2026-05-17T00:00:00.000Z";
function intent(id: string): PaymentIntent {
return {
id,
status: "created",
amount: MONEY,
capturedAmount: { amount: 0n, currency: "USD" },
refundedAmount: { amount: 0n, currency: "USD" },
captureMethod: "automatic",
refunds: [],
createdAt: NOW,
updatedAt: NOW,
};
}
function refund(id: string, paymentIntentId: string): Refund {
return {
id,
paymentIntentId,
amount: MONEY,
status: "succeeded",
createdAt: NOW,
};
}
function makeProvider(name: string): PaymentProvider {
return {
name,
createIntent: async () => intent(`${name}_intent`),
confirmIntent: async (id) => intent(id),
captureIntent: async (id) => intent(id),
cancelIntent: async (id) => intent(id),
refund: async (input: RefundInput) =>
refund(`${name}_refund`, input.paymentIntentId),
getIntent: async (id) => intent(id),
};
}
describe("ProviderRegistry", () => {
it("register + get round-trips a provider", { timeout: 5000 }, () => {
const registry = new ProviderRegistry();
const stripe = makeProvider("stripe");
registry.register(stripe);
expect(registry.get("stripe")).toBe(stripe);
});
it("throws PROVIDER_NOT_REGISTERED on unknown name", {
timeout: 5000,
}, () => {
const registry = new ProviderRegistry();
let caught: unknown;
try {
registry.get("nope");
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(PaymentProviderError);
const err = caught as PaymentProviderError;
expect(err.code).toBe(PAYMENT_PROVIDER_CODES.PROVIDER_NOT_REGISTERED);
expect(err.provider).toBe("nope");
});
it("throws PROVIDER_ALREADY_REGISTERED on duplicate name", {
timeout: 5000,
}, () => {
const registry = new ProviderRegistry();
registry.register(makeProvider("stripe"));
let caught: unknown;
try {
registry.register(makeProvider("stripe"));
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(PaymentProviderError);
const err = caught as PaymentProviderError;
expect(err.code).toBe(
PAYMENT_PROVIDER_CODES.PROVIDER_ALREADY_REGISTERED
);
expect(err.provider).toBe("stripe");
});
it("unregister returns true on hit and false on miss", {
timeout: 5000,
}, () => {
const registry = new ProviderRegistry();
registry.register(makeProvider("stripe"));
expect(registry.unregister("stripe")).toBe(true);
expect(registry.has("stripe")).toBe(false);
expect(registry.unregister("stripe")).toBe(false);
});
it("list returns a frozen snapshot in insertion order", {
timeout: 5000,
}, () => {
const registry = new ProviderRegistry();
const a = makeProvider("a");
const b = makeProvider("b");
const c = makeProvider("c");
registry.register(a);
registry.register(b);
registry.register(c);
const snapshot = registry.list();
expect(snapshot).toEqual([a, b, c]);
expect(Object.isFrozen(snapshot)).toBe(true);
});
it("size tracks register and unregister", { timeout: 5000 }, () => {
const registry = new ProviderRegistry();
expect(registry.size).toBe(0);
registry.register(makeProvider("a"));
registry.register(makeProvider("b"));
expect(registry.size).toBe(2);
registry.unregister("a");
expect(registry.size).toBe(1);
registry.unregister("b");
expect(registry.size).toBe(0);
});
it("has reports membership without throwing", { timeout: 5000 }, () => {
const registry = new ProviderRegistry();
expect(registry.has("stripe")).toBe(false);
registry.register(makeProvider("stripe"));
expect(registry.has("stripe")).toBe(true);
});
});

View File

@@ -0,0 +1,79 @@
import type { PaymentProvider } from "@suaveplan/payment";
import {
providerAlreadyRegistered,
providerNotRegistered,
} from "../error-codes/error-codes.js";
/**
* Process-local, synchronous registry that indexes `PaymentProvider`
* instances by their `name`. Sibling `@suaveplan/payment-provider-*`
* packages contribute provider instances; consumers register the ones
* they intend to route to and look them up by string name.
*
* The registry intentionally has no persistence, no auto-discovery,
* and no async lifecycle — those concerns belong to higher-level
* orchestrators (e.g. `@suaveplan/payment`'s `PaymentEngine`).
*/
export class ProviderRegistry {
/** Insertion-ordered map keyed by `provider.name`. */
private readonly providers = new Map<string, PaymentProvider>();
/**
* Index a provider by `provider.name`. Throws
* `PaymentProviderError(PAYMENT_PROVIDER.PROVIDER_ALREADY_REGISTERED)`
* when the name is already present — explicit duplicate handling
* is preferable to silent overwrite because two different provider
* instances sharing a name almost always indicate a configuration
* mistake.
*/
public register(provider: PaymentProvider): void {
if (this.providers.has(provider.name)) {
throw providerAlreadyRegistered(provider.name);
}
this.providers.set(provider.name, provider);
}
/**
* Remove a provider by name. Returns `true` if a provider was
* removed and `false` otherwise — the second case is intentionally
* non-throwing so callers can use the method as an idempotent
* cleanup hook.
*/
public unregister(name: string): boolean {
return this.providers.delete(name);
}
/**
* Resolve a provider by name. Throws
* `PaymentProviderError(PAYMENT_PROVIDER.PROVIDER_NOT_REGISTERED)`
* when the name is absent — strict lookup prevents accidental
* routing through `undefined`. Callers that want soft semantics
* should use {@link ProviderRegistry.has} first.
*/
public get(name: string): PaymentProvider {
const provider = this.providers.get(name);
if (provider === undefined) {
throw providerNotRegistered(name);
}
return provider;
}
/** Report whether a provider with the given name is registered. */
public has(name: string): boolean {
return this.providers.has(name);
}
/**
* Return a frozen snapshot of all registered providers in
* insertion order. The result is frozen so callers cannot mutate
* the registry through the returned array.
*/
public list(): readonly PaymentProvider[] {
return Object.freeze(Array.from(this.providers.values()));
}
/** Number of currently registered providers. */
public get size(): number {
return this.providers.size;
}
}

View File

@@ -0,0 +1,11 @@
export type {
ApiKeyConfig,
Base64EncodeFn,
HttpOptions,
MerchantKeyConfig,
NowFn,
OAuthConfig,
ProviderCommonOptions,
ProviderResponse,
RandomIdFn,
} from "./provider.js";

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.

View File

@@ -0,0 +1,79 @@
import type { FetchLike } from "../http/http.js";
/**
* Function returning the current epoch milliseconds. Injectable on
* providers that cache time-sensitive material (PayPal token cache).
* Defaults to `Date.now`.
*/
export type NowFn = () => number;
/**
* Function returning an opaque, sufficiently-unique identifier string.
* Used by providers that auto-generate default idempotency keys
* (Square, Adyen). Defaults to `crypto.randomUUID()` at call time.
*/
export type RandomIdFn = () => string;
/**
* Function encoding an ASCII string as Base-64. Used by `PayPalProvider`
* for the OAuth `Authorization: Basic …` header so the package runs on
* every runtime that names Base-64 differently. Defaults to `btoa`.
*/
export type Base64EncodeFn = (input: string) => string;
/** Common injectable seams shared by every provider config. */
export interface ProviderCommonOptions {
/** Custom HTTP client. Defaults to `globalThis.fetch`. */
readonly fetch?: FetchLike | undefined;
/** Per-request timeout in milliseconds. Default `30_000`. */
readonly timeoutMs?: number | undefined;
/** External `AbortSignal` composed with the internal timeout signal.*/
readonly signal?: AbortSignal | undefined;
}
/** Configuration for API-key-based providers (Stripe, Mollie). */
export interface ApiKeyConfig extends ProviderCommonOptions {
readonly apiKey: string;
readonly baseUrl?: string | undefined;
readonly webhookSecret?: string | undefined;
}
/** Configuration for OAuth-based providers (PayPal). */
export interface OAuthConfig extends ProviderCommonOptions {
readonly clientId: string;
readonly clientSecret: string;
readonly baseUrl?: string | undefined;
readonly webhookSecret?: string | undefined;
/** Clock for the OAuth token-cache TTL. Defaults to `Date.now`. */
readonly now?: NowFn | undefined;
/** Base-64 encoder for the OAuth Basic header. Defaults to `btoa`. */
readonly base64Encode?: Base64EncodeFn | undefined;
}
/** Configuration for Adyen (API key + merchant account + injectable random id). */
export interface MerchantKeyConfig extends ProviderCommonOptions {
readonly apiKey: string;
readonly merchantAccount: string;
readonly baseUrl?: string | undefined;
readonly webhookHmacKey?: string | undefined;
readonly randomId?: RandomIdFn | undefined;
}
/** HTTP response wrapper returned by `httpRequest`. */
export interface ProviderResponse<T = unknown> {
readonly status: number;
readonly data: T;
}
/** Options for the `httpRequest` helper. */
export interface HttpOptions extends ProviderCommonOptions {
readonly method: "GET" | "POST" | "DELETE" | "PATCH";
readonly url: string;
readonly headers?: Record<string, string> | undefined;
readonly body?: unknown;
/** Provider name surfaced on the typed error.*/
readonly provider?: string | undefined;
}

View File

@@ -0,0 +1,41 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const SRC_ROOT = join(__dirname, "..", "src");
function walkTypescript(dir: string): string[] {
const entries = readdirSync(dir);
const files: string[] = [];
for (const entry of entries) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) {
files.push(...walkTypescript(full));
} else if (
entry.endsWith(".ts") &&
!entry.endsWith(".test.ts") &&
!entry.endsWith(".spec.ts")
) {
files.push(full);
}
}
return files;
}
describe("universal dep graph", () => {
it("src contains no node/bun/fs/path/stream/net/http/crypto imports", {
timeout: 5000,
}, () => {
const banned =
/from\s+['"][?:node:|bun:|fs|path|stream|net|http|crypto]('")/;
const offenders: string[] = [];
for (const file of walkTypescript(SRC_ROOT)) {
const text = readFileSync(file, "utf8");
if (banned.test(text)) {
offenders.push(file);
}
}
expect(offenders).toEqual([]);
});
});

View File

@@ -0,0 +1,5 @@
// Polyfills for the payment-providers test environment — none required.
// Every provider uses an injectable FetchLike, so tests run without any
// platform fetch dependency. This file exists to mirror the sibling-package
// layout and to give vitest a stable setup-file path.
export {};

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,34 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/payment-providers",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/payment",
"tslib",
],
},
},
});

View File

@@ -0,0 +1,27 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/*.bench.ts",
"src/__benchmarks__/**",
"tests/**",
],
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
},
testTimeout: 30000,
},
});