12 KiB
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-paypalatpackages/universal/payment-provider-paypal/. PayPalProvider implements PaymentProvider— class adapter withname: "paypal".PayPalConfig extends OAuthConfig— re-exported config interface with the PayPal-specificsandboxflag (trueflips the default base URL tohttps://api-m.sandbox.paypal.com).- OAuth2 client-credentials token cache —
httpRequest-driven token fetch with theBasic <base64(clientId:clientSecret)>header; cached in-memory with the PayPal-suppliedexpires_invalue (minus a 60 s skew); refreshed on demand. Token-fetch failures raisePaymentProviderError(OAUTH_TOKEN_FAILURE)with the cause attached. PayPalProvider.createIntent/confirmIntent/captureIntent/cancelIntent/refund/getIntent— fullPaymentProviderimplementation against the PayPal Orders v2 + Payments Captures Refund endpoints, using the cached bearer token, surfacingPayPal-Request-Idfor idempotency keys, mapping PayPal statuses to genesisPaymentStatus/RefundStatus, and marshalling theMoneyLikebigintminor-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) plusREADME.mdlisting every co-located.mdunder## Modules. - 100 / 100 / 100 / 100 Istanbul coverage from day one.
MODIFIED
STATUS.md— add the new package to theCompletedtable.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 throughMANIFEST.md/STATUS.md/SUMMARY.mdper the CLAUDE.md note "Detailed package descriptions live in each package'sREADME.mdand co-located.mdfiles".
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
webhookSecretfield exists onOAuthConfigfor symmetry with the other adapters but is not yet wired to a PayPal-specific verifier; the slim core'sWebhookVerifierships in@suaveplan/payment, and the PayPal-flavoured verifier is its own follow-up change once@suaveplan/paymentlands a verifier-plugin slot. - OpenTelemetry spans / counters on the PayPal adapter. Same deferral as the
slim core — align with
@suaveplan/paymentonce that package lands its observability surface. - Disputes / chargebacks API support. The slim
PaymentProvidercontract has no dispute methods; when@suaveplan/paymentextends 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-shotPaymentIntentcontract 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, theOAUTH_TOKEN_FAILUREfailure path, the injectablenow/base64Encodeseams, the sandbox flag, thePayPal-Request-Ididempotency header, the amount marshalling round-trip, and the universal-tier purity by injecting aFetchLikeclosure for every HTTP path. Uses@suaveplan/testing/runnerexclusively. No mocks ofglobalThis.fetch— every test passes an explicitFetchLikerecorder. UsesFakeClockfrom@suaveplan/testingfor 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-paypalreturns 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 |