Files
2026-05-27 23:47:53 +01:00

8.2 KiB

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

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:

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<PaymentIntent>
  • confirmIntent(intentId: string): Promise<PaymentIntent>
  • captureIntent(intentId: string): Promise<PaymentIntent>
  • cancelIntent(intentId: string): Promise<PaymentIntent> — returns a synthesised intent because PayPal's void endpoint returns no body
  • refund(input: RefundInput): Promise<Refund>
  • getIntent(intentId: string): Promise<PaymentIntent>

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.