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
PaymentProviderErrorwhosecodematches the expectedPAYMENT_PROVIDER.*constant
Scenario: cause-bearing factories preserve the underlying error
- WHEN
parseErrororoauthTokenFailureis invoked with acause - THEN the resulting
PaymentProviderError'scauseis 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, andbodyExcerptfields 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
FetchLikeresolves withnew Response("{\"ok\":true}", { status: 200 }) - THEN
httpRequestresolves with{ status: 200, data: { ok: true } }
Scenario: 204 response returns empty data
- WHEN the injected
FetchLikeresolves withnew Response(null, { status: 204 }) - THEN
httpRequestresolves with{ status: 204, data: {} }
Scenario: non-ok response throws typed error
- WHEN the injected
FetchLikeresolves with status 404 and body{ "message": "Not Found" } - THEN
httpRequestrejects with aPaymentProviderErrorwhosecode === "PAYMENT_PROVIDER.HTTP_ERROR",httpStatus === 404, andbodyExcerptcontaining"Not Found"
Scenario: timeout aborts via AbortSignal
- WHEN the injected
FetchLikenever resolves andtimeoutMsis set to 50 - THEN
httpRequestrejects with aPaymentProviderErrorwhosecode === "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_intentsforcreateIntent, forwardingamount,currency(lower-cased),capture_method, optionalcustomer,description,metadata,payment_method. - POST
${baseUrl}/payment_intents/{id}/{confirm,capture,cancel}for the matching methods. - POST
${baseUrl}/refundsforrefund, forwardingpayment_intentand optionalamount,reason,metadata. - GET
${baseUrl}/payment_intents/{id}forgetIntent. - Authenticate every call with
Authorization: Bearer ${apiKey}. - Forward
idempotencyKeyas theIdempotency-Keyheader oncreateIntentandrefund. - 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
createIntentis invoked withidempotencyKey: "idem_1" - THEN the recording
FetchLikeobserves a request with headerIdempotency-Key: idem_1
Scenario: status mapping is total
- WHEN
getIntentreturns a payload with an unknown Stripe status - THEN the mapped
PaymentIntent.statusis"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/ordersforcreateIntent, mappingcaptureMethod === "manual"→intent: "AUTHORIZE"and otherwiseintent: "CAPTURE", marshallingamountto a string with two fractional digits (e.g.5000n→"50.00"). - POST
${baseUrl}/checkout/orders/{id}/{authorize,capture,void}forconfirmIntent/captureIntent/cancelIntent. - POST
${baseUrl}/payments/captures/{id}/refundforrefund. - GET
${baseUrl}/checkout/orders/{id}forgetIntent. - Authenticate every order call with
Authorization: Bearer ${token}wheretokenis fetched via the OAuth2 client-credentials flow from${baseUrl.replace("/v2","/v1/oauth2/token")}with headerAuthorization: Basic ${base64Encode("${clientId}:${clientSecret}")}. - Cache the access token until
expires_in * 1000 - 60_000ms past the injectednow(). The cache SHALL be reused across calls and SHALL refresh transparently after expiry. - Forward
idempotencyKeyas thePayPal-Request-Idheader oncreateIntentandrefund. - 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
createIntentinvocations are issued back-to-back with the same provider instance and the injectednow()advances by less thanexpires_in * 1000 - 60_000ms between them - THEN the recording
FetchLikeobserves exactly one OAuth request and two order requests
Scenario: token cache refresh after expiry
- WHEN the second
createIntentis issued after the injectednow()advances past the cache TTL - THEN the recording
FetchLikeobserves 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}/paymentsforcreateIntent, forwardingamount_money,idempotency_key(caller-supplied or injected-randomId-generated),autocomplete(trueunlesscaptureMethod === "manual"), optionalcustomer_id,note,source_id. - POST
${baseUrl}/payments/{id}/{complete,cancel}for capture / cancel. - POST
${baseUrl}/refundsforrefund, generatingidempotency_keythe same way. - GET
${baseUrl}/payments/{id}forgetIntent. - Set
Authorization: Bearer ${apiKey}andSquare-Version: 2024-01-18on every call. confirmIntentSHALL be a read-through togetIntent(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_customerwhen the upstream record carries anyreasonstring, elseother.
SquareProvider.name SHALL equal "square".
Scenario: default idempotency key uses injected randomId
- WHEN
createIntentis invoked withoutidempotencyKeyand the provider was constructed withrandomId: () => "deterministic-id" - THEN the recording
FetchLikeobserves a request body whoseidempotency_keyequals"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}/paymentsforcreateIntent, marshallingamountto a{ currency, value: "50.00" }shape via decimal formatting, defaultingdescriptionto"Payment"andredirectUrlto"https://localhost/return"when not supplied. - DELETE
${baseUrl}/payments/{id}forcancelIntent. - POST
${baseUrl}/payments/{id}/refundsforrefund. - GET
${baseUrl}/payments/{id}forgetIntent. confirmIntentandcaptureIntentSHALL be read-throughs togetIntent(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
FetchLikeobserves a request body whoseamount.value === "50.00"andamount.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}/paymentsforcreateIntent, includingmerchantAccount,amount,reference(caller-suppliedidempotencyKeyor injected-randomId-generated), optionalshopperStatement,returnUrl,paymentMethod.storedPaymentMethodId,metadata. - POST
${baseUrl}/payments/{id}/{details,captures,cancels,refunds}forconfirmIntent/captureIntent/cancelIntent/refund. - GET
${baseUrl}/payments/{id}forgetIntent. - Set
X-API-Key: ${apiKey}on every call and forwardIdempotency-KeyoncreateIntent/refundwhen 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, orrefundis invoked - THEN the recording
FetchLikeobserves a request body containingmerchantAccount: "<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"