Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,107 @@
# @suaveplan/payment-provider-paypal - PayPal payment provider for @suaveplan/payment-providers. Implements the PaymentProvider interface against the PayPal Orders v2 + Payments Captures Refund APIs, with an OAuth2 client-credentials token cache, injectable now/base64Encode/FetchLike seams, sandbox-endpoint shortcut, MoneyLike bigint marshalling, and structured PaymentProviderError surfaces. Universal — runs unchanged on Node, Bun, browsers, and Web Workers.
PayPal sibling adapter for the slim
[`@suaveplan/payment-providers`](../payment-providers/README.md) foundation.
Ships `PayPalProvider` — a `PaymentProvider` implementation against the
PayPal Orders v2 + Payments Captures Refund APIs, with an OAuth2
client-credentials token cache, injectable `now` / `base64Encode` /
`FetchLike` seams, a `sandbox` shortcut, `MoneyLike` bigint marshalling,
and structured `PaymentProviderError` surfaces. Universal — runs
unchanged on Node, Bun, browsers, and Web Workers.
<!-- begin:auto-docs -->
## Documentation tree
- [paypal](./src/paypal/paypal.md) — `PayPalProvider` is the PayPal-flavoured implementation of the `PaymentProvider` contract exported by `@suaveplan/payme…
- [mapping](./src/paypal/mapping.md) — Pure mapping utilities used by `PayPalProvider` to bridge the PayPal Orders v2 + Payments Captures Refund wire shapes i…
- [paypal](./src/paypal/paypal.md) — `PayPalProvider` is the PayPal-flavoured implementation of the `PaymentProvider` contract exported by `@suaveplan/payme…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/payment-provider-paypal @suaveplan/payment-providers @suaveplan/payment
```
## Quick start
```ts
import { ProviderRegistry } from "@suaveplan/payment-providers";
import { PayPalProvider } from "@suaveplan/payment-provider-paypal";
const registry = new ProviderRegistry();
registry.register(
new PayPalProvider({
clientId: process.env.PAYPAL_CLIENT_ID ?? "",
clientSecret: process.env.PAYPAL_CLIENT_SECRET ?? "",
sandbox: true,
})
);
const paypal = registry.get("paypal");
const intent = await paypal.createIntent({
amount: { amount: 5000n, currency: "USD" },
description: "Order #1234",
captureMethod: "automatic",
idempotencyKey: "order-1234",
});
console.log(intent.id, intent.status);
```
## Features
- `class PayPalProvider implements PaymentProvider` with literal-typed
`name: "paypal"`
- OAuth2 client-credentials token cache with 60 s clock-skew buffer,
refreshed transparently on expiry
- Injectable `now: NowFn`, `base64Encode: Base64EncodeFn`, and
`fetch: FetchLike` for universal determinism and testability
- `sandbox` flag → `https://api-m.sandbox.paypal.com`; explicit `baseUrl`
always wins
- `MoneyLike` bigint minor-unit amounts marshalled to PayPal's major-unit
decimal-string format
- `PayPal-Request-Id` idempotency-header support on `createIntent` and
`refund`
- Exhaustive PayPal-status → `PaymentStatus` / `RefundStatus`
mapping; unknown statuses map to `failed`
- Structured `PaymentProviderError` (from `@suaveplan/payment-providers`)
on every failure path — `OAUTH_TOKEN_FAILURE`, `HTTP_ERROR`, `TIMEOUT`,
`PARSE_ERROR` — always with `provider: "paypal"`
- Universal: zero Node / browser / React imports; `sideEffects: false`
- 100% Istanbul coverage; sibling `.md` per implementation file
## Modules
- [`src/paypal/paypal.md`](./src/paypal/paypal.md) — `PayPalProvider`
class, `PayPalConfig` interface, lifecycle methods, OAuth token cache
- [`src/paypal/mapping.md`](./src/paypal/mapping.md) — pure helpers:
`mapStatus`, `mapRefundStatus`, `parseAmount`, `formatAmount`,
`mapOrder`
## Foundation
This package depends on
[`@suaveplan/payment-providers`](../payment-providers/README.md) for:
- `httpRequest`, `composeAbortSignal`, `FetchLike`
- `OAuthConfig`, `NowFn`, `Base64EncodeFn`
- `PaymentProviderError`, `PAYMENT_PROVIDER_CODES`, `oauthTokenFailure`
and on [`@suaveplan/payment`](../payment/README.md) for the structural
`PaymentProvider`, `PaymentIntent`, `Refund`, `PaymentStatus`,
`RefundStatus`, `CreatePaymentIntentInput`, and `RefundInput` types.
## Related sibling packages
- `@suaveplan/payment-provider-stripe`
- `@suaveplan/payment-provider-square`
- `@suaveplan/payment-provider-mollie`
- `@suaveplan/payment-provider-adyen`
All four follow the same pattern: depend on the slim core, expose a
single `*Provider` class, register cleanly into `ProviderRegistry`.
## License
MIT

View File

