Files
suaveplan/openspec/archive/2026-05-17-slim-payment-providers/specs/payment-providers/spec.md
2026-05-27 23:29:53 +01:00

9.8 KiB

Capability Spec Delta: payment-providers (slim-payment-providers)

Package: @suaveplan/payment-providers Status: PROPOSED

Every requirement below carries a **Verified by:** line at draft time. This delta MODIFIES the canonical spec produced by 2026-05-17-add-payment-providers. The ## REMOVED Requirements block strips the five concrete-provider requirements that will re-land in sibling packages; the ## ADDED Requirements block introduces the registry surface that grows in their place.

REMOVED Requirements

Requirement: StripeProvider full lifecycle

The StripeProvider class and its entire requirement block (status mapping, refund-reason mapping, idempotency-key forwarding, StripeProvider.name === "stripe") is removed from this package. The same surface re-lands in @suaveplan/payment-provider-stripe with the class and **Verified by:** test names preserved verbatim.


Requirement: PayPalProvider OAuth token cache and order lifecycle

The PayPalProvider class and its entire requirement block (OAuth2 client-credentials flow, injectable now() cache TTL, injectable base64Encode, idempotency-key forwarding, status mapping, PayPalProvider.name === "paypal", OAUTH_TOKEN_FAILURE wrapping) is removed from this package. The same surface re-lands in @suaveplan/payment-provider-paypal with the class and **Verified by:** test names preserved verbatim. The OAUTH_TOKEN_FAILURE code and the oauthTokenFailure factory remain in this foundation so the sibling package can raise them.


Requirement: SquareProvider lifecycle with injectable randomId

The SquareProvider class, its entire requirement block, and the Square-specific SquareConfig type are removed from this package. The class and config re-land in @suaveplan/payment-provider-square with the **Verified by:** test names preserved verbatim.


Requirement: MollieProvider lifecycle with string-amount marshalling

The MollieProvider class and its entire requirement block are removed from this package. The class re-lands in @suaveplan/payment-provider-mollie with the **Verified by:** test names preserved verbatim.


Requirement: AdyenProvider lifecycle with merchant-account context

The AdyenProvider class and its entire requirement block are removed from this package. The class re-lands in @suaveplan/payment-provider-adyen with the **Verified by:** test names preserved verbatim.


ADDED Requirements

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"


Requirement: PAYMENT_PROVIDER_CODES adds registry codes

PAYMENT_PROVIDER_CODES SHALL be extended with PROVIDER_NOT_REGISTERED and PROVIDER_ALREADY_REGISTERED. The package SHALL export providerNotRegistered(name) and providerAlreadyRegistered(name) factories that return a PaymentProviderError carrying the offending name on the provider discriminant. Both codes SHALL be reachable from real call sites exercised by the test suite (i.e. raised through ProviderRegistry methods, not only from the factories in isolation).

Scenario: providerNotRegistered carries the name as provider discriminant

  • WHEN providerNotRegistered("stripe") is invoked
  • THEN the resulting PaymentProviderError has code === "PAYMENT_PROVIDER.PROVIDER_NOT_REGISTERED" and provider === "stripe"

Scenario: providerAlreadyRegistered carries the name as provider discriminant

  • WHEN providerAlreadyRegistered("stripe") is invoked
  • THEN the resulting PaymentProviderError has code === "PAYMENT_PROVIDER.PROVIDER_ALREADY_REGISTERED" and provider === "stripe"

Verified by: 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" 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"


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"