8.9 KiB
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
MollieProviderclass implementingPaymentProviderfrom@suaveplan/payment. Constructor acceptsApiKeyConfig(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-unitsbigint. Pure, exported, total over the shape Mollie emits.mapMollieStatus(status: string): PaymentStatusandmapMollieRefundStatus(status: string): RefundStatus— pure, exported, total status maps with documented unknown-status fallback.- Subpath exports
./mollie/molliefor tree-shaking.
REMOVED
Nothing — this is a fresh package.
MODIFIED
STATUS.md— new row for@suaveplan/payment-provider-mollieflipped 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.
// 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
webhookSecretfield onApiKeyConfigis 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
PaymentProvidercontract. Future siblings can layer on top. - OpenTelemetry spans / counters — deferred to align with
@suaveplan/paymentand@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
genesisimports 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 everyPaymentProvidermethod using injectedFetchLikeclosures that record requests and return controlledResponses. Covers:createIntenthappy path + default description / redirectUrlconfirmIntent/captureIntentread-throughcancelIntentHTTP DELETErefundwith explicit amount + withoutgetIntentHTTP 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.