# PayPalProvider _(no submodules)_ ## Purpose `PayPalProvider` is the PayPal-flavoured implementation of the `PaymentProvider` contract exported by `@suaveplan/payment`. It maps every suaveplan payment-lifecycle method onto the PayPal Orders v2 and Payments Captures Refund APIs, takes care of the OAuth2 client-credentials handshake once per token lifetime, and surfaces every failure as a typed `PaymentProviderError` raised by the slim core. The package exists so that applications can register a PayPal adapter into a `ProviderRegistry` without writing the token-cache, idempotency-header, status-mapping, or decimal-string-marshalling boilerplate the upstream API requires. The provider is deliberately universal: it has no `node:*` / `bun:*` / `react` / DOM imports, and every runtime dependency (the HTTP client, the OAuth Basic header's base-64 encoder, and the clock the token cache uses) is injected through the constructor config. That means the same class runs unchanged on Node, Bun, modern browsers, and Web Workers — and that tests can replace the HTTP transport, the clock, and the base-64 encoder with closures, so the package needs no global mocking and no platform-specific polyfills. ## Features - Implements the full `PaymentProvider` surface (`createIntent`, `confirmIntent`, `captureIntent`, `cancelIntent`, `refund`, `getIntent`) with a literal-typed `name: "paypal"` discriminant - OAuth2 client-credentials token cache with 60 s clock-skew buffer; reused across every method call until expiry, refreshed on demand - Injectable `now: NowFn` for the token-cache TTL (tests can pin expiry without freezing global `Date`) - Injectable `base64Encode: Base64EncodeFn` for the `Authorization: Basic` header (defaults to `globalThis.btoa`; runtimes without `btoa` supply their own) - Injectable `fetch: FetchLike` for every HTTP call (tests pass a recorder closure; production passes a custom client when needed) - `sandbox` flag toggles the default `https://api-m.sandbox.paypal.com` endpoint; an explicit `baseUrl` always wins - `MoneyLike` `bigint` minor-unit amounts marshalled to PayPal's major-unit decimal-string format (`5000n` ↔ `"50.00"`) - Idempotency keys surface as PayPal's `PayPal-Request-Id` header on `createIntent` and `refund` - Every failure surfaces as `PaymentProviderError` with `provider: "paypal"`, the URL on the `url` discriminant, and the underlying cause attached ## Usage: Basic ```ts import { PayPalProvider } from "@suaveplan/payment-provider-paypal"; const paypal = new PayPalProvider({ clientId: process.env.PAYPAL_CLIENT_ID ?? "", clientSecret: process.env.PAYPAL_CLIENT_SECRET ?? "", }); const intent = await paypal.createIntent({ amount: { amount: 5000n, currency: "USD" }, description: "Order #1234", captureMethod: "automatic", idempotencyKey: "order-1234", }); console.log(intent.id, intent.status); // "ORDER_xxx" "created" ``` ## Usage: Advanced Combine the sandbox flag, an injected `FetchLike`, an injected `NowFn`, and a `ProviderRegistry` from the slim core: ```ts import { type FetchLike, ProviderRegistry, } from "@suaveplan/payment-providers"; import { PayPalProvider } from "@suaveplan/payment-provider-paypal"; const calls: string[] = []; const recorder: FetchLike = async (url, init) => { calls.push(`${init?.method ?? "GET"} ${url}`); // Forward to the real PayPal sandbox via a custom client, add // logging / retry / circuit-breaker around it, etc. return fetch(url, init); }; let clock = Date.parse("2026-05-17T00:00:00Z"); const paypal = new PayPalProvider({ clientId: process.env.PAYPAL_SANDBOX_CLIENT_ID ?? "", clientSecret: process.env.PAYPAL_SANDBOX_CLIENT_SECRET ?? "", sandbox: true, fetch: recorder, now: () => clock, timeoutMs: 10_000, }); const registry = new ProviderRegistry(); registry.register(paypal); const intent = await registry .get("paypal") .createIntent({ amount: { amount: 999n, currency: "EUR" }, captureMethod: "manual", }); // Advance the clock past the token expiry — the next call refreshes. clock += 4_000_000; const confirmed = await paypal.confirmIntent(intent.id); console.log(calls); // → ["POST https://api-m.sandbox.paypal.com/v1/oauth2/token", // "POST https://api-m.sandbox.paypal.com/v2/checkout/orders", // "POST https://api-m.sandbox.paypal.com/v1/oauth2/token", // "POST https://api-m.sandbox.paypal.com/v2/checkout/orders/.../authorize"] console.log(confirmed.status); // "authorized" ``` ## API Reference ### `class PayPalProvider implements PaymentProvider` Constructor: `new PayPalProvider(config: PayPalConfig)`. | Field of `PayPalConfig` | Type | Default | Notes | |---|---|---|---| | `clientId` | `string` | — | PayPal REST app client ID | | `clientSecret` | `string` | — | PayPal REST app client secret | | `baseUrl` | `string?` | derived | Wins over `sandbox` when set | | `sandbox` | `boolean?` | `false` | Selects `https://api-m.sandbox.paypal.com` when `true` | | `timeoutMs` | `number?` | `30_000` | Per-HTTP-call timeout | | `fetch` | `FetchLike?` | `globalThis.fetch` | Injectable HTTP client | | `signal` | `AbortSignal?` | — | External signal composed with the per-call timeout | | `now` | `NowFn?` | `Date.now` | Clock for the token cache + `cancelIntent` timestamps | | `base64Encode` | `Base64EncodeFn?` | `globalThis.btoa` | OAuth Basic-header encoder | | `webhookSecret` | `string?` | — | Carried through for symmetry; not yet wired to a verifier | Public methods: - `name: "paypal"` — literal-typed discriminant - `createIntent(input: CreatePaymentIntentInput): Promise` - `confirmIntent(intentId: string): Promise` - `captureIntent(intentId: string): Promise` - `cancelIntent(intentId: string): Promise` — returns a synthesised intent because PayPal's void endpoint returns no body - `refund(input: RefundInput): Promise` - `getIntent(intentId: string): Promise` Errors: every method may throw `PaymentProviderError` with one of `PAYMENT_PROVIDER.OAUTH_TOKEN_FAILURE`, `…HTTP_ERROR`, `…TIMEOUT`, or `…PARSE_ERROR`. The `provider` discriminant is always `"paypal"`. ### `interface PayPalConfig extends OAuthConfig` See the table above. Re-exported from `@suaveplan/payment-provider-paypal` and from the `./paypal/paypal` subpath. ### Status mapping | PayPal status | `PaymentStatus` | |---|---| | `CREATED` / `SAVED` | `created` | | `APPROVED` | `authorized` | | `VOIDED` | `canceled` | | `COMPLETED` | `captured` | | `PAYER_ACTION_REQUIRED` | `pending` | | anything else | `failed` | | PayPal refund status | `RefundStatus` | |---|---| | `COMPLETED` | `succeeded` | | `PENDING` | `pending` | | anything else | `failed` | ## Implementation Notes - The OAuth token is cached in-memory on the provider instance. Two instances do not share a cache; build a singleton at the application composition root if you want a process-wide cache. - The token TTL is `tokenAcquiredAt + (expires_in * 1000) - 60_000` to guard against PayPal-side clock skew. Tests pin the expiry by injecting `now` and asserting the recorded call count after the boundary. - `cancelIntent` returns a synthesised `PaymentIntent` because PayPal's void endpoint returns no body. The timestamps come from `new Date(now()).toISOString()` so tests can pin them by injecting a deterministic `now`. - Amount marshalling assumes the `MoneyLike` `amount` is denominated in the smallest indivisible unit of `currency` (e.g. cents for USD). Zero- decimal currencies (JPY) are not yet auto-detected — pass the major-unit value as the bigint when using them and trim the `.00` suffix in your consumer if needed; future work tracked in the parity checklist. - The `Refund` returned from `refund` carries the caller's `paymentIntentId` verbatim, not the PayPal `capture_id`, because the `Refund.paymentIntentId` field semantically points to the capture / intent the caller addressed. - Adding new failure paths: surface them through the slim core's factories (`httpError`, `timeoutError`, `parseError`, `oauthTokenFailure`) so the `provider` discriminant stays consistent. Never raise a bare `Error` from this package.