Initial Commit
This commit is contained in:
72
packages/money/payment/payment-provider-mollie/README.md
Normal file
72
packages/money/payment/payment-provider-mollie/README.md
Normal 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).
|
||||
|
||||
67
packages/money/payment/payment-provider-mollie/package.json
Normal file
67
packages/money/payment/payment-provider-mollie/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
formatMollieAmount,
|
||||
MOLLIE_DEFAULT_BASE_URL,
|
||||
MOLLIE_PROVIDER_NAME,
|
||||
MollieProvider,
|
||||
mapMollieRefundStatus,
|
||||
mapMollieStatus,
|
||||
parseMollieAmount,
|
||||
} from "./mollie/mollie.js";
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
formatMollieAmount,
|
||||
MOLLIE_DEFAULT_BASE_URL,
|
||||
MOLLIE_PROVIDER_NAME,
|
||||
MollieProvider,
|
||||
mapMollieRefundStatus,
|
||||
mapMollieStatus,
|
||||
parseMollieAmount,
|
||||
} from "./mollie.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 { };
|
||||
12
packages/money/payment/payment-provider-mollie/tsconfig.json
Normal file
12
packages/money/payment/payment-provider-mollie/tsconfig.json
Normal 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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user