Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,218 @@
<!-- 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 -->