Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,184 @@
# Add @suaveplan/payment-provider-paypal — PayPal adapter for the slim payment-providers core
**Package**: `@suaveplan/payment-provider-paypal`
**Status**: PROPOSED
**Type**: new-package
- **Spec delta**: ./specs/payment-provider-paypal/spec.md
## Why
The 2026-05-17 slim-down (`openspec/archive/2026-05-17-slim-payment-providers/`)
extracted five concrete provider adapters out of `@suaveplan/payment-providers`
and committed them to land as sibling packages
(`@suaveplan/payment-provider-{stripe,paypal,square,mollie,adyen}`).
This change ships the PayPal sibling. Without it, consumers that want to talk
to the PayPal Orders v2 API have to either depend on the upstream bundled
package (no longer published in genesis) or hand-roll OAuth2 client-credentials
plumbing, request signing, status mapping, and the bigint-amount marshalling
PayPal requires. The genesis stance — and the foundation contract the slim
core was designed around — is that each adapter ships as its own
publish unit on the slim foundation.
The PayPal adapter additionally requires injectable seams that the other
adapters do not: a `NowFn` for the OAuth token-cache TTL (so tests can pin
expiry without freezing global `Date`) and a `Base64EncodeFn` for the
`Authorization: Basic …` header (so the universal-tier package runs unchanged
on Bun, Node, browsers, and Web Workers despite the underlying base-64
primitive being named differently in each — `btoa`, `Buffer.from(…).toString("base64")`,
WebCrypto, etc.). The slim core already exports both seams; this package
wires them at the constructor boundary so consumers and tests can override
either independently.
## What changes
### ADDED
- New package `@suaveplan/payment-provider-paypal` at `packages/universal/payment-provider-paypal/`.
- `PayPalProvider implements PaymentProvider` — class adapter with `name: "paypal"`.
- `PayPalConfig extends OAuthConfig` — re-exported config interface with the
PayPal-specific `sandbox` flag (`true` flips the default base URL to
`https://api-m.sandbox.paypal.com`).
- OAuth2 client-credentials token cache — `httpRequest`-driven token fetch with
the `Basic <base64(clientId:clientSecret)>` header; cached in-memory with the
PayPal-supplied `expires_in` value (minus a 60 s skew); refreshed on demand.
Token-fetch failures raise `PaymentProviderError(OAUTH_TOKEN_FAILURE)` with
the cause attached.
- `PayPalProvider.createIntent` / `confirmIntent` / `captureIntent` /
`cancelIntent` / `refund` / `getIntent` — full `PaymentProvider`
implementation against the PayPal Orders v2 + Payments Captures Refund
endpoints, using the cached bearer token, surfacing `PayPal-Request-Id`
for idempotency keys, mapping PayPal statuses to genesis `PaymentStatus` /
`RefundStatus`, and marshalling the `MoneyLike` `bigint` minor-unit amounts
to the major-unit decimal string PayPal accepts (`5000n` → `"50.00"`).
- 6-entry subpath exports: `.`, `./paypal/paypal`, plus `./package.json`.
- Sibling docs `src/paypal/paypal.md` (≥200 words, basic + advanced examples,
full API reference) plus `README.md` listing every co-located `.md` under
`## Modules`.
- 100 / 100 / 100 / 100 Istanbul coverage from day one.
### MODIFIED
- `STATUS.md` — add the new package to the `Completed` table.
- `SUMMARY.md` — append the new package paragraph.
- `MANIFEST.md` — auto-regenerate after the package lands.
- `CLAUDE.md` — no entry; the package is one of the sibling provider plugins
already named in the post-slim package listing and is documented through
`MANIFEST.md` / `STATUS.md` / `SUMMARY.md` per the CLAUDE.md note "Detailed
package descriptions live in each package's `README.md` and co-located
`.md` files".
### REMOVED
- None — purely additive.
## Out of scope
- The other four sibling adapters (`payment-provider-stripe`, `-square`,
`-mollie`, `-adyen`). Each ships under its own OpenSpec change.
- Webhook signature verification for PayPal. The `webhookSecret` field exists
on `OAuthConfig` for symmetry with the other adapters but is not yet wired
to a PayPal-specific verifier; the slim core's `WebhookVerifier` ships in
`@suaveplan/payment`, and the PayPal-flavoured verifier is its own
follow-up change once `@suaveplan/payment` lands a verifier-plugin slot.
- OpenTelemetry spans / counters on the PayPal adapter. Same deferral as the
slim core — align with `@suaveplan/payment` once that package lands its
observability surface.
- Disputes / chargebacks API support. The slim `PaymentProvider` contract
has no dispute methods; when `@suaveplan/payment` extends the contract,
every sibling adapter (including PayPal) extends in lockstep.
- Vault / vaulted-payment-method support (PayPal v3 payment-tokens API).
Same gating — the slim contract has no vault method.
- Subscription / billing-plan support (PayPal `/v1/billing/*`). Out of the
one-shot `PaymentIntent` contract scope.
## Source mapping
This change derives from `../suaveplan/enterprise/payment-providers.md`
(the enterprise audit doc that the bundled upstream `payment-providers`
package was authored against — the same audit the slim core migration also
cited) plus the upstream concrete implementation at
`../suaveplan/packages/universal/payment-providers/src/paypal.ts` and the
slim archive at `openspec/archive/2026-05-17-slim-payment-providers/`.
| Requirement | Source | Notes |
|---|---|---|
| Universal-tier purity (zero browser / Node / React deps) | `enterprise/payment-providers.md` L37 | inherited from slim core; this sibling preserves it |
| Zod schemas + injectable clock + structured errors + AbortSignal | `enterprise/payment-providers.md` L40-L43 | inherited via slim core's `httpRequest`, `composeAbortSignal`, `PaymentProviderError`, `NowFn` |
| OAuth2 client-credentials with cached token | `../suaveplan/packages/universal/payment-providers/src/paypal.ts` L223-L244 | verbatim; injectable `now` + `base64Encode` added for universal-tier determinism |
| `createIntent` → POST `/v2/checkout/orders` with `intent` (`AUTHORIZE` / `CAPTURE`), `purchase_units[0].amount`, `description`, optional `PayPal-Request-Id` | upstream `paypal.ts` L112-L137 | verbatim |
| `confirmIntent` → POST `/v2/checkout/orders/{id}/authorize`, maps `APPROVED` → `authorized` | upstream `paypal.ts` L139-L148 | verbatim |
| `captureIntent` → POST `/v2/checkout/orders/{id}/capture`, sums `purchase_units[].payments.captures[].amount.value` | upstream `paypal.ts` L150-L159, L71-L93 | verbatim |
| `cancelIntent` → POST `/v2/checkout/orders/{id}/void`, returns synthetic `canceled` intent | upstream `paypal.ts` L161-L180 | verbatim; intent timestamps now derived from `now()` not `new Date()` |
| `refund` → POST `/v2/payments/captures/{id}/refund` with optional `amount`, optional `PayPal-Request-Id` | upstream `paypal.ts` L182-L210 | verbatim |
| `getIntent` → GET `/v2/checkout/orders/{id}` | upstream `paypal.ts` L212-L221 | verbatim |
| Status mapping (`CREATED`/`SAVED` → `created`, `APPROVED` → `authorized`, `VOIDED` → `canceled`, `COMPLETED` → `captured`, `PAYER_ACTION_REQUIRED` → `pending`, default → `failed`) | upstream `paypal.ts` L46-L56 | verbatim |
| Refund status mapping (`COMPLETED` → `succeeded`, `PENDING` → `pending`, else → `failed`) | upstream `paypal.ts` L58-L62 | verbatim |
| `MoneyLike` bigint minor-unit → decimal-string major-unit marshalling (`5000n` ↔ `"50.00"`) | upstream `paypal.ts` L64-L69, L116-L117 | verbatim |
| Re-uses slim core `httpRequest` + `composeAbortSignal` + `oauthTokenFailure` | `archive/2026-05-17-slim-payment-providers/proposal.md` "Future sibling package will depend on" table row for PayPal | verbatim — this change consumes exactly that surface |
### Requirements with no source line
| Requirement | Justification |
|---|---|
| 100% Istanbul coverage threshold | Genesis-wide policy (CLAUDE.md rule "100% test coverage"), not package-specific |
| Tests via `@suaveplan/testing/runner` | Genesis-wide policy (CLAUDE.md rule 11 / AGENTS.md §7); upstream tests use the wrapper but the rule applies regardless |
| Injectable `now: NowFn` defaulting to `Date.now` | Genesis-wide universal-tier determinism policy; upstream used direct `Date.now()` and the slim core already exposes `NowFn` for this exact use case |
| Injectable `base64Encode: Base64EncodeFn` defaulting to `globalThis.btoa` | Genesis-wide universal-tier policy; upstream used direct `btoa` (browser-only). The slim core already exposes `Base64EncodeFn` for runtimes that lack `btoa` (older Node, custom embeddings) |
| `sandbox` constructor flag | Convenience addition above the upstream baseline so consumers can toggle the PayPal sandbox endpoint without restating the full URL; resolves to `https://api-m.sandbox.paypal.com` when `true` |
| Constructor-time validation that the OAuth Basic header isn't accidentally re-derived on every request (token cache must be hit) | The upstream code already had this behaviour; genesis pins it as a normative scenario so a future refactor can't regress it |
## Verification approach
- `src/paypal/paypal.test.ts` — exercises every method, every status-mapping
branch, the token cache hit and refresh-on-expiry paths, the
`OAUTH_TOKEN_FAILURE` failure path, the injectable `now` / `base64Encode`
seams, the sandbox flag, the `PayPal-Request-Id` idempotency header, the
amount marshalling round-trip, and the universal-tier purity by injecting a
`FetchLike` closure for every HTTP path. Uses `@suaveplan/testing/runner`
exclusively. No mocks of `globalThis.fetch` — every test passes an
explicit `FetchLike` recorder. Uses `FakeClock` from `@suaveplan/testing`
for token-expiry assertions.
- `tests/dep-graph.test.ts` — pins the dependency invariants: only
`@suaveplan/payment`, `@suaveplan/payment-providers`, `@suaveplan/error`,
`tslib`. No Node, browser, or React deps anywhere in the source.
## Migration impact
None — additive only. No existing consumer imports `PayPalProvider` from
`@suaveplan/payment-providers` in the genesis tree (the slim-down change
already removed it from the core barrel and no in-repo dependent re-added
it). New external consumers follow the install line in `README.md`.
## Impact
### Affected dependents
Run `bunx turbo ls --filter=...@suaveplan/payment-provider-paypal` to
enumerate.
- No in-repo dependents — the package is brand-new and nothing currently
imports `@suaveplan/payment-provider-paypal`. `bunx turbo ls
--filter=...@suaveplan/payment-provider-paypal` returns only the package
itself. [x] no action needed (reason: zero dependents)
## Parity Checklist
| Capability | Upstream (`packages/universal/payment-providers/src/paypal.ts`) | Migrated | Notes |
|---|---|---|---|
| `class PayPalProvider implements PaymentProvider`, `name: "paypal"` | yes | yes | verbatim |
| `OAuthConfig` constructor (`clientId`, `clientSecret`, `baseUrl`, `timeoutMs`) | yes | yes | extended with `sandbox`, `now`, `base64Encode` |
| OAuth2 client-credentials token cache w/ `expires_in` honoured + 60 s skew | yes | yes | verbatim; `Date.now()` swapped for injectable `now` |
| `Basic <base64(clientId:clientSecret)>` header via `btoa` | yes | yes (⬆) | `btoa` swapped for injectable `base64Encode` defaulting to `globalThis.btoa` |
| `createIntent` (intent / amount / description / optional idempotency-key as `PayPal-Request-Id`) | yes | yes | verbatim |
| `confirmIntent` / `captureIntent` / `cancelIntent` / `refund` / `getIntent` | yes | yes | verbatim |
| Status mapping (7 cases incl. default `failed`) | yes | yes | verbatim |
| Refund status mapping (3 cases) | yes | yes | verbatim |
| `MoneyLike` bigint ↔ PayPal decimal string marshalling | yes | yes | verbatim |
| Synthetic `canceled` PaymentIntent on void (timestamps via `new Date().toISOString()`) | yes | yes (⬆) | `new Date()` swapped for `new Date(now()).toISOString()` so tests can pin |
| Tests via `from "@suaveplan/testing"` | yes | yes (⬆) | rewritten to `@suaveplan/testing/runner` per AGENTS.md §7 |
| `vi.spyOn(globalThis, "fetch")` test pattern | yes | 🚫 — replaced | replaced with explicit `FetchLike` recorder closures so tests don't monkey-patch globals (matches the slim core's test style; CLAUDE.md rule 2) |
| `sandbox` shortcut flag | 🚫 absent | ✅ added | convenience; resolves to `https://api-m.sandbox.paypal.com` |
| Sibling `.md` per implementation file (≥200 words, basic + advanced examples) | minimal (20-line stub) | ✅ rewritten | upstream `.md` was a placeholder; new version meets the genesis doc standard |
| 100 / 100 / 100 / 100 Istanbul coverage | yes | yes | verbatim threshold |

