Move docs/ package mirror into document-navigator plugin

Relocate the ~6k-file docs tree from the repo root into
plugins/document-navigator/docs/ so the plugin is self-contained and
portable. docs/superpowers/ (specs and plans) stays at the repo root.

Moved via cp+rm rather than git mv because directory rename() fails with
EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the
identical-content files at commit time.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 01:10:00 +01:00
parent fe3fedf42e
commit 9bf56a806b
6022 changed files with 12437 additions and 0 deletions

View File

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

View File

@@ -0,0 +1,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.