8.6 KiB
Design: add-payment-provider-mollie
This design doc captures the non-normative rationale for the
@suaveplan/payment-provider-mollie sibling adapter. The cross-cutting
architecture for the @suaveplan/payment-provider* family — the
foundation-with-sibling-plugins shape, the slim core, the registry
contract, the shared HTTP helpers — lives in
../../archive/2026-05-17-slim-payment-providers/design.md.
This file references that document by relative path and does not
duplicate it.
File layout
packages/universal/payment-provider-mollie/
package.json
README.md
vite.config.ts
vitest.config.ts
tsconfig.json
tsconfig.lib.json
src/
index.ts # explicit named barrel (rule 6a)
mollie/
index.ts # explicit named feature barrel
mollie.ts # MollieProvider class + helpers
mollie.md # sibling docs (rule 4)
mollie.test.ts # vitest, runner from @suaveplan/testing/runner
tests/
setup/
polyfills.ts # no-op (universal tier; placeholder)
The mollie/ feature folder mirrors the binance / ecb sibling layout
(packages/finance/currency-provider-binance/). A single feature folder
is appropriate because the entire adapter is one class plus four pure
helpers — splitting further would manufacture surface area without
clarifying responsibility.
Public surface
// from "@suaveplan/payment-provider-mollie"
export class MollieProvider implements PaymentProvider {
readonly name: "mollie";
constructor(config: ApiKeyConfig);
createIntent(input: CreatePaymentIntentInput): Promise<PaymentIntent>;
confirmIntent(intentId: string): Promise<PaymentIntent>;
captureIntent(intentId: string): Promise<PaymentIntent>;
cancelIntent(intentId: string): Promise<PaymentIntent>;
refund(input: RefundInput): Promise<Refund>;
getIntent(intentId: string): Promise<PaymentIntent>;
}
export const MOLLIE_DEFAULT_BASE_URL: "https://api.mollie.com/v2";
export const MOLLIE_PROVIDER_NAME: "mollie";
export function formatMollieAmount(amount: bigint): string;
export function parseMollieAmount(value: string): bigint;
export function mapMollieStatus(status: string): PaymentStatus;
export function mapMollieRefundStatus(status: string): RefundStatus;
formatMollieAmount and parseMollieAmount are exported (not
file-locals as in the upstream) because:
- They are the package's contribution to the family — every other
sibling marshals integer minor-units to a numeric field; only Mollie
has the string-
{currency,value}shape. - Pure helpers are individually unit-testable for the carry / padding /
large-value edges that bit hand-rolled implementations elsewhere
(see e.g. the upstream's silent loss of cents on values >= 10^15
when implemented with floating-point arithmetic — this package uses
bigintend-to-end and proves it under test). - Future Mollie-adjacent siblings (e.g. a hypothetical Mollie Customers / Subscriptions adapter) can reuse the helpers without re-implementation.
The status maps (mapMollieStatus, mapMollieRefundStatus) are
exported for the same reason: they are pure, total, and individually
testable without invoking the network path.
Amount marshalling
Mollie wire shape (REST API v2):
{ "amount": { "currency": "EUR", "value": "10.00" } }
The value is always a decimal-formatted string with exactly the
currency's minor-unit precision (2 for most ISO 4217 fiat). The genesis
Money model is bigint minor-units:
{ amount: 1000n, currency: "EUR" } // 10.00 EUR
formatMollieAmount:
0n→"0.00"1n→"0.01"(single cent — pad-left to 2)100n→"1.00"12345n→"123.45"12345678901234n→"123456789012.34"(large; bigint avoids overflow)
parseMollieAmount:
"0.00"→0n"0.01"→1n"10"→1000n(no decimal point — treat minor-units as zero)"10.5"→1050n(single decimal — pad-right to 2)"10.123"→1012n(over-precision — truncate, do not round; Mollie emits exactly minor-unit precision, so over-precision is upstream garbage and the safest action is to ignore the excess)"123456789012.34"→12345678901234n(large; bigint round-trip)
The two helpers are inverses for every bigint produced from a Mollie
response. For minted values the round-trip is exact.
The package targets the two-decimal currencies that Mollie supports (EUR, USD, GBP, CAD, AUD, CHF, DKK, NOK, SEK, PLN, HUF, CZK, BGN, RON, HRK, ISK, JPY-via-Mollie's special handling, etc.). Mollie's API documentation defines minor-unit precision per currency; for the currencies Mollie currently accepts, all use 2 decimals. JPY is not in Mollie's supported list as of API v2, so the universal 2-decimal assumption is currently lossless. If Mollie adds a 0-decimal currency in the future the helpers can be parameterised — out of scope for this change.
Tests
Tests inject a recording FetchLike closure into the constructor
instead of monkey-patching globalThis.fetch. This:
- Satisfies rule 8 (no
vi.spyOn(globalThis, "fetch")). - Lets every test assert on the exact
url+initthe provider produced — including theAuthorizationheader and the JSON body shape. - Runs identically under
bun testandvitest runbecause no runtime globals are mutated.
The recording adapter pattern is taken verbatim from
@suaveplan/payment-providers's own http.test.ts — see the
recorder(handler) helper there. This package re-implements it locally
(small, file-local, and the testing package does not own the shape).
Edge cases the test plan covers explicitly:
- 1-cent amount (
1n) — proves leading-zero padding. - Exact major-unit amount (
100n,1000n) — proves trailing-zero padding. - Large amount (
12345678901234n) — provesbigintend-to-end with no floating-point loss. - Round-trip: every minted intent's
amount.amount === bigint(input). - Round-trip: refund parsed from Mollie response equals the bigint that was sent.
parseMollieAmountwith a minor-only value ("0.99"), a single-decimal value ("5.5"), a no-decimal value ("10"), and an over-precision value ("10.123").- Status mapping for every defined Mollie status + an arbitrary unknown string.
- Refund-status mapping for every defined Mollie refund status + an arbitrary unknown string.
- Payment-method mapping for
creditcard/ideal/ undefined. - Network failure (
fetchthrows) surfaces as aPaymentProviderErrorwith codePAYMENT_PROVIDER.HTTP_ERROR— this path is exercised by the slim core'shttpRequestand is covered transitively by passing a rejectingfetchinto the constructor. - Non-OK HTTP response (e.g. 422 from Mollie) surfaces as
PaymentProviderError(PAYMENT_PROVIDER.HTTP_ERROR).
Errors
Every failure surfaces as PaymentProviderError from the slim core:
PAYMENT_PROVIDER.HTTP_ERROR— Mollie returns non-2xx, orfetchitself throws a non-AbortError.PAYMENT_PROVIDER.TIMEOUT— the per-calltimeoutMselapsed.PAYMENT_PROVIDER.PARSE_ERROR— Mollie returns a non-JSON body (unexpected at the protocol level but defensive).
These all originate inside httpRequest. The adapter never throws
raw Error.
Tier purity
Universal-tier — no Node-specific imports (node:fs, node:crypto,
Buffer, process.env), no browser-only globals (window,
document, localStorage). The package imports:
@suaveplan/payment— types only (PaymentProvider,PaymentIntent,PaymentStatus,RefundStatus,CreatePaymentIntentInput,RefundInput,Refund).@suaveplan/payment-providers—httpRequest,composeAbortSignal,FetchLike,ApiKeyConfig,PaymentProviderError,PAYMENT_PROVIDER_CODES,httpError,parseError.tslib— runtime helpers (catalog dep).
composeAbortSignal is imported per the task brief; it is not used in
the production path (the slim-core httpRequest already composes the
caller signal with its internal timeout) but is re-exported so future
adapter additions (e.g. webhook fetch, listing fetch) can wire it.
Re-export is via the package barrel only — the implementation does not
hold a reference, so tree-shaking remains exact.
Versioning + publish
0.1.0 — every fresh genesis package starts at 0.1.0. Promotion to
1.0.0 is gated on the broader payment-provider family graduation.
publishConfig.registry points at the SuavePlan Verdaccio
(https://npmjs.fq.io/) per the sibling pattern.