Files
suaveplan/openspec/archive/2026-05-17-add-payment-provider-paypal/proposal.md
2026-05-27 23:29:53 +01:00

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