Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,179 @@
# Add @suaveplan/payment-providers — universal-tier provider plugins (Stripe / PayPal / Square / Mollie / Adyen)
**Package**: `@suaveplan/payment-providers`
**Status**: PROPOSED
**Type**: migration (upstream `../suaveplan/packages/universal/payment-providers`)
- **Spec delta**: ./specs/payment-providers/spec.md
## Why
`STATUS.md` line 133 lists `@suaveplan/payment-providers` as Pending. The
upstream package supplies the five reference adapters that implement the
`@suaveplan/payment` `PaymentProvider` interface — Stripe, PayPal,
Square, Mollie, and Adyen. With `@suaveplan/payment` shipped in the same
wave, every commerce-tier downstream
(`@suaveplan/api-plugin-payment`, `@suaveplan/booking`,
`@suaveplan/api-plugin-banking`) needs at least one of these adapters to
deliver an end-to-end payment lifecycle.
The migration preserves the entire upstream public surface verbatim
(`StripeProvider`, `PayPalProvider`, `SquareProvider`, `MollieProvider`,
`AdyenProvider`, `httpRequest`, `ApiKeyConfig`, `OAuthConfig`,
`MerchantKeyConfig`, `ProviderResponse`, `HttpOptions`) while upgrading
five foundational concerns to the genesis quality bar:
1. **Injectable `FetchLike`** — every provider accepts an optional
`fetch` config field defaulting to `globalThis.fetch`. Tests use a
real recording `FetchLike` rather than `vi.spyOn(globalThis, "fetch")`,
so the no-mocks rule (CLAUDE.md §2) is preserved without losing
coverage.
2. **Injectable `now` / `randomId`** — `PayPalProvider`'s token-cache
clock and `SquareProvider` / `AdyenProvider`'s default
idempotency-key generator are config fields. This removes module-scope
`Date.now()` and `crypto.randomUUID()`, aligning with the deterministic
universal-tier rule.
3. **Injectable `base64Encode`** — PayPal's OAuth Basic header uses
`btoa` by default but accepts an injected encoder so the package runs
on every runtime that names Base-64 differently.
4. **Typed `PaymentProviderError`** extending
`SuaveplanError<PaymentProviderErrorCode>` replaces the upstream
`throw new Error(...)` raised inside `httpRequest`. The
`PAYMENT_PROVIDER_CODES` registry is declared via
`defineCodes("PAYMENT_PROVIDER", [...])` (4 codes:
`HTTP_ERROR`, `TIMEOUT`, `PARSE_ERROR`, `OAUTH_TOKEN_FAILURE`) and
every former `Error` boundary upgrades to a typed factory carrying
the HTTP status, response body excerpt, and provider name as
discriminants.
5. **Folder layout** — the upstream flat `src/{stripe,paypal,...}.ts`
becomes `src/providers/{stripe,paypal,square,mollie,adyen}/<name>.ts`
per genesis convention (one feature module per folder, sibling `.md`,
tests, types, barrel). The folder-naming rule §10 is respected by
naming children only with the provider name (no `-provider` suffix).
`httpRequest` moves to `src/http/http.ts` and its config types to
`src/types/provider.ts` so consumers can tree-shake.
The 8 upstream source files expand to 15 (5 provider impls × {`<name>.ts`, `<name>.md`, `<name>.test.ts`, `index.ts`} per folder + flat `http/`, `types/`, `error-codes/` modules). One sibling `.md` per implementation file lands at genesis quality (≥200 words, basic + advanced examples, full API reference).
## What changes
- ADDED: `StripeProvider` — Stripe REST API adapter, Bearer auth, manual
- automatic capture, idempotency keys, status + refund-reason mapping.
- ADDED: `PayPalProvider` — PayPal Orders v2 adapter, OAuth2 client-
credentials flow with token cache, injectable `now` for cache TTL,
injectable `base64Encode` for the Basic auth header.
- ADDED: `SquareProvider` — Square Payments adapter, Bearer auth,
manual + automatic capture, `Square-Version` header, injectable
`randomId` for default idempotency keys.
- ADDED: `MollieProvider` — Mollie Payments v2 adapter, Bearer auth,
string-based amount marshalling (`"50.00"`), redirect URL handling.
- ADDED: `AdyenProvider` — Adyen Checkout v71 adapter, `X-API-Key`
header, merchant-account context, injectable `randomId`.
- ADDED: `httpRequest` — universal `FetchLike` wrapper with timeout
via `AbortSignal`, JSON serialisation, typed errors.
- ADDED: `PAYMENT_PROVIDER_CODES` registry via
`defineCodes("PAYMENT_PROVIDER", [...])` — 4 codes (`HTTP_ERROR`,
`TIMEOUT`, `PARSE_ERROR`, `OAUTH_TOKEN_FAILURE`).
- ADDED: `PaymentProviderError extends SuaveplanError<PaymentProviderErrorCode>`
carrying optional `provider`, `httpStatus`, `bodyExcerpt`, and `url`
discriminants; factories `httpError`, `timeoutError`, `parseError`,
`oauthTokenFailure`.
- ADDED: `FetchLike` type alias re-exported from
`@suaveplan/payment-providers/http/http`.
- ADDED: Subpath exports per public module — 9 entries covering every
feature; tree-shakeable.
## Out of scope
- **`@suaveplan/idempotency` integration.** Providers accept
`idempotencyKey` and forward it to the provider; auto-enforcement
via the `@suaveplan/idempotency` middleware is a follow-up change
once that package lands.
- **OTel spans / counters.** The enterprise audit calls for per-provider
observability; the genesis `@suaveplan/payment` migration also
deferred this. Wiring spans here without spans in the engine creates
asymmetry. Defer until both packages land OTel together.
- **Webhook signature verification.** Each provider supports webhook
signing (Stripe HMAC, Adyen HMAC, PayPal cert chain, etc.). The
upstream package accepts `webhookSecret` / `webhookHmacKey` config
fields but does NOT implement verification — that lives in
`@suaveplan/payment`'s `WebhookVerifier`. The config fields are
preserved as opaque pass-throughs so future verifier integration
has the secret material it needs.
- **Schema-registry integration.** The `PaymentProvider` interface is
validated at the type level; runtime Zod schemas around provider
inputs / outputs are a `@suaveplan/payment` concern.
- **Adyen notification HMAC.** The `webhookHmacKey` config field is
preserved verbatim from upstream but the matching HMAC verifier ships
with `@suaveplan/payment`'s `WebhookVerifier`, not here.
## Source mapping
This change derives from `../suaveplan/enterprise/payment-providers.md`.
| Requirement | Source lines | Notes |
|---|---|---|
| Universal-tier purity (no Node / browser / React) | L37 | verbatim |
| Structured errors extending `@suaveplan/error` | L42, L61 | verbatim |
| 100% test coverage / Istanbul | L50 | verbatim |
| 100% docs coverage with sibling `.md` per symbol | L51 | verbatim |
| File-size budget 250 soft / 300 hard / 500 test | L52 | verbatim |
| No mocks — real services / injectable seams | L53 | verbatim |
| No re-exports of external deps | L54 | verbatim |
| `sideEffects: false` + tree-shake friendly | L55 | verbatim |
| `workspace:^` internal / catalog external deps | L56 | verbatim |
| Biome clean | L58 | verbatim |
| TypeScript strict; no `any` / `ts-ignore` | L59 | verbatim |
| Five providers implementing `PaymentProvider` (Stripe, PayPal, Square, Mollie, Adyen) | L10-L13 (Purpose), upstream `src/index.ts` L1-L7 | verbatim — preserve full public surface |
| Cancellation via `AbortSignal` | L44 | verbatim |
| Deterministic clock / RNG | L41 | enhancement above upstream — providers gain injectable `now` / `randomId` / `base64Encode` |
### Requirements with no source line
| Requirement | Justification |
|---|---|
| Injectable `FetchLike` per provider | Required to satisfy `CLAUDE.md` rule 2 ("NEVER use mocks") while preserving 100% coverage. Upstream tests `vi.spyOn(globalThis, "fetch")`; genesis cannot. |
| Folder-per-provider with sibling `.md` | Required by `CLAUDE.md` "Package Structure (library packages)" — five files per feature module. Upstream uses a flat `src/<provider>.ts` layout. |
| `PAYMENT_PROVIDER_CODES` registry (4 codes) | Required by `CLAUDE.md` rule 8 + enterprise §3 "Structured errors". Upstream `httpRequest` throws raw `Error`; genesis upgrades to typed. |
| Subpath exports per provider | Required by `openspec/AGENTS.md` §6 — every feature module owns an `exports` entry for tree-shaking. |
## Verification approach
Every requirement in the spec delta is verified by tests in the package's `src/<feature>/<feature>.test.ts` files plus a cross-cutting tier-purity grep test in `tests/dep-graph.test.ts`. Each provider's test file uses a real `FetchLike` (recording adapter) — never `vi.spyOn` / `vi.mock` — and walks every method (`createIntent`, `confirmIntent`, `captureIntent`, `cancelIntent`, `refund`, `getIntent`) plus the full status-mapping matrix.
- `src/http/http.test.ts` — covers `httpRequest`, timeout, error wrapping, typed `PaymentProviderError`
- `src/error-codes/error-codes.test.ts` — every code reachable, cause propagation, discriminants
- `src/providers/stripe/stripe.test.ts` — `StripeProvider` full surface + status / refund-reason mapping
- `src/providers/paypal/paypal.test.ts` — `PayPalProvider` full surface + OAuth token cache + refresh
- `src/providers/square/square.test.ts` — `SquareProvider` full surface + `randomId` injection
- `src/providers/mollie/mollie.test.ts` — `MollieProvider` full surface + amount marshalling
- `src/providers/adyen/adyen.test.ts` — `AdyenProvider` full surface + merchant-account context
- `tests/dep-graph.test.ts` — universal-tier purity grep (no `node:*` / `bun:*` / `fs` / `path` imports)
## Migration impact
None — additive only. No genesis consumer imports `@suaveplan/payment-providers` yet. Future commerce-tier consumers (`@suaveplan/api-plugin-payment`, `@suaveplan/booking`) will import these providers by their public class names — every upstream export is preserved.
## Impact
### Affected dependents
Run `bunx turbo ls --filter=...@suaveplan/payment-providers` to enumerate. No dependents on the genesis tree yet (only `@suaveplan/payment` is published; downstream consumers are still pending). The change is additive.
- No dependents (verified by `bunx turbo ls --filter=...@suaveplan/payment-providers` — empty result).
## Parity Checklist
| Capability | Upstream | Migrated | Notes |
|---|---|---|---|
| `StripeProvider` (createIntent, confirmIntent, captureIntent, cancelIntent, refund, getIntent) | yes | yes | full surface preserved |
| `PayPalProvider` + OAuth token cache | yes | yes | now injectable `now()` for TTL + `base64Encode` for Basic header |
| `SquareProvider` | yes | yes | now injectable `randomId` for default idempotency keys |
| `MollieProvider` | yes | yes | full surface preserved |
| `AdyenProvider` + merchant-account context | yes | yes | now injectable `randomId` |
| `httpRequest` helper | yes | yes | now raises typed `PaymentProviderError` instead of raw `Error`; accepts injectable `FetchLike` |
| `ApiKeyConfig` / `OAuthConfig` / `MerchantKeyConfig` / `ProviderResponse` / `HttpOptions` types | yes | yes | full surface preserved + added `fetch`, `now`, `randomId`, `base64Encode` optional fields |
| `vi.spyOn(globalThis, "fetch")` in tests | yes | replaced | uses real `FetchLike` recorder per CLAUDE.md rule 2 |
| Webhook HMAC verifier | scoped out | scoped out | upstream defers to `@suaveplan/payment` `WebhookVerifier`; genesis preserves the config fields verbatim |
| OTel spans / counters | absent | absent | deferred per `## Out of scope` to align with `@suaveplan/payment` |
| `@suaveplan/idempotency` middleware | absent | absent | deferred — pass-through preserved |

