Files
suaveplan/openspec/specs/payment-provider-paypal/spec.md
2026-05-27 23:29:53 +01:00

14 KiB

Capability Spec: payment-provider-paypal

Package: @suaveplan/payment-provider-paypal Status: SHIPPED

Every requirement below carries a **Verified by:** line.

History: originally folded by 2026-05-17-add-payment-provider-paypal as the PayPal sibling adapter on the slim @suaveplan/payment-providers core.

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.name equals the string "paypal" (literal type)

Scenario: PaymentProvider conformance

  • WHEN a caller assigns a PayPalProvider instance to a variable of type PaymentProvider from @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 FetchLike recorder
  • THEN the recorder observes exactly one /v1/oauth2/token POST and two Orders-v2 calls (one per invocation)

Scenario: token refresh after expiry

  • WHEN the caller invokes a method, advances the injected NowFn past expiresAt, and invokes a second method
  • THEN the recorder observes two /v1/oauth2/token POSTs and two Orders-v2 calls

Scenario: OAuth header uses the injected base64 encoder

  • WHEN a caller injects a custom base64Encode that 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 FetchLike returns a 401 from the token endpoint
  • THEN the caller observes a PaymentProviderError with code === "PAYMENT_PROVIDER.OAUTH_TOKEN_FAILURE", provider === "paypal", cause set to the underlying PaymentProviderError(HTTP_ERROR), and the recorder shows zero Orders-v2 calls

Scenario: network-level token failure

  • WHEN the injected FetchLike throws a transport error on the token endpoint
  • THEN the same OAUTH_TOKEN_FAILURE is raised with the transport error on cause

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: true and baseUrl: "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/orders with a body whose intent === "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 no PayPal-Request-Id header

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 injected now returning 1_700_000_000_000
  • THEN the recorder sees a POST to …/v2/checkout/orders/ORDER_x/void, the returned intent has id === "ORDER_x", status === "canceled", and createdAt === 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-Id header

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 a COMPLETED order with no captures
  • THEN the returned PaymentIntent has id === "ORDER_y", status === "captured", and capturedAmount.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 getIntent and PayPal returns each of the six documented statuses in turn
  • THEN the returned PaymentIntent.status equals the mapped value above; an undocumented status returns failed

Scenario: refund-status mapping

  • WHEN the caller invokes refund({ paymentIntentId: "x" }) and PayPal returns each of COMPLETED, PENDING, and an unknown status
  • THEN the returned Refund.status equals succeeded, pending, and failed respectively

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.json and the source-import graph
  • THEN the only @suaveplan/* deps are payment, payment-providers, and error; the only external dep is tslib; no node: / bun: / react / DOM-global imports appear in src/

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 PaymentProviderError from @suaveplan/payment-providers and the provider discriminant equals "paypal"

Verified by: src/paypal/paypal.test.ts::"every failure path narrows to PaymentProviderError with provider 'paypal'"