# 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(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"`