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 by2026-05-17-add-payment-providers. The## REMOVED Requirementsblock strips the five concrete-provider requirements that will re-land in sibling packages; the## ADDED Requirementsblock 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 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"
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
PaymentProviderErrorhascode === "PAYMENT_PROVIDER.PROVIDER_NOT_REGISTERED"andprovider === "stripe"
Scenario: providerAlreadyRegistered carries the name as provider discriminant
- WHEN
providerAlreadyRegistered("stripe")is invoked - THEN the resulting
PaymentProviderErrorhascode === "PAYMENT_PROVIDER.PROVIDER_ALREADY_REGISTERED"andprovider === "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
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"