# Capability Spec Delta: payment-providers (add-payment-providers) **Package**: `@suaveplan/payment-providers` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line at authoring time. ## ADDED Requirements ### Requirement: Universal-tier purity The package SHALL ship as a universal-tier package — no `node:*`, `bun:*`, `fs`, `path`, `stream`, `net`, `http`, or bare `crypto` imports in any file under `src/`. The package SHALL run unchanged under Node ≥ 18, Bun, modern browsers, and Web Workers. `package.json` SHALL declare `"sideEffects": false`. Every HTTP call SHALL go through the injectable `FetchLike` so the package never reaches into a runtime- specific HTTP stack. #### Scenario: no platform imports in src/ - **WHEN** `grep -rnE "from ['\"](node:|bun:|fs|path|stream|net|http|crypto)['\"]" packages/universal/payment-providers/src/` is run - **THEN** the command produces zero matches **Verified by:** `tests/dep-graph.test.ts::"src contains no node/bun/fs/path/stream/net/http/crypto imports"` --- ### Requirement: Stable PAYMENT_PROVIDER_CODES registry and PaymentProviderError class The package SHALL declare an error-code registry via `defineCodes("PAYMENT_PROVIDER", […])` containing exactly the codes `HTTP_ERROR`, `TIMEOUT`, `PARSE_ERROR`, and `OAUTH_TOKEN_FAILURE`. Every public failure path triggered by network or parsing errors SHALL throw a `PaymentProviderError extends SuaveplanError` carrying one of those codes. `PaymentProviderError` SHALL expose optional `provider`, `httpStatus`, `bodyExcerpt`, and `url` discriminants. The package SHALL NOT throw raw `Error` instances from any production code path under `src/`. #### Scenario: every code reachable from a real call site - **WHEN** the test suite exercises each factory (`httpError`, `timeoutError`, `parseError`, `oauthTokenFailure`) - **THEN** each path produces a `PaymentProviderError` whose `code` matches the expected `PAYMENT_PROVIDER.*` constant #### Scenario: cause-bearing factories preserve the underlying error - **WHEN** `parseError` or `oauthTokenFailure` is invoked with a `cause` - **THEN** the resulting `PaymentProviderError`'s `cause` is the original error #### Scenario: HTTP error carries discriminants from the response - **WHEN** `httpError(provider, status, url, body)` is invoked - **THEN** the resulting error's `httpStatus`, `url`, `provider`, and `bodyExcerpt` fields are all populated **Verified by:** `src/error-codes/error-codes.test.ts::"every PAYMENT_PROVIDER.* code is reachable from a factory"` AND `src/error-codes/error-codes.test.ts::"preserves cause through parseError"` AND `src/error-codes/error-codes.test.ts::"preserves cause through oauthTokenFailure"` AND `src/error-codes/error-codes.test.ts::"httpError carries provider/url/httpStatus/bodyExcerpt discriminants"` --- ### Requirement: httpRequest helper with injectable FetchLike and timeout The package SHALL export `httpRequest(options: HttpOptions): Promise>` that wraps an injectable `FetchLike` (defaulting to `globalThis.fetch`). The helper SHALL serialise `body` as JSON, set `Content-Type: application/json` and `Accept: application/json` headers (overridable by callers), enforce a per-call `timeoutMs` (default 30 000) via `AbortSignal` with a clean `clearTimeout` in the `finally` block, parse the response body as JSON (returning `{}` when the body is empty), and on a non-ok response throw a `PaymentProviderError(PAYMENT_PROVIDER.HTTP_ERROR)` carrying the HTTP status, URL, and a `bodyExcerpt` capped at 256 chars. A caller-supplied `signal` SHALL be composed with the internal timeout signal so external cancellation is honoured. #### Scenario: 200 response returns parsed body - **WHEN** the injected `FetchLike` resolves with `new Response("{\"ok\":true}", { status: 200 })` - **THEN** `httpRequest` resolves with `{ status: 200, data: { ok: true } }` #### Scenario: 204 response returns empty data - **WHEN** the injected `FetchLike` resolves with `new Response(null, { status: 204 })` - **THEN** `httpRequest` resolves with `{ status: 204, data: {} }` #### Scenario: non-ok response throws typed error - **WHEN** the injected `FetchLike` resolves with status 404 and body `{ "message": "Not Found" }` - **THEN** `httpRequest` rejects with a `PaymentProviderError` whose `code === "PAYMENT_PROVIDER.HTTP_ERROR"`, `httpStatus === 404`, and `bodyExcerpt` containing `"Not Found"` #### Scenario: timeout aborts via AbortSignal - **WHEN** the injected `FetchLike` never resolves and `timeoutMs` is set to 50 - **THEN** `httpRequest` rejects with a `PaymentProviderError` whose `code === "PAYMENT_PROVIDER.TIMEOUT"` **Verified by:** `src/http/http.test.ts::"makes a GET request and parses JSON"` AND `src/http/http.test.ts::"sends JSON body on POST"` AND `src/http/http.test.ts::"includes custom headers"` AND `src/http/http.test.ts::"throws PaymentProviderError on non-ok response"` AND `src/http/http.test.ts::"throws HTTP_ERROR when no message in body"` AND `src/http/http.test.ts::"handles empty response body"` AND `src/http/http.test.ts::"throws on non-ok empty response"` AND `src/http/http.test.ts::"throws TIMEOUT when AbortSignal fires"` AND `src/http/http.test.ts::"supports DELETE and PATCH methods"` AND `src/http/http.test.ts::"composes caller AbortSignal with internal timeout"` --- ### Requirement: StripeProvider full lifecycle The package SHALL export `StripeProvider` implementing `PaymentProvider` from `@suaveplan/payment`. The provider SHALL: - POST `${baseUrl}/payment_intents` for `createIntent`, forwarding `amount`, `currency` (lower-cased), `capture_method`, optional `customer`, `description`, `metadata`, `payment_method`. - POST `${baseUrl}/payment_intents/{id}/{confirm,capture,cancel}` for the matching methods. - POST `${baseUrl}/refunds` for `refund`, forwarding `payment_intent` and optional `amount`, `reason`, `metadata`. - GET `${baseUrl}/payment_intents/{id}` for `getIntent`. - Authenticate every call with `Authorization: Bearer ${apiKey}`. - Forward `idempotencyKey` as the `Idempotency-Key` header on `createIntent` and `refund`. - Map `requires_payment_method` / `requires_confirmation` → `created`; `requires_action` / `processing` → `pending`; `requires_capture` → `authorized`; `succeeded` → `captured`; `canceled` → `canceled`; every other Stripe status → `failed`. - Map refund status `succeeded` → `succeeded`; `pending` → `pending`; every other status → `failed`. - Map refund reason `duplicate` → `duplicate`, `fraudulent` → `fraudulent`, `requested_by_customer` → `requested_by_customer`, every other value → `other`. `StripeProvider.name` SHALL equal `"stripe"`. #### Scenario: createIntent forwards idempotency key - **WHEN** `createIntent` is invoked with `idempotencyKey: "idem_1"` - **THEN** the recording `FetchLike` observes a request with header `Idempotency-Key: idem_1` #### Scenario: status mapping is total - **WHEN** `getIntent` returns a payload with an unknown Stripe status - **THEN** the mapped `PaymentIntent.status` is `"failed"` **Verified by:** `src/providers/stripe/stripe.test.ts::"has correct name"` AND `src/providers/stripe/stripe.test.ts::"creates a payment intent"` AND `src/providers/stripe/stripe.test.ts::"confirms an intent"` AND `src/providers/stripe/stripe.test.ts::"captures an intent"` AND `src/providers/stripe/stripe.test.ts::"cancels an intent"` AND `src/providers/stripe/stripe.test.ts::"creates a refund"` AND `src/providers/stripe/stripe.test.ts::"refunds without explicit amount"` AND `src/providers/stripe/stripe.test.ts::"fetches an intent"` AND `src/providers/stripe/stripe.test.ts::"maps status requires_payment_method to created"` AND `src/providers/stripe/stripe.test.ts::"maps status requires_action to pending"` AND `src/providers/stripe/stripe.test.ts::"maps status processing to pending"` AND `src/providers/stripe/stripe.test.ts::"maps status requires_capture to authorized"` AND `src/providers/stripe/stripe.test.ts::"maps status succeeded to captured"` AND `src/providers/stripe/stripe.test.ts::"maps status canceled to canceled"` AND `src/providers/stripe/stripe.test.ts::"maps unknown status to failed"` AND `src/providers/stripe/stripe.test.ts::"maps refund status succeeded to succeeded"` AND `src/providers/stripe/stripe.test.ts::"maps refund status pending to pending"` AND `src/providers/stripe/stripe.test.ts::"maps refund status failed to failed"` AND `src/providers/stripe/stripe.test.ts::"maps refund status canceled to failed"` AND `src/providers/stripe/stripe.test.ts::"maps intent without payment method"` AND `src/providers/stripe/stripe.test.ts::"maps refund reason duplicate"` AND `src/providers/stripe/stripe.test.ts::"maps refund reason fraudulent"` AND `src/providers/stripe/stripe.test.ts::"maps refund reason other for unknown"` --- ### Requirement: PayPalProvider OAuth token cache and order lifecycle The package SHALL export `PayPalProvider` implementing `PaymentProvider`. The provider SHALL: - POST `${baseUrl}/checkout/orders` for `createIntent`, mapping `captureMethod === "manual"` → `intent: "AUTHORIZE"` and otherwise `intent: "CAPTURE"`, marshalling `amount` to a string with two fractional digits (e.g. `5000n` → `"50.00"`). - POST `${baseUrl}/checkout/orders/{id}/{authorize,capture,void}` for `confirmIntent` / `captureIntent` / `cancelIntent`. - POST `${baseUrl}/payments/captures/{id}/refund` for `refund`. - GET `${baseUrl}/checkout/orders/{id}` for `getIntent`. - Authenticate every order call with `Authorization: Bearer ${token}` where `token` is fetched via the OAuth2 client-credentials flow from `${baseUrl.replace("/v2","/v1/oauth2/token")}` with header `Authorization: Basic ${base64Encode("${clientId}:${clientSecret}")}`. - Cache the access token until `expires_in * 1000 - 60_000` ms past the injected `now()`. The cache SHALL be reused across calls and SHALL refresh transparently after expiry. - Forward `idempotencyKey` as the `PayPal-Request-Id` header on `createIntent` and `refund`. - Map status `CREATED` / `SAVED` → `created`; `APPROVED` → `authorized`; `VOIDED` → `canceled`; `COMPLETED` → `captured`; `PAYER_ACTION_REQUIRED` → `pending`; every other status → `failed`. - Map refund status `COMPLETED` → `succeeded`; `PENDING` → `pending`; every other status → `failed`. - Translate `intent === "AUTHORIZE"` → `captureMethod: "manual"` and every other intent → `automatic`. - When the OAuth call fails, raise `PaymentProviderError(PAYMENT_PROVIDER.OAUTH_TOKEN_FAILURE)` carrying the cause. `PayPalProvider.name` SHALL equal `"paypal"`. #### Scenario: token cache hit avoids second OAuth call - **WHEN** two `createIntent` invocations are issued back-to-back with the same provider instance and the injected `now()` advances by less than `expires_in * 1000 - 60_000` ms between them - **THEN** the recording `FetchLike` observes exactly one OAuth request and two order requests #### Scenario: token cache refresh after expiry - **WHEN** the second `createIntent` is issued after the injected `now()` advances past the cache TTL - **THEN** the recording `FetchLike` observes two OAuth requests and two order requests **Verified by:** `src/providers/paypal/paypal.test.ts::"has correct name"` AND `src/providers/paypal/paypal.test.ts::"creates an order with OAuth token"` AND `src/providers/paypal/paypal.test.ts::"reuses cached token"` AND `src/providers/paypal/paypal.test.ts::"refreshes token after expiry"` AND `src/providers/paypal/paypal.test.ts::"authorizes an order"` AND `src/providers/paypal/paypal.test.ts::"captures an order"` AND `src/providers/paypal/paypal.test.ts::"voids an order"` AND `src/providers/paypal/paypal.test.ts::"refunds a capture"` AND `src/providers/paypal/paypal.test.ts::"refunds without explicit amount"` AND `src/providers/paypal/paypal.test.ts::"fetches an order"` AND `src/providers/paypal/paypal.test.ts::"maps status CREATED to created"` AND `src/providers/paypal/paypal.test.ts::"maps status SAVED to created"` AND `src/providers/paypal/paypal.test.ts::"maps status APPROVED to authorized"` AND `src/providers/paypal/paypal.test.ts::"maps status VOIDED to canceled"` AND `src/providers/paypal/paypal.test.ts::"maps status COMPLETED to captured"` AND `src/providers/paypal/paypal.test.ts::"maps status PAYER_ACTION_REQUIRED to pending"` AND `src/providers/paypal/paypal.test.ts::"maps unknown status to failed"` AND `src/providers/paypal/paypal.test.ts::"maps refund status COMPLETED to succeeded"` AND `src/providers/paypal/paypal.test.ts::"maps refund status PENDING to pending"` AND `src/providers/paypal/paypal.test.ts::"maps refund status CANCELLED to failed"` AND `src/providers/paypal/paypal.test.ts::"handles order with AUTHORIZE intent"` AND `src/providers/paypal/paypal.test.ts::"handles order without payments field"` AND `src/providers/paypal/paypal.test.ts::"wraps OAuth failure in OAUTH_TOKEN_FAILURE"` --- ### Requirement: SquareProvider lifecycle with injectable randomId The package SHALL export `SquareProvider` implementing `PaymentProvider`. The provider SHALL: - POST `${baseUrl}/payments` for `createIntent`, forwarding `amount_money`, `idempotency_key` (caller-supplied or injected-`randomId`-generated), `autocomplete` (`true` unless `captureMethod === "manual"`), optional `customer_id`, `note`, `source_id`. - POST `${baseUrl}/payments/{id}/{complete,cancel}` for capture / cancel. - POST `${baseUrl}/refunds` for `refund`, generating `idempotency_key` the same way. - GET `${baseUrl}/payments/{id}` for `getIntent`. - Set `Authorization: Bearer ${apiKey}` and `Square-Version: 2024-01-18` on every call. - `confirmIntent` SHALL be a read-through to `getIntent` (Square auto- confirms). - Map status `APPROVED` → `authorized`; `COMPLETED` → `captured`; `CANCELED` → `canceled`; `FAILED` → `failed`; `PENDING` → `pending`; every other status → `failed`. - Map refund status `COMPLETED` → `succeeded`; `PENDING` → `pending`; every other status → `failed`. - Default refund reason `requested_by_customer` when the upstream record carries any `reason` string, else `other`. `SquareProvider.name` SHALL equal `"square"`. #### Scenario: default idempotency key uses injected randomId - **WHEN** `createIntent` is invoked without `idempotencyKey` and the provider was constructed with `randomId: () => "deterministic-id"` - **THEN** the recording `FetchLike` observes a request body whose `idempotency_key` equals `"deterministic-id"` **Verified by:** `src/providers/square/square.test.ts::"has correct name"` AND `src/providers/square/square.test.ts::"creates a payment"` AND `src/providers/square/square.test.ts::"creates manual capture payment"` AND `src/providers/square/square.test.ts::"returns current state (no-op for Square)"` AND `src/providers/square/square.test.ts::"completes a payment"` AND `src/providers/square/square.test.ts::"cancels a payment"` AND `src/providers/square/square.test.ts::"creates a refund"` AND `src/providers/square/square.test.ts::"refunds without amount"` AND `src/providers/square/square.test.ts::"fetches a payment"` AND `src/providers/square/square.test.ts::"maps status APPROVED to authorized"` AND `src/providers/square/square.test.ts::"maps status COMPLETED to captured"` AND `src/providers/square/square.test.ts::"maps status CANCELED to canceled"` AND `src/providers/square/square.test.ts::"maps status FAILED to failed"` AND `src/providers/square/square.test.ts::"maps status PENDING to pending"` AND `src/providers/square/square.test.ts::"maps unknown status to failed"` AND `src/providers/square/square.test.ts::"maps payment without card details"` AND `src/providers/square/square.test.ts::"maps refund without reason"` AND `src/providers/square/square.test.ts::"maps refund status PENDING"` AND `src/providers/square/square.test.ts::"maps refund status REJECTED to failed"` AND `src/providers/square/square.test.ts::"uses injected randomId for default idempotency key"` --- ### Requirement: MollieProvider lifecycle with string-amount marshalling The package SHALL export `MollieProvider` implementing `PaymentProvider`. The provider SHALL: - POST `${baseUrl}/payments` for `createIntent`, marshalling `amount` to a `{ currency, value: "50.00" }` shape via decimal formatting, defaulting `description` to `"Payment"` and `redirectUrl` to `"https://localhost/return"` when not supplied. - DELETE `${baseUrl}/payments/{id}` for `cancelIntent`. - POST `${baseUrl}/payments/{id}/refunds` for `refund`. - GET `${baseUrl}/payments/{id}` for `getIntent`. - `confirmIntent` and `captureIntent` SHALL be read-throughs to `getIntent` (Mollie auto-captures). - Set `Authorization: Bearer ${apiKey}` on every call. - Map status `open` → `created`; `pending` → `pending`; `authorized` → `authorized`; `paid` → `captured`; `canceled` / `expired` → `canceled`; `failed` → `failed`; every other status → `failed`. - Map refund status `refunded` → `succeeded`; `pending` / `queued` / `processing` → `pending`; every other status → `failed`. - Map `method === "creditcard"` → `type: "card"`; every other method → `type: "wallet"`. `MollieProvider.name` SHALL equal `"mollie"`. #### Scenario: amount marshalling round-trip - **WHEN** `createIntent({ amount: { amount: 5000n, currency: "EUR" } })` is invoked - **THEN** the recording `FetchLike` observes a request body whose `amount.value === "50.00"` and `amount.currency === "EUR"` **Verified by:** `src/providers/mollie/mollie.test.ts::"has correct name"` AND `src/providers/mollie/mollie.test.ts::"creates a payment"` AND `src/providers/mollie/mollie.test.ts::"uses default description and redirect"` AND `src/providers/mollie/mollie.test.ts::"returns current state on confirm"` AND `src/providers/mollie/mollie.test.ts::"returns current state on capture (Mollie auto-captures)"` AND `src/providers/mollie/mollie.test.ts::"cancels a payment"` AND `src/providers/mollie/mollie.test.ts::"creates a refund"` AND `src/providers/mollie/mollie.test.ts::"refunds without explicit amount"` AND `src/providers/mollie/mollie.test.ts::"fetches a payment"` AND `src/providers/mollie/mollie.test.ts::"maps status open to created"` AND `src/providers/mollie/mollie.test.ts::"maps status pending to pending"` AND `src/providers/mollie/mollie.test.ts::"maps status authorized to authorized"` AND `src/providers/mollie/mollie.test.ts::"maps status paid to captured"` AND `src/providers/mollie/mollie.test.ts::"maps status canceled to canceled"` AND `src/providers/mollie/mollie.test.ts::"maps status expired to canceled"` AND `src/providers/mollie/mollie.test.ts::"maps status failed to failed"` AND `src/providers/mollie/mollie.test.ts::"maps unknown status to failed"` AND `src/providers/mollie/mollie.test.ts::"maps refund status refunded to succeeded"` AND `src/providers/mollie/mollie.test.ts::"maps refund status pending to pending"` AND `src/providers/mollie/mollie.test.ts::"maps refund status queued to pending"` AND `src/providers/mollie/mollie.test.ts::"maps refund status processing to pending"` AND `src/providers/mollie/mollie.test.ts::"maps refund status failed to failed"` AND `src/providers/mollie/mollie.test.ts::"maps payment without method"` AND `src/providers/mollie/mollie.test.ts::"maps non-card method as wallet"` --- ### Requirement: AdyenProvider lifecycle with merchant-account context The package SHALL export `AdyenProvider` implementing `PaymentProvider`. The provider SHALL: - POST `${baseUrl}/payments` for `createIntent`, including `merchantAccount`, `amount`, `reference` (caller-supplied `idempotencyKey` or injected-`randomId`-generated), optional `shopperStatement`, `returnUrl`, `paymentMethod.storedPaymentMethodId`, `metadata`. - POST `${baseUrl}/payments/{id}/{details,captures,cancels,refunds}` for `confirmIntent` / `captureIntent` / `cancelIntent` / `refund`. - GET `${baseUrl}/payments/{id}` for `getIntent`. - Set `X-API-Key: ${apiKey}` on every call and forward `Idempotency-Key` on `createIntent` / `refund` when supplied. - Map result `Authorised` → `authorized`; `Refused` / `Error` → `failed`; `Cancelled` → `canceled`; `Pending` / `Received` / `RedirectShopper` / `IdentifyShopper` / `ChallengeShopper` / `PresentToShopper` → `pending`; every other code → `failed`. - Map refund status `received` → `pending`; every other status → `failed`. - Map payment-method type `scheme` → `card`; every other type → `wallet`. `AdyenProvider.name` SHALL equal `"adyen"`. #### Scenario: merchant-account context forwarded on capture / cancel / refund - **WHEN** `captureIntent`, `cancelIntent`, or `refund` is invoked - **THEN** the recording `FetchLike` observes a request body containing `merchantAccount: ""` **Verified by:** `src/providers/adyen/adyen.test.ts::"has correct name"` AND `src/providers/adyen/adyen.test.ts::"creates a payment"` AND `src/providers/adyen/adyen.test.ts::"uses automatic capture by default"` AND `src/providers/adyen/adyen.test.ts::"submits payment details on confirm"` AND `src/providers/adyen/adyen.test.ts::"captures a payment"` AND `src/providers/adyen/adyen.test.ts::"cancels a payment"` AND `src/providers/adyen/adyen.test.ts::"creates a refund"` AND `src/providers/adyen/adyen.test.ts::"refunds without amount"` AND `src/providers/adyen/adyen.test.ts::"fetches a payment"` AND `src/providers/adyen/adyen.test.ts::"maps result Authorised to authorized"` AND `src/providers/adyen/adyen.test.ts::"maps result Refused to failed"` AND `src/providers/adyen/adyen.test.ts::"maps result Cancelled to canceled"` AND `src/providers/adyen/adyen.test.ts::"maps result Error to failed"` AND `src/providers/adyen/adyen.test.ts::"maps result Pending to pending"` AND `src/providers/adyen/adyen.test.ts::"maps result Received to pending"` AND `src/providers/adyen/adyen.test.ts::"maps result RedirectShopper to pending"` AND `src/providers/adyen/adyen.test.ts::"maps result IdentifyShopper to pending"` AND `src/providers/adyen/adyen.test.ts::"maps result ChallengeShopper to pending"` AND `src/providers/adyen/adyen.test.ts::"maps result PresentToShopper to pending"` AND `src/providers/adyen/adyen.test.ts::"maps unknown result to failed"` AND `src/providers/adyen/adyen.test.ts::"maps payment without paymentMethod"` AND `src/providers/adyen/adyen.test.ts::"maps non-scheme payment type as wallet"` AND `src/providers/adyen/adyen.test.ts::"maps refund received to pending"` AND `src/providers/adyen/adyen.test.ts::"maps other refund statuses to failed"`