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

214 lines
8.6 KiB
Markdown

# 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`](../../archive/2026-05-17-slim-payment-providers/design.md).
This file references that document by relative path and does not
duplicate it.
## File layout
```text
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
```ts
// 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):
```json
{ "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:
```ts
{ 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.