317 lines
14 KiB
Markdown
317 lines
14 KiB
Markdown
<!-- markdownlint-disable MD024 -->
|
|
|
|
# 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'"`
|
|
|
|
<!-- markdownlint-enable MD024 -->
|