Initial Commit
This commit is contained in:
107
packages/money/payment/payment-provider-paypal/README.md
Normal file
107
packages/money/payment/payment-provider-paypal/README.md
Normal 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
|
||||
|
||||
62
packages/money/payment/payment-provider-paypal/package.json
Normal file
62
packages/money/payment/payment-provider-paypal/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
20
packages/money/payment/payment-provider-paypal/src/index.ts
Normal file
20
packages/money/payment/payment-provider-paypal/src/index.ts
Normal 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";
|
||||
@@ -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";
|
||||
@@ -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`.
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
111
packages/money/payment/payment-provider-paypal/tests/fixtures/paypal-recorder.ts
vendored
Normal file
111
packages/money/payment/payment-provider-paypal/tests/fixtures/paypal-recorder.ts
vendored
Normal 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";
|
||||
@@ -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 {};
|
||||
12
packages/money/payment/payment-provider-paypal/tsconfig.json
Normal file
12
packages/money/payment/payment-provider-paypal/tsconfig.json
Normal 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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user