Initial Commit
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user