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,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.