175 lines
8.9 KiB
Markdown
175 lines
8.9 KiB
Markdown
# 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`.
|