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

196 lines
9.8 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# 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"`
<!-- markdownlint-enable MD024 -->