Files
2026-05-27 23:29:53 +01:00

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-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"