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

22 KiB

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<PaymentProviderErrorCode> 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<T>(options: HttpOptions): Promise<ProviderResponse<T>> 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: "<configured-value>"

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"