Files
2026-05-27 23:29:53 +01:00

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:

  1. 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.
  2. 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 bigint end-to-end and proves it under test).
  3. 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:

  1. Satisfies rule 8 (no vi.spyOn(globalThis, "fetch")).
  2. Lets every test assert on the exact url + init the provider produced — including the Authorization header and the JSON body shape.
  3. Runs identically under bun test and vitest run because 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) — proves bigint end-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.
  • parseMollieAmount with 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 (fetch throws) surfaces as a PaymentProviderError with code PAYMENT_PROVIDER.HTTP_ERROR — this path is exercised by the slim core's httpRequest and is covered transitively by passing a rejecting fetch into 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, or fetch itself throws a non-AbortError.
  • PAYMENT_PROVIDER.TIMEOUT — the per-call timeoutMs elapsed.
  • 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.