View File

@@ -0,0 +1,295 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: payment-providers (add-payment-providers)
**Package**: `@suaveplan/payment-providers`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring time.
## ADDED Requirements
### Requirement: Universal-tier purity
The package SHALL ship as a universal-tier package — no `node:*`,
`bun:*`, `fs`, `path`, `stream`, `net`, `http`, or bare `crypto`
imports in any file under `src/`. The package SHALL run unchanged under
Node ≥ 18, Bun, modern browsers, and Web Workers. `package.json` SHALL
declare `"sideEffects": false`. Every HTTP call SHALL go through the
injectable `FetchLike` so the package never reaches into a runtime-
specific HTTP stack.
#### Scenario: no platform imports in src/
- **WHEN** `grep -rnE "from ['\"](node:|bun:|fs|path|stream|net|http|crypto)['\"]" packages/universal/payment-providers/src/` is run
- **THEN** the command produces zero matches
**Verified by:** `tests/dep-graph.test.ts::"src contains no node/bun/fs/path/stream/net/http/crypto imports"`
---
### Requirement: Stable PAYMENT_PROVIDER_CODES registry and PaymentProviderError class
The package SHALL declare an error-code registry via
`defineCodes("PAYMENT_PROVIDER", […])` containing exactly the codes
`HTTP_ERROR`, `TIMEOUT`, `PARSE_ERROR`, and `OAUTH_TOKEN_FAILURE`.
Every public failure path triggered by network or parsing errors SHALL
throw a `PaymentProviderError extends SuaveplanError<PaymentProviderErrorCode>`
carrying one of those codes. `PaymentProviderError` SHALL expose
optional `provider`, `httpStatus`, `bodyExcerpt`, and `url`
discriminants. The package SHALL NOT throw raw `Error` instances from
any production code path under `src/`.
#### Scenario: every code reachable from a real call site
- **WHEN** the test suite exercises each factory (`httpError`, `timeoutError`, `parseError`, `oauthTokenFailure`)
- **THEN** each path produces a `PaymentProviderError` whose `code` matches the expected `PAYMENT_PROVIDER.*` constant
#### Scenario: cause-bearing factories preserve the underlying error
- **WHEN** `parseError` or `oauthTokenFailure` is invoked with a `cause`
- **THEN** the resulting `PaymentProviderError`'s `cause` is the original error
#### Scenario: HTTP error carries discriminants from the response
- **WHEN** `httpError(provider, status, url, body)` is invoked
- **THEN** the resulting error's `httpStatus`, `url`, `provider`, and `bodyExcerpt` fields are all populated
**Verified by:** `src/error-codes/error-codes.test.ts::"every PAYMENT_PROVIDER.* code is reachable from a factory"` AND `src/error-codes/error-codes.test.ts::"preserves cause through parseError"` AND `src/error-codes/error-codes.test.ts::"preserves cause through oauthTokenFailure"` AND `src/error-codes/error-codes.test.ts::"httpError carries provider/url/httpStatus/bodyExcerpt discriminants"`
---
### Requirement: httpRequest helper with injectable FetchLike and timeout
The package SHALL export `httpRequest<T>(options: HttpOptions): Promise<ProviderResponse<T>>`
that wraps an injectable `FetchLike` (defaulting to `globalThis.fetch`).
The helper SHALL serialise `body` as JSON, set `Content-Type: application/json`
and `Accept: application/json` headers (overridable by callers),
enforce a per-call `timeoutMs` (default 30 000) via `AbortSignal` with
a clean `clearTimeout` in the `finally` block, parse the response body
as JSON (returning `{}` when the body is empty), and on a non-ok
response throw a `PaymentProviderError(PAYMENT_PROVIDER.HTTP_ERROR)`
carrying the HTTP status, URL, and a `bodyExcerpt` capped at 256 chars.
A caller-supplied `signal` SHALL be composed with the internal timeout
signal so external cancellation is honoured.
#### Scenario: 200 response returns parsed body
- **WHEN** the injected `FetchLike` resolves with `new Response("{\"ok\":true}", { status: 200 })`
- **THEN** `httpRequest` resolves with `{ status: 200, data: { ok: true } }`
#### Scenario: 204 response returns empty data
- **WHEN** the injected `FetchLike` resolves with `new Response(null, { status: 204 })`
- **THEN** `httpRequest` resolves with `{ status: 204, data: {} }`
#### Scenario: non-ok response throws typed error
- **WHEN** the injected `FetchLike` resolves with status 404 and body `{ "message": "Not Found" }`
- **THEN** `httpRequest` rejects with a `PaymentProviderError` whose `code === "PAYMENT_PROVIDER.HTTP_ERROR"`, `httpStatus === 404`, and `bodyExcerpt` containing `"Not Found"`
#### Scenario: timeout aborts via AbortSignal
- **WHEN** the injected `FetchLike` never resolves and `timeoutMs` is set to 50
- **THEN** `httpRequest` rejects with a `PaymentProviderError` whose `code === "PAYMENT_PROVIDER.TIMEOUT"`
**Verified by:** `src/http/http.test.ts::"makes a GET request and parses JSON"` AND `src/http/http.test.ts::"sends JSON body on POST"` AND `src/http/http.test.ts::"includes custom headers"` AND `src/http/http.test.ts::"throws PaymentProviderError on non-ok response"` AND `src/http/http.test.ts::"throws HTTP_ERROR when no message in body"` AND `src/http/http.test.ts::"handles empty response body"` AND `src/http/http.test.ts::"throws on non-ok empty response"` AND `src/http/http.test.ts::"throws TIMEOUT when AbortSignal fires"` AND `src/http/http.test.ts::"supports DELETE and PATCH methods"` AND `src/http/http.test.ts::"composes caller AbortSignal with internal timeout"`
---
### Requirement: StripeProvider full lifecycle
The package SHALL export `StripeProvider` implementing
`PaymentProvider` from `@suaveplan/payment`. The provider SHALL:
- POST `${baseUrl}/payment_intents` for `createIntent`, forwarding
`amount`, `currency` (lower-cased), `capture_method`, optional
`customer`, `description`, `metadata`, `payment_method`.
- POST `${baseUrl}/payment_intents/{id}/{confirm,capture,cancel}` for the
matching methods.
- POST `${baseUrl}/refunds` for `refund`, forwarding `payment_intent`
and optional `amount`, `reason`, `metadata`.
- GET `${baseUrl}/payment_intents/{id}` for `getIntent`.
- Authenticate every call with `Authorization: Bearer ${apiKey}`.
- Forward `idempotencyKey` as the `Idempotency-Key` header on
`createIntent` and `refund`.
- Map `requires_payment_method` / `requires_confirmation` → `created`;
`requires_action` / `processing` → `pending`; `requires_capture` →
`authorized`; `succeeded` → `captured`; `canceled` → `canceled`;
every other Stripe status → `failed`.
- Map refund status `succeeded` → `succeeded`; `pending` → `pending`;
every other status → `failed`.
- Map refund reason `duplicate` → `duplicate`, `fraudulent` →
`fraudulent`, `requested_by_customer` → `requested_by_customer`,
every other value → `other`.
`StripeProvider.name` SHALL equal `"stripe"`.
#### Scenario: createIntent forwards idempotency key
- **WHEN** `createIntent` is invoked with `idempotencyKey: "idem_1"`
- **THEN** the recording `FetchLike` observes a request with header `Idempotency-Key: idem_1`
#### Scenario: status mapping is total
- **WHEN** `getIntent` returns a payload with an unknown Stripe status
- **THEN** the mapped `PaymentIntent.status` is `"failed"`
**Verified by:** `src/providers/stripe/stripe.test.ts::"has correct name"` AND `src/providers/stripe/stripe.test.ts::"creates a payment intent"` AND `src/providers/stripe/stripe.test.ts::"confirms an intent"` AND `src/providers/stripe/stripe.test.ts::"captures an intent"` AND `src/providers/stripe/stripe.test.ts::"cancels an intent"` AND `src/providers/stripe/stripe.test.ts::"creates a refund"` AND `src/providers/stripe/stripe.test.ts::"refunds without explicit amount"` AND `src/providers/stripe/stripe.test.ts::"fetches an intent"` AND `src/providers/stripe/stripe.test.ts::"maps status requires_payment_method to created"` AND `src/providers/stripe/stripe.test.ts::"maps status requires_action to pending"` AND `src/providers/stripe/stripe.test.ts::"maps status processing to pending"` AND `src/providers/stripe/stripe.test.ts::"maps status requires_capture to authorized"` AND `src/providers/stripe/stripe.test.ts::"maps status succeeded to captured"` AND `src/providers/stripe/stripe.test.ts::"maps status canceled to canceled"` AND `src/providers/stripe/stripe.test.ts::"maps unknown status to failed"` AND `src/providers/stripe/stripe.test.ts::"maps refund status succeeded to succeeded"` AND `src/providers/stripe/stripe.test.ts::"maps refund status pending to pending"` AND `src/providers/stripe/stripe.test.ts::"maps refund status failed to failed"` AND `src/providers/stripe/stripe.test.ts::"maps refund status canceled to failed"` AND `src/providers/stripe/stripe.test.ts::"maps intent without payment method"` AND `src/providers/stripe/stripe.test.ts::"maps refund reason duplicate"` AND `src/providers/stripe/stripe.test.ts::"maps refund reason fraudulent"` AND `src/providers/stripe/stripe.test.ts::"maps refund reason other for unknown"`
---
### Requirement: PayPalProvider OAuth token cache and order lifecycle
The package SHALL export `PayPalProvider` implementing
`PaymentProvider`. The provider SHALL:
- POST `${baseUrl}/checkout/orders` for `createIntent`, mapping
`captureMethod === "manual"` → `intent: "AUTHORIZE"` and otherwise
`intent: "CAPTURE"`, marshalling `amount` to a string with two
fractional digits (e.g. `5000n` → `"50.00"`).
- POST `${baseUrl}/checkout/orders/{id}/{authorize,capture,void}` for
`confirmIntent` / `captureIntent` / `cancelIntent`.
- POST `${baseUrl}/payments/captures/{id}/refund` for `refund`.
- GET `${baseUrl}/checkout/orders/{id}` for `getIntent`.
- Authenticate every order call with `Authorization: Bearer ${token}`
where `token` is fetched via the OAuth2 client-credentials flow from
`${baseUrl.replace("/v2","/v1/oauth2/token")}` with header
`Authorization: Basic ${base64Encode("${clientId}:${clientSecret}")}`.
- Cache the access token until `expires_in * 1000 - 60_000` ms past the
injected `now()`. The cache SHALL be reused across calls and SHALL
refresh transparently after expiry.
- Forward `idempotencyKey` as the `PayPal-Request-Id` header on
`createIntent` and `refund`.
- Map status `CREATED` / `SAVED` → `created`; `APPROVED` →
`authorized`; `VOIDED` → `canceled`; `COMPLETED` → `captured`;
`PAYER_ACTION_REQUIRED` → `pending`; every other status → `failed`.
- Map refund status `COMPLETED` → `succeeded`; `PENDING` → `pending`;
every other status → `failed`.
- Translate `intent === "AUTHORIZE"` → `captureMethod: "manual"` and
every other intent → `automatic`.
- When the OAuth call fails, raise
`PaymentProviderError(PAYMENT_PROVIDER.OAUTH_TOKEN_FAILURE)` carrying
the cause.
`PayPalProvider.name` SHALL equal `"paypal"`.
#### Scenario: token cache hit avoids second OAuth call
- **WHEN** two `createIntent` invocations are issued back-to-back with the same provider instance and the injected `now()` advances by less than `expires_in * 1000 - 60_000` ms between them
- **THEN** the recording `FetchLike` observes exactly one OAuth request and two order requests
#### Scenario: token cache refresh after expiry
- **WHEN** the second `createIntent` is issued after the injected `now()` advances past the cache TTL
- **THEN** the recording `FetchLike` observes two OAuth requests and two order requests
**Verified by:** `src/providers/paypal/paypal.test.ts::"has correct name"` AND `src/providers/paypal/paypal.test.ts::"creates an order with OAuth token"` AND `src/providers/paypal/paypal.test.ts::"reuses cached token"` AND `src/providers/paypal/paypal.test.ts::"refreshes token after expiry"` AND `src/providers/paypal/paypal.test.ts::"authorizes an order"` AND `src/providers/paypal/paypal.test.ts::"captures an order"` AND `src/providers/paypal/paypal.test.ts::"voids an order"` AND `src/providers/paypal/paypal.test.ts::"refunds a capture"` AND `src/providers/paypal/paypal.test.ts::"refunds without explicit amount"` AND `src/providers/paypal/paypal.test.ts::"fetches an order"` AND `src/providers/paypal/paypal.test.ts::"maps status CREATED to created"` AND `src/providers/paypal/paypal.test.ts::"maps status SAVED to created"` AND `src/providers/paypal/paypal.test.ts::"maps status APPROVED to authorized"` AND `src/providers/paypal/paypal.test.ts::"maps status VOIDED to canceled"` AND `src/providers/paypal/paypal.test.ts::"maps status COMPLETED to captured"` AND `src/providers/paypal/paypal.test.ts::"maps status PAYER_ACTION_REQUIRED to pending"` AND `src/providers/paypal/paypal.test.ts::"maps unknown status to failed"` AND `src/providers/paypal/paypal.test.ts::"maps refund status COMPLETED to succeeded"` AND `src/providers/paypal/paypal.test.ts::"maps refund status PENDING to pending"` AND `src/providers/paypal/paypal.test.ts::"maps refund status CANCELLED to failed"` AND `src/providers/paypal/paypal.test.ts::"handles order with AUTHORIZE intent"` AND `src/providers/paypal/paypal.test.ts::"handles order without payments field"` AND `src/providers/paypal/paypal.test.ts::"wraps OAuth failure in OAUTH_TOKEN_FAILURE"`
---
### Requirement: SquareProvider lifecycle with injectable randomId
The package SHALL export `SquareProvider` implementing
`PaymentProvider`. The provider SHALL:
- POST `${baseUrl}/payments` for `createIntent`, forwarding
`amount_money`, `idempotency_key` (caller-supplied or
injected-`randomId`-generated), `autocomplete` (`true` unless
`captureMethod === "manual"`), optional `customer_id`, `note`,
`source_id`.
- POST `${baseUrl}/payments/{id}/{complete,cancel}` for capture / cancel.
- POST `${baseUrl}/refunds` for `refund`, generating
`idempotency_key` the same way.
- GET `${baseUrl}/payments/{id}` for `getIntent`.
- Set `Authorization: Bearer ${apiKey}` and `Square-Version: 2024-01-18`
on every call.
- `confirmIntent` SHALL be a read-through to `getIntent` (Square auto-
confirms).
- Map status `APPROVED` → `authorized`; `COMPLETED` → `captured`;
`CANCELED` → `canceled`; `FAILED` → `failed`; `PENDING` → `pending`;
every other status → `failed`.
- Map refund status `COMPLETED` → `succeeded`; `PENDING` → `pending`;
every other status → `failed`.
- Default refund reason `requested_by_customer` when the upstream
record carries any `reason` string, else `other`.
`SquareProvider.name` SHALL equal `"square"`.
#### Scenario: default idempotency key uses injected randomId
- **WHEN** `createIntent` is invoked without `idempotencyKey` and the provider was constructed with `randomId: () => "deterministic-id"`
- **THEN** the recording `FetchLike` observes a request body whose `idempotency_key` equals `"deterministic-id"`
**Verified by:** `src/providers/square/square.test.ts::"has correct name"` AND `src/providers/square/square.test.ts::"creates a payment"` AND `src/providers/square/square.test.ts::"creates manual capture payment"` AND `src/providers/square/square.test.ts::"returns current state (no-op for Square)"` AND `src/providers/square/square.test.ts::"completes a payment"` AND `src/providers/square/square.test.ts::"cancels a payment"` AND `src/providers/square/square.test.ts::"creates a refund"` AND `src/providers/square/square.test.ts::"refunds without amount"` AND `src/providers/square/square.test.ts::"fetches a payment"` AND `src/providers/square/square.test.ts::"maps status APPROVED to authorized"` AND `src/providers/square/square.test.ts::"maps status COMPLETED to captured"` AND `src/providers/square/square.test.ts::"maps status CANCELED to canceled"` AND `src/providers/square/square.test.ts::"maps status FAILED to failed"` AND `src/providers/square/square.test.ts::"maps status PENDING to pending"` AND `src/providers/square/square.test.ts::"maps unknown status to failed"` AND `src/providers/square/square.test.ts::"maps payment without card details"` AND `src/providers/square/square.test.ts::"maps refund without reason"` AND `src/providers/square/square.test.ts::"maps refund status PENDING"` AND `src/providers/square/square.test.ts::"maps refund status REJECTED to failed"` AND `src/providers/square/square.test.ts::"uses injected randomId for default idempotency key"`
---
### Requirement: MollieProvider lifecycle with string-amount marshalling
The package SHALL export `MollieProvider` implementing
`PaymentProvider`. The provider SHALL:
- POST `${baseUrl}/payments` for `createIntent`, marshalling `amount`
to a `{ currency, value: "50.00" }` shape via decimal formatting,
defaulting `description` to `"Payment"` and `redirectUrl` to
`"https://localhost/return"` when not supplied.
- DELETE `${baseUrl}/payments/{id}` for `cancelIntent`.
- POST `${baseUrl}/payments/{id}/refunds` for `refund`.
- GET `${baseUrl}/payments/{id}` for `getIntent`.
- `confirmIntent` and `captureIntent` SHALL be read-throughs to
`getIntent` (Mollie auto-captures).
- Set `Authorization: Bearer ${apiKey}` on every call.
- Map status `open` → `created`; `pending` → `pending`; `authorized` →
`authorized`; `paid` → `captured`; `canceled` / `expired` →
`canceled`; `failed` → `failed`; every other status → `failed`.
- Map refund status `refunded` → `succeeded`; `pending` / `queued` /
`processing` → `pending`; every other status → `failed`.
- Map `method === "creditcard"` → `type: "card"`; every other method →
`type: "wallet"`.
`MollieProvider.name` SHALL equal `"mollie"`.
#### Scenario: amount marshalling round-trip
- **WHEN** `createIntent({ amount: { amount: 5000n, currency: "EUR" } })` is invoked
- **THEN** the recording `FetchLike` observes a request body whose `amount.value === "50.00"` and `amount.currency === "EUR"`
**Verified by:** `src/providers/mollie/mollie.test.ts::"has correct name"` AND `src/providers/mollie/mollie.test.ts::"creates a payment"` AND `src/providers/mollie/mollie.test.ts::"uses default description and redirect"` AND `src/providers/mollie/mollie.test.ts::"returns current state on confirm"` AND `src/providers/mollie/mollie.test.ts::"returns current state on capture (Mollie auto-captures)"` AND `src/providers/mollie/mollie.test.ts::"cancels a payment"` AND `src/providers/mollie/mollie.test.ts::"creates a refund"` AND `src/providers/mollie/mollie.test.ts::"refunds without explicit amount"` AND `src/providers/mollie/mollie.test.ts::"fetches a payment"` AND `src/providers/mollie/mollie.test.ts::"maps status open to created"` AND `src/providers/mollie/mollie.test.ts::"maps status pending to pending"` AND `src/providers/mollie/mollie.test.ts::"maps status authorized to authorized"` AND `src/providers/mollie/mollie.test.ts::"maps status paid to captured"` AND `src/providers/mollie/mollie.test.ts::"maps status canceled to canceled"` AND `src/providers/mollie/mollie.test.ts::"maps status expired to canceled"` AND `src/providers/mollie/mollie.test.ts::"maps status failed to failed"` AND `src/providers/mollie/mollie.test.ts::"maps unknown status to failed"` AND `src/providers/mollie/mollie.test.ts::"maps refund status refunded to succeeded"` AND `src/providers/mollie/mollie.test.ts::"maps refund status pending to pending"` AND `src/providers/mollie/mollie.test.ts::"maps refund status queued to pending"` AND `src/providers/mollie/mollie.test.ts::"maps refund status processing to pending"` AND `src/providers/mollie/mollie.test.ts::"maps refund status failed to failed"` AND `src/providers/mollie/mollie.test.ts::"maps payment without method"` AND `src/providers/mollie/mollie.test.ts::"maps non-card method as wallet"`
---
### Requirement: AdyenProvider lifecycle with merchant-account context
The package SHALL export `AdyenProvider` implementing
`PaymentProvider`. The provider SHALL:
- POST `${baseUrl}/payments` for `createIntent`, including
`merchantAccount`, `amount`, `reference` (caller-supplied
`idempotencyKey` or injected-`randomId`-generated), optional
`shopperStatement`, `returnUrl`, `paymentMethod.storedPaymentMethodId`,
`metadata`.
- POST `${baseUrl}/payments/{id}/{details,captures,cancels,refunds}`
for `confirmIntent` / `captureIntent` / `cancelIntent` / `refund`.
- GET `${baseUrl}/payments/{id}` for `getIntent`.
- Set `X-API-Key: ${apiKey}` on every call and forward
`Idempotency-Key` on `createIntent` / `refund` when supplied.
- Map result `Authorised` → `authorized`; `Refused` / `Error` →
`failed`; `Cancelled` → `canceled`; `Pending` / `Received` /
`RedirectShopper` / `IdentifyShopper` / `ChallengeShopper` /
`PresentToShopper` → `pending`; every other code → `failed`.
- Map refund status `received` → `pending`; every other status →
`failed`.
- Map payment-method type `scheme` → `card`; every other type →
`wallet`.
`AdyenProvider.name` SHALL equal `"adyen"`.
#### Scenario: merchant-account context forwarded on capture / cancel / refund
- **WHEN** `captureIntent`, `cancelIntent`, or `refund` is invoked
- **THEN** the recording `FetchLike` observes a request body containing `merchantAccount: "<configured-value>"`
**Verified by:** `src/providers/adyen/adyen.test.ts::"has correct name"` AND `src/providers/adyen/adyen.test.ts::"creates a payment"` AND `src/providers/adyen/adyen.test.ts::"uses automatic capture by default"` AND `src/providers/adyen/adyen.test.ts::"submits payment details on confirm"` AND `src/providers/adyen/adyen.test.ts::"captures a payment"` AND `src/providers/adyen/adyen.test.ts::"cancels a payment"` AND `src/providers/adyen/adyen.test.ts::"creates a refund"` AND `src/providers/adyen/adyen.test.ts::"refunds without amount"` AND `src/providers/adyen/adyen.test.ts::"fetches a payment"` AND `src/providers/adyen/adyen.test.ts::"maps result Authorised to authorized"` AND `src/providers/adyen/adyen.test.ts::"maps result Refused to failed"` AND `src/providers/adyen/adyen.test.ts::"maps result Cancelled to canceled"` AND `src/providers/adyen/adyen.test.ts::"maps result Error to failed"` AND `src/providers/adyen/adyen.test.ts::"maps result Pending to pending"` AND `src/providers/adyen/adyen.test.ts::"maps result Received to pending"` AND `src/providers/adyen/adyen.test.ts::"maps result RedirectShopper to pending"` AND `src/providers/adyen/adyen.test.ts::"maps result IdentifyShopper to pending"` AND `src/providers/adyen/adyen.test.ts::"maps result ChallengeShopper to pending"` AND `src/providers/adyen/adyen.test.ts::"maps result PresentToShopper to pending"` AND `src/providers/adyen/adyen.test.ts::"maps unknown result to failed"` AND `src/providers/adyen/adyen.test.ts::"maps payment without paymentMethod"` AND `src/providers/adyen/adyen.test.ts::"maps non-scheme payment type as wallet"` AND `src/providers/adyen/adyen.test.ts::"maps refund received to pending"` AND `src/providers/adyen/adyen.test.ts::"maps other refund statuses to failed"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,74 @@
# Tasks — add @suaveplan/payment-providers
> Every checkbox MUST reflect work actually done in this change. Retroactive ticking is forbidden.
## 1. Spec authoring
- [x] 1.1 Read `../suaveplan/enterprise/payment-providers.md` end-to-end.
- [x] 1.2 Read upstream source at `../suaveplan/packages/universal/payment-providers/src/` end-to-end.
- [x] 1.3 Read `openspec/AGENTS.md` §13 + §14.
- [x] 1.4 Draft `proposal.md` from template.
- [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-providers/spec.md` from template.
- [x] 1.7 Every requirement carries a `**Verified by:**` line at draft time.
## 2. Implementation
- [x] 2.1 `src/types/provider.ts` — `ApiKeyConfig`, `OAuthConfig`, `MerchantKeyConfig`, `ProviderResponse<T>`, `HttpOptions`. Add optional `fetch?: FetchLike`, `now?: () => number`, `randomId?: () => string`, `base64Encode?: (s: string) => string`, `signal?: AbortSignal` config fields.
- [x] 2.2 `src/error-codes/error-codes.ts` — `PAYMENT_PROVIDER_CODES` (`HTTP_ERROR`, `TIMEOUT`, `PARSE_ERROR`, `OAUTH_TOKEN_FAILURE`) via `defineCodes`; `PaymentProviderError extends SuaveplanError<PaymentProviderErrorCode>` carrying `provider`/`httpStatus`/`bodyExcerpt`/`url` discriminants; 4 factory functions.
- [x] 2.3 `src/http/http.ts` — `FetchLike` type; `httpRequest<T>` with injectable `FetchLike`, timeout via `AbortSignal`, JSON serialisation, typed-error wrapping.
- [x] 2.4 `src/providers/stripe/stripe.ts` — `StripeProvider implements PaymentProvider`; status + refund-reason mapping; idempotency key forwarding; injectable `FetchLike`.
- [x] 2.5 `src/providers/paypal/paypal.ts` — `PayPalProvider implements PaymentProvider`; OAuth2 client-credentials token cache; injectable `now()` for TTL and `base64Encode` for Basic header.
- [x] 2.6 `src/providers/square/square.ts` — `SquareProvider implements PaymentProvider`; `Square-Version` header; injectable `randomId` for default idempotency keys.
- [x] 2.7 `src/providers/mollie/mollie.ts` — `MollieProvider implements PaymentProvider`; string amount marshalling; redirect URL default.
- [x] 2.8 `src/providers/adyen/adyen.ts` — `AdyenProvider implements PaymentProvider`; merchant-account context; injectable `randomId`.
- [x] 2.9 Per-feature `index.ts` barrels — explicit named re-exports only (no `export *`).
- [x] 2.10 `src/index.ts` — explicit named root barrel re-exporting every public symbol.
- [x] 2.11 `package.json` at `0.1.0` with `workspace:^` for `@suaveplan/{payment,error}` and `catalog:build` for `tslib`; 9 subpath exports; `sideEffects: false`.
- [x] 2.12 `vite.config.ts` + `tsconfig{,.lib}.json` + `vitest.config.ts` + `tests/setup/polyfills.ts`.
- [x] 2.13 `tests/dep-graph.test.ts` — universal-tier purity grep.
## 3. Tests
- [x] 3.1 `src/error-codes/error-codes.test.ts` — every code reachable via factory; discriminants set / unset; cause propagation.
- [x] 3.2 `src/http/http.test.ts` — GET / POST / DELETE / PATCH, custom headers, JSON body, empty body, 204, non-ok with `message`, non-ok with `error`, timeout abort, typed-error wrapping.
- [x] 3.3 `src/providers/stripe/stripe.test.ts` — every method, status-mapping matrix (7 entries), refund-reason mapping (4 entries), idempotency header, no-payment-method branch, custom baseUrl.
- [x] 3.4 `src/providers/paypal/paypal.test.ts` — every method, OAuth token cache hit / refresh / expiry boundary, AUTHORIZE vs CAPTURE intent, status-mapping matrix, refund-status mapping, missing payments field.
- [x] 3.5 `src/providers/square/square.test.ts` — every method, status-mapping matrix, refund-status mapping (COMPLETED / PENDING / REJECTED), default-idempotency-key via injected `randomId`, no-card-details branch, refund-without-reason.
- [x] 3.6 `src/providers/mollie/mollie.test.ts` — every method, status-mapping matrix (8 entries), refund-status mapping (5 entries), default description / redirect, non-card method as wallet, missing method.
- [x] 3.7 `src/providers/adyen/adyen.test.ts` — every method, status-mapping matrix (11 entries), refund-status mapping (received → pending, other → failed), idempotency header, missing paymentMethod, non-scheme type as wallet.
- [x] 3.8 `tests/dep-graph.test.ts` — grep for forbidden `node:` / `bun:` / `fs` / `path` / `crypto` / `http` imports.
- [x] 3.9 Every `it(...)` carries `{ timeout: 5000 }` (or 30000 for async network — none here, all are deterministic with injected `FetchLike`).
## 4. Documentation
- [x] 4.1 Sibling `.md` for every implementation `.ts` (10 modules) — ≥200 words, basic + advanced examples, full API reference.
- [x] 4.2 `README.md` linking every co-located `.md` under `## Modules`.
- [x] 4.3 Update `CLAUDE.md` package listing — payment-providers entry already exists under the package family, no edit required; verified before archive.
## 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-providers` — green.
- [x] 5.3 `bunx turbo test:coverage --filter=@suaveplan/payment-providers` — 100/100/100/100.
- [x] 5.4 `bunx turbo build --filter=@suaveplan/payment-providers` — green.
- [x] 5.5 Banned-pattern scan (`@deprecated`/`TODO`/`FIXME`/`ts-ignore`/`ts-expect-error`/`.skip`/`.only`) — zero output.
## 6. Pre-archive 9-row checklist (AGENTS.md §13)
- [x] 6.1 Generics threaded; no `any`.
- [x] 6.2 Zod schemas wired at every declared boundary — N/A (deferred per `## Out of scope`; provider contracts are upstream-validated upstream of the network).
- [x] 6.3 Every error code in `PAYMENT_PROVIDER_CODES` thrown by a production path.
- [x] 6.4 Strict-mode behaviours tested both on and off — N/A (no strict-mode toggle).
- [x] 6.5 OTel instruments emitted from production paths — N/A (deferred per `## Out of scope`).
- [x] 6.6 Lifecycle dedup tested where claimed — yes (PayPal token cache hit / refresh tests).
- [x] 6.7 No retroactive tick.
- [x] 6.8 No `export *` in any feature-module barrel.
- [x] 6.9 No direct `vitest` / `bun:test` imports in test files.
## 7. Archive
- [x] 7.1 Fold spec delta into `openspec/specs/payment-providers/spec.md`.
- [x] 7.2 Move `openspec/changes/add-payment-providers/` → `openspec/archive/2026-05-17-add-payment-providers/`.
- [x] 7.3 Refresh `MANIFEST.md` via `bun scripts/folder-reorg/generate-manifest.ts`.
- [x] 7.4 Update `STATUS.md` — flip `@suaveplan/payment-providers` from Pending to Completed.