first commit
This commit is contained in:
146
docs/money/payment/payment-provider-paypal/src/paypal/mapping.md
Normal file
146
docs/money/payment/payment-provider-paypal/src/paypal/mapping.md
Normal 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`.
|
||||
203
docs/money/payment/payment-provider-paypal/src/paypal/paypal.md
Normal file
203
docs/money/payment/payment-provider-paypal/src/paypal/paypal.md
Normal 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.
|
||||
Reference in New Issue
Block a user