Initial Commit
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
# Add @suaveplan/payment-provider-paypal — PayPal adapter for the slim payment-providers core
|
||||
|
||||
**Package**: `@suaveplan/payment-provider-paypal`
|
||||
**Status**: PROPOSED
|
||||
**Type**: new-package
|
||||
|
||||
- **Spec delta**: ./specs/payment-provider-paypal/spec.md
|
||||
|
||||
## Why
|
||||
|
||||
The 2026-05-17 slim-down (`openspec/archive/2026-05-17-slim-payment-providers/`)
|
||||
extracted five concrete provider adapters out of `@suaveplan/payment-providers`
|
||||
and committed them to land as sibling packages
|
||||
(`@suaveplan/payment-provider-{stripe,paypal,square,mollie,adyen}`).
|
||||
|
||||
This change ships the PayPal sibling. Without it, consumers that want to talk
|
||||
to the PayPal Orders v2 API have to either depend on the upstream bundled
|
||||
package (no longer published in genesis) or hand-roll OAuth2 client-credentials
|
||||
plumbing, request signing, status mapping, and the bigint-amount marshalling
|
||||
PayPal requires. The genesis stance — and the foundation contract the slim
|
||||
core was designed around — is that each adapter ships as its own
|
||||
publish unit on the slim foundation.
|
||||
|
||||
The PayPal adapter additionally requires injectable seams that the other
|
||||
adapters do not: a `NowFn` for the OAuth token-cache TTL (so tests can pin
|
||||
expiry without freezing global `Date`) and a `Base64EncodeFn` for the
|
||||
`Authorization: Basic …` header (so the universal-tier package runs unchanged
|
||||
on Bun, Node, browsers, and Web Workers despite the underlying base-64
|
||||
primitive being named differently in each — `btoa`, `Buffer.from(…).toString("base64")`,
|
||||
WebCrypto, etc.). The slim core already exports both seams; this package
|
||||
wires them at the constructor boundary so consumers and tests can override
|
||||
either independently.
|
||||
|
||||
## What changes
|
||||
|
||||
### ADDED
|
||||
|
||||
- New package `@suaveplan/payment-provider-paypal` at `packages/universal/payment-provider-paypal/`.
|
||||
- `PayPalProvider implements PaymentProvider` — class adapter with `name: "paypal"`.
|
||||
- `PayPalConfig extends OAuthConfig` — re-exported config interface with the
|
||||
PayPal-specific `sandbox` flag (`true` flips the default base URL to
|
||||
`https://api-m.sandbox.paypal.com`).
|
||||
- OAuth2 client-credentials token cache — `httpRequest`-driven token fetch with
|
||||
the `Basic <base64(clientId:clientSecret)>` header; cached in-memory with the
|
||||
PayPal-supplied `expires_in` value (minus a 60 s skew); refreshed on demand.
|
||||
Token-fetch failures raise `PaymentProviderError(OAUTH_TOKEN_FAILURE)` with
|
||||
the cause attached.
|
||||
- `PayPalProvider.createIntent` / `confirmIntent` / `captureIntent` /
|
||||
`cancelIntent` / `refund` / `getIntent` — full `PaymentProvider`
|
||||
implementation against the PayPal Orders v2 + Payments Captures Refund
|
||||
endpoints, using the cached bearer token, surfacing `PayPal-Request-Id`
|
||||
for idempotency keys, mapping PayPal statuses to genesis `PaymentStatus` /
|
||||
`RefundStatus`, and marshalling the `MoneyLike` `bigint` minor-unit amounts
|
||||
to the major-unit decimal string PayPal accepts (`5000n` → `"50.00"`).
|
||||
- 6-entry subpath exports: `.`, `./paypal/paypal`, plus `./package.json`.
|
||||
- Sibling docs `src/paypal/paypal.md` (≥200 words, basic + advanced examples,
|
||||
full API reference) plus `README.md` listing every co-located `.md` under
|
||||
`## Modules`.
|
||||
- 100 / 100 / 100 / 100 Istanbul coverage from day one.
|
||||
|
||||
### MODIFIED
|
||||
|
||||
- `STATUS.md` — add the new package to the `Completed` table.
|
||||
- `SUMMARY.md` — append the new package paragraph.
|
||||
- `MANIFEST.md` — auto-regenerate after the package lands.
|
||||
- `CLAUDE.md` — no entry; the package is one of the sibling provider plugins
|
||||
already named in the post-slim package listing and is documented through
|
||||
`MANIFEST.md` / `STATUS.md` / `SUMMARY.md` per the CLAUDE.md note "Detailed
|
||||
package descriptions live in each package's `README.md` and co-located
|
||||
`.md` files".
|
||||
|
||||
### REMOVED
|
||||
|
||||
- None — purely additive.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- The other four sibling adapters (`payment-provider-stripe`, `-square`,
|
||||
`-mollie`, `-adyen`). Each ships under its own OpenSpec change.
|
||||
- Webhook signature verification for PayPal. The `webhookSecret` field exists
|
||||
on `OAuthConfig` for symmetry with the other adapters but is not yet wired
|
||||
to a PayPal-specific verifier; the slim core's `WebhookVerifier` ships in
|
||||
`@suaveplan/payment`, and the PayPal-flavoured verifier is its own
|
||||
follow-up change once `@suaveplan/payment` lands a verifier-plugin slot.
|
||||
- OpenTelemetry spans / counters on the PayPal adapter. Same deferral as the
|
||||
slim core — align with `@suaveplan/payment` once that package lands its
|
||||
observability surface.
|
||||
- Disputes / chargebacks API support. The slim `PaymentProvider` contract
|
||||
has no dispute methods; when `@suaveplan/payment` extends the contract,
|
||||
every sibling adapter (including PayPal) extends in lockstep.
|
||||
- Vault / vaulted-payment-method support (PayPal v3 payment-tokens API).
|
||||
Same gating — the slim contract has no vault method.
|
||||
- Subscription / billing-plan support (PayPal `/v1/billing/*`). Out of the
|
||||
one-shot `PaymentIntent` contract scope.
|
||||
|
||||
## Source mapping
|
||||
|
||||
This change derives from `../suaveplan/enterprise/payment-providers.md`
|
||||
(the enterprise audit doc that the bundled upstream `payment-providers`
|
||||
package was authored against — the same audit the slim core migration also
|
||||
cited) plus the upstream concrete implementation at
|
||||
`../suaveplan/packages/universal/payment-providers/src/paypal.ts` and the
|
||||
slim archive at `openspec/archive/2026-05-17-slim-payment-providers/`.
|
||||
|
||||
| Requirement | Source | Notes |
|
||||
|---|---|---|
|
||||
| Universal-tier purity (zero browser / Node / React deps) | `enterprise/payment-providers.md` L37 | inherited from slim core; this sibling preserves it |
|
||||
| Zod schemas + injectable clock + structured errors + AbortSignal | `enterprise/payment-providers.md` L40-L43 | inherited via slim core's `httpRequest`, `composeAbortSignal`, `PaymentProviderError`, `NowFn` |
|
||||
| OAuth2 client-credentials with cached token | `../suaveplan/packages/universal/payment-providers/src/paypal.ts` L223-L244 | verbatim; injectable `now` + `base64Encode` added for universal-tier determinism |
|
||||
| `createIntent` → POST `/v2/checkout/orders` with `intent` (`AUTHORIZE` / `CAPTURE`), `purchase_units[0].amount`, `description`, optional `PayPal-Request-Id` | upstream `paypal.ts` L112-L137 | verbatim |
|
||||
| `confirmIntent` → POST `/v2/checkout/orders/{id}/authorize`, maps `APPROVED` → `authorized` | upstream `paypal.ts` L139-L148 | verbatim |
|
||||
| `captureIntent` → POST `/v2/checkout/orders/{id}/capture`, sums `purchase_units[].payments.captures[].amount.value` | upstream `paypal.ts` L150-L159, L71-L93 | verbatim |
|
||||
| `cancelIntent` → POST `/v2/checkout/orders/{id}/void`, returns synthetic `canceled` intent | upstream `paypal.ts` L161-L180 | verbatim; intent timestamps now derived from `now()` not `new Date()` |
|
||||
| `refund` → POST `/v2/payments/captures/{id}/refund` with optional `amount`, optional `PayPal-Request-Id` | upstream `paypal.ts` L182-L210 | verbatim |
|
||||
| `getIntent` → GET `/v2/checkout/orders/{id}` | upstream `paypal.ts` L212-L221 | verbatim |
|
||||
| Status mapping (`CREATED`/`SAVED` → `created`, `APPROVED` → `authorized`, `VOIDED` → `canceled`, `COMPLETED` → `captured`, `PAYER_ACTION_REQUIRED` → `pending`, default → `failed`) | upstream `paypal.ts` L46-L56 | verbatim |
|
||||
| Refund status mapping (`COMPLETED` → `succeeded`, `PENDING` → `pending`, else → `failed`) | upstream `paypal.ts` L58-L62 | verbatim |
|
||||
| `MoneyLike` bigint minor-unit → decimal-string major-unit marshalling (`5000n` ↔ `"50.00"`) | upstream `paypal.ts` L64-L69, L116-L117 | verbatim |
|
||||
| Re-uses slim core `httpRequest` + `composeAbortSignal` + `oauthTokenFailure` | `archive/2026-05-17-slim-payment-providers/proposal.md` "Future sibling package will depend on" table row for PayPal | verbatim — this change consumes exactly that surface |
|
||||
|
||||
### Requirements with no source line
|
||||
|
||||
| Requirement | Justification |
|
||||
|---|---|
|
||||
| 100% Istanbul coverage threshold | Genesis-wide policy (CLAUDE.md rule "100% test coverage"), not package-specific |
|
||||
| Tests via `@suaveplan/testing/runner` | Genesis-wide policy (CLAUDE.md rule 11 / AGENTS.md §7); upstream tests use the wrapper but the rule applies regardless |
|
||||
| Injectable `now: NowFn` defaulting to `Date.now` | Genesis-wide universal-tier determinism policy; upstream used direct `Date.now()` and the slim core already exposes `NowFn` for this exact use case |
|
||||
| Injectable `base64Encode: Base64EncodeFn` defaulting to `globalThis.btoa` | Genesis-wide universal-tier policy; upstream used direct `btoa` (browser-only). The slim core already exposes `Base64EncodeFn` for runtimes that lack `btoa` (older Node, custom embeddings) |
|
||||
| `sandbox` constructor flag | Convenience addition above the upstream baseline so consumers can toggle the PayPal sandbox endpoint without restating the full URL; resolves to `https://api-m.sandbox.paypal.com` when `true` |
|
||||
| Constructor-time validation that the OAuth Basic header isn't accidentally re-derived on every request (token cache must be hit) | The upstream code already had this behaviour; genesis pins it as a normative scenario so a future refactor can't regress it |
|
||||
|
||||
## Verification approach
|
||||
|
||||
- `src/paypal/paypal.test.ts` — exercises every method, every status-mapping
|
||||
branch, the token cache hit and refresh-on-expiry paths, the
|
||||
`OAUTH_TOKEN_FAILURE` failure path, the injectable `now` / `base64Encode`
|
||||
seams, the sandbox flag, the `PayPal-Request-Id` idempotency header, the
|
||||
amount marshalling round-trip, and the universal-tier purity by injecting a
|
||||
`FetchLike` closure for every HTTP path. Uses `@suaveplan/testing/runner`
|
||||
exclusively. No mocks of `globalThis.fetch` — every test passes an
|
||||
explicit `FetchLike` recorder. Uses `FakeClock` from `@suaveplan/testing`
|
||||
for token-expiry assertions.
|
||||
- `tests/dep-graph.test.ts` — pins the dependency invariants: only
|
||||
`@suaveplan/payment`, `@suaveplan/payment-providers`, `@suaveplan/error`,
|
||||
`tslib`. No Node, browser, or React deps anywhere in the source.
|
||||
|
||||
## Migration impact
|
||||
|
||||
None — additive only. No existing consumer imports `PayPalProvider` from
|
||||
`@suaveplan/payment-providers` in the genesis tree (the slim-down change
|
||||
already removed it from the core barrel and no in-repo dependent re-added
|
||||
it). New external consumers follow the install line in `README.md`.
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
Run `bunx turbo ls --filter=...@suaveplan/payment-provider-paypal` to
|
||||
enumerate.
|
||||
|
||||
- No in-repo dependents — the package is brand-new and nothing currently
|
||||
imports `@suaveplan/payment-provider-paypal`. `bunx turbo ls
|
||||
--filter=...@suaveplan/payment-provider-paypal` returns only the package
|
||||
itself. [x] no action needed (reason: zero dependents)
|
||||
|
||||
## Parity Checklist
|
||||
|
||||
| Capability | Upstream (`packages/universal/payment-providers/src/paypal.ts`) | Migrated | Notes |
|
||||
|---|---|---|---|
|
||||
| `class PayPalProvider implements PaymentProvider`, `name: "paypal"` | yes | yes | verbatim |
|
||||
| `OAuthConfig` constructor (`clientId`, `clientSecret`, `baseUrl`, `timeoutMs`) | yes | yes | extended with `sandbox`, `now`, `base64Encode` |
|
||||
| OAuth2 client-credentials token cache w/ `expires_in` honoured + 60 s skew | yes | yes | verbatim; `Date.now()` swapped for injectable `now` |
|
||||
| `Basic <base64(clientId:clientSecret)>` header via `btoa` | yes | yes (⬆) | `btoa` swapped for injectable `base64Encode` defaulting to `globalThis.btoa` |
|
||||
| `createIntent` (intent / amount / description / optional idempotency-key as `PayPal-Request-Id`) | yes | yes | verbatim |
|
||||
| `confirmIntent` / `captureIntent` / `cancelIntent` / `refund` / `getIntent` | yes | yes | verbatim |
|
||||
| Status mapping (7 cases incl. default `failed`) | yes | yes | verbatim |
|
||||
| Refund status mapping (3 cases) | yes | yes | verbatim |
|
||||
| `MoneyLike` bigint ↔ PayPal decimal string marshalling | yes | yes | verbatim |
|
||||
| Synthetic `canceled` PaymentIntent on void (timestamps via `new Date().toISOString()`) | yes | yes (⬆) | `new Date()` swapped for `new Date(now()).toISOString()` so tests can pin |
|
||||
| Tests via `from "@suaveplan/testing"` | yes | yes (⬆) | rewritten to `@suaveplan/testing/runner` per AGENTS.md §7 |
|
||||
| `vi.spyOn(globalThis, "fetch")` test pattern | yes | 🚫 — replaced | replaced with explicit `FetchLike` recorder closures so tests don't monkey-patch globals (matches the slim core's test style; CLAUDE.md rule 2) |
|
||||
| `sandbox` shortcut flag | 🚫 absent | ✅ added | convenience; resolves to `https://api-m.sandbox.paypal.com` |
|
||||
| Sibling `.md` per implementation file (≥200 words, basic + advanced examples) | minimal (20-line stub) | ✅ rewritten | upstream `.md` was a placeholder; new version meets the genesis doc standard |
|
||||
| 100 / 100 / 100 / 100 Istanbul coverage | yes | yes | verbatim threshold |
|
||||
@@ -0,0 +1,312 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: payment-provider-paypal (add-payment-provider-paypal)
|
||||
|
||||
**Package**: `@suaveplan/payment-provider-paypal`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. (See `openspec/AGENTS.md` Section 13.)
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: PayPalProvider implements PaymentProvider
|
||||
|
||||
The package SHALL export a class `PayPalProvider` that implements the
|
||||
`PaymentProvider` structural interface from `@suaveplan/payment` with a
|
||||
literal-typed `name: "paypal"` property and the six lifecycle methods
|
||||
(`createIntent`, `confirmIntent`, `captureIntent`, `cancelIntent`, `refund`,
|
||||
`getIntent`). Every method MUST be `async` and MUST return a `PaymentIntent`
|
||||
or `Refund` shape from `@suaveplan/payment`.
|
||||
|
||||
#### Scenario: name discriminant
|
||||
|
||||
- **WHEN** a caller constructs `new PayPalProvider({ clientId: "id", clientSecret: "secret" })`
|
||||
- **THEN** `provider.name` equals the string `"paypal"` (literal type)
|
||||
|
||||
#### Scenario: PaymentProvider conformance
|
||||
|
||||
- **WHEN** a caller assigns a `PayPalProvider` instance to a variable of type
|
||||
`PaymentProvider` from `@suaveplan/payment`
|
||||
- **THEN** the assignment type-checks without casts, and all six methods are
|
||||
callable with their declared signatures
|
||||
|
||||
**Verified by:** `src/paypal/paypal.test.ts::"has literal name 'paypal'"` AND `src/paypal/paypal.test.ts::"satisfies the PaymentProvider structural contract"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: OAuth2 client-credentials token cache
|
||||
|
||||
The package SHALL acquire and cache an OAuth2 bearer token by posting
|
||||
`grant_type=client_credentials` to `/v1/oauth2/token` with an
|
||||
`Authorization: Basic <base64Encode(clientId + ":" + clientSecret)>` header
|
||||
and a `Content-Type: application/x-www-form-urlencoded` header. The cached
|
||||
token MUST be reused for every subsequent call until `now() >= expiresAt`,
|
||||
where `expiresAt = tokenAcquiredAt + (expires_in * 1000) - 60_000` (60 s
|
||||
clock-skew buffer). A subsequent call after expiry MUST refresh the token
|
||||
transparently.
|
||||
|
||||
#### Scenario: first call acquires a token, subsequent calls reuse it
|
||||
|
||||
- **WHEN** the caller invokes any method twice in quick succession with an
|
||||
injected `FetchLike` recorder
|
||||
- **THEN** the recorder observes exactly one `/v1/oauth2/token` POST and two
|
||||
Orders-v2 calls (one per invocation)
|
||||
|
||||
#### Scenario: token refresh after expiry
|
||||
|
||||
- **WHEN** the caller invokes a method, advances the injected `NowFn` past
|
||||
`expiresAt`, and invokes a second method
|
||||
- **THEN** the recorder observes two `/v1/oauth2/token` POSTs and two
|
||||
Orders-v2 calls
|
||||
|
||||
#### Scenario: OAuth header uses the injected base64 encoder
|
||||
|
||||
- **WHEN** a caller injects a custom `base64Encode` that prepends `"X-"`
|
||||
- **THEN** the Basic header for the token call equals `"Basic X-<encoded>"`
|
||||
|
||||
**Verified by:** `src/paypal/paypal-token.test.ts::"reuses a cached OAuth token across method calls"` AND `src/paypal/paypal-token.test.ts::"refreshes the token after the cached expiry passes"` AND `src/paypal/paypal-token.test.ts::"derives the Basic header via the injected base64 encoder"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Token-fetch failure raises OAUTH_TOKEN_FAILURE
|
||||
|
||||
When the `/v1/oauth2/token` POST fails (network error, non-2xx response, or
|
||||
malformed JSON), the package SHALL raise
|
||||
`PaymentProviderError(PAYMENT_PROVIDER.OAUTH_TOKEN_FAILURE)` with `provider:
|
||||
"paypal"`, the token URL on the `url` discriminant, and the underlying
|
||||
failure attached as `cause`. The error SHALL be raised before any
|
||||
subsequent Orders-v2 HTTP call is attempted.
|
||||
|
||||
#### Scenario: non-2xx token response
|
||||
|
||||
- **WHEN** the injected `FetchLike` returns a 401 from the token endpoint
|
||||
- **THEN** the caller observes a `PaymentProviderError` with
|
||||
`code === "PAYMENT_PROVIDER.OAUTH_TOKEN_FAILURE"`, `provider === "paypal"`,
|
||||
`cause` set to the underlying `PaymentProviderError(HTTP_ERROR)`, and the
|
||||
recorder shows zero Orders-v2 calls
|
||||
|
||||
#### Scenario: network-level token failure
|
||||
|
||||
- **WHEN** the injected `FetchLike` throws a transport error on the token
|
||||
endpoint
|
||||
- **THEN** the same `OAUTH_TOKEN_FAILURE` is raised with the transport error
|
||||
on `cause`
|
||||
|
||||
**Verified by:** `src/paypal/paypal-token.test.ts::"raises OAUTH_TOKEN_FAILURE when the token endpoint returns a non-2xx"` AND `src/paypal/paypal-token.test.ts::"raises OAUTH_TOKEN_FAILURE when the token endpoint throws"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: PayPalConfig constructor surface
|
||||
|
||||
The package SHALL export a `PayPalConfig` interface extending the slim
|
||||
core's `OAuthConfig`. It MUST add a single optional `sandbox?: boolean`
|
||||
field. When `sandbox` is `true` and `baseUrl` is not supplied, the
|
||||
provider MUST use `https://api-m.sandbox.paypal.com` as the default base
|
||||
URL; otherwise (when `baseUrl` is unset and `sandbox` is not `true`), it
|
||||
MUST default to `https://api-m.paypal.com`. An explicit `baseUrl` always
|
||||
wins over the `sandbox` flag.
|
||||
|
||||
#### Scenario: sandbox flag selects the sandbox endpoint
|
||||
|
||||
- **WHEN** a caller constructs `new PayPalProvider({ clientId, clientSecret, sandbox: true })`
|
||||
and the recorder observes any call
|
||||
- **THEN** the recorder sees URLs prefixed with
|
||||
`https://api-m.sandbox.paypal.com`
|
||||
|
||||
#### Scenario: explicit baseUrl overrides sandbox
|
||||
|
||||
- **WHEN** a caller passes both `sandbox: true` and `baseUrl: "https://override/v2"`
|
||||
- **THEN** the recorder sees URLs prefixed with `https://override`
|
||||
|
||||
**Verified by:** `src/paypal/paypal-token.test.ts::"defaults to the sandbox endpoint when sandbox flag is true"` AND `src/paypal/paypal-token.test.ts::"explicit baseUrl overrides the sandbox flag"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: createIntent maps CreatePaymentIntentInput to PayPal orders
|
||||
|
||||
`createIntent` SHALL POST `/v2/checkout/orders` with the cached bearer
|
||||
token, a body of `{ intent, purchase_units: [{ amount: { currency_code,
|
||||
value }, description? }] }`, and — when the caller supplies
|
||||
`idempotencyKey` — a `PayPal-Request-Id` header equal to that key. The
|
||||
`intent` MUST be `"AUTHORIZE"` when `captureMethod === "manual"` and
|
||||
`"CAPTURE"` otherwise. The body's `value` MUST be the MoneyLike `bigint`
|
||||
amount converted to a major-unit decimal string with exactly two minor-unit
|
||||
digits (e.g. `5000n` → `"50.00"`).
|
||||
|
||||
#### Scenario: manual capture creates an AUTHORIZE order
|
||||
|
||||
- **WHEN** a caller invokes `createIntent({ amount: { amount: 5000n, currency: "USD" }, captureMethod: "manual", idempotencyKey: "idem-1" })`
|
||||
- **THEN** the recorder sees a POST to `…/v2/checkout/orders` with a body
|
||||
whose `intent === "AUTHORIZE"`, `purchase_units[0].amount.value === "50.00"`,
|
||||
and headers include `"PayPal-Request-Id": "idem-1"` AND
|
||||
`"Authorization": "Bearer <cached-token>"`
|
||||
|
||||
#### Scenario: automatic capture creates a CAPTURE order
|
||||
|
||||
- **WHEN** a caller invokes `createIntent({ amount: { amount: 1000n, currency: "EUR" }, captureMethod: "automatic" })`
|
||||
- **THEN** the recorder sees a body whose `intent === "CAPTURE"`,
|
||||
`purchase_units[0].amount.value === "10.00"`, and the request carries no
|
||||
`PayPal-Request-Id` header
|
||||
|
||||
**Verified by:** `src/paypal/paypal.test.ts::"createIntent with manual captureMethod posts an AUTHORIZE order"` AND `src/paypal/paypal.test.ts::"createIntent with automatic captureMethod posts a CAPTURE order without an idempotency header"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: confirmIntent posts authorize and captureIntent posts capture
|
||||
|
||||
`confirmIntent` SHALL POST `/v2/checkout/orders/{intentId}/authorize` with
|
||||
the cached bearer token and return the mapped `PaymentIntent`.
|
||||
`captureIntent` SHALL POST `/v2/checkout/orders/{intentId}/capture` with
|
||||
the cached bearer token and return the mapped `PaymentIntent` whose
|
||||
`capturedAmount.amount` is the sum of every
|
||||
`purchase_units[].payments.captures[].amount.value` parsed as a `bigint`
|
||||
minor-unit value.
|
||||
|
||||
#### Scenario: confirmIntent maps APPROVED to authorized
|
||||
|
||||
- **WHEN** PayPal returns an order with `status: "APPROVED"`
|
||||
- **THEN** the returned `PaymentIntent.status === "authorized"`
|
||||
|
||||
#### Scenario: captureIntent sums every capture
|
||||
|
||||
- **WHEN** PayPal returns an order with two captures of `"25.00"` USD each
|
||||
- **THEN** the returned `PaymentIntent.capturedAmount.amount === 5000n`
|
||||
|
||||
**Verified by:** `src/paypal/paypal.test.ts::"confirmIntent posts authorize and maps APPROVED to authorized"` AND `src/paypal/paypal.test.ts::"captureIntent sums every purchase_unit capture into capturedAmount"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: cancelIntent voids the order and returns a canceled intent
|
||||
|
||||
`cancelIntent` SHALL POST `/v2/checkout/orders/{intentId}/void` with the
|
||||
cached bearer token and return a synthesised `PaymentIntent` carrying the
|
||||
same `id`, `status: "canceled"`, zero `amount` / `capturedAmount` /
|
||||
`refundedAmount` (USD currency), and `createdAt` / `updatedAt` equal to
|
||||
`new Date(now()).toISOString()`. The injected `NowFn` MUST be the sole
|
||||
source of these timestamps so tests can pin them.
|
||||
|
||||
#### Scenario: cancelIntent issues a void and returns canceled intent
|
||||
|
||||
- **WHEN** a caller invokes `cancelIntent("ORDER_x")` with an injected
|
||||
`now` returning `1_700_000_000_000`
|
||||
- **THEN** the recorder sees a POST to `…/v2/checkout/orders/ORDER_x/void`,
|
||||
the returned intent has `id === "ORDER_x"`, `status === "canceled"`, and
|
||||
`createdAt === updatedAt === new Date(1_700_000_000_000).toISOString()`
|
||||
|
||||
**Verified by:** `src/paypal/paypal.test.ts::"cancelIntent voids the order and returns a canceled intent with injected-now timestamps"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: refund posts to /payments/captures/{id}/refund
|
||||
|
||||
`refund` SHALL POST `/v2/payments/captures/{paymentIntentId}/refund` with
|
||||
the cached bearer token. When the caller supplies `amount`, the body MUST
|
||||
include `{ amount: { currency_code, value } }` with the same decimal-string
|
||||
marshalling rule as `createIntent`. When the caller supplies
|
||||
`idempotencyKey`, the request MUST carry a `PayPal-Request-Id` header.
|
||||
When the caller supplies neither, the request body MUST be an empty
|
||||
object and no idempotency header is sent. The returned `Refund` MUST
|
||||
carry the PayPal refund `id`, the caller's `paymentIntentId`, the parsed
|
||||
amount, and the mapped `RefundStatus`.
|
||||
|
||||
#### Scenario: full-amount refund with idempotency key
|
||||
|
||||
- **WHEN** a caller invokes `refund({ paymentIntentId: "CAP_x", amount: { amount: 2000n, currency: "USD" }, idempotencyKey: "rfn-1" })`
|
||||
- **THEN** the recorder sees a POST whose body
|
||||
is `{ amount: { currency_code: "USD", value: "20.00" } }` and headers
|
||||
include `"PayPal-Request-Id": "rfn-1"`
|
||||
|
||||
#### Scenario: bare refund with no amount or idempotency
|
||||
|
||||
- **WHEN** a caller invokes `refund({ paymentIntentId: "CAP_x" })`
|
||||
- **THEN** the recorder sees a POST with an empty-object body and no
|
||||
`PayPal-Request-Id` header
|
||||
|
||||
**Verified by:** `src/paypal/paypal.test.ts::"refund with explicit amount and idempotency posts amount body and PayPal-Request-Id header"` AND `src/paypal/paypal.test.ts::"refund without amount or idempotency posts an empty body and no idempotency header"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: getIntent fetches an order
|
||||
|
||||
`getIntent` SHALL GET `/v2/checkout/orders/{intentId}` with the cached
|
||||
bearer token and return the mapped `PaymentIntent`.
|
||||
|
||||
#### Scenario: getIntent fetches and maps an order
|
||||
|
||||
- **WHEN** a caller invokes `getIntent("ORDER_y")` and PayPal returns a
|
||||
`COMPLETED` order with no captures
|
||||
- **THEN** the returned `PaymentIntent` has `id === "ORDER_y"`,
|
||||
`status === "captured"`, and `capturedAmount.amount === 0n`
|
||||
|
||||
**Verified by:** `src/paypal/paypal.test.ts::"getIntent fetches the order and maps PayPal status to PaymentStatus"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: PayPal status mapping is exhaustive
|
||||
|
||||
The package SHALL map PayPal order statuses to genesis `PaymentStatus`
|
||||
exactly as follows: `CREATED` → `created`, `SAVED` → `created`, `APPROVED`
|
||||
→ `authorized`, `VOIDED` → `canceled`, `COMPLETED` → `captured`,
|
||||
`PAYER_ACTION_REQUIRED` → `pending`. Any other PayPal status — including
|
||||
the empty string and unknown strings — MUST map to `failed`. The
|
||||
refund-status mapping is: `COMPLETED` → `succeeded`, `PENDING` →
|
||||
`pending`, every other value → `failed`.
|
||||
|
||||
#### Scenario: every documented PayPal status maps correctly
|
||||
|
||||
- **WHEN** the caller invokes `getIntent` and PayPal returns each of the
|
||||
six documented statuses in turn
|
||||
- **THEN** the returned `PaymentIntent.status` equals the mapped value
|
||||
above; an undocumented status returns `failed`
|
||||
|
||||
#### Scenario: refund-status mapping
|
||||
|
||||
- **WHEN** the caller invokes `refund({ paymentIntentId: "x" })` and PayPal
|
||||
returns each of `COMPLETED`, `PENDING`, and an unknown status
|
||||
- **THEN** the returned `Refund.status` equals `succeeded`, `pending`, and
|
||||
`failed` respectively
|
||||
|
||||
**Verified by:** `src/paypal/paypal.test.ts::"maps every documented PayPal order status to PaymentStatus"` AND `src/paypal/paypal.test.ts::"maps every documented PayPal refund status to RefundStatus"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Universal-tier purity — no Node, browser, or React deps
|
||||
|
||||
The package SHALL run unchanged on Node, Bun, browsers, and Web Workers.
|
||||
Source files MUST NOT import from `node:*`, `bun:*`, `react`, the DOM
|
||||
globals (`window`, `document`, `localStorage`, …), or any other
|
||||
runtime-specific surface. The base-64 encoder used for the OAuth Basic
|
||||
header MUST go through the injected `Base64EncodeFn` (defaulting to
|
||||
`globalThis.btoa`) so a runtime without `btoa` can supply its own
|
||||
encoder without forking the package.
|
||||
|
||||
#### Scenario: dependency graph has only universal-tier siblings
|
||||
|
||||
- **WHEN** the dep-graph test reads the package's `package.json` and the
|
||||
source-import graph
|
||||
- **THEN** the only `@suaveplan/*` deps are `payment`, `payment-providers`,
|
||||
and `error`; the only external dep is `tslib`; no `node:` / `bun:` /
|
||||
`react` / DOM-global imports appear in `src/`
|
||||
|
||||
**Verified by:** `tests/dep-graph.test.ts::"declares only universal-tier dependencies"` AND `tests/dep-graph.test.ts::"source imports only @suaveplan/* siblings, tslib, and node-free intrinsics"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Errors raised only as PaymentProviderError
|
||||
|
||||
The package SHALL never raise a bare `Error` or any non-`PaymentProviderError`
|
||||
class from `@suaveplan/error`. Every failure path — token failure, HTTP
|
||||
non-2xx, JSON parse failure, transport error — surfaces as a
|
||||
`PaymentProviderError` from the slim core's factories (`oauthTokenFailure`,
|
||||
`httpError`, `timeoutError`, `parseError`), with `provider === "paypal"`.
|
||||
|
||||
#### Scenario: every failure path narrows to PaymentProviderError
|
||||
|
||||
- **WHEN** any of the four documented failure paths fires
|
||||
- **THEN** the thrown value is an `instanceof PaymentProviderError` from
|
||||
`@suaveplan/payment-providers` and the `provider` discriminant equals
|
||||
`"paypal"`
|
||||
|
||||
**Verified by:** `src/paypal/paypal.test.ts::"every failure path narrows to PaymentProviderError with provider 'paypal'"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
@@ -0,0 +1,58 @@
|
||||
# Tasks — add-payment-provider-paypal
|
||||
|
||||
> Every checkbox MUST reflect work actually done in this change. No retroactive ticking.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [x] 1.1 Read `../suaveplan/enterprise/payment-providers.md` end-to-end
|
||||
- [x] 1.2 Read the slim-core spec and code (`packages/universal/payment-providers/src/`) plus the slim archive (`openspec/archive/2026-05-17-slim-payment-providers/`)
|
||||
- [x] 1.3 Read `openspec/AGENTS.md` Sections 13 and 14 (and §7 test-runner rule)
|
||||
- [x] 1.4 Draft `proposal.md` from `openspec/templates/proposal.md`
|
||||
- [x] 1.5 Fill `## Source mapping` table — every requirement cites a line range OR appears in the "no source line" table with justification
|
||||
- [x] 1.6 Draft `specs/payment-provider-paypal/spec.md` from `openspec/templates/spec-delta.md`
|
||||
- [x] 1.7 Every requirement carries a `**Verified by:**` line naming concrete `it(...)` titles
|
||||
|
||||
## 2. Implementation
|
||||
|
||||
- [x] 2.1 Scaffold `packages/universal/payment-provider-paypal/` (package.json, tsconfig.json, tsconfig.lib.json, vite.config.ts, vitest.config.ts, README.md, tests/setup/polyfills.ts, tests/dep-graph.test.ts)
|
||||
- [x] 2.2 Implement `src/paypal/paypal.types.ts` — `PayPalConfig` (extends `OAuthConfig` + `sandbox?: boolean`), `PayPalOrder`, `PayPalRefund`, `PayPalTokenResponse` wire shapes
|
||||
- [x] 2.3 Implement `src/paypal/paypal.ts` — `PayPalProvider` class with constructor, token cache, all 6 `PaymentProvider` methods, status mapping helpers, amount marshalling, idempotency-header wiring
|
||||
- [x] 2.4 Wire `src/paypal/index.ts` (named exports) and `src/index.ts` package barrel (named exports)
|
||||
- [x] 2.5 Add subpath exports in `package.json` (`.`, `./paypal/paypal`, `./package.json`)
|
||||
|
||||
## 3. Tests
|
||||
|
||||
- [x] 3.1 `src/paypal/paypal.test.ts` — every scenario in the spec delta exercised via a `FetchLike` recorder closure (no `globalThis.fetch` patching); `FakeClock` for token-expiry assertions; all `it(...)` carry `{ timeout: 5000 }`
|
||||
- [x] 3.2 `tests/dep-graph.test.ts` — pins dependency invariants (only `payment`, `payment-providers`, `error`, `tslib`; no Node/browser/React imports)
|
||||
- [x] 3.3 Every test file imports runner primitives from `@suaveplan/testing/runner` (§7 / CLAUDE.md rule 11)
|
||||
- [x] 3.4 Coverage at 100/100/100/100
|
||||
|
||||
## 4. Documentation
|
||||
|
||||
- [x] 4.1 `src/paypal/paypal.md` — Purpose / Features / Basic + Advanced Usage / API Reference / Implementation Notes (≥200 words)
|
||||
- [x] 4.2 `README.md` — install / quick-start / Modules links / migration
|
||||
- [x] 4.3 No `CLAUDE.md` entry required (per CLAUDE.md doc-policy note — sibling-plugin packages are documented through `MANIFEST.md` / `STATUS.md` / `SUMMARY.md` and the per-package `.md` files)
|
||||
|
||||
## 5. Pre-archive verification (the 5-gate triple)
|
||||
|
||||
- [x] 5.1 `bun run check` — Biome 0/0/0 across the monorepo
|
||||
- [x] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/payment-provider-paypal` — green
|
||||
- [x] 5.3 `bunx turbo test:coverage --filter=@suaveplan/payment-provider-paypal` — 100/100/100/100
|
||||
- [x] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/payment-provider-paypal` — exit 0, no `error` findings
|
||||
- [x] 5.5 `bunx turbo build --filter=@suaveplan/payment-provider-paypal` — green
|
||||
|
||||
## 6. Pre-archive 7-row checklist (AGENTS.md §13)
|
||||
|
||||
- [x] 6.1 Generics threaded (`PayPalProvider.name` is the literal `"paypal"`; `httpRequest<T>` generics threaded through every call site)
|
||||
- [x] 6.2 Zod schemas wired at every declared boundary — N/A (this adapter consumes the slim core's typed `httpRequest` and the `@suaveplan/payment` interface; no public Zod boundary beyond the structural `OAuthConfig` extension)
|
||||
- [x] 6.3 Every error code reachable: `OAUTH_TOKEN_FAILURE` raised by token-fetch failure path; `HTTP_ERROR` / `TIMEOUT` / `PARSE_ERROR` reachable transitively via `httpRequest`; tested in both directions
|
||||
- [x] 6.4 Strict-mode behaviors — `sandbox` flag both on and off; token cache hit AND refresh; refund with AND without amount/idempotency; createIntent with AND without idempotency
|
||||
- [x] 6.5 OTel instruments — N/A; deferred per the slim core's matching deferral until `@suaveplan/payment` lands an observability surface
|
||||
- [x] 6.6 Lifecycle dedup — token cache reuse asserted (single token call across two intents); refresh-after-expiry asserted
|
||||
- [x] 6.7 No retroactive tick — every box above corresponds to work done in this change
|
||||
|
||||
## 7. Archive
|
||||
|
||||
- [x] 7.1 Fold spec delta into `openspec/specs/payment-provider-paypal/spec.md`
|
||||
- [x] 7.2 Move `openspec/changes/add-payment-provider-paypal/` → `openspec/archive/2026-05-17-add-payment-provider-paypal/`
|
||||
- [x] 7.3 Refresh `MANIFEST.md`, `MISSING.yml`, `STATUS.md`, `SUMMARY.md`
|
||||
Reference in New Issue
Block a user