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

185 lines
12 KiB
Markdown

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