12 KiB
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-providersas a 5-adapter bundle; slimmed by2026-05-17-slim-payment-providersinto 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
PaymentProviderErrorwhosecodematches the expectedPAYMENT_PROVIDER.*constant
Scenario: cause-bearing factories preserve the underlying error
- WHEN
parseErrororoauthTokenFailureis invoked with acause - THEN the resulting
PaymentProviderError'scauseis 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, andbodyExcerptfields are all populated
Scenario: registry-aware factories carry the offending name
- WHEN
providerNotRegistered("nope")orproviderAlreadyRegistered("stripe")is invoked - THEN the resulting
PaymentProviderError'sproviderdiscriminant 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
FetchLikeresolves withnew Response("{\"ok\":true}", { status: 200 }) - THEN
httpRequestresolves with{ status: 200, data: { ok: true } }
Scenario: 204 response returns empty data
- WHEN the injected
FetchLikeresolves withnew Response(null, { status: 204 }) - THEN
httpRequestresolves with{ status: 204, data: {} }
Scenario: non-ok response throws typed error
- WHEN the injected
FetchLikeresolves with status 404 and body{ "message": "Not Found" } - THEN
httpRequestrejects with aPaymentProviderErrorwhosecode === "PAYMENT_PROVIDER.HTTP_ERROR",httpStatus === 404, andbodyExcerptcontaining"Not Found"
Scenario: timeout aborts via AbortSignal
- WHEN the injected
FetchLikenever resolves andtimeoutMsis set to 50 - THEN
httpRequestrejects with aPaymentProviderErrorwhosecode === "PAYMENT_PROVIDER.TIMEOUT"
Scenario: string body passes through unchanged
- WHEN
httpRequest({ body: "grant_type=client_credentials", … })is invoked - THEN the recording
FetchLikeobserves a request whoseinit.bodyis 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
reasonis the propagatedAbortSignal
Scenario: already-aborted external aborts the composed signal immediately
- WHEN the external signal is already aborted at the time of
composeAbortSignalcall - 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 byregistry.get("stripe") - THEN the second call returns the same
PaymentProviderreference
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
PaymentProviderErrorwhosecode === "PAYMENT_PROVIDER.PROVIDER_NOT_REGISTERED"and whoseprovider === "nope"
Scenario: duplicate register throws PROVIDER_ALREADY_REGISTERED
- WHEN a provider with
name: "stripe"is registered twice - THEN the second call throws a
PaymentProviderErrorwhosecode === "PAYMENT_PROVIDER.PROVIDER_ALREADY_REGISTERED"and whoseprovider === "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 subsequentregistry.has("stripe")returnsfalse, and a secondregistry.unregister("stripe")returnsfalse
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.isFrozenistrue)
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 aProviderRegistry - 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.isFrozenistruefor the result and the assignment throws
Scenario: method identity is preserved
- WHEN the same function
fis passed as bothspec.createIntentandspec.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"