196 lines
9.8 KiB
Markdown
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 -->
|