@@ -0,0 +1,62 @@
{
"name": "@suaveplan/payment-provider-paypal",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "PayPal payment provider for @suaveplan/payment-providers. Implements the PaymentProvider interface against the PayPal Orders v2 + Payments Captures Refund APIs, with an OAuth2 client-credentials token cache, injectable now/base64Encode/FetchLike seams, sandbox-endpoint shortcut, MoneyLike bigint marshalling, and structured PaymentProviderError surfaces. Universal — runs unchanged on Node, Bun, browsers, and Web Workers.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
},
"./paypal/paypal": {
"import": { "types": "./dist/paypal/paypal.d.ts", "default": "./dist/paypal/paypal.js" },
"require": { "types": "./dist/paypal/paypal.d.ts", "default": "./dist/paypal/paypal.js" }
},
"./paypal/mapping": {
"import": { "types": "./dist/paypal/mapping.d.ts", "default": "./dist/paypal/mapping.js" },
"require": { "types": "./dist/paypal/mapping.d.ts", "default": "./dist/paypal/mapping.js" }
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/payment": "workspace:^",
"@suaveplan/payment-providers": "workspace:^",
"tslib": "catalog:build"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,20 @@
export {
formatAmount,
mapOrder,
mapRefundStatus,
mapStatus,
parseAmount,
} from "./paypal/mapping.js";
export { PayPalProvider } from "./paypal/paypal.js";
export type {
CachedToken,
PayPalAmount,
PayPalCapture,
PayPalConfig,
PayPalEmbeddedRefund,
PayPalOrder,
PayPalPayments,
PayPalPurchaseUnit,
PayPalRefund,
PayPalTokenResponse,
} from "./paypal/paypal.types.js";

View File

@@ -0,0 +1,20 @@
export {
formatAmount,
mapOrder,
mapRefundStatus,
mapStatus,
parseAmount,
} from "./mapping.js";
export { PayPalProvider } from "./paypal.js";
export type {
CachedToken,
PayPalAmount,
PayPalCapture,
PayPalConfig,
PayPalEmbeddedRefund,
PayPalOrder,
PayPalPayments,
PayPalPurchaseUnit,
PayPalRefund,
PayPalTokenResponse,
} from "./paypal.types.js";

View File

@@ -0,0 +1,146 @@
# PayPal mapping helpers
## Purpose
Pure mapping utilities used by `PayPalProvider` to bridge the PayPal Orders
v2 + Payments Captures Refund wire shapes into the suaveplan-canonical
`PaymentIntent` and `RefundStatus` types. Splitting them out of the
provider class keeps the lifecycle methods focused on transport and lets
the mapping rules be reviewed and tested in isolation. Every function is
synchronous, side-effect-free, and total over the documented PayPal
inputs (unknown statuses fall through to `failed`, missing decimals fall
back to `00`).
## Features
- `mapStatus(ppStatus)` — exhaustive PayPal-order-status → suaveplan
`PaymentStatus` mapping; unknown statuses (including the empty string)
map to `failed`
- `mapRefundStatus(status)` — PayPal-refund-status → suaveplan
`RefundStatus` (`COMPLETED` → `succeeded`, `PENDING` → `pending`,
everything else → `failed`)
- `parseAmount(value)` — parses a PayPal major-unit decimal string into
a `bigint` count of minor units (`"50.00"` → `5000n`, `"500"` → `50000n`)
- `formatAmount(minorUnits)` — formats a non-negative `bigint` minor-unit
amount into a PayPal major-unit decimal string with two fractional
digits (`5000n` → `"50.00"`)
- `mapOrder(order)` — full PayPal Orders v2 → `PaymentIntent` projection,
summing every nested `payments.captures[].amount.value` into
`capturedAmount`
## Usage: Basic
```ts
import {
formatAmount,
mapStatus,
parseAmount,
} from "@suaveplan/payment-provider-paypal/paypal/paypal";
// (Re-exported via the package root.)
const status = mapStatus("COMPLETED"); // "captured"
const minor = parseAmount("12.34"); // 1234n
const wire = formatAmount(5000n); // "50.00"
```
## Usage: Advanced
`mapOrder` is the round-trip function — it consumes a raw PayPal Orders v2
JSON shape and returns the `PaymentIntent` the rest of the
codebase consumes:
```ts
import type { PayPalOrder } from "@suaveplan/payment-provider-paypal";
import { mapOrder } from "@suaveplan/payment-provider-paypal/paypal/paypal";
const raw: PayPalOrder = {
id: "ORDER_42",
status: "COMPLETED",
intent: "AUTHORIZE",
purchase_units: [
{
amount: { currency_code: "EUR", value: "99.99" },
payments: {
captures: [
{
id: "CAP1",
amount: { value: "50.00", currency_code: "EUR" },
status: "COMPLETED",
},
{
id: "CAP2",
amount: { value: "49.99", currency_code: "EUR" },
status: "COMPLETED",
},
],
},
},
],
create_time: "2026-05-17T00:00:00Z",
update_time: "2026-05-17T00:00:01Z",
};
const intent = mapOrder(raw);
intent.status; // "captured"
intent.amount; // { amount: 9999n, currency: "EUR" }
intent.capturedAmount; // { amount: 9999n, currency: "EUR" }
intent.captureMethod; // "manual" (because intent === "AUTHORIZE")
```
## API Reference
### `mapStatus(ppStatus: string): PaymentStatus`
| PayPal status | Returns |
|---|---|
| `CREATED` / `SAVED` | `"created"` |
| `APPROVED` | `"authorized"` |
| `VOIDED` | `"canceled"` |
| `COMPLETED` | `"captured"` |
| `PAYER_ACTION_REQUIRED` | `"pending"` |
| anything else | `"failed"` |
### `mapRefundStatus(status: string): RefundStatus`
| PayPal refund status | Returns |
|---|---|
| `COMPLETED` | `"succeeded"` |
| `PENDING` | `"pending"` |
| anything else | `"failed"` |
### `parseAmount(value: string): bigint`
Returns the minor-unit count. Splits on the first `.`, pads the
fractional half to exactly two digits (zero-decimal currencies like JPY
have no `.` separator and pad to `"00"`), then composes
`BigInt(major) * 100n + BigInt(minorPadded)`.
### `formatAmount(minorUnits: bigint): string`
Returns `"<major>.<minor>"` where `minor` is zero-padded to two digits.
`MoneyLike.amount` is non-negative by contract, so no sign
handling is performed.
### `mapOrder(order: PayPalOrder): PaymentIntent`
Returns the full `PaymentIntent` derived from a PayPal Orders v2
response. The first purchase unit drives `amount` and currency; all
captures across that unit sum into `capturedAmount`. `refundedAmount`
is always reported as `0n` here — the provider's `refund` method
returns standalone `Refund` records rather than nested refund totals.
## Implementation Notes
- All functions are synchronous and have no I/O, no logging, and no
observable side effects. They are safe to call from any tier.
- `mapOrder` deliberately omits PayPal's nested `payments.refunds[]`
entries from the `PaymentIntent.refunds` list — that surface is
reserved for future expansion when `@suaveplan/payment` adds a
refund-list field to `PaymentIntent`.
- The default currency `"USD"` is only reached when PayPal returns an
order with no purchase units. That happens for void responses and for
malformed test fixtures; production payment-intent responses always
carry a purchase unit.
- `formatAmount` and `parseAmount` are inverses for any non-negative
minor-unit `bigint` ≤ `2^53` (PayPal max). Property-style round-trip
tests live in `paypal.test.ts`.

View File

@@ -0,0 +1,93 @@
import type {
PaymentIntent,
PaymentStatus,
RefundStatus,
} from "@suaveplan/payment";
import type { PayPalOrder } from "./paypal.types.js";
/** Maps a PayPal Orders v2 status to the genesis `PaymentStatus` union. */
export function mapStatus(ppStatus: string): PaymentStatus {
switch (ppStatus) {
case "CREATED":
case "SAVED":
return "created";
case "APPROVED":
return "authorized";
case "VOIDED":
return "canceled";
case "COMPLETED":
return "captured";
case "PAYER_ACTION_REQUIRED":
return "pending";
default:
return "failed";
}
}
/** Maps a PayPal refund status to the genesis `RefundStatus` union. */
export function mapRefundStatus(status: string): RefundStatus {
if (status === "COMPLETED") {
return "succeeded";
}
if (status === "PENDING") {
return "pending";
}
return "failed";
}
/**
* Parses a PayPal major-unit decimal-string amount into a `bigint` count
* of minor units. PayPal omits the decimal separator for zero-decimal
* currencies (JPY, KRW, …), in which case `minor` is `undefined` and we
* fall back to "00".
*/
export function parseAmount(value: string): bigint {
// `String.prototype.split` always returns a non-empty array — even for
// the empty string it returns `[""]` — so destructuring `[major, minor]`
// is total.
const [major, minor] = value.split(".");
const minorPadded = (minor ?? "00").padEnd(2, "0").slice(0, 2);
return BigInt(major as string) * 100n + BigInt(minorPadded);
}
/**
* Formats a non-negative `bigint` count of minor units into a PayPal
* major-unit decimal string with two fractional digits. Genesis
* `MoneyLike.amount` values for payment intents and refunds are
* non-negative by contract.
*/
export function formatAmount(minorUnits: bigint): string {
const major = minorUnits / 100n;
const minor = minorUnits % 100n;
const minorStr = minor.toString().padStart(2, "0");
return `${major.toString()}.${minorStr}`;
}
/**
* Maps a PayPal Orders v2 order to the genesis `PaymentIntent` shape.
* Sums every `purchase_units[].payments.captures[].amount.value` into
* `capturedAmount`, defaults the currency to USD when no purchase unit is
* present, and derives `captureMethod` from the order `intent`
* (`AUTHORIZE` → manual, anything else → automatic).
*/
export function mapOrder(order: PayPalOrder): PaymentIntent {
const unit = order.purchase_units[0];
const currency = unit?.amount.currency_code ?? "USD";
const totalAmount = unit ? parseAmount(unit.amount.value) : 0n;
const captures = unit?.payments?.captures ?? [];
const capturedAmount = captures.reduce(
(sum, capture) => sum + parseAmount(capture.amount.value),
0n
);
return {
id: order.id,
status: mapStatus(order.status),
amount: { amount: totalAmount, currency },
capturedAmount: { amount: capturedAmount, currency },
refundedAmount: { amount: 0n, currency },
captureMethod: order.intent === "AUTHORIZE" ? "manual" : "automatic",
refunds: [],
createdAt: order.create_time,
updatedAt: order.update_time,
};
}

View File

@@ -0,0 +1,189 @@
import { PAYMENT_PROVIDER_CODES } from "@suaveplan/payment-providers";
import { FakeClock } from "@suaveplan/testing";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
baseOrder,
jsonResponse,
make,
PROD,
recorder,
TOKEN_RESPONSE,
TOKEN_RESPONSE_REFRESH,
} from "../../tests/fixtures/paypal-recorder.js";
describe("PayPalProvider — OAuth token cache", () => {
it("reuses a cached OAuth token across method calls", {
timeout: 5000,
}, async () => {
const rec = recorder((r) => {
if (r.url.endsWith("/v1/oauth2/token")) {
return jsonResponse(TOKEN_RESPONSE);
}
return jsonResponse(baseOrder());
});
const provider = make({ fetch: rec.fetch });
await provider.createIntent({
amount: { amount: 1000n, currency: "USD" },
});
await provider.createIntent({
amount: { amount: 2000n, currency: "USD" },
});
const tokenCalls = rec.calls.filter((c) =>
c.url.endsWith("/v1/oauth2/token")
);
const orderCalls = rec.calls.filter((c) =>
c.url.endsWith("/v2/checkout/orders")
);
expect(tokenCalls).toHaveLength(1);
expect(orderCalls).toHaveLength(2);
});
it("refreshes the token after the cached expiry passes", {
timeout: 5000,
}, async () => {
const clock = new FakeClock({ startTime: 1_000_000 });
let tokenIdx = 0;
const rec = recorder((r) => {
if (r.url.endsWith("/v1/oauth2/token")) {
tokenIdx += 1;
return jsonResponse(
tokenIdx === 1 ? TOKEN_RESPONSE : TOKEN_RESPONSE_REFRESH
);
}
return jsonResponse(baseOrder());
});
const provider = make({ fetch: rec.fetch, now: () => clock.now() });
await provider.createIntent({
amount: { amount: 100n, currency: "USD" },
});
// expires_in 3600s = 3,600,000ms, minus 60s skew = 3,540,000ms
clock.advance(3_540_001);
await provider.createIntent({
amount: { amount: 200n, currency: "USD" },
});
const tokenCalls = rec.calls.filter((c) =>
c.url.endsWith("/v1/oauth2/token")
);
expect(tokenCalls).toHaveLength(2);
const orderCalls = rec.calls.filter(
(c) => c.url.endsWith("/v2/checkout/orders") && c.method === "POST"
);
expect(orderCalls[1]?.headers.Authorization).toBe("Bearer tok_xyz");
});
it("derives the Basic header via the injected base64 encoder", {
timeout: 5000,
}, async () => {
const rec = recorder((r) =>
r.url.endsWith("/v1/oauth2/token")
? jsonResponse(TOKEN_RESPONSE)
: jsonResponse(baseOrder())
);
const provider = make({
fetch: rec.fetch,
base64Encode: (input) => `X-${input}`,
});
await provider.createIntent({
amount: { amount: 100n, currency: "USD" },
});
const tokenCall = rec.calls.find((c) =>
c.url.endsWith("/v1/oauth2/token")
);
expect(tokenCall?.headers.Authorization).toBe(
"Basic X-client_x:secret_y"
);
expect(tokenCall?.body).toBe("grant_type=client_credentials");
expect(tokenCall?.headers["Content-Type"]).toBe(
"application/x-www-form-urlencoded"
);
});
});
describe("PayPalProvider — OAuth failure modes", () => {
it("raises OAUTH_TOKEN_FAILURE when the token endpoint returns a non-2xx", {
timeout: 5000,
}, async () => {
const rec = recorder((r) =>
r.url.endsWith("/v1/oauth2/token")
? jsonResponse({ error: "invalid_client" }, 401)
: jsonResponse(baseOrder())
);
const provider = make({ fetch: rec.fetch });
await expect(
provider.createIntent({
amount: { amount: 100n, currency: "USD" },
})
).rejects.toMatchObject({
code: PAYMENT_PROVIDER_CODES.OAUTH_TOKEN_FAILURE,
provider: "paypal",
url: `${PROD}/v1/oauth2/token`,
});
const orderCalls = rec.calls.filter((c) =>
c.url.includes("/v2/checkout/orders")
);
expect(orderCalls).toEqual([]);
});
it("raises OAUTH_TOKEN_FAILURE when the token endpoint throws", {
timeout: 5000,
}, async () => {
const transport = new Error("ECONNRESET");
const rec = recorder((r) => {
if (r.url.endsWith("/v1/oauth2/token")) {
throw transport;
}
return jsonResponse(baseOrder());
});
const provider = make({ fetch: rec.fetch });
await expect(
provider.createIntent({
amount: { amount: 100n, currency: "USD" },
})
).rejects.toMatchObject({
code: PAYMENT_PROVIDER_CODES.OAUTH_TOKEN_FAILURE,
provider: "paypal",
});
});
});
describe("PayPalProvider — endpoint resolution", () => {
it("defaults to the sandbox endpoint when sandbox flag is true", {
timeout: 5000,
}, async () => {
const rec = recorder((r) =>
r.url.includes("/v1/oauth2/token")
? jsonResponse(TOKEN_RESPONSE)
: jsonResponse(baseOrder())
);
const provider = make({ fetch: rec.fetch, sandbox: true });
await provider.createIntent({
amount: { amount: 100n, currency: "USD" },
});
for (const call of rec.calls) {
expect(
call.url.startsWith("https://api-m.sandbox.paypal.com")
).toBe(true);
}
});
it("explicit baseUrl overrides the sandbox flag", {
timeout: 5000,
}, async () => {
const rec = recorder((r) =>
r.url.includes("/v1/oauth2/token")
? jsonResponse(TOKEN_RESPONSE)
: jsonResponse(baseOrder())
);
const provider = make({
fetch: rec.fetch,
sandbox: true,
baseUrl: "https://override.example",
});
await provider.createIntent({
amount: { amount: 100n, currency: "USD" },
});
for (const call of rec.calls) {
expect(call.url.startsWith("https://override.example")).toBe(true);
}
});
});

