# 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 ` 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 ` 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 |