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
PaymentProvidersurface (createIntent,confirmIntent,captureIntent,cancelIntent,refund,getIntent) with a literal-typedname: "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: NowFnfor the token-cache TTL (tests can pin expiry without freezing globalDate) - Injectable
base64Encode: Base64EncodeFnfor theAuthorization: Basicheader (defaults toglobalThis.btoa; runtimes withoutbtoasupply their own) - Injectable
fetch: FetchLikefor every HTTP call (tests pass a recorder closure; production passes a custom client when needed) sandboxflag toggles the defaulthttps://api-m.sandbox.paypal.comendpoint; an explicitbaseUrlalways winsMoneyLikebigintminor-unit amounts marshalled to PayPal's major-unit decimal-string format (5000n↔"50.00")- Idempotency keys surface as PayPal's
PayPal-Request-Idheader oncreateIntentandrefund - Every failure surfaces as
PaymentProviderErrorwithprovider: "paypal", the URL on theurldiscriminant, 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 discriminantcreateIntent(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 bodyrefund(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_000to guard against PayPal-side clock skew. Tests pin the expiry by injectingnowand asserting the recorded call count after the boundary. cancelIntentreturns a synthesisedPaymentIntentbecause PayPal's void endpoint returns no body. The timestamps come fromnew Date(now()).toISOString()so tests can pin them by injecting a deterministicnow.- Amount marshalling assumes the
MoneyLikeamountis denominated in the smallest indivisible unit ofcurrency(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.00suffix in your consumer if needed; future work tracked in the parity checklist. - The
Refundreturned fromrefundcarries the caller'spaymentIntentIdverbatim, not the PayPalcapture_id, because theRefund.paymentIntentIdfield 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 theproviderdiscriminant stays consistent. Never raise a bareErrorfrom this package.