View File

@@ -0,0 +1,312 @@
<!-- markdownlint-disable MD024 -->
# 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. (See `openspec/AGENTS.md` Section 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.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 -->

View File

@@ -0,0 +1,58 @@
# Tasks — add-payment-provider-paypal
> Every checkbox MUST reflect work actually done in this change. No retroactive ticking.
## 1. Spec authoring
- [x] 1.1 Read `../suaveplan/enterprise/payment-providers.md` end-to-end
- [x] 1.2 Read the slim-core spec and code (`packages/universal/payment-providers/src/`) plus the slim archive (`openspec/archive/2026-05-17-slim-payment-providers/`)
- [x] 1.3 Read `openspec/AGENTS.md` Sections 13 and 14 (and §7 test-runner rule)
- [x] 1.4 Draft `proposal.md` from `openspec/templates/proposal.md`
- [x] 1.5 Fill `## Source mapping` table — every requirement cites a line range OR appears in the "no source line" table with justification
- [x] 1.6 Draft `specs/payment-provider-paypal/spec.md` from `openspec/templates/spec-delta.md`
- [x] 1.7 Every requirement carries a `**Verified by:**` line naming concrete `it(...)` titles
## 2. Implementation
- [x] 2.1 Scaffold `packages/universal/payment-provider-paypal/` (package.json, tsconfig.json, tsconfig.lib.json, vite.config.ts, vitest.config.ts, README.md, tests/setup/polyfills.ts, tests/dep-graph.test.ts)
- [x] 2.2 Implement `src/paypal/paypal.types.ts` — `PayPalConfig` (extends `OAuthConfig` + `sandbox?: boolean`), `PayPalOrder`, `PayPalRefund`, `PayPalTokenResponse` wire shapes
- [x] 2.3 Implement `src/paypal/paypal.ts` — `PayPalProvider` class with constructor, token cache, all 6 `PaymentProvider` methods, status mapping helpers, amount marshalling, idempotency-header wiring
- [x] 2.4 Wire `src/paypal/index.ts` (named exports) and `src/index.ts` package barrel (named exports)
- [x] 2.5 Add subpath exports in `package.json` (`.`, `./paypal/paypal`, `./package.json`)
## 3. Tests
- [x] 3.1 `src/paypal/paypal.test.ts` — every scenario in the spec delta exercised via a `FetchLike` recorder closure (no `globalThis.fetch` patching); `FakeClock` for token-expiry assertions; all `it(...)` carry `{ timeout: 5000 }`
- [x] 3.2 `tests/dep-graph.test.ts` — pins dependency invariants (only `payment`, `payment-providers`, `error`, `tslib`; no Node/browser/React imports)
- [x] 3.3 Every test file imports runner primitives from `@suaveplan/testing/runner` (§7 / CLAUDE.md rule 11)
- [x] 3.4 Coverage at 100/100/100/100
## 4. Documentation
- [x] 4.1 `src/paypal/paypal.md` — Purpose / Features / Basic + Advanced Usage / API Reference / Implementation Notes (≥200 words)
- [x] 4.2 `README.md` — install / quick-start / Modules links / migration
- [x] 4.3 No `CLAUDE.md` entry required (per CLAUDE.md doc-policy note — sibling-plugin packages are documented through `MANIFEST.md` / `STATUS.md` / `SUMMARY.md` and the per-package `.md` files)
## 5. Pre-archive verification (the 5-gate triple)
- [x] 5.1 `bun run check` — Biome 0/0/0 across the monorepo
- [x] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/payment-provider-paypal` — green
- [x] 5.3 `bunx turbo test:coverage --filter=@suaveplan/payment-provider-paypal` — 100/100/100/100
- [x] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/payment-provider-paypal` — exit 0, no `error` findings
- [x] 5.5 `bunx turbo build --filter=@suaveplan/payment-provider-paypal` — green
## 6. Pre-archive 7-row checklist (AGENTS.md §13)
- [x] 6.1 Generics threaded (`PayPalProvider.name` is the literal `"paypal"`; `httpRequest<T>` generics threaded through every call site)
- [x] 6.2 Zod schemas wired at every declared boundary — N/A (this adapter consumes the slim core's typed `httpRequest` and the `@suaveplan/payment` interface; no public Zod boundary beyond the structural `OAuthConfig` extension)
- [x] 6.3 Every error code reachable: `OAUTH_TOKEN_FAILURE` raised by token-fetch failure path; `HTTP_ERROR` / `TIMEOUT` / `PARSE_ERROR` reachable transitively via `httpRequest`; tested in both directions
- [x] 6.4 Strict-mode behaviors — `sandbox` flag both on and off; token cache hit AND refresh; refund with AND without amount/idempotency; createIntent with AND without idempotency
- [x] 6.5 OTel instruments — N/A; deferred per the slim core's matching deferral until `@suaveplan/payment` lands an observability surface
- [x] 6.6 Lifecycle dedup — token cache reuse asserted (single token call across two intents); refresh-after-expiry asserted
- [x] 6.7 No retroactive tick — every box above corresponds to work done in this change
## 7. Archive
- [x] 7.1 Fold spec delta into `openspec/specs/payment-provider-paypal/spec.md`
- [x] 7.2 Move `openspec/changes/add-payment-provider-paypal/` → `openspec/archive/2026-05-17-add-payment-provider-paypal/`
- [x] 7.3 Refresh `MANIFEST.md`, `MISSING.yml`, `STATUS.md`, `SUMMARY.md`