Files
suaveplan/openspec/archive/2026-05-17-add-payment-provider-mollie/proposal.md
2026-05-27 23:29:53 +01:00

175 lines
8.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Add @suaveplan/payment-provider-mollie — sibling Mollie adapter on the slim core
**Package**: `@suaveplan/payment-provider-mollie`
**Status**: PROPOSED
**Type**: new package (genesis-original, follow-up to the slim refactor)
- **Design**: ./design.md
- **Spec delta**: ./specs/payment-provider-mollie/spec.md
## Why
The slim refactor (`openspec/archive/2026-05-17-slim-payment-providers/`)
removed `MollieProvider` from `@suaveplan/payment-providers` and
committed it to re-land as a sibling package. This change ships that
sibling — `@suaveplan/payment-provider-mollie` — implementing the
`PaymentProvider` contract from `@suaveplan/payment` on top of the slim
core (`httpRequest`, `composeAbortSignal`, `ApiKeyConfig`,
`PaymentProviderError`, `PAYMENT_PROVIDER_CODES`).
Mollie's quirk is **amount marshalling**: every money field on the wire
is a `{ currency, value }` object where `value` is a decimal-formatted
string (`"50.00"`, `"0.01"`, `"12345678.90"`). The genesis money model
is `bigint` minor-units via `@suaveplan/money` (`{ amount: 5000n,
currency: "EUR" }`). The provider owns the round-trip — formatting
minor-units to two-decimal strings for outbound requests, and parsing
incoming strings back to `bigint` minor-units — with full coverage of
the carry / padding / large-value edges that bit hand-rolled
implementations elsewhere.
## What changes
### ADDED
- `MollieProvider` class implementing
`PaymentProvider` from `@suaveplan/payment`. Constructor accepts
`ApiKeyConfig` (apiKey + optional baseUrl / timeoutMs / fetch /
signal / webhookSecret).
- `MOLLIE_DEFAULT_BASE_URL = "https://api.mollie.com/v2"`.
- `MOLLIE_PROVIDER_NAME = "mollie"`.
- `formatMollieAmount(amount: bigint): string` — minor-units →
`"X.YY"` two-decimal string. Pure, exported, deterministic.
- `parseMollieAmount(value: string): bigint` — `"X.YY"` →
minor-units `bigint`. Pure, exported, total over the shape Mollie
emits.
- `mapMollieStatus(status: string): PaymentStatus` and
`mapMollieRefundStatus(status: string): RefundStatus` — pure,
exported, total status maps with documented unknown-status fallback.
- Subpath exports `./mollie/mollie` for tree-shaking.
### REMOVED
Nothing — this is a fresh package.
### MODIFIED
- `STATUS.md` — new row for `@suaveplan/payment-provider-mollie`
flipped to ✅.
- `SUMMARY.md` — new paragraph for the sibling.
- `MANIFEST.md` / `MISSING.yml` — refreshed via the generator scripts.
## Source mapping
The shape of the adapter is anchored to upstream `MollieProvider` at
`/mnt/s/web/suaveplan/packages/universal/payment-providers/src/mollie.ts`
(the pre-slim, pre-sibling implementation) and to the original migration
spec at
`openspec/archive/2026-05-17-add-payment-providers/specs/payment-providers/spec.md`
(Mollie requirement block, L228–L258). The class signature, HTTP verbs,
status mapping, refund-status mapping, payment-method mapping, default
description / redirectUrl, and `Authorization: Bearer ${apiKey}` header
are preserved verbatim.
Departures from upstream:
| Surface | Upstream | Sibling | Reason |
|---|---|---|---|
| `vi.spyOn(globalThis, "fetch")` in tests | yes | replaced with real `FetchLike` closure injected via constructor | rule 8 / slim-core pattern — recording adapter, no global monkey-patching |
| `vi` / `vitest` test-runner import | yes | `@suaveplan/testing/runner` | AGENTS.md §7 |
| `@suaveplan/payment-providers` internal `httpRequest` | yes | unchanged — now imported from the public slim-core export | follow-up to slim refactor |
| Amount-formatting helpers (`formatAmount`, `parseAmount`) | private file-locals | exported (`formatMollieAmount`, `parseMollieAmount`) | reusable, individually unit-testable for edge cases |
## Migration
This is a fresh package — there is no in-tree migration. External
consumers that previously imported `MollieProvider` from
`@suaveplan/payment-providers` (pre-slim) MUST switch the import to
`@suaveplan/payment-provider-mollie`. The class name, constructor
signature, and `PaymentProvider` interface contract are preserved
verbatim; only the package name changes.
```ts
// Before (pre-slim)
import { MollieProvider } from "@suaveplan/payment-providers";
// After
import { MollieProvider } from "@suaveplan/payment-provider-mollie";
```
## Out of scope
- Webhook signature verification — Mollie does not sign its webhooks
with a shared secret in the standard v2 API surface that this
adapter implements. The `webhookSecret` field on `ApiKeyConfig` is
accepted (for shape parity with the other sibling providers) but
not consumed by this package.
- iDEAL issuer enumeration, Mollie Profiles API, Mollie Customers
API, Mollie Subscriptions API — out of scope for the
`PaymentProvider` contract. Future siblings can layer on top.
- OpenTelemetry spans / counters — deferred to align with
`@suaveplan/payment` and `@suaveplan/payment-providers`, which also
do not yet ship OTel wiring.
## Impact
### Affected dependents
Run `bunx turbo ls --filter=...@suaveplan/payment-provider-mollie` to
enumerate.
- No dependents (verified by the fresh-package status — this package is
brand-new and nothing on `genesis` imports it yet). Future consumers
(apps, plugins, payment-engine bootstraps) opt in by adding the
dependency.
## Parity Checklist
This is a fresh sibling that preserves the upstream `MollieProvider`
behaviour verbatim. Parity is against
`/mnt/s/web/suaveplan/packages/universal/payment-providers/src/mollie.ts`:
| Capability | Upstream | Migrated | Notes |
|---|---|---|---|
| `MollieProvider implements PaymentProvider` | yes | ✅ | verbatim, name `"mollie"` |
| `ApiKeyConfig` constructor (`apiKey`, optional `baseUrl`, `timeoutMs`, `webhookSecret`, `fetch`, `signal`) | yes | ✅ | now imports `ApiKeyConfig` from the slim core; the upstream version inlined the same shape |
| `Authorization: Bearer ${apiKey}` header on every call | yes | ✅ | unchanged |
| `createIntent` → `POST /payments` | yes | ✅ | unchanged |
| `confirmIntent` → read-through to `getIntent` | yes | ✅ | unchanged (Mollie auto-captures) |
| `captureIntent` → read-through to `getIntent` | yes | ✅ | unchanged (Mollie auto-captures) |
| `cancelIntent` → `DELETE /payments/{id}` | yes | ✅ | unchanged |
| `refund` → `POST /payments/{id}/refunds` | yes | ✅ | unchanged; refund without explicit amount also covered |
| `getIntent` → `GET /payments/{id}` | yes | ✅ | unchanged |
| Default `description = "Payment"` | yes | ✅ | unchanged |
| Default `redirectUrl = "https://localhost/return"` | yes | ✅ | unchanged |
| Status map (`open` → `created`, `pending`, `authorized`, `paid` → `captured`, `canceled`, `expired` → `canceled`, `failed`, other → `failed`) | yes | ✅ | exported as `mapMollieStatus` for direct unit coverage |
| Refund status map (`refunded` → `succeeded`, `pending`/`queued`/`processing` → `pending`, other → `failed`) | yes | ✅ | exported as `mapMollieRefundStatus` |
| Payment method map (`creditcard` → `card`, other defined method → `wallet`, undefined → no method) | yes | ✅ | unchanged |
| `card` brand + last4 extraction from `details` | yes | ✅ | unchanged |
| Amount marshalling: `bigint` → `"X.YY"` (`formatAmount`) | yes | ✅ | extracted + exported as `formatMollieAmount`; covered with edge cases 1 cent, large values, exact decimals |
| Amount marshalling: `"X.YY"` → `bigint` (`parseAmount`) | yes | ✅ | extracted + exported as `parseMollieAmount`; covered with single-decimal, missing decimal, padded decimal |
| All errors raised as `PaymentProviderError` from the slim core | yes | ✅ | unchanged — `httpRequest` raises typed errors; this package never throws raw `Error` |
| `vi.spyOn` in tests | yes | 🚫 replaced | rule 8 / slim-core pattern: tests inject a recording `FetchLike` |
| Direct `from "vitest"` test imports | yes | 🚫 replaced | AGENTS.md §7: `from "@suaveplan/testing/runner"` |
## Verification approach
- `src/mollie/mollie.test.ts` — exercises every `PaymentProvider`
method using injected `FetchLike` closures that record requests and
return controlled `Response`s. Covers:
- `createIntent` happy path + default description / redirectUrl
- `confirmIntent` / `captureIntent` read-through
- `cancelIntent` HTTP DELETE
- `refund` with explicit amount + without
- `getIntent` HTTP GET with card brand + last4 extraction
- Full status-map matrix (all 8 cases incl. unknown → `failed`)
- Full refund-status-map matrix (all 5 cases)
- Payment-method map for `creditcard` / `ideal` / undefined
- Amount marshalling edge cases: 1 cent (`1n` → `"0.01"`), exact
dollar (`100n` → `"1.00"`), large (`12345678901234n` →
`"123456789012.34"`), and the inverse round-trips.
- `Authorization: Bearer …` header surfaced on every recorded call.
- Network failure surfaces as `PaymentProviderError` (HTTP_ERROR).
- All tests import from `@suaveplan/testing/runner`.
- 100/100/100/100 coverage gate (vitest istanbul) enforced in
`vitest.config.ts`.