214 lines
8.6 KiB
Markdown
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.
|