14 KiB
Capability Spec Delta: payment-provider-paypal (add-payment-provider-paypal)
Package: @suaveplan/payment-provider-paypal
Status: PROPOSED
Every requirement below carries a
**Verified by:**line at authoring time. No exceptions. (Seeopenspec/AGENTS.mdSection 13.)
ADDED Requirements
Requirement: PayPalProvider implements PaymentProvider
The package SHALL export a class PayPalProvider that implements the
PaymentProvider structural interface from @suaveplan/payment with a
literal-typed name: "paypal" property and the six lifecycle methods
(createIntent, confirmIntent, captureIntent, cancelIntent, refund,
getIntent). Every method MUST be async and MUST return a PaymentIntent
or Refund shape from @suaveplan/payment.
Scenario: name discriminant
- WHEN a caller constructs
new PayPalProvider({ clientId: "id", clientSecret: "secret" }) - THEN
provider.nameequals the string"paypal"(literal type)
Scenario: PaymentProvider conformance
- WHEN a caller assigns a
PayPalProviderinstance to a variable of typePaymentProviderfrom@suaveplan/payment - THEN the assignment type-checks without casts, and all six methods are callable with their declared signatures
Verified by: src/paypal/paypal.test.ts::"has literal name 'paypal'" AND src/paypal/paypal.test.ts::"satisfies the PaymentProvider structural contract"
Requirement: OAuth2 client-credentials token cache
The package SHALL acquire and cache an OAuth2 bearer token by posting
grant_type=client_credentials to /v1/oauth2/token with an
Authorization: Basic <base64Encode(clientId + ":" + clientSecret)> header
and a Content-Type: application/x-www-form-urlencoded header. The cached
token MUST be reused for every subsequent call until now() >= expiresAt,
where expiresAt = tokenAcquiredAt + (expires_in * 1000) - 60_000 (60 s
clock-skew buffer). A subsequent call after expiry MUST refresh the token
transparently.
Scenario: first call acquires a token, subsequent calls reuse it
- WHEN the caller invokes any method twice in quick succession with an
injected
FetchLikerecorder - THEN the recorder observes exactly one
/v1/oauth2/tokenPOST and two Orders-v2 calls (one per invocation)
Scenario: token refresh after expiry
- WHEN the caller invokes a method, advances the injected
NowFnpastexpiresAt, and invokes a second method - THEN the recorder observes two
/v1/oauth2/tokenPOSTs and two Orders-v2 calls
Scenario: OAuth header uses the injected base64 encoder
- WHEN a caller injects a custom
base64Encodethat prepends"X-" - THEN the Basic header for the token call equals
"Basic X-<encoded>"
Verified by: src/paypal/paypal-token.test.ts::"reuses a cached OAuth token across method calls" AND src/paypal/paypal-token.test.ts::"refreshes the token after the cached expiry passes" AND src/paypal/paypal-token.test.ts::"derives the Basic header via the injected base64 encoder"
Requirement: Token-fetch failure raises OAUTH_TOKEN_FAILURE
When the /v1/oauth2/token POST fails (network error, non-2xx response, or
malformed JSON), the package SHALL raise
PaymentProviderError(PAYMENT_PROVIDER.OAUTH_TOKEN_FAILURE) with provider: "paypal", the token URL on the url discriminant, and the underlying
failure attached as cause. The error SHALL be raised before any
subsequent Orders-v2 HTTP call is attempted.
Scenario: non-2xx token response
- WHEN the injected
FetchLikereturns a 401 from the token endpoint - THEN the caller observes a
PaymentProviderErrorwithcode === "PAYMENT_PROVIDER.OAUTH_TOKEN_FAILURE",provider === "paypal",causeset to the underlyingPaymentProviderError(HTTP_ERROR), and the recorder shows zero Orders-v2 calls
Scenario: network-level token failure
- WHEN the injected
FetchLikethrows a transport error on the token endpoint - THEN the same
OAUTH_TOKEN_FAILUREis raised with the transport error oncause
Verified by: src/paypal/paypal-token.test.ts::"raises OAUTH_TOKEN_FAILURE when the token endpoint returns a non-2xx" AND src/paypal/paypal-token.test.ts::"raises OAUTH_TOKEN_FAILURE when the token endpoint throws"
Requirement: PayPalConfig constructor surface
The package SHALL export a PayPalConfig interface extending the slim
core's OAuthConfig. It MUST add a single optional sandbox?: boolean
field. When sandbox is true and baseUrl is not supplied, the
provider MUST use https://api-m.sandbox.paypal.com as the default base
URL; otherwise (when baseUrl is unset and sandbox is not true), it
MUST default to https://api-m.paypal.com. An explicit baseUrl always
wins over the sandbox flag.
Scenario: sandbox flag selects the sandbox endpoint
- WHEN a caller constructs
new PayPalProvider({ clientId, clientSecret, sandbox: true })and the recorder observes any call - THEN the recorder sees URLs prefixed with
https://api-m.sandbox.paypal.com
Scenario: explicit baseUrl overrides sandbox
- WHEN a caller passes both
sandbox: trueandbaseUrl: "https://override/v2" - THEN the recorder sees URLs prefixed with
https://override
Verified by: src/paypal/paypal-token.test.ts::"defaults to the sandbox endpoint when sandbox flag is true" AND src/paypal/paypal-token.test.ts::"explicit baseUrl overrides the sandbox flag"
Requirement: createIntent maps CreatePaymentIntentInput to PayPal orders
createIntent SHALL POST /v2/checkout/orders with the cached bearer
token, a body of { intent, purchase_units: [{ amount: { currency_code, value }, description? }] }, and — when the caller supplies
idempotencyKey — a PayPal-Request-Id header equal to that key. The
intent MUST be "AUTHORIZE" when captureMethod === "manual" and
"CAPTURE" otherwise. The body's value MUST be the MoneyLike bigint
amount converted to a major-unit decimal string with exactly two minor-unit
digits (e.g. 5000n → "50.00").
Scenario: manual capture creates an AUTHORIZE order
- WHEN a caller invokes
createIntent({ amount: { amount: 5000n, currency: "USD" }, captureMethod: "manual", idempotencyKey: "idem-1" }) - THEN the recorder sees a POST to
…/v2/checkout/orderswith a body whoseintent === "AUTHORIZE",purchase_units[0].amount.value === "50.00", and headers include"PayPal-Request-Id": "idem-1"AND"Authorization": "Bearer <cached-token>"
Scenario: automatic capture creates a CAPTURE order
- WHEN a caller invokes
createIntent({ amount: { amount: 1000n, currency: "EUR" }, captureMethod: "automatic" }) - THEN the recorder sees a body whose
intent === "CAPTURE",purchase_units[0].amount.value === "10.00", and the request carries noPayPal-Request-Idheader
Verified by: src/paypal/paypal.test.ts::"createIntent with manual captureMethod posts an AUTHORIZE order" AND src/paypal/paypal.test.ts::"createIntent with automatic captureMethod posts a CAPTURE order without an idempotency header"
Requirement: confirmIntent posts authorize and captureIntent posts capture
confirmIntent SHALL POST /v2/checkout/orders/{intentId}/authorize with
the cached bearer token and return the mapped PaymentIntent.
captureIntent SHALL POST /v2/checkout/orders/{intentId}/capture with
the cached bearer token and return the mapped PaymentIntent whose
capturedAmount.amount is the sum of every
purchase_units[].payments.captures[].amount.value parsed as a bigint
minor-unit value.
Scenario: confirmIntent maps APPROVED to authorized
- WHEN PayPal returns an order with
status: "APPROVED" - THEN the returned
PaymentIntent.status === "authorized"
Scenario: captureIntent sums every capture
- WHEN PayPal returns an order with two captures of
"25.00"USD each - THEN the returned
PaymentIntent.capturedAmount.amount === 5000n
Verified by: src/paypal/paypal.test.ts::"confirmIntent posts authorize and maps APPROVED to authorized" AND src/paypal/paypal.test.ts::"captureIntent sums every purchase_unit capture into capturedAmount"
Requirement: cancelIntent voids the order and returns a canceled intent
cancelIntent SHALL POST /v2/checkout/orders/{intentId}/void with the
cached bearer token and return a synthesised PaymentIntent carrying the
same id, status: "canceled", zero amount / capturedAmount /
refundedAmount (USD currency), and createdAt / updatedAt equal to
new Date(now()).toISOString(). The injected NowFn MUST be the sole
source of these timestamps so tests can pin them.
Scenario: cancelIntent issues a void and returns canceled intent
- WHEN a caller invokes
cancelIntent("ORDER_x")with an injectednowreturning1_700_000_000_000 - THEN the recorder sees a POST to
…/v2/checkout/orders/ORDER_x/void, the returned intent hasid === "ORDER_x",status === "canceled", andcreatedAt === updatedAt === new Date(1_700_000_000_000).toISOString()
Verified by: src/paypal/paypal.test.ts::"cancelIntent voids the order and returns a canceled intent with injected-now timestamps"
Requirement: refund posts to /payments/captures/{id}/refund
refund SHALL POST /v2/payments/captures/{paymentIntentId}/refund with
the cached bearer token. When the caller supplies amount, the body MUST
include { amount: { currency_code, value } } with the same decimal-string
marshalling rule as createIntent. When the caller supplies
idempotencyKey, the request MUST carry a PayPal-Request-Id header.
When the caller supplies neither, the request body MUST be an empty
object and no idempotency header is sent. The returned Refund MUST
carry the PayPal refund id, the caller's paymentIntentId, the parsed
amount, and the mapped RefundStatus.
Scenario: full-amount refund with idempotency key
- WHEN a caller invokes
refund({ paymentIntentId: "CAP_x", amount: { amount: 2000n, currency: "USD" }, idempotencyKey: "rfn-1" }) - THEN the recorder sees a POST whose body
is
{ amount: { currency_code: "USD", value: "20.00" } }and headers include"PayPal-Request-Id": "rfn-1"
Scenario: bare refund with no amount or idempotency
- WHEN a caller invokes
refund({ paymentIntentId: "CAP_x" }) - THEN the recorder sees a POST with an empty-object body and no
PayPal-Request-Idheader
Verified by: src/paypal/paypal.test.ts::"refund with explicit amount and idempotency posts amount body and PayPal-Request-Id header" AND src/paypal/paypal.test.ts::"refund without amount or idempotency posts an empty body and no idempotency header"
Requirement: getIntent fetches an order
getIntent SHALL GET /v2/checkout/orders/{intentId} with the cached
bearer token and return the mapped PaymentIntent.
Scenario: getIntent fetches and maps an order
- WHEN a caller invokes
getIntent("ORDER_y")and PayPal returns aCOMPLETEDorder with no captures - THEN the returned
PaymentIntenthasid === "ORDER_y",status === "captured", andcapturedAmount.amount === 0n
Verified by: src/paypal/paypal.test.ts::"getIntent fetches the order and maps PayPal status to PaymentStatus"
Requirement: PayPal status mapping is exhaustive
The package SHALL map PayPal order statuses to genesis PaymentStatus
exactly as follows: CREATED → created, SAVED → created, APPROVED
→ authorized, VOIDED → canceled, COMPLETED → captured,
PAYER_ACTION_REQUIRED → pending. Any other PayPal status — including
the empty string and unknown strings — MUST map to failed. The
refund-status mapping is: COMPLETED → succeeded, PENDING →
pending, every other value → failed.
Scenario: every documented PayPal status maps correctly
- WHEN the caller invokes
getIntentand PayPal returns each of the six documented statuses in turn - THEN the returned
PaymentIntent.statusequals the mapped value above; an undocumented status returnsfailed
Scenario: refund-status mapping
- WHEN the caller invokes
refund({ paymentIntentId: "x" })and PayPal returns each ofCOMPLETED,PENDING, and an unknown status - THEN the returned
Refund.statusequalssucceeded,pending, andfailedrespectively
Verified by: src/paypal/paypal.test.ts::"maps every documented PayPal order status to PaymentStatus" AND src/paypal/paypal.test.ts::"maps every documented PayPal refund status to RefundStatus"
Requirement: Universal-tier purity — no Node, browser, or React deps
The package SHALL run unchanged on Node, Bun, browsers, and Web Workers.
Source files MUST NOT import from node:*, bun:*, react, the DOM
globals (window, document, localStorage, …), or any other
runtime-specific surface. The base-64 encoder used for the OAuth Basic
header MUST go through the injected Base64EncodeFn (defaulting to
globalThis.btoa) so a runtime without btoa can supply its own
encoder without forking the package.
Scenario: dependency graph has only universal-tier siblings
- WHEN the dep-graph test reads the package's
package.jsonand the source-import graph - THEN the only
@suaveplan/*deps arepayment,payment-providers, anderror; the only external dep istslib; nonode:/bun:/react/ DOM-global imports appear insrc/
Verified by: tests/dep-graph.test.ts::"declares only universal-tier dependencies" AND tests/dep-graph.test.ts::"source imports only @suaveplan/* siblings, tslib, and node-free intrinsics"
Requirement: Errors raised only as PaymentProviderError
The package SHALL never raise a bare Error or any non-PaymentProviderError
class from @suaveplan/error. Every failure path — token failure, HTTP
non-2xx, JSON parse failure, transport error — surfaces as a
PaymentProviderError from the slim core's factories (oauthTokenFailure,
httpError, timeoutError, parseError), with provider === "paypal".
Scenario: every failure path narrows to PaymentProviderError
- WHEN any of the four documented failure paths fires
- THEN the thrown value is an
instanceof PaymentProviderErrorfrom@suaveplan/payment-providersand theproviderdiscriminant equals"paypal"
Verified by: src/paypal/paypal.test.ts::"every failure path narrows to PaymentProviderError with provider 'paypal'"