219 lines
12 KiB
Markdown
219 lines
12 KiB
Markdown
<!-- markdownlint-disable MD024 -->
|
|
|
|
# Capability Spec: payment-providers
|
|
|
|
**Package**: `@suaveplan/payment-providers`
|
|
**Status**: SHIPPED
|
|
|
|
> Every requirement below carries a `**Verified by:**` line.
|
|
>
|
|
> History: originally folded by `2026-05-17-add-payment-providers` as a
|
|
> 5-adapter bundle; slimmed by `2026-05-17-slim-payment-providers` into
|
|
> the present foundation-with-sibling-plugins shape. Concrete providers
|
|
> (Stripe, PayPal, Square, Mollie, Adyen) ship as separate
|
|
> `@suaveplan/payment-provider-<name>` sibling packages.
|
|
|
|
## Requirements
|
|
|
|
### Requirement: Universal-tier purity
|
|
|
|
The package SHALL ship as a universal-tier package — no `node:*`,
|
|
`bun:*`, `fs`, `path`, `stream`, `net`, `http`, or bare `crypto`
|
|
imports in any file under `src/`. The package SHALL run unchanged under
|
|
Node ≥ 18, Bun, modern browsers, and Web Workers. `package.json` SHALL
|
|
declare `"sideEffects": false`. Every HTTP call SHALL go through the
|
|
injectable `FetchLike` so the package never reaches into a runtime-
|
|
specific HTTP stack.
|
|
|
|
#### Scenario: no platform imports in src/
|
|
|
|
- **WHEN** `grep -rnE "from ['\"](node:|bun:|fs|path|stream|net|http|crypto)['\"]" packages/payment/payment-providers/src/` is run
|
|
- **THEN** the command produces zero matches
|
|
|
|
**Verified by:** `tests/dep-graph.test.ts::"src contains no node/bun/fs/path/stream/net/http/crypto imports"`
|
|
|
|
---
|
|
|
|
### Requirement: Stable PAYMENT_PROVIDER_CODES registry and PaymentProviderError class
|
|
|
|
The package SHALL declare an error-code registry via
|
|
`defineCodes("PAYMENT_PROVIDER", […])` containing exactly the codes
|
|
`HTTP_ERROR`, `TIMEOUT`, `PARSE_ERROR`, `OAUTH_TOKEN_FAILURE`,
|
|
`PROVIDER_NOT_REGISTERED`, and `PROVIDER_ALREADY_REGISTERED`. Every
|
|
public failure path triggered by network, parsing, OAuth, or registry
|
|
errors SHALL throw a `PaymentProviderError extends SuaveplanError<PaymentProviderErrorCode>`
|
|
carrying one of those codes. `PaymentProviderError` SHALL expose
|
|
optional `provider`, `httpStatus`, `bodyExcerpt`, and `url`
|
|
discriminants. The package SHALL NOT throw raw `Error` instances from
|
|
any production code path under `src/`.
|
|
|
|
#### Scenario: every code reachable from a real call site
|
|
|
|
- **WHEN** the test suite exercises each factory (`httpError`, `timeoutError`, `parseError`, `oauthTokenFailure`, `providerNotRegistered`, `providerAlreadyRegistered`)
|
|
- **THEN** each path produces a `PaymentProviderError` whose `code` matches the expected `PAYMENT_PROVIDER.*` constant
|
|
|
|
#### Scenario: cause-bearing factories preserve the underlying error
|
|
|
|
- **WHEN** `parseError` or `oauthTokenFailure` is invoked with a `cause`
|
|
- **THEN** the resulting `PaymentProviderError`'s `cause` is the original error
|
|
|
|
#### Scenario: HTTP error carries discriminants from the response
|
|
|
|
- **WHEN** `httpError(provider, status, url, body)` is invoked
|
|
- **THEN** the resulting error's `httpStatus`, `url`, `provider`, and `bodyExcerpt` fields are all populated
|
|
|
|
#### Scenario: registry-aware factories carry the offending name
|
|
|
|
- **WHEN** `providerNotRegistered("nope")` or `providerAlreadyRegistered("stripe")` is invoked
|
|
- **THEN** the resulting `PaymentProviderError`'s `provider` discriminant equals the supplied name and the message contains the same name
|
|
|
|
**Verified by:** `src/error-codes/error-codes.test.ts::"every PAYMENT_PROVIDER.* code is reachable from a factory"` AND `src/error-codes/error-codes.test.ts::"preserves cause through parseError"` AND `src/error-codes/error-codes.test.ts::"preserves cause through oauthTokenFailure"` AND `src/error-codes/error-codes.test.ts::"httpError carries provider/url/httpStatus/bodyExcerpt discriminants"` AND `src/error-codes/error-codes.test.ts::"providerNotRegistered carries the name as provider discriminant"` AND `src/error-codes/error-codes.test.ts::"providerAlreadyRegistered carries the name as provider discriminant"`
|
|
|
|
---
|
|
|
|
### Requirement: httpRequest helper with injectable FetchLike and timeout
|
|
|
|
The package SHALL export `httpRequest<T>(options: HttpOptions): Promise<ProviderResponse<T>>`
|
|
that wraps an injectable `FetchLike` (defaulting to `globalThis.fetch`).
|
|
The helper SHALL serialise `body` as JSON (string bodies pass through
|
|
verbatim), set `Content-Type: application/json` and `Accept:
|
|
application/json` headers (overridable by callers), enforce a per-call
|
|
`timeoutMs` (default 30 000) via `AbortSignal` with a clean
|
|
`clearTimeout` in the `finally` block, parse the response body as JSON
|
|
(returning `{}` when the body is empty), and on a non-ok response throw
|
|
a `PaymentProviderError(PAYMENT_PROVIDER.HTTP_ERROR)` carrying the HTTP
|
|
status, URL, and a `bodyExcerpt` capped at 256 chars. A caller-
|
|
supplied `signal` SHALL be composed with the internal timeout signal so
|
|
external cancellation is honoured.
|
|
|
|
#### Scenario: 200 response returns parsed body
|
|
|
|
- **WHEN** the injected `FetchLike` resolves with `new Response("{\"ok\":true}", { status: 200 })`
|
|
- **THEN** `httpRequest` resolves with `{ status: 200, data: { ok: true } }`
|
|
|
|
#### Scenario: 204 response returns empty data
|
|
|
|
- **WHEN** the injected `FetchLike` resolves with `new Response(null, { status: 204 })`
|
|
- **THEN** `httpRequest` resolves with `{ status: 204, data: {} }`
|
|
|
|
#### Scenario: non-ok response throws typed error
|
|
|
|
- **WHEN** the injected `FetchLike` resolves with status 404 and body `{ "message": "Not Found" }`
|
|
- **THEN** `httpRequest` rejects with a `PaymentProviderError` whose `code === "PAYMENT_PROVIDER.HTTP_ERROR"`, `httpStatus === 404`, and `bodyExcerpt` containing `"Not Found"`
|
|
|
|
#### Scenario: timeout aborts via AbortSignal
|
|
|
|
- **WHEN** the injected `FetchLike` never resolves and `timeoutMs` is set to 50
|
|
- **THEN** `httpRequest` rejects with a `PaymentProviderError` whose `code === "PAYMENT_PROVIDER.TIMEOUT"`
|
|
|
|
#### Scenario: string body passes through unchanged
|
|
|
|
- **WHEN** `httpRequest({ body: "grant_type=client_credentials", … })` is invoked
|
|
- **THEN** the recording `FetchLike` observes a request whose `init.body` is the same string (not JSON-encoded)
|
|
|
|
**Verified by:** `src/http/http.test.ts::"makes a GET request and parses JSON"` AND `src/http/http.test.ts::"sends JSON body on POST"` AND `src/http/http.test.ts::"includes custom headers"` AND `src/http/http.test.ts::"throws PaymentProviderError on non-ok response"` AND `src/http/http.test.ts::"throws HTTP_ERROR when no message in body"` AND `src/http/http.test.ts::"handles empty response body"` AND `src/http/http.test.ts::"throws on non-ok empty response"` AND `src/http/http.test.ts::"throws TIMEOUT when AbortSignal fires"` AND `src/http/http.test.ts::"supports DELETE and PATCH methods"` AND `src/http/http.test.ts::"passes through a string body without re-serialising as JSON"` AND `src/http/http.test.ts::"composes caller AbortSignal with internal timeout"`
|
|
|
|
---
|
|
|
|
### Requirement: composeAbortSignal is publicly exported
|
|
|
|
The package SHALL export `composeAbortSignal(internal, external)` from
|
|
`src/http/http.ts` and re-export it from `src/index.ts`. The function
|
|
SHALL return the internal signal when `external` is `undefined`, SHALL
|
|
return a fresh `AbortSignal` that aborts when either input aborts
|
|
otherwise, and SHALL abort immediately when `external` is already
|
|
aborted at call time. The reason of the aborting input SHALL propagate
|
|
to the composed signal's `reason`.
|
|
|
|
#### Scenario: external undefined returns internal signal directly
|
|
|
|
- **WHEN** `composeAbortSignal(internal, undefined)` is invoked
|
|
- **THEN** the returned signal is reference-equal to `internal`
|
|
|
|
#### Scenario: internal abort propagates to composed signal
|
|
|
|
- **WHEN** the internal `AbortController.abort("boom")` is invoked after composition
|
|
- **THEN** the composed signal is aborted and its `reason` is the propagated `AbortSignal`
|
|
|
|
#### Scenario: already-aborted external aborts the composed signal immediately
|
|
|
|
- **WHEN** the external signal is already aborted at the time of `composeAbortSignal` call
|
|
- **THEN** the composed signal is aborted before the function returns
|
|
|
|
**Verified by:** `src/http/http.test.ts::"composeAbortSignal returns internal when external is undefined"` AND `src/http/http.test.ts::"composeAbortSignal propagates internal abort"` AND `src/http/http.test.ts::"composeAbortSignal propagates external abort"` AND `src/http/http.test.ts::"composeAbortSignal aborts immediately when external is pre-aborted"` AND `src/http/http.test.ts::"composes caller AbortSignal with internal timeout"`
|
|
|
|
---
|
|
|
|
### Requirement: ProviderRegistry indexes PaymentProvider instances by name
|
|
|
|
The package SHALL export a `ProviderRegistry` class that indexes
|
|
`PaymentProvider` instances (from `@suaveplan/payment`) by their `name`
|
|
field. The class SHALL expose `register(provider)`,
|
|
`unregister(name)`, `get(name)`, `has(name)`, `list()`, and a numeric
|
|
`size` getter. `register` SHALL throw
|
|
`PaymentProviderError(PAYMENT_PROVIDER.PROVIDER_ALREADY_REGISTERED)`
|
|
when a provider with the same name is already present. `get` SHALL
|
|
throw `PaymentProviderError(PAYMENT_PROVIDER.PROVIDER_NOT_REGISTERED)`
|
|
when the requested name is absent. `list()` SHALL return a frozen
|
|
snapshot in insertion order so callers cannot mutate the registry
|
|
through the returned array.
|
|
|
|
#### Scenario: register + get round-trips a provider
|
|
|
|
- **WHEN** a caller invokes `registry.register(stripe)` followed by `registry.get("stripe")`
|
|
- **THEN** the second call returns the same `PaymentProvider` reference
|
|
|
|
#### Scenario: get on an unknown name throws PROVIDER_NOT_REGISTERED
|
|
|
|
- **WHEN** `registry.get("nope")` is invoked on an empty registry
|
|
- **THEN** the call throws a `PaymentProviderError` whose `code === "PAYMENT_PROVIDER.PROVIDER_NOT_REGISTERED"` and whose `provider === "nope"`
|
|
|
|
#### Scenario: duplicate register throws PROVIDER_ALREADY_REGISTERED
|
|
|
|
- **WHEN** a provider with `name: "stripe"` is registered twice
|
|
- **THEN** the second call throws a `PaymentProviderError` whose `code === "PAYMENT_PROVIDER.PROVIDER_ALREADY_REGISTERED"` and whose `provider === "stripe"`
|
|
|
|
#### Scenario: unregister returns true on hit and false on miss
|
|
|
|
- **WHEN** `registry.unregister("stripe")` is invoked after a successful registration
|
|
- **THEN** the call returns `true`, a subsequent `registry.has("stripe")` returns `false`, and a second `registry.unregister("stripe")` returns `false`
|
|
|
|
#### Scenario: list returns a frozen snapshot in insertion order
|
|
|
|
- **WHEN** three providers are registered in order A, B, C and `registry.list()` is invoked
|
|
- **THEN** the returned array equals `[A, B, C]` and is frozen (`Object.isFrozen` is `true`)
|
|
|
|
**Verified by:** `src/registry/registry.test.ts::"register + get round-trips a provider"` AND `src/registry/registry.test.ts::"throws PROVIDER_NOT_REGISTERED on unknown name"` AND `src/registry/registry.test.ts::"throws PROVIDER_ALREADY_REGISTERED on duplicate name"` AND `src/registry/registry.test.ts::"unregister returns true on hit and false on miss"` AND `src/registry/registry.test.ts::"list returns a frozen snapshot in insertion order"` AND `src/registry/registry.test.ts::"size tracks register and unregister"` AND `src/registry/registry.test.ts::"has reports membership without throwing"`
|
|
|
|
---
|
|
|
|
### Requirement: definePaymentProvider factory returns a frozen literal-typed provider
|
|
|
|
The package SHALL export `definePaymentProvider<TName extends string>(spec)`
|
|
that returns a `PaymentProvider & { readonly name: TName }`. The factory
|
|
SHALL freeze the returned object (`Object.isFrozen(result) === true`)
|
|
and SHALL preserve method identity (the function instance passed in
|
|
`spec.createIntent` SHALL be the same reference exposed on
|
|
`result.createIntent`). The returned value SHALL be assignable to
|
|
`PaymentProvider` so it can be passed directly to
|
|
`ProviderRegistry.register`.
|
|
|
|
#### Scenario: factory result registers cleanly into ProviderRegistry
|
|
|
|
- **WHEN** `definePaymentProvider({ name: "test", createIntent: …, … })` is registered into a `ProviderRegistry`
|
|
- **THEN** `registry.get("test")` returns the same reference the factory produced
|
|
|
|
#### Scenario: returned object is frozen
|
|
|
|
- **WHEN** the caller attempts to assign to a method on the factory result (in strict mode)
|
|
- **THEN** `Object.isFrozen` is `true` for the result and the assignment throws
|
|
|
|
#### Scenario: method identity is preserved
|
|
|
|
- **WHEN** the same function `f` is passed as both `spec.createIntent` and `spec.getIntent`
|
|
- **THEN** `result.createIntent === f && result.getIntent === f`
|
|
|
|
**Verified by:** `src/registry/define.test.ts::"factory result is assignable to PaymentProvider"` AND `src/registry/define.test.ts::"factory result registers cleanly into ProviderRegistry"` AND `src/registry/define.test.ts::"returned object is frozen"` AND `src/registry/define.test.ts::"method identity is preserved"` AND `src/registry/define.test.ts::"literal name narrows the result type"`
|
|
|
|
<!-- markdownlint-enable MD024 -->
|