185 lines
12 KiB
Markdown
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 |
|