Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,72 @@
# @suaveplan/payment-provider-mollie - Universal Mollie Payments API v2 adapter for @suaveplan/payment. Sibling provider package on the slim @suaveplan/payment-providers core: implements PaymentProvider with Mollie's string-amount marshalling ({ currency, value: "X.YY" }) round-trip via exported formatMollieAmount / parseMollieAmount helpers.
Universal Mollie Payments API v2 adapter for `@suaveplan/payment`.
Sibling provider package on the slim `@suaveplan/payment-providers`
core: implements `PaymentProvider` with Mollie's string-amount
marshalling (`{ currency, value: "X.YY" }`) round-trip via the
exported `formatMollieAmount` / `parseMollieAmount` helpers.
<!-- begin:auto-docs -->
## Documentation tree
- [mollie](./src/mollie/mollie.md) — `MollieProvider` is the sibling adapter that implements the `PaymentProvider` contract from `@suaveplan/payment…
- [mollie](./src/mollie/mollie.md) — `MollieProvider` is the sibling adapter that implements the `PaymentProvider` contract from `@suaveplan/payment…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/payment-provider-mollie @suaveplan/payment-providers @suaveplan/payment
```
## Quick start
```ts
import { ProviderRegistry } from "@suaveplan/payment-providers";
import { MollieProvider } from "@suaveplan/payment-provider-mollie";
const registry = new ProviderRegistry();
registry.register(
new MollieProvider({ apiKey: process.env.MOLLIE_API_KEY ?? "" })
);
const mollie = registry.get("mollie");
const intent = await mollie.createIntent({
amount: { amount: 5000n, currency: "EUR" },
description: "Order #42",
returnUrl: "https://shop.example/return",
});
console.log(intent.id, intent.status);
```
## Features
- `MollieProvider` — full `PaymentProvider` lifecycle
(`createIntent`, `confirmIntent`, `captureIntent`, `cancelIntent`,
`refund`, `getIntent`) on top of the slim `httpRequest` helper.
- Pure, exported amount helpers — `formatMollieAmount(amount: bigint)`
and `parseMollieAmount(value: string)` — that round-trip losslessly
via `bigint` and cover the 1-cent, exact-major, large-value, and
over-precision edges.
- Total exported status maps — `mapMollieStatus` and
`mapMollieRefundStatus` — each individually unit-testable without
invoking the network path.
- `Authorization: Bearer <apiKey>` header auto-applied on every call.
- Universal — no Node-specific or browser-specific globals; runs
on Node 18+, Bun, browsers, and Web Workers.
- All failures surface as `PaymentProviderError` from
`@suaveplan/payment-providers` carrying a `PAYMENT_PROVIDER_*` code.
## Modules
- [`src/mollie/mollie.md`](./src/mollie/mollie.md) —
`MollieProvider` class, helpers, and status maps.
## Sibling packages
Part of the `@suaveplan/payment-provider-*` family. See
[`@suaveplan/payment-providers`](../payment-providers/README.md) for the
shared foundation (`ProviderRegistry`, `httpRequest`,
`composeAbortSignal`, `PaymentProviderError`, the `PAYMENT_PROVIDER_*`
code registry, and the shared config shapes).

View File

@@ -0,0 +1,67 @@
{
"name": "@suaveplan/payment-provider-mollie",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Universal Mollie Payments API v2 adapter for @suaveplan/payment. Sibling provider package on the slim @suaveplan/payment-providers core: implements PaymentProvider with Mollie's string-amount marshalling ({ currency, value: \"X.YY\" }) round-trip via exported formatMollieAmount / parseMollieAmount helpers.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./mollie/mollie": {
"import": {
"types": "./dist/mollie/mollie.d.ts",
"default": "./dist/mollie/mollie.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/payment": "workspace:^",
"@suaveplan/payment-providers": "workspace:^",
"tslib": "catalog:build"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,9 @@
export {
formatMollieAmount,
MOLLIE_DEFAULT_BASE_URL,
MOLLIE_PROVIDER_NAME,
MollieProvider,
mapMollieRefundStatus,
mapMollieStatus,
parseMollieAmount,
} from "./mollie/mollie.js";

View File

@@ -0,0 +1,9 @@
export {
formatMollieAmount,
MOLLIE_DEFAULT_BASE_URL,
MOLLIE_PROVIDER_NAME,
MollieProvider,
mapMollieRefundStatus,
mapMollieStatus,
parseMollieAmount,
} from "./mollie.js";

View File

@@ -0,0 +1,185 @@
# MollieProvider
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`MollieProvider` is the sibling adapter that implements the
`PaymentProvider` contract from `@suaveplan/payment` against the Mollie
Payments API v2. It is the only `@suaveplan/payment-provider-*` sibling
that has to marshal money amounts to and from a string representation:
every Mollie request and response carries amounts as
`{ currency: "EUR", value: "10.00" }`, while the rest of the suaveplan
payment family models money as `bigint` minor-units. This package
owns that round-trip end-to-end and exposes the underlying helpers
(`formatMollieAmount`, `parseMollieAmount`) so callers can test or reuse
them in their own integrations.
## Features
- `MollieProvider` class implementing `PaymentProvider` with name
`"mollie"`.
- Constructor takes `ApiKeyConfig` from `@suaveplan/payment-providers`
(`apiKey`, optional `baseUrl`, `timeoutMs`, `fetch`, `signal`,
`webhookSecret`).
- Auto-sets `Authorization: Bearer <apiKey>` on every call.
- Uses the slim-core `httpRequest` helper for JSON serialisation,
per-call timeout, external `AbortSignal` composition, and typed
error surfacing.
- Defaults `description` to `"Payment"` and `redirectUrl` to
`"https://localhost/return"` when omitted, matching upstream
behaviour.
- `confirmIntent` and `captureIntent` are read-throughs to `getIntent`
because Mollie auto-captures.
- Pure, exported amount helpers (`formatMollieAmount`,
`parseMollieAmount`) and status maps (`mapMollieStatus`,
`mapMollieRefundStatus`) — all individually unit-testable.
- All failures surface as `PaymentProviderError` carrying a
`PAYMENT_PROVIDER_*` code; raw `Error` is never thrown.
## Usage: Basic Example
```ts
import { ProviderRegistry } from "@suaveplan/payment-providers";
import { MollieProvider } from "@suaveplan/payment-provider-mollie";
const registry = new ProviderRegistry();
registry.register(new MollieProvider({ apiKey: process.env.MOLLIE_API_KEY ?? "" }));
const mollie = registry.get("mollie");
const intent = await mollie.createIntent({
amount: { amount: 5000n, currency: "EUR" },
description: "Order #42",
returnUrl: "https://shop.example/return",
});
console.log(intent.id, intent.status);
```
## Usage: Advanced Example
```ts
import { MollieProvider, formatMollieAmount, parseMollieAmount } from "@suaveplan/payment-provider-mollie";
import type { FetchLike } from "@suaveplan/payment-providers";
// Inject a recording FetchLike for tests — no global monkey-patching.
const recorded: { url: string; init?: RequestInit }[] = [];
const recordingFetch: FetchLike = async (url, init) => {
recorded.push({ url, init });
return new Response(JSON.stringify({
id: "tr_xyz",
status: "paid",
amount: { value: "12.34", currency: "EUR" },
createdAt: "2026-01-01T00:00:00+00:00",
}), { status: 200, headers: { "content-type": "application/json" } });
};
const provider = new MollieProvider({
apiKey: "test_xyz",
baseUrl: "https://mollie.mock/v2",
timeoutMs: 5_000,
fetch: recordingFetch,
});
// Round-trip amount marshalling without hitting the network:
const wire = formatMollieAmount(1234n); // "12.34"
const back = parseMollieAmount(wire); // 1234n
// Drive the full lifecycle for assertions:
const intent = await provider.createIntent({
amount: { amount: 1234n, currency: "EUR" },
});
const captured = await provider.captureIntent(intent.id); // read-through
const refund = await provider.refund({
paymentIntentId: intent.id,
amount: { amount: 1234n, currency: "EUR" },
});
```
## API Reference
### `MollieProvider`
- `new MollieProvider(config: ApiKeyConfig)` — constructs the adapter.
`config.apiKey` is required; `baseUrl`, `timeoutMs`, `fetch`,
`signal`, and `webhookSecret` are optional. `baseUrl` defaults to
`MOLLIE_DEFAULT_BASE_URL`; `timeoutMs` defaults to `30_000`.
- `name: "mollie"` — literal-typed instance field.
- `createIntent(input: CreatePaymentIntentInput): Promise<PaymentIntent>` —
POST `${baseUrl}/payments`. Defaults `description` and `redirectUrl`
when omitted.
- `confirmIntent(intentId: string): Promise<PaymentIntent>` —
read-through to `getIntent`.
- `captureIntent(intentId: string): Promise<PaymentIntent>` —
read-through to `getIntent` (Mollie auto-captures).
- `cancelIntent(intentId: string): Promise<PaymentIntent>` — DELETE
`${baseUrl}/payments/${intentId}`.
- `refund(input: RefundInput): Promise<Refund>` — POST
`${baseUrl}/payments/${intentId}/refunds`. Omits the body's `amount`
field when `input.amount` is undefined (Mollie issues a full refund).
- `getIntent(intentId: string): Promise<PaymentIntent>` — GET
`${baseUrl}/payments/${intentId}`.
- Throws: `PaymentProviderError` with code
`PAYMENT_PROVIDER.HTTP_ERROR` / `PAYMENT_PROVIDER.TIMEOUT` /
`PAYMENT_PROVIDER.PARSE_ERROR`.
### `MOLLIE_DEFAULT_BASE_URL: "https://api.mollie.com/v2"`
Production Mollie REST v2 base URL constant.
### `MOLLIE_PROVIDER_NAME: "mollie"`
Stable provider identifier.
### `formatMollieAmount(amount: bigint): string`
Format a minor-units `bigint` into a two-decimal string. Always
produces exactly two minor digits, pads left for sub-major values
(`1n` → `"0.01"`), and supports negative values.
### `parseMollieAmount(value: string): bigint`
Parse a Mollie wire-format decimal string into a minor-units `bigint`.
Accepts no-decimal, single-decimal, two-decimal, or over-precision
inputs; over-precision is truncated, never rounded.
### `mapMollieStatus(status: string): PaymentStatus`
Total map from Mollie's payment-status string to the canonical
`PaymentStatus` union. Unknown values fall back to `"failed"`.
### `mapMollieRefundStatus(status: string): RefundStatus`
Total map from Mollie's refund-status string to the canonical
`RefundStatus` union. Unknown values fall back to `"failed"`.
## Implementation Notes
- The package is universal — no Node-specific or browser-specific
globals. The only runtime concept beyond the language standard is
the WHATWG `fetch` surface, available in every supported runtime.
- Tests inject a recording `FetchLike` closure into the constructor
instead of mutating `globalThis.fetch`. This satisfies rule 8 (no
mocks) and matches the binance / ecb sibling pattern.
- `confirmIntent` and `captureIntent` are deliberate read-throughs to
`getIntent`: Mollie auto-captures, so there is no separate confirm or
capture step at the protocol level. Returning the current state
preserves the `PaymentProvider` contract without falsifying the
underlying behaviour.
- `formatMollieAmount` and `parseMollieAmount` are inverses for every
`bigint` produced from a Mollie response. Round-trip is exact for
zero, single cent, exact major unit, and large values up to
`2^53`-and-beyond range (`bigint` is arbitrary-precision; the helpers
never go through `number`).
- Over-precision in `parseMollieAmount` (`"10.123"` → `1012n`) is
truncation, not rounding. Mollie emits exactly minor-unit precision;
over-precision is upstream garbage and the safest action is to
ignore the excess rather than to bias the result.
- The card-brand normalisation accepts any of `visa` / `mastercard` /
`amex` / `discover` / `diners` / `jcb` / `unionpay`; every other
label collapses to `"unknown"` rather than leaking arbitrary strings
into the `CardBrand` union.
- Forwarded `AbortSignal` composition (caller `signal` + per-call
timeout) is handled by the slim-core `httpRequest`; this package
does not re-implement it.

View File

@@ -0,0 +1,538 @@
import {
PAYMENT_PROVIDER_CODES,
PaymentProviderError,
} from "@suaveplan/payment-providers";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
formatMollieAmount,
MOLLIE_DEFAULT_BASE_URL,
MOLLIE_PROVIDER_NAME,
MollieProvider,
mapMollieRefundStatus,
mapMollieStatus,
parseMollieAmount,
} from "./mollie.js";
interface Recorded {
readonly url: string;
readonly init: RequestInit | undefined;
}
interface RecorderShape {
readonly fetch: (url: string, init?: RequestInit) => Promise<Response>;
readonly calls: Recorded[];
}
function recorder(
handler: (url: string, init: RequestInit | undefined) => Response
): RecorderShape {
const calls: Recorded[] = [];
const fetchImpl = async (
url: string,
init?: RequestInit
): Promise<Response> => {
calls.push({ url, init });
return handler(url, init);
};
return { fetch: fetchImpl, calls };
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
const MOLLIE_PAYMENT = {
id: "tr_123",
status: "paid",
amount: { value: "50.00", currency: "EUR" },
description: "Test payment",
metadata: { orderId: "42" },
method: "creditcard",
details: { cardNumber: "************4242", cardLabel: "Visa" },
createdAt: "2026-01-01T00:00:00+00:00",
};
const MOLLIE_REFUND = {
id: "re_123",
paymentId: "tr_123",
amount: { value: "20.00", currency: "EUR" },
status: "refunded",
createdAt: "2026-01-02T00:00:00+00:00",
};
function makeProvider(rec: RecorderShape): MollieProvider {
return new MollieProvider({
apiKey: "test_xyz",
baseUrl: "https://mollie.mock/v2",
fetch: rec.fetch,
});
}
function headersFromInit(
init: RequestInit | undefined
): Record<string, string> {
return (init?.headers as Record<string, string>) ?? {};
}
function bodyFromInit(init: RequestInit | undefined): Record<string, unknown> {
const body = init?.body;
if (typeof body !== "string") {
return {};
}
return JSON.parse(body) as Record<string, unknown>;
}
describe("MollieProvider — identity", () => {
it("exposes the literal provider name", { timeout: 5000 }, () => {
const rec = recorder(() => jsonResponse(MOLLIE_PAYMENT));
const provider = makeProvider(rec);
expect(provider.name).toBe("mollie");
expect(MOLLIE_PROVIDER_NAME).toBe("mollie");
});
it("exposes the default Mollie base URL constant", {
timeout: 5000,
}, () => {
expect(MOLLIE_DEFAULT_BASE_URL).toBe("https://api.mollie.com/v2");
});
it("falls back to the default base URL when none provided", {
timeout: 5000,
}, async () => {
const rec = recorder(() => jsonResponse(MOLLIE_PAYMENT));
const provider = new MollieProvider({
apiKey: "test_xyz",
fetch: rec.fetch,
});
await provider.getIntent("tr_123");
expect(rec.calls[0]?.url).toBe(
"https://api.mollie.com/v2/payments/tr_123"
);
});
it("sends Authorization: Bearer header on every request", {
timeout: 5000,
}, async () => {
const rec = recorder(() => jsonResponse(MOLLIE_PAYMENT));
const provider = makeProvider(rec);
await provider.getIntent("tr_123");
await provider.cancelIntent("tr_123");
for (const call of rec.calls) {
expect(headersFromInit(call.init).Authorization).toBe(
"Bearer test_xyz"
);
}
expect(rec.calls).toHaveLength(2);
});
});
describe("MollieProvider — createIntent", () => {
it("marshals amount to two-decimal string on createIntent", {
timeout: 5000,
}, async () => {
const rec = recorder(() =>
jsonResponse({ ...MOLLIE_PAYMENT, status: "open" })
);
const provider = makeProvider(rec);
const result = await provider.createIntent({
amount: { amount: 5000n, currency: "EUR" },
description: "Test",
metadata: { orderId: "42" },
returnUrl: "https://example.com/return",
});
const body = bodyFromInit(rec.calls[0]?.init);
const amount = body.amount as { currency: string; value: string };
expect(amount.value).toBe("50.00");
expect(amount.currency).toBe("EUR");
expect(body.description).toBe("Test");
expect(body.redirectUrl).toBe("https://example.com/return");
expect(result.id).toBe("tr_123");
expect(result.status).toBe("created");
});
it("applies default description and redirectUrl when omitted", {
timeout: 5000,
}, async () => {
const rec = recorder(() =>
jsonResponse({ ...MOLLIE_PAYMENT, status: "open" })
);
const provider = makeProvider(rec);
await provider.createIntent({
amount: { amount: 1000n, currency: "EUR" },
});
const body = bodyFromInit(rec.calls[0]?.init);
expect(body.description).toBe("Payment");
expect(body.redirectUrl).toBe("https://localhost/return");
expect(body.metadata).toBeUndefined();
});
it("forwards metadata on createIntent", { timeout: 5000 }, async () => {
const rec = recorder(() =>
jsonResponse({ ...MOLLIE_PAYMENT, status: "open" })
);
const provider = makeProvider(rec);
await provider.createIntent({
amount: { amount: 1000n, currency: "EUR" },
metadata: { orderId: "99" },
});
const body = bodyFromInit(rec.calls[0]?.init);
expect(body.metadata).toEqual({ orderId: "99" });
});
});
describe("MollieProvider — read-throughs", () => {
it("confirmIntent reads through to getIntent", {
timeout: 5000,
}, async () => {
const rec = recorder(() => jsonResponse(MOLLIE_PAYMENT));
const provider = makeProvider(rec);
const result = await provider.confirmIntent("tr_123");
expect(result.id).toBe("tr_123");
expect(rec.calls[0]?.init?.method).toBe("GET");
expect(rec.calls[0]?.url).toBe(
"https://mollie.mock/v2/payments/tr_123"
);
});
it("captureIntent reads through to getIntent", {
timeout: 5000,
}, async () => {
const rec = recorder(() => jsonResponse(MOLLIE_PAYMENT));
const provider = makeProvider(rec);
const result = await provider.captureIntent("tr_123");
expect(result.status).toBe("captured");
expect(result.capturedAmount.amount).toBe(5000n);
});
});
describe("MollieProvider — cancelIntent", () => {
it("cancelIntent issues DELETE and maps canceled status", {
timeout: 5000,
}, async () => {
const rec = recorder(() =>
jsonResponse({ ...MOLLIE_PAYMENT, status: "canceled" })
);
const provider = makeProvider(rec);
const result = await provider.cancelIntent("tr_123");
expect(rec.calls[0]?.init?.method).toBe("DELETE");
expect(rec.calls[0]?.url).toBe(
"https://mollie.mock/v2/payments/tr_123"
);
expect(result.status).toBe("canceled");
});
});
describe("MollieProvider — refund", () => {
it("refund posts with explicit amount and round-trips through parseMollieAmount", {
timeout: 5000,
}, async () => {
const rec = recorder(() => jsonResponse(MOLLIE_REFUND));
const provider = makeProvider(rec);
const result = await provider.refund({
paymentIntentId: "tr_123",
amount: { amount: 2000n, currency: "EUR" },
});
const body = bodyFromInit(rec.calls[0]?.init);
const amount = body.amount as { currency: string; value: string };
expect(amount.value).toBe("20.00");
expect(amount.currency).toBe("EUR");
expect(rec.calls[0]?.url).toBe(
"https://mollie.mock/v2/payments/tr_123/refunds"
);
expect(result.id).toBe("re_123");
expect(result.status).toBe("succeeded");
expect(result.amount.amount).toBe(2000n);
});
it("refund without explicit amount posts empty body", {
timeout: 5000,
}, async () => {
const rec = recorder(() => jsonResponse(MOLLIE_REFUND));
const provider = makeProvider(rec);
const result = await provider.refund({ paymentIntentId: "tr_123" });
const body = bodyFromInit(rec.calls[0]?.init);
expect(body).toEqual({});
expect(result.id).toBe("re_123");
});
});
describe("MollieProvider — getIntent + payment-method mapping", () => {
it("getIntent maps a creditcard payment to card with brand and last4", {
timeout: 5000,
}, async () => {
const rec = recorder(() => jsonResponse(MOLLIE_PAYMENT));
const provider = makeProvider(rec);
const result = await provider.getIntent("tr_123");
expect(result.id).toBe("tr_123");
expect(result.paymentMethod?.type).toBe("card");
expect(result.paymentMethod?.brand).toBe("visa");
expect(result.paymentMethod?.last4).toBe("4242");
});
it("getIntent normalises an unknown card label to 'unknown' brand", {
timeout: 5000,
}, async () => {
const rec = recorder(() =>
jsonResponse({
...MOLLIE_PAYMENT,
details: {
cardNumber: "************0000",
cardLabel: "Bancomat",
},
})
);
const provider = makeProvider(rec);
const result = await provider.getIntent("tr_123");
expect(result.paymentMethod?.brand).toBe("unknown");
});
it("getIntent maps a non-card method to wallet", {
timeout: 5000,
}, async () => {
const rec = recorder(() =>
jsonResponse({
...MOLLIE_PAYMENT,
method: "ideal",
details: undefined,
})
);
const provider = makeProvider(rec);
const result = await provider.getIntent("tr_123");
expect(result.paymentMethod?.type).toBe("wallet");
expect(result.paymentMethod?.last4).toBeUndefined();
expect(result.paymentMethod?.brand).toBeUndefined();
});
it("getIntent yields undefined paymentMethod when method is missing", {
timeout: 5000,
}, async () => {
const rec = recorder(() =>
jsonResponse({ ...MOLLIE_PAYMENT, method: undefined })
);
const provider = makeProvider(rec);
const result = await provider.getIntent("tr_123");
expect(result.paymentMethod).toBeUndefined();
});
it("getIntent omits description and metadata when Mollie omits them", {
timeout: 5000,
}, async () => {
const rec = recorder(() =>
jsonResponse({
...MOLLIE_PAYMENT,
description: undefined,
metadata: undefined,
})
);
const provider = makeProvider(rec);
const result = await provider.getIntent("tr_123");
expect(result.description).toBeUndefined();
expect(result.metadata).toBeUndefined();
});
it("getIntent reports zero captured for non-paid statuses", {
timeout: 5000,
}, async () => {
const rec = recorder(() =>
jsonResponse({ ...MOLLIE_PAYMENT, status: "open" })
);
const provider = makeProvider(rec);
const result = await provider.getIntent("tr_123");
expect(result.capturedAmount.amount).toBe(0n);
});
});
describe("mapMollieStatus", () => {
it("mapMollieStatus covers every defined Mollie status", {
timeout: 5000,
}, () => {
expect(mapMollieStatus("open")).toBe("created");
expect(mapMollieStatus("pending")).toBe("pending");
expect(mapMollieStatus("authorized")).toBe("authorized");
expect(mapMollieStatus("paid")).toBe("captured");
expect(mapMollieStatus("canceled")).toBe("canceled");
expect(mapMollieStatus("expired")).toBe("canceled");
expect(mapMollieStatus("failed")).toBe("failed");
});
it("mapMollieStatus falls back to failed on unknown status", {
timeout: 5000,
}, () => {
expect(mapMollieStatus("unknown")).toBe("failed");
expect(mapMollieStatus("")).toBe("failed");
});
});
describe("mapMollieRefundStatus", () => {
it("mapMollieRefundStatus covers every defined refund status", {
timeout: 5000,
}, () => {
expect(mapMollieRefundStatus("refunded")).toBe("succeeded");
expect(mapMollieRefundStatus("pending")).toBe("pending");
expect(mapMollieRefundStatus("queued")).toBe("pending");
expect(mapMollieRefundStatus("processing")).toBe("pending");
expect(mapMollieRefundStatus("failed")).toBe("failed");
});
it("mapMollieRefundStatus falls back to failed on unknown status", {
timeout: 5000,
}, () => {
expect(mapMollieRefundStatus("weird")).toBe("failed");
});
});
describe("formatMollieAmount", () => {
it("formatMollieAmount produces 0.00 for zero", { timeout: 5000 }, () => {
expect(formatMollieAmount(0n)).toBe("0.00");
});
it("formatMollieAmount pads single cent to 0.01", {
timeout: 5000,
}, () => {
expect(formatMollieAmount(1n)).toBe("0.01");
expect(formatMollieAmount(9n)).toBe("0.09");
});
it("formatMollieAmount produces two trailing zeros for exact major unit", {
timeout: 5000,
}, () => {
expect(formatMollieAmount(100n)).toBe("1.00");
expect(formatMollieAmount(1000n)).toBe("10.00");
});
it("formatMollieAmount handles large bigint values without loss", {
timeout: 5000,
}, () => {
expect(formatMollieAmount(12345678901234n)).toBe("123456789012.34");
expect(parseMollieAmount(formatMollieAmount(12345678901234n))).toBe(
12345678901234n
);
});
it("formatMollieAmount formats negative amounts with leading minus", {
timeout: 5000,
}, () => {
expect(formatMollieAmount(-50n)).toBe("-0.50");
expect(formatMollieAmount(-12345n)).toBe("-123.45");
});
});
describe("parseMollieAmount", () => {
it("parseMollieAmount accepts no-decimal, single-decimal, and two-decimal strings", {
timeout: 5000,
}, () => {
expect(parseMollieAmount("10")).toBe(1000n);
expect(parseMollieAmount("10.5")).toBe(1050n);
expect(parseMollieAmount("10.50")).toBe(1050n);
expect(parseMollieAmount("0.01")).toBe(1n);
expect(parseMollieAmount("0.00")).toBe(0n);
});
it("parseMollieAmount truncates over-precision without rounding", {
timeout: 5000,
}, () => {
expect(parseMollieAmount("10.123")).toBe(1012n);
expect(parseMollieAmount("10.999")).toBe(1099n);
});
it("parseMollieAmount parses negative wire values", {
timeout: 5000,
}, () => {
expect(parseMollieAmount("-10.50")).toBe(-1050n);
});
it("formatMollieAmount and parseMollieAmount round-trip for every minted bigint", {
timeout: 5000,
}, () => {
const samples: bigint[] = [
0n,
1n,
9n,
10n,
99n,
100n,
999n,
1_000n,
123_456n,
999_999_999n,
12_345_678_901_234n,
];
for (const n of samples) {
expect(parseMollieAmount(formatMollieAmount(n))).toBe(n);
}
});
});
describe("MollieProvider — error surfaces", () => {
it("surfaces a PaymentProviderError when fetch rejects", {
timeout: 5000,
}, async () => {
const failingFetch = async (): Promise<Response> => {
throw new TypeError("network down");
};
const provider = new MollieProvider({
apiKey: "test_xyz",
baseUrl: "https://mollie.mock/v2",
fetch: failingFetch,
});
await expect(provider.getIntent("tr_123")).rejects.toBeInstanceOf(
PaymentProviderError
);
try {
await provider.getIntent("tr_123");
} catch (err) {
const e = err as PaymentProviderError;
expect(e.code).toBe(PAYMENT_PROVIDER_CODES.HTTP_ERROR);
expect(e.provider).toBe("mollie");
}
});
it("surfaces HTTP_ERROR when Mollie responds with 422", {
timeout: 5000,
}, async () => {
const rec = recorder(() =>
jsonResponse({ message: "validation failed" }, 422)
);
const provider = makeProvider(rec);
try {
await provider.createIntent({
amount: { amount: 5000n, currency: "EUR" },
});
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(PaymentProviderError);
const e = err as PaymentProviderError;
expect(e.code).toBe(PAYMENT_PROVIDER_CODES.HTTP_ERROR);
expect(e.httpStatus).toBe(422);
expect(e.provider).toBe("mollie");
}
});
});
describe("MollieProvider — signal forwarding", () => {
it("propagates the constructor AbortSignal into the FetchLike init", {
timeout: 5000,
}, async () => {
const controller = new AbortController();
const seen: (AbortSignal | undefined)[] = [];
const observingFetch = async (
_url: string,
init?: RequestInit
): Promise<Response> => {
seen.push(init?.signal ?? undefined);
return jsonResponse(MOLLIE_PAYMENT);
};
const provider = new MollieProvider({
apiKey: "test_xyz",
baseUrl: "https://mollie.mock/v2",
fetch: observingFetch,
signal: controller.signal,
});
await provider.getIntent("tr_123");
expect(seen[0]).toBeDefined();
expect(seen[0]?.aborted).toBe(false);
});
});

View File

@@ -0,0 +1,332 @@
import type {
CardBrand,
CreatePaymentIntentInput,
PaymentIntent,
PaymentMethod,
PaymentProvider,
PaymentStatus,
Refund,
RefundInput,
RefundStatus,
} from "@suaveplan/payment";
import {
type ApiKeyConfig,
type FetchLike,
httpRequest,
} from "@suaveplan/payment-providers";
/**
* Default Mollie Payments REST v2 base URL. Override via
* `ApiKeyConfig.baseUrl` for testing or for routing through a
* regional gateway. The value is the canonical production endpoint
* Mollie publishes in their v2 docs.
*/
export const MOLLIE_DEFAULT_BASE_URL = "https://api.mollie.com/v2";
/** Stable provider identifier surfaced through `PaymentProvider.name`. */
export const MOLLIE_PROVIDER_NAME = "mollie";
const DEFAULT_TIMEOUT_MS = 30_000;
const DEFAULT_DESCRIPTION = "Payment";
const DEFAULT_REDIRECT_URL = "https://localhost/return";
const MINOR_UNIT_DIGITS = 2;
const HUNDRED = 100n;
interface MollieAmountWire {
readonly currency: string;
readonly value: string;
}
interface MolliePayment {
readonly id: string;
readonly status: string;
readonly amount: MollieAmountWire;
readonly description?: string;
readonly metadata?: Record<string, string>;
readonly method?: string;
readonly details?: {
readonly cardNumber?: string;
readonly cardLabel?: string;
};
readonly createdAt: string;
}
interface MollieRefund {
readonly id: string;
readonly paymentId: string;
readonly amount: MollieAmountWire;
readonly status: string;
readonly description?: string;
readonly createdAt: string;
}
/**
* Format a minor-units `bigint` into a Mollie-compatible two-decimal
* string. The output is always exactly two decimal places, padded
* left for sub-major values (`1n` → `"0.01"`) and trailing zeros
* preserved for whole-major values (`100n` → `"1.00"`).
*/
export function formatMollieAmount(amount: bigint): string {
const sign = amount < 0n ? "-" : "";
const abs = amount < 0n ? -amount : amount;
const major = abs / HUNDRED;
const minor = abs % HUNDRED;
return `${sign}${major.toString()}.${minor.toString().padStart(MINOR_UNIT_DIGITS, "0")}`;
}
/**
* Parse a Mollie wire-format decimal string into a minor-units `bigint`.
* Accepts strings with no decimal (`"10"` → `1000n`), single-decimal
* (`"10.5"` → `1050n`), exact two-decimal (`"10.50"` → `1050n`), or
* over-precision (`"10.123"` → `1012n` — excess minor digits are
* truncated, never rounded).
*/
export function parseMollieAmount(value: string): bigint {
const trimmed = value.trim();
const negative = trimmed.startsWith("-");
const body = negative ? trimmed.slice(1) : trimmed;
const dot = body.indexOf(".");
const majorRaw = dot === -1 ? body : body.slice(0, dot);
const minorRaw = dot === -1 ? "" : body.slice(dot + 1);
const minorPadded = minorRaw
.padEnd(MINOR_UNIT_DIGITS, "0")
.slice(0, MINOR_UNIT_DIGITS);
const majorBig = BigInt(majorRaw);
const minorBig = BigInt(minorPadded);
const total = majorBig * HUNDRED + minorBig;
return negative ? -total : total;
}
/**
* Map a Mollie payment status to the canonical `PaymentStatus` union
* from `@suaveplan/payment`. Unknown / future statuses fall back to
* `"failed"` — a forward-defensive choice that surfaces unrecognised
* states as failures rather than silently treating them as success.
*/
export function mapMollieStatus(status: string): PaymentStatus {
switch (status) {
case "open":
return "created";
case "pending":
return "pending";
case "authorized":
return "authorized";
case "paid":
return "captured";
case "canceled":
case "expired":
return "canceled";
default:
return "failed";
}
}
/**
* Map a Mollie refund status to the canonical `RefundStatus` union.
* Mollie publishes `refunded`, `pending`, `queued`, `processing`, and
* `failed`; unknown values fall back to `"failed"`.
*/
export function mapMollieRefundStatus(status: string): RefundStatus {
switch (status) {
case "refunded":
return "succeeded";
case "pending":
case "queued":
case "processing":
return "pending";
default:
return "failed";
}
}
const KNOWN_CARD_BRANDS: readonly CardBrand[] = [
"visa",
"mastercard",
"amex",
"discover",
"diners",
"jcb",
"unionpay",
"unknown",
];
function normaliseCardBrand(label: string | undefined): CardBrand | undefined {
if (label === undefined) {
return undefined;
}
const lower = label.toLowerCase();
return (KNOWN_CARD_BRANDS as readonly string[]).includes(lower)
? (lower as CardBrand)
: "unknown";
}
function buildPaymentMethod(payment: MolliePayment): PaymentMethod | undefined {
if (payment.method === undefined) {
return undefined;
}
const type = payment.method === "creditcard" ? "card" : "wallet";
const last4 = payment.details?.cardNumber?.slice(-4);
const brand = normaliseCardBrand(payment.details?.cardLabel);
const method: PaymentMethod = {
id: `mollie_${payment.id}`,
type,
...(brand !== undefined ? { brand } : {}),
...(last4 !== undefined ? { last4 } : {}),
};
return method;
}
function mapPayment(payment: MolliePayment): PaymentIntent {
const currency = payment.amount.currency;
const total = parseMollieAmount(payment.amount.value);
const captured = payment.status === "paid" ? total : 0n;
const paymentMethod = buildPaymentMethod(payment);
const intent: PaymentIntent = {
id: payment.id,
status: mapMollieStatus(payment.status),
amount: { amount: total, currency },
capturedAmount: { amount: captured, currency },
refundedAmount: { amount: 0n, currency },
captureMethod: "automatic",
...(payment.description !== undefined
? { description: payment.description }
: {}),
...(paymentMethod !== undefined ? { paymentMethod } : {}),
refunds: [],
...(payment.metadata !== undefined
? { metadata: payment.metadata }
: {}),
createdAt: payment.createdAt,
updatedAt: payment.createdAt,
};
return intent;
}
function mapRefund(refund: MollieRefund): Refund {
return {
id: refund.id,
paymentIntentId: refund.paymentId,
amount: {
amount: parseMollieAmount(refund.amount.value),
currency: refund.amount.currency,
},
status: mapMollieRefundStatus(refund.status),
createdAt: refund.createdAt,
};
}
/**
* Mollie Payments API v2 adapter. Implements the `PaymentProvider`
* contract from `@suaveplan/payment` on top of the slim
* `@suaveplan/payment-providers` core. Marshals genesis Money
* (`bigint` minor-units) to / from Mollie's `{ currency, value: "X.YY" }`
* wire shape, sets `Authorization: Bearer ${apiKey}` on every call,
* and raises only `PaymentProviderError`.
*/
export class MollieProvider implements PaymentProvider {
public readonly name = MOLLIE_PROVIDER_NAME;
private readonly apiKey: string;
private readonly baseUrl: string;
private readonly timeoutMs: number;
private readonly fetchImpl: FetchLike | undefined;
private readonly signal: AbortSignal | undefined;
public constructor(config: ApiKeyConfig) {
this.apiKey = config.apiKey;
this.baseUrl = config.baseUrl ?? MOLLIE_DEFAULT_BASE_URL;
this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
this.fetchImpl = config.fetch;
this.signal = config.signal;
}
public async createIntent(
input: CreatePaymentIntentInput
): Promise<PaymentIntent> {
const body: Record<string, unknown> = {
amount: {
currency: input.amount.currency,
value: formatMollieAmount(input.amount.amount),
},
description: input.description ?? DEFAULT_DESCRIPTION,
redirectUrl: input.returnUrl ?? DEFAULT_REDIRECT_URL,
};
if (input.metadata !== undefined) {
body["metadata"] = input.metadata;
}
const { data } = await httpRequest<MolliePayment>({
method: "POST",
url: `${this.baseUrl}/payments`,
headers: this.headers(),
body,
...this.commonOptions(),
});
return mapPayment(data);
}
public async confirmIntent(intentId: string): Promise<PaymentIntent> {
return this.getIntent(intentId);
}
public async captureIntent(intentId: string): Promise<PaymentIntent> {
return this.getIntent(intentId);
}
public async cancelIntent(intentId: string): Promise<PaymentIntent> {
const { data } = await httpRequest<MolliePayment>({
method: "DELETE",
url: `${this.baseUrl}/payments/${intentId}`,
headers: this.headers(),
...this.commonOptions(),
});
return mapPayment(data);
}
public async refund(input: RefundInput): Promise<Refund> {
const body: Record<string, unknown> = {};
if (input.amount !== undefined) {
body["amount"] = {
currency: input.amount.currency,
value: formatMollieAmount(input.amount.amount),
};
}
const { data } = await httpRequest<MollieRefund>({
method: "POST",
url: `${this.baseUrl}/payments/${input.paymentIntentId}/refunds`,
headers: this.headers(),
body,
...this.commonOptions(),
});
return mapRefund(data);
}
public async getIntent(intentId: string): Promise<PaymentIntent> {
const { data } = await httpRequest<MolliePayment>({
method: "GET",
url: `${this.baseUrl}/payments/${intentId}`,
headers: this.headers(),
...this.commonOptions(),
});
return mapPayment(data);
}
private headers(): Record<string, string> {
return { Authorization: `Bearer ${this.apiKey}` };
}
private commonOptions(): {
readonly timeoutMs: number;
readonly provider: string;
readonly fetch: FetchLike | undefined;
readonly signal: AbortSignal | undefined;
} {
return {
timeoutMs: this.timeoutMs,
provider: MOLLIE_PROVIDER_NAME,
fetch: this.fetchImpl,
signal: this.signal,
};
}
}

View File

@@ -0,0 +1,6 @@
// Polyfills for test environment — none required. The package is universal
// and uses only `Response`, `BigInt`, `Number.parseInt`, the WHATWG `fetch`
// surface, and an injected `FetchLike` closure — all available in every
// supported runtime (Node 18+, Bun, browsers, Web Workers). This file exists
// so vitest has a stable setup file path mirroring the sibling-package layout.
export { };

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,35 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/payment-provider-mollie",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/payment",
"@suaveplan/payment-providers",
"tslib",
],
},
},
});

View File

@@ -0,0 +1,32 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/*.bench.ts",
"src/__benchmarks__/**",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 30000,
},
});