# 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`.