Initial Commit
This commit is contained in:
213
packages/money/payment/payment-providers/README.md
Normal file
213
packages/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
|
||||
|
||||
69
packages/money/payment/payment-providers/package.json
Normal file
69
packages/money/payment/payment-providers/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
105
packages/money/payment/payment-providers/src/http/http.md
Normal file
105
packages/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.
|
||||
385
packages/money/payment/payment-providers/src/http/http.test.ts
Normal file
385
packages/money/payment/payment-providers/src/http/http.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
152
packages/money/payment/payment-providers/src/http/http.ts
Normal file
152
packages/money/payment/payment-providers/src/http/http.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type { FetchLike } from "./http.js";
|
||||
export {
|
||||
composeAbortSignal,
|
||||
httpRequest,
|
||||
} from "./http.js";
|
||||
37
packages/money/payment/payment-providers/src/index.ts
Normal file
37
packages/money/payment/payment-providers/src/index.ts
Normal 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";
|
||||
119
packages/money/payment/payment-providers/src/registry/define.md
Normal file
119
packages/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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
definePaymentProvider,
|
||||
type PaymentProviderSpec,
|
||||
} from "./define.js";
|
||||
export { ProviderRegistry } from "./registry.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
11
packages/money/payment/payment-providers/src/types/index.ts
Normal file
11
packages/money/payment/payment-providers/src/types/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export type {
|
||||
ApiKeyConfig,
|
||||
Base64EncodeFn,
|
||||
HttpOptions,
|
||||
MerchantKeyConfig,
|
||||
NowFn,
|
||||
OAuthConfig,
|
||||
ProviderCommonOptions,
|
||||
ProviderResponse,
|
||||
RandomIdFn,
|
||||
} from "./provider.js";
|
||||
102
packages/money/payment/payment-providers/src/types/provider.md
Normal file
102
packages/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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 {};
|
||||
12
packages/money/payment/payment-providers/tsconfig.json
Normal file
12
packages/money/payment/payment-providers/tsconfig.json
Normal 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"]
|
||||
}
|
||||
12
packages/money/payment/payment-providers/tsconfig.lib.json
Normal file
12
packages/money/payment/payment-providers/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
34
packages/money/payment/payment-providers/vite.config.ts
Normal file
34
packages/money/payment/payment-providers/vite.config.ts
Normal 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",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
27
packages/money/payment/payment-providers/vitest.config.ts
Normal file
27
packages/money/payment/payment-providers/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user