View File

@@ -0,0 +1,203 @@
# PayPalProvider
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## 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<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.

View File

@@ -0,0 +1,458 @@
import type { PaymentProvider } from "@suaveplan/payment";
import {
PAYMENT_PROVIDER_CODES,
PaymentProviderError,
} from "@suaveplan/payment-providers";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
baseOrder,
baseRefund,
jsonResponse,
make,
PROD,
recorder,
TOKEN_RESPONSE,
} from "../../tests/fixtures/paypal-recorder.js";
import { PayPalProvider } from "./paypal.js";
describe("PayPalProvider — lifecycle methods", () => {
it("has literal name 'paypal'", { timeout: 5000 }, () => {
const provider = make({
fetch: recorder(() => jsonResponse({})).fetch,
});
expect(provider.name).toBe("paypal");
const literal: "paypal" = provider.name;
expect(literal).toBe("paypal");
});
it("satisfies the PaymentProvider structural contract", {
timeout: 5000,
}, () => {
const provider = make({
fetch: recorder(() => jsonResponse({})).fetch,
});
const asContract: PaymentProvider = provider;
expect(typeof asContract.createIntent).toBe("function");
expect(typeof asContract.confirmIntent).toBe("function");
expect(typeof asContract.captureIntent).toBe("function");
expect(typeof asContract.cancelIntent).toBe("function");
expect(typeof asContract.refund).toBe("function");
expect(typeof asContract.getIntent).toBe("function");
});
it("createIntent with manual captureMethod posts an AUTHORIZE order", {
timeout: 5000,
}, async () => {
const rec = recorder((r) =>
r.url.endsWith("/v1/oauth2/token")
? jsonResponse(TOKEN_RESPONSE)
: jsonResponse(baseOrder())
);
const provider = make({ fetch: rec.fetch });
const intent = await provider.createIntent({
amount: { amount: 5000n, currency: "USD" },
description: "Manual cap",
captureMethod: "manual",
idempotencyKey: "idem-1",
});
expect(intent.id).toBe("ORDER_123");
expect(intent.status).toBe("created");
const orderCall = rec.calls.find((c) =>
c.url.endsWith("/v2/checkout/orders")
);
expect(orderCall?.method).toBe("POST");
expect(orderCall?.headers["PayPal-Request-Id"]).toBe("idem-1");
expect(orderCall?.headers.Authorization).toBe("Bearer tok_abc");
const body = JSON.parse(orderCall?.body ?? "{}") as {
intent: string;
purchase_units: Array<{
amount: { value: string; currency_code: string };
description?: string;
}>;
};
expect(body.intent).toBe("AUTHORIZE");
expect(body.purchase_units[0]?.amount.value).toBe("50.00");
expect(body.purchase_units[0]?.amount.currency_code).toBe("USD");
expect(body.purchase_units[0]?.description).toBe("Manual cap");
});
it("createIntent with automatic captureMethod posts a CAPTURE order without an idempotency header", {
timeout: 5000,
}, async () => {
const rec = recorder((r) =>
r.url.endsWith("/v1/oauth2/token")
? jsonResponse(TOKEN_RESPONSE)
: jsonResponse(baseOrder())
);
const provider = make({ fetch: rec.fetch });
await provider.createIntent({
amount: { amount: 1000n, currency: "EUR" },
captureMethod: "automatic",
});
const orderCall = rec.calls.find((c) =>
c.url.endsWith("/v2/checkout/orders")
);
expect(orderCall?.headers["PayPal-Request-Id"]).toBeUndefined();
const body = JSON.parse(orderCall?.body ?? "{}") as {
intent: string;
purchase_units: Array<{
amount: { value: string; currency_code: string };
description?: string;
}>;
};
expect(body.intent).toBe("CAPTURE");
expect(body.purchase_units[0]?.amount.value).toBe("10.00");
expect(body.purchase_units[0]?.amount.currency_code).toBe("EUR");
expect(body.purchase_units[0]?.description).toBeUndefined();
});
it("confirmIntent posts authorize and maps APPROVED to authorized", {
timeout: 5000,
}, async () => {
const rec = recorder((r) => {
if (r.url.endsWith("/v1/oauth2/token")) {
return jsonResponse(TOKEN_RESPONSE);
}
return jsonResponse({ ...baseOrder(), status: "APPROVED" });
});
const provider = make({ fetch: rec.fetch });
const intent = await provider.confirmIntent("ORDER_X");
expect(intent.status).toBe("authorized");
const authCall = rec.calls.find((c) => c.url.includes("/authorize"));
expect(authCall?.method).toBe("POST");
expect(authCall?.url).toBe(
`${PROD}/v2/checkout/orders/ORDER_X/authorize`
);
});
it("captureIntent sums every purchase_unit capture into capturedAmount", {
timeout: 5000,
}, async () => {
const captured = {
...baseOrder(),
status: "COMPLETED",
purchase_units: [
{
amount: { currency_code: "USD", value: "50.00" },
payments: {
captures: [
{
id: "CAP1",
amount: {
value: "25.00",
currency_code: "USD",
},
status: "COMPLETED",
},
{
id: "CAP2",
amount: {
value: "25.00",
currency_code: "USD",
},
status: "COMPLETED",
},
],
},
},
],
};
const rec = recorder((r) =>
r.url.endsWith("/v1/oauth2/token")
? jsonResponse(TOKEN_RESPONSE)
: jsonResponse(captured)
);
const provider = make({ fetch: rec.fetch });
const intent = await provider.captureIntent("ORDER_X");
expect(intent.status).toBe("captured");
expect(intent.capturedAmount.amount).toBe(5000n);
const captureCall = rec.calls.find((c) => c.url.includes("/capture"));
expect(captureCall?.method).toBe("POST");
});
it("cancelIntent voids the order and returns a canceled intent with injected-now timestamps", {
timeout: 5000,
}, async () => {
const rec = recorder((r) =>
r.url.endsWith("/v1/oauth2/token")
? jsonResponse(TOKEN_RESPONSE)
: new Response(null, { status: 204 })
);
const fixed = 1_700_000_000_000;
const provider = make({ fetch: rec.fetch, now: () => fixed });
const intent = await provider.cancelIntent("ORDER_X");
expect(intent.id).toBe("ORDER_X");
expect(intent.status).toBe("canceled");
expect(intent.amount.amount).toBe(0n);
expect(intent.capturedAmount.amount).toBe(0n);
expect(intent.refundedAmount.amount).toBe(0n);
expect(intent.captureMethod).toBe("automatic");
const stamp = new Date(fixed).toISOString();
expect(intent.createdAt).toBe(stamp);
expect(intent.updatedAt).toBe(stamp);
const voidCall = rec.calls.find((c) => c.url.includes("/void"));
expect(voidCall?.method).toBe("POST");
expect(voidCall?.url).toBe(`${PROD}/v2/checkout/orders/ORDER_X/void`);
});
it("refund with explicit amount and idempotency posts amount body and PayPal-Request-Id header", {
timeout: 5000,
}, async () => {
const rec = recorder((r) =>
r.url.endsWith("/v1/oauth2/token")
? jsonResponse(TOKEN_RESPONSE)
: jsonResponse(baseRefund())
);
const provider = make({ fetch: rec.fetch });
const refund = await provider.refund({
paymentIntentId: "CAP_X",
amount: { amount: 2000n, currency: "USD" },
idempotencyKey: "rfn-1",
});
expect(refund.id).toBe("REFUND_123");
expect(refund.paymentIntentId).toBe("CAP_X");
expect(refund.amount.amount).toBe(2000n);
expect(refund.amount.currency).toBe("USD");
expect(refund.status).toBe("succeeded");
const refundCall = rec.calls.find((c) => c.url.includes("/refund"));
expect(refundCall?.url).toBe(
`${PROD}/v2/payments/captures/CAP_X/refund`
);
expect(refundCall?.headers["PayPal-Request-Id"]).toBe("rfn-1");
const body = JSON.parse(refundCall?.body ?? "{}") as {
amount?: { currency_code: string; value: string };
};
expect(body.amount).toEqual({
currency_code: "USD",
value: "20.00",
});
});
it("refund without amount or idempotency posts an empty body and no idempotency header", {
timeout: 5000,
}, async () => {
const rec = recorder((r) =>
r.url.endsWith("/v1/oauth2/token")
? jsonResponse(TOKEN_RESPONSE)
: jsonResponse(baseRefund())
);
const provider = make({ fetch: rec.fetch });
const refund = await provider.refund({ paymentIntentId: "CAP_X" });
expect(refund.id).toBe("REFUND_123");
const refundCall = rec.calls.find((c) => c.url.includes("/refund"));
expect(refundCall?.headers["PayPal-Request-Id"]).toBeUndefined();
expect(refundCall?.body).toBe(JSON.stringify({}));
});
it("getIntent fetches the order and maps PayPal status to PaymentStatus", {
timeout: 5000,
}, async () => {
const rec = recorder((r) =>
r.url.endsWith("/v1/oauth2/token")
? jsonResponse(TOKEN_RESPONSE)
: jsonResponse({
...baseOrder(),
id: "ORDER_Y",
status: "COMPLETED",
})
);
const provider = make({ fetch: rec.fetch });
const intent = await provider.getIntent("ORDER_Y");
expect(intent.id).toBe("ORDER_Y");
expect(intent.status).toBe("captured");
expect(intent.capturedAmount.amount).toBe(0n);
const getCall = rec.calls.find((c) =>
c.url.endsWith("/v2/checkout/orders/ORDER_Y")
);
expect(getCall?.method).toBe("GET");
});
});
describe("PayPalProvider — status mapping", () => {
it("maps every documented PayPal order status to PaymentStatus", {
timeout: 5000,
}, async () => {
const cases: ReadonlyArray<readonly [string, string, string]> = [
["CREATED", "created", "CAPTURE"],
["SAVED", "created", "CAPTURE"],
["APPROVED", "authorized", "CAPTURE"],
["VOIDED", "canceled", "CAPTURE"],
["COMPLETED", "captured", "CAPTURE"],
["PAYER_ACTION_REQUIRED", "pending", "CAPTURE"],
["UNKNOWN_STATUS", "failed", "CAPTURE"],
// intent AUTHORIZE → captureMethod "manual"
["CREATED", "created", "AUTHORIZE"],
];
for (const [ppStatus, expected, intent] of cases) {
const rec = recorder((r) =>
r.url.endsWith("/v1/oauth2/token")
? jsonResponse(TOKEN_RESPONSE)
: jsonResponse({ ...baseOrder(), status: ppStatus, intent })
);
const provider = make({ fetch: rec.fetch });
const result = await provider.getIntent("ORDER_X");
expect(result.status).toBe(expected);
expect(result.captureMethod).toBe(
intent === "AUTHORIZE" ? "manual" : "automatic"
);
}
});
it("maps every documented PayPal refund status to RefundStatus", {
timeout: 5000,
}, async () => {
const cases: ReadonlyArray<readonly [string, string]> = [
["COMPLETED", "succeeded"],
["PENDING", "pending"],
["CANCELLED", "failed"],
];
for (const [ppStatus, expected] of cases) {
const rec = recorder((r) =>
r.url.endsWith("/v1/oauth2/token")
? jsonResponse(TOKEN_RESPONSE)
: jsonResponse({ ...baseRefund(), status: ppStatus })
);
const provider = make({ fetch: rec.fetch });
const refund = await provider.refund({ paymentIntentId: "CAP_X" });
expect(refund.status).toBe(expected);
}
});
it("mapOrder handles a missing purchase_units array", {
timeout: 5000,
}, async () => {
const rec = recorder((r) =>
r.url.endsWith("/v1/oauth2/token")
? jsonResponse(TOKEN_RESPONSE)
: jsonResponse({
id: "ORDER_EMPTY",
status: "CREATED",
intent: "CAPTURE",
purchase_units: [],
create_time: "2026-01-01T00:00:00Z",
update_time: "2026-01-01T00:00:00Z",
})
);
const provider = make({ fetch: rec.fetch });
const intent = await provider.getIntent("ORDER_EMPTY");
expect(intent.amount.amount).toBe(0n);
expect(intent.amount.currency).toBe("USD");
expect(intent.capturedAmount.amount).toBe(0n);
});
it("mapOrder handles a purchase_unit without a payments block", {
timeout: 5000,
}, async () => {
const rec = recorder((r) =>
r.url.endsWith("/v1/oauth2/token")
? jsonResponse(TOKEN_RESPONSE)
: jsonResponse({
id: "ORDER_NOPAY",
status: "CREATED",
intent: "CAPTURE",
purchase_units: [
{
amount: { currency_code: "GBP", value: "12.34" },
},
],
create_time: "2026-01-01T00:00:00Z",
update_time: "2026-01-01T00:00:00Z",
})
);
const provider = make({ fetch: rec.fetch });
const intent = await provider.getIntent("ORDER_NOPAY");
expect(intent.amount.amount).toBe(1234n);
expect(intent.amount.currency).toBe("GBP");
expect(intent.capturedAmount.amount).toBe(0n);
});
it("parseAmount handles values without a decimal separator", {
timeout: 5000,
}, async () => {
const rec = recorder((r) =>
r.url.endsWith("/v1/oauth2/token")
? jsonResponse(TOKEN_RESPONSE)
: jsonResponse({
...baseOrder(),
purchase_units: [
{
amount: { currency_code: "JPY", value: "500" },
},
],
})
);
const provider = make({ fetch: rec.fetch });
const intent = await provider.getIntent("ORDER_NODEC");
expect(intent.amount.amount).toBe(50000n);
expect(intent.amount.currency).toBe("JPY");
});
});
describe("PayPalProvider — error narrowing", () => {
it("every failure path narrows to PaymentProviderError with provider 'paypal'", {
timeout: 5000,
}, async () => {
// OAUTH_TOKEN_FAILURE path.
const rec1 = recorder(() => jsonResponse({ err: "no" }, 500));
const provider1 = make({ fetch: rec1.fetch });
const err1 = await provider1.getIntent("X").catch((e: unknown) => e);
expect(err1).toBeInstanceOf(PaymentProviderError);
expect((err1 as PaymentProviderError).provider).toBe("paypal");
expect((err1 as PaymentProviderError).code).toBe(
PAYMENT_PROVIDER_CODES.OAUTH_TOKEN_FAILURE
);
// HTTP_ERROR path (token OK, order call non-2xx).
const rec2 = recorder((r) =>
r.url.endsWith("/v1/oauth2/token")
? jsonResponse(TOKEN_RESPONSE)
: jsonResponse({ message: "Order not found" }, 404)
);
const provider2 = make({ fetch: rec2.fetch });
const err2 = await provider2
.getIntent("MISSING")
.catch((e: unknown) => e);
expect(err2).toBeInstanceOf(PaymentProviderError);
expect((err2 as PaymentProviderError).provider).toBe("paypal");
expect((err2 as PaymentProviderError).code).toBe(
PAYMENT_PROVIDER_CODES.HTTP_ERROR
);
// PARSE_ERROR path (token OK, order call returns malformed JSON).
const rec3 = recorder((r) =>
r.url.endsWith("/v1/oauth2/token")
? jsonResponse(TOKEN_RESPONSE)
: new Response("{not-json", { status: 200 })
);
const provider3 = make({ fetch: rec3.fetch });
const err3 = await provider3.getIntent("BAD").catch((e: unknown) => e);
expect(err3).toBeInstanceOf(PaymentProviderError);
expect((err3 as PaymentProviderError).provider).toBe("paypal");
expect((err3 as PaymentProviderError).code).toBe(
PAYMENT_PROVIDER_CODES.PARSE_ERROR
);
// TIMEOUT path.
const provider4 = new PayPalProvider({
clientId: "c",
clientSecret: "s",
fetch: async (url, init) => {
if (url.endsWith("/v1/oauth2/token")) {
return jsonResponse(TOKEN_RESPONSE);
}
return await new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () =>
reject(new DOMException("aborted", "AbortError"))
);
});
},
timeoutMs: 25,
});
const err4 = await provider4.getIntent("HANG").catch((e: unknown) => e);
expect(err4).toBeInstanceOf(PaymentProviderError);
expect((err4 as PaymentProviderError).provider).toBe("paypal");
expect((err4 as PaymentProviderError).code).toBe(
PAYMENT_PROVIDER_CODES.TIMEOUT
);
});
});

View File

@@ -0,0 +1,252 @@
import type {
CreatePaymentIntentInput,
PaymentIntent,
PaymentProvider,
Refund,
RefundInput,
} from "@suaveplan/payment";
import {
type Base64EncodeFn,
type FetchLike,
httpRequest,
type NowFn,
oauthTokenFailure,
} from "@suaveplan/payment-providers";
import {
formatAmount,
mapOrder,
mapRefundStatus,
parseAmount,
} from "./mapping.js";
import type {
CachedToken,
PayPalConfig,
PayPalOrder,
PayPalRefund,
PayPalTokenResponse,
} from "./paypal.types.js";
const DEFAULT_PRODUCTION_BASE_URL = "https://api-m.paypal.com";
const DEFAULT_SANDBOX_BASE_URL = "https://api-m.sandbox.paypal.com";
const DEFAULT_TIMEOUT_MS = 30_000;
const TOKEN_SKEW_MS = 60_000;
const PROVIDER_NAME = "paypal";
function defaultBase64(): Base64EncodeFn {
return (input) => globalThis.btoa(input);
}
function defaultNow(): NowFn {
return () => Date.now();
}
function resolveBaseUrl(config: PayPalConfig): string {
if (config.baseUrl !== undefined) {
return config.baseUrl;
}
return config.sandbox === true
? DEFAULT_SANDBOX_BASE_URL
: DEFAULT_PRODUCTION_BASE_URL;
}
/**
* PayPal payment provider implementing the slim `PaymentProvider` contract
* from `@suaveplan/payment` against the PayPal Orders v2 + Payments
* Captures Refund APIs. Uses an in-memory OAuth2 client-credentials token
* cache and the slim core's `httpRequest` helper for every HTTP call.
*
* Universal: zero `node:*` / `bun:*` / DOM / React imports. The OAuth
* Basic header derives via the injected `base64Encode` seam (defaulting to
* `globalThis.btoa`), and the token-cache TTL derives via the injected
* `now` seam (defaulting to `Date.now`), so the package runs unchanged on
* Node, Bun, browsers, and Web Workers.
*/
export class PayPalProvider implements PaymentProvider {
public readonly name = "paypal" as const;
private readonly clientId: string;
private readonly clientSecret: string;
private readonly baseUrl: string;
private readonly timeoutMs: number;
private readonly fetchImpl: FetchLike | undefined;
private readonly externalSignal: AbortSignal | undefined;
private readonly now: NowFn;
private readonly base64Encode: Base64EncodeFn;
private cachedToken: CachedToken | null = null;
public constructor(config: PayPalConfig) {
this.clientId = config.clientId;
this.clientSecret = config.clientSecret;
this.baseUrl = resolveBaseUrl(config);
this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
this.fetchImpl = config.fetch;
this.externalSignal = config.signal;
this.now = config.now ?? defaultNow();
this.base64Encode = config.base64Encode ?? defaultBase64();
}
public async createIntent(
input: CreatePaymentIntentInput
): Promise<PaymentIntent> {
const token = await this.acquireToken();
const body = {
intent: input.captureMethod === "manual" ? "AUTHORIZE" : "CAPTURE",
purchase_units: [
{
amount: {
currency_code: input.amount.currency,
value: formatAmount(input.amount.amount),
},
...(input.description !== undefined
? { description: input.description }
: {}),
},
],
};
const { data } = await this.callApi<PayPalOrder>({
method: "POST",
path: "/v2/checkout/orders",
token,
body,
idempotencyKey: input.idempotencyKey,
});
return mapOrder(data);
}
public async confirmIntent(intentId: string): Promise<PaymentIntent> {
const token = await this.acquireToken();
const { data } = await this.callApi<PayPalOrder>({
method: "POST",
path: `/v2/checkout/orders/${intentId}/authorize`,
token,
});
return mapOrder(data);
}
public async captureIntent(intentId: string): Promise<PaymentIntent> {
const token = await this.acquireToken();
const { data } = await this.callApi<PayPalOrder>({
method: "POST",
path: `/v2/checkout/orders/${intentId}/capture`,
token,
});
return mapOrder(data);
}
public async cancelIntent(intentId: string): Promise<PaymentIntent> {
const token = await this.acquireToken();
await this.callApi({
method: "POST",
path: `/v2/checkout/orders/${intentId}/void`,
token,
});
const stamp = new Date(this.now()).toISOString();
return {
id: intentId,
status: "canceled",
amount: { amount: 0n, currency: "USD" },
capturedAmount: { amount: 0n, currency: "USD" },
refundedAmount: { amount: 0n, currency: "USD" },
captureMethod: "automatic",
refunds: [],
createdAt: stamp,
updatedAt: stamp,
};
}
public async refund(input: RefundInput): Promise<Refund> {
const token = await this.acquireToken();
const body: Record<string, unknown> = {};
if (input.amount !== undefined) {
body["amount"] = {
currency_code: input.amount.currency,
value: formatAmount(input.amount.amount),
};
}
const { data } = await this.callApi<PayPalRefund>({
method: "POST",
path: `/v2/payments/captures/${input.paymentIntentId}/refund`,
token,
body,
idempotencyKey: input.idempotencyKey,
});
return {
id: data.id,
paymentIntentId: input.paymentIntentId,
amount: {
amount: parseAmount(data.amount.value),
currency: data.amount.currency_code,
},
status: mapRefundStatus(data.status),
createdAt: data.create_time,
};
}
public async getIntent(intentId: string): Promise<PaymentIntent> {
const token = await this.acquireToken();
const { data } = await this.callApi<PayPalOrder>({
method: "GET",
path: `/v2/checkout/orders/${intentId}`,
token,
});
return mapOrder(data);
}
private async callApi<T = unknown>(args: {
readonly method: "GET" | "POST" | "DELETE" | "PATCH";
readonly path: string;
readonly token: string;
readonly body?: unknown;
readonly idempotencyKey?: string | undefined;
}): Promise<{ readonly status: number; readonly data: T }> {
const headers: Record<string, string> = {
Authorization: `Bearer ${args.token}`,
};
if (args.idempotencyKey !== undefined) {
headers["PayPal-Request-Id"] = args.idempotencyKey;
}
return httpRequest<T>({
method: args.method,
url: `${this.baseUrl}${args.path}`,
headers,
...(args.body !== undefined ? { body: args.body } : {}),
fetch: this.fetchImpl,
signal: this.externalSignal,
timeoutMs: this.timeoutMs,
provider: PROVIDER_NAME,
});
}
private async acquireToken(): Promise<string> {
const current = this.cachedToken;
if (current !== null && this.now() < current.expiresAt) {
return current.accessToken;
}
const tokenUrl = `${this.baseUrl}/v1/oauth2/token`;
const basic = this.base64Encode(
`${this.clientId}:${this.clientSecret}`
);
try {
const { data } = await httpRequest<PayPalTokenResponse>({
method: "POST",
url: tokenUrl,
headers: {
Authorization: `Basic ${basic}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: "grant_type=client_credentials",
fetch: this.fetchImpl,
signal: this.externalSignal,
timeoutMs: this.timeoutMs,
provider: PROVIDER_NAME,
});
const acquiredAt = this.now();
this.cachedToken = {
accessToken: data.access_token,
expiresAt: acquiredAt + data.expires_in * 1000 - TOKEN_SKEW_MS,
};
return data.access_token;
} catch (cause) {
throw oauthTokenFailure(PROVIDER_NAME, tokenUrl, cause);
}
}
}

View File

@@ -0,0 +1,79 @@
import type { OAuthConfig } from "@suaveplan/payment-providers";
/**
* Constructor configuration for {@link PayPalProvider}. Extends the slim
* core's {@link OAuthConfig} with a single optional `sandbox` shortcut:
* when `true` and `baseUrl` is unset, the provider routes traffic to
* `https://api-m.sandbox.paypal.com`; otherwise it routes to
* `https://api-m.paypal.com`. An explicit `baseUrl` always wins.
*/
export interface PayPalConfig extends OAuthConfig {
/**
* When `true` and `baseUrl` is unset, the provider routes to the
* PayPal sandbox endpoint. Default `false` (production).
*/
readonly sandbox?: boolean;
}
/** PayPal Orders v2 `purchase_unit` amount object. */
export interface PayPalAmount {
readonly currency_code: string;
readonly value: string;
}
/** PayPal Orders v2 capture entry. */
export interface PayPalCapture {
readonly id: string;
readonly amount: PayPalAmount;
readonly status: string;
}
/** PayPal Orders v2 refund entry (nested under `payments.refunds`). */
export interface PayPalEmbeddedRefund {
readonly id: string;
readonly amount: PayPalAmount;
readonly status: string;
}
/** PayPal Orders v2 `purchase_unit.payments` block. */
export interface PayPalPayments {
readonly captures?: ReadonlyArray<PayPalCapture>;
readonly refunds?: ReadonlyArray<PayPalEmbeddedRefund>;
}
/** PayPal Orders v2 `purchase_unit`. */
export interface PayPalPurchaseUnit {
readonly amount: PayPalAmount;
readonly description?: string;
readonly payments?: PayPalPayments;
}
/** PayPal Orders v2 order wire shape. */
export interface PayPalOrder {
readonly id: string;
readonly status: string;
readonly intent: string;
readonly purchase_units: ReadonlyArray<PayPalPurchaseUnit>;
readonly create_time: string;
readonly update_time: string;
}
/** PayPal Payments-Captures-Refund response wire shape. */
export interface PayPalRefund {
readonly id: string;
readonly amount: PayPalAmount;
readonly status: string;
readonly create_time: string;
}
/** PayPal OAuth2 token endpoint response wire shape. */
export interface PayPalTokenResponse {
readonly access_token: string;
readonly expires_in: number;
}
/** Internal token-cache entry — `null` when no token is held. */
export interface CachedToken {
readonly accessToken: string;
readonly expiresAt: number;
}

View File

@@ -0,0 +1,80 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const PKG_ROOT = join(__dirname, "..");
const SRC_ROOT = join(PKG_ROOT, "src");
interface PackageJson {
readonly dependencies?: Record<string, string>;
readonly devDependencies?: Record<string, string>;
readonly peerDependencies?: Record<string, string>;
}
function walkTypescript(dir: string): string[] {
const entries = readdirSync(dir);
const files: string[] = [];
for (const entry of entries) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) {
files.push(...walkTypescript(full));
} else if (
entry.endsWith(".ts") &&
!entry.endsWith(".test.ts") &&
!entry.endsWith(".spec.ts")
) {
files.push(full);
}
}
return files;
}
describe("universal dep graph", () => {
it("declares only universal dependencies", { timeout: 5000 }, () => {
const pkg = JSON.parse(
readFileSync(join(PKG_ROOT, "package.json"), "utf8")
) as PackageJson;
const runtime = Object.keys(pkg.dependencies ?? {}).sort();
expect(runtime).toEqual(
[
"@suaveplan/error",
"@suaveplan/payment",
"@suaveplan/payment-providers",
"tslib",
].sort()
);
});
it("source imports only @suaveplan/* siblings, tslib, and node-free intrinsics", {
timeout: 5000,
}, () => {
// Match any "from 'X'" or "from \"X\"" import specifier.
const importPattern = /from\s+['"]([^'"]+)['"]/g;
const offenders: string[] = [];
for (const file of walkTypescript(SRC_ROOT)) {
const text = readFileSync(file, "utf8");
for (const match of text.matchAll(importPattern)) {
const spec = match[1] ?? "";
if (spec.startsWith("node:") || spec.startsWith("bun:")) {
offenders.push(`${file}: ${spec}`);
continue;
}
if (
spec === "fs" ||
spec === "path" ||
spec === "crypto" ||
spec === "stream" ||
spec === "net" ||
spec === "http" ||
spec === "https" ||
spec === "react" ||
spec === "react-dom"
) {
offenders.push(`${file}: ${spec}`);
}
}
}
expect(offenders).toEqual([]);
});
});

View File

@@ -0,0 +1,111 @@
import type { FetchLike } from "@suaveplan/payment-providers";
import { PayPalProvider } from "../../src/paypal/paypal.js";
export interface Recorded {
readonly method: string;
readonly url: string;
readonly headers: Record<string, string>;
readonly body: string | undefined;
}
export interface Recorder {
readonly fetch: FetchLike;
readonly calls: Recorded[];
}
export function recorder(
handler: (recorded: Recorded) => Response | Promise<Response>
): Recorder {
const calls: Recorded[] = [];
const fetchImpl: FetchLike = async (url, init) => {
const headers: Record<string, string> = {};
const raw = init?.headers as Record<string, string> | undefined;
if (raw !== undefined) {
for (const [k, v] of Object.entries(raw)) {
headers[k] = v;
}
}
const body = init?.body;
const bodyStr =
typeof body === "string"
? body
: body === undefined
? undefined
: String(body);
const recorded: Recorded = {
method: init?.method ?? "GET",
url,
headers,
body: bodyStr,
};
calls.push(recorded);
return handler(recorded);
};
return { fetch: fetchImpl, calls };
}
export function jsonResponse(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), {
status,
headers: { "content-type": "application/json" },
});
}
export const TOKEN_RESPONSE = {
access_token: "tok_abc",
expires_in: 3600,
};
export const TOKEN_RESPONSE_REFRESH = {
access_token: "tok_xyz",
expires_in: 3600,
};
export function baseOrder(): Record<string, unknown> {
return {
id: "ORDER_123",
status: "CREATED",
intent: "CAPTURE",
purchase_units: [
{
amount: { currency_code: "USD", value: "50.00" },
description: "Test order",
payments: { captures: [], refunds: [] },
},
],
create_time: "2026-01-01T00:00:00Z",
update_time: "2026-01-01T00:00:00Z",
};
}
export function baseRefund(): Record<string, unknown> {
return {
id: "REFUND_123",
amount: { value: "20.00", currency_code: "USD" },
status: "COMPLETED",
create_time: "2026-01-02T00:00:00Z",
};
}
export interface MakeArgs {
readonly fetch: FetchLike;
readonly now?: () => number;
readonly base64Encode?: (input: string) => string;
readonly baseUrl?: string;
readonly sandbox?: boolean;
}
export function make(args: MakeArgs): PayPalProvider {
return new PayPalProvider({
clientId: "client_x",
clientSecret: "secret_y",
fetch: args.fetch,
...(args.now !== undefined ? { now: args.now } : {}),
...(args.base64Encode !== undefined
? { base64Encode: args.base64Encode }
: {}),
...(args.baseUrl !== undefined ? { baseUrl: args.baseUrl } : {}),
...(args.sandbox !== undefined ? { sandbox: args.sandbox } : {}),
});
}
export const PROD = "https://api-m.paypal.com";

View File

@@ -0,0 +1,6 @@
// Polyfills for the payment-provider-paypal test environment — none required.
// The package consumes the slim core's `FetchLike` seam and the `Base64EncodeFn`
// injectable; every test passes an explicit recorder closure, so no platform
// `fetch` or `btoa` dependency is needed. This file exists to mirror the
// sibling-package layout and to give vitest a stable setup-file path.
export {};

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,35 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/payment-provider-paypal",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/payment",
"@suaveplan/payment-providers",
"tslib",
],
},
},
});

View File

@@ -0,0 +1,27 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/*.bench.ts",
"src/__benchmarks__/**",
"tests/**",
],
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
},
testTimeout: 30000,
},
});