Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -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.

View File

@@ -0,0 +1,174 @@
# Add @suaveplan/payment-provider-mollie — sibling Mollie adapter on the slim core
**Package**: `@suaveplan/payment-provider-mollie`
**Status**: PROPOSED
**Type**: new package (genesis-original, follow-up to the slim refactor)
- **Design**: ./design.md
- **Spec delta**: ./specs/payment-provider-mollie/spec.md
## Why
The slim refactor (`openspec/archive/2026-05-17-slim-payment-providers/`)
removed `MollieProvider` from `@suaveplan/payment-providers` and
committed it to re-land as a sibling package. This change ships that
sibling — `@suaveplan/payment-provider-mollie` — implementing the
`PaymentProvider` contract from `@suaveplan/payment` on top of the slim
core (`httpRequest`, `composeAbortSignal`, `ApiKeyConfig`,
`PaymentProviderError`, `PAYMENT_PROVIDER_CODES`).
Mollie's quirk is **amount marshalling**: every money field on the wire
is a `{ currency, value }` object where `value` is a decimal-formatted
string (`"50.00"`, `"0.01"`, `"12345678.90"`). The genesis money model
is `bigint` minor-units via `@suaveplan/money` (`{ amount: 5000n,
currency: "EUR" }`). The provider owns the round-trip — formatting
minor-units to two-decimal strings for outbound requests, and parsing
incoming strings back to `bigint` minor-units — with full coverage of
the carry / padding / large-value edges that bit hand-rolled
implementations elsewhere.
## What changes
### ADDED
- `MollieProvider` class implementing
`PaymentProvider` from `@suaveplan/payment`. Constructor accepts
`ApiKeyConfig` (apiKey + optional baseUrl / timeoutMs / fetch /
signal / webhookSecret).
- `MOLLIE_DEFAULT_BASE_URL = "https://api.mollie.com/v2"`.
- `MOLLIE_PROVIDER_NAME = "mollie"`.
- `formatMollieAmount(amount: bigint): string` — minor-units →
`"X.YY"` two-decimal string. Pure, exported, deterministic.
- `parseMollieAmount(value: string): bigint` — `"X.YY"` →
minor-units `bigint`. Pure, exported, total over the shape Mollie
emits.
- `mapMollieStatus(status: string): PaymentStatus` and
`mapMollieRefundStatus(status: string): RefundStatus` — pure,
exported, total status maps with documented unknown-status fallback.
- Subpath exports `./mollie/mollie` for tree-shaking.
### REMOVED
Nothing — this is a fresh package.
### MODIFIED
- `STATUS.md` — new row for `@suaveplan/payment-provider-mollie`
flipped to ✅.
- `SUMMARY.md` — new paragraph for the sibling.
- `MANIFEST.md` / `MISSING.yml` — refreshed via the generator scripts.
## Source mapping
The shape of the adapter is anchored to upstream `MollieProvider` at
`/mnt/s/web/suaveplan/packages/universal/payment-providers/src/mollie.ts`
(the pre-slim, pre-sibling implementation) and to the original migration
spec at
`openspec/archive/2026-05-17-add-payment-providers/specs/payment-providers/spec.md`
(Mollie requirement block, L228–L258). The class signature, HTTP verbs,
status mapping, refund-status mapping, payment-method mapping, default
description / redirectUrl, and `Authorization: Bearer ${apiKey}` header
are preserved verbatim.
Departures from upstream:
| Surface | Upstream | Sibling | Reason |
|---|---|---|---|
| `vi.spyOn(globalThis, "fetch")` in tests | yes | replaced with real `FetchLike` closure injected via constructor | rule 8 / slim-core pattern — recording adapter, no global monkey-patching |
| `vi` / `vitest` test-runner import | yes | `@suaveplan/testing/runner` | AGENTS.md §7 |
| `@suaveplan/payment-providers` internal `httpRequest` | yes | unchanged — now imported from the public slim-core export | follow-up to slim refactor |
| Amount-formatting helpers (`formatAmount`, `parseAmount`) | private file-locals | exported (`formatMollieAmount`, `parseMollieAmount`) | reusable, individually unit-testable for edge cases |
## Migration
This is a fresh package — there is no in-tree migration. External
consumers that previously imported `MollieProvider` from
`@suaveplan/payment-providers` (pre-slim) MUST switch the import to
`@suaveplan/payment-provider-mollie`. The class name, constructor
signature, and `PaymentProvider` interface contract are preserved
verbatim; only the package name changes.
```ts
// Before (pre-slim)
import { MollieProvider } from "@suaveplan/payment-providers";
// After
import { MollieProvider } from "@suaveplan/payment-provider-mollie";
```
## Out of scope
- Webhook signature verification — Mollie does not sign its webhooks
with a shared secret in the standard v2 API surface that this
adapter implements. The `webhookSecret` field on `ApiKeyConfig` is
accepted (for shape parity with the other sibling providers) but
not consumed by this package.
- iDEAL issuer enumeration, Mollie Profiles API, Mollie Customers
API, Mollie Subscriptions API — out of scope for the
`PaymentProvider` contract. Future siblings can layer on top.
- OpenTelemetry spans / counters — deferred to align with
`@suaveplan/payment` and `@suaveplan/payment-providers`, which also
do not yet ship OTel wiring.
## Impact
### Affected dependents
Run `bunx turbo ls --filter=...@suaveplan/payment-provider-mollie` to
enumerate.
- No dependents (verified by the fresh-package status — this package is
brand-new and nothing on `genesis` imports it yet). Future consumers
(apps, plugins, payment-engine bootstraps) opt in by adding the
dependency.
## Parity Checklist
This is a fresh sibling that preserves the upstream `MollieProvider`
behaviour verbatim. Parity is against
`/mnt/s/web/suaveplan/packages/universal/payment-providers/src/mollie.ts`:
| Capability | Upstream | Migrated | Notes |
|---|---|---|---|
| `MollieProvider implements PaymentProvider` | yes | ✅ | verbatim, name `"mollie"` |
| `ApiKeyConfig` constructor (`apiKey`, optional `baseUrl`, `timeoutMs`, `webhookSecret`, `fetch`, `signal`) | yes | ✅ | now imports `ApiKeyConfig` from the slim core; the upstream version inlined the same shape |
| `Authorization: Bearer ${apiKey}` header on every call | yes | ✅ | unchanged |
| `createIntent` → `POST /payments` | yes | ✅ | unchanged |
| `confirmIntent` → read-through to `getIntent` | yes | ✅ | unchanged (Mollie auto-captures) |
| `captureIntent` → read-through to `getIntent` | yes | ✅ | unchanged (Mollie auto-captures) |
| `cancelIntent` → `DELETE /payments/{id}` | yes | ✅ | unchanged |
| `refund` → `POST /payments/{id}/refunds` | yes | ✅ | unchanged; refund without explicit amount also covered |
| `getIntent` → `GET /payments/{id}` | yes | ✅ | unchanged |
| Default `description = "Payment"` | yes | ✅ | unchanged |
| Default `redirectUrl = "https://localhost/return"` | yes | ✅ | unchanged |
| Status map (`open` → `created`, `pending`, `authorized`, `paid` → `captured`, `canceled`, `expired` → `canceled`, `failed`, other → `failed`) | yes | ✅ | exported as `mapMollieStatus` for direct unit coverage |
| Refund status map (`refunded` → `succeeded`, `pending`/`queued`/`processing` → `pending`, other → `failed`) | yes | ✅ | exported as `mapMollieRefundStatus` |
| Payment method map (`creditcard` → `card`, other defined method → `wallet`, undefined → no method) | yes | ✅ | unchanged |
| `card` brand + last4 extraction from `details` | yes | ✅ | unchanged |
| Amount marshalling: `bigint` → `"X.YY"` (`formatAmount`) | yes | ✅ | extracted + exported as `formatMollieAmount`; covered with edge cases 1 cent, large values, exact decimals |
| Amount marshalling: `"X.YY"` → `bigint` (`parseAmount`) | yes | ✅ | extracted + exported as `parseMollieAmount`; covered with single-decimal, missing decimal, padded decimal |
| All errors raised as `PaymentProviderError` from the slim core | yes | ✅ | unchanged — `httpRequest` raises typed errors; this package never throws raw `Error` |
| `vi.spyOn` in tests | yes | 🚫 replaced | rule 8 / slim-core pattern: tests inject a recording `FetchLike` |
| Direct `from "vitest"` test imports | yes | 🚫 replaced | AGENTS.md §7: `from "@suaveplan/testing/runner"` |
## Verification approach
- `src/mollie/mollie.test.ts` — exercises every `PaymentProvider`
method using injected `FetchLike` closures that record requests and
return controlled `Response`s. Covers:
- `createIntent` happy path + default description / redirectUrl
- `confirmIntent` / `captureIntent` read-through
- `cancelIntent` HTTP DELETE
- `refund` with explicit amount + without
- `getIntent` HTTP GET with card brand + last4 extraction
- Full status-map matrix (all 8 cases incl. unknown → `failed`)
- Full refund-status-map matrix (all 5 cases)
- Payment-method map for `creditcard` / `ideal` / undefined
- Amount marshalling edge cases: 1 cent (`1n` → `"0.01"`), exact
dollar (`100n` → `"1.00"`), large (`12345678901234n` →
`"123456789012.34"`), and the inverse round-trips.
- `Authorization: Bearer …` header surfaced on every recorded call.
- Network failure surfaces as `PaymentProviderError` (HTTP_ERROR).
- All tests import from `@suaveplan/testing/runner`.
- 100/100/100/100 coverage gate (vitest istanbul) enforced in
`vitest.config.ts`.

View File

@@ -0,0 +1,272 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: payment-provider-mollie (add-payment-provider-mollie)
**Package**: `@suaveplan/payment-provider-mollie`
**Status**: PROPOSED
> Every requirement below carries a `**Verified by:**` line at authoring
> time. The package is fresh on `genesis`, so this delta uses only
> `## ADDED Requirements`.
## ADDED Requirements
### Requirement: MollieProvider implements PaymentProvider
The package SHALL export a `MollieProvider` class that implements the
`PaymentProvider` interface from `@suaveplan/payment`. `MollieProvider.name`
SHALL equal the string literal `"mollie"`. The class SHALL accept an
`ApiKeyConfig` (from `@suaveplan/payment-providers`) in its constructor,
storing the resolved `apiKey`, `baseUrl` (default
`https://api.mollie.com/v2`), `timeoutMs` (default `30_000`), and optional
`fetch` / `signal` references for forwarding into the slim-core
`httpRequest` helper. Every outbound request SHALL carry the header
`Authorization: Bearer <apiKey>`.
#### Scenario: provider name is "mollie"
- **WHEN** `new MollieProvider({ apiKey: "test_x" }).name` is read
- **THEN** the value is the string `"mollie"`
#### Scenario: bearer token surfaced on every call
- **WHEN** any provider method dispatches a request through the injected `FetchLike`
- **THEN** the recorded `init.headers.Authorization` equals `"Bearer <apiKey>"`
**Verified by:** `src/mollie/mollie.test.ts::"exposes the literal provider name"` AND `src/mollie/mollie.test.ts::"sends Authorization: Bearer header on every request"`
---
### Requirement: createIntent posts to /payments with Mollie amount shape
`MollieProvider.createIntent(input)` SHALL POST to
`${baseUrl}/payments` with a JSON body that contains `amount: { currency,
value }` where `value` is the result of `formatMollieAmount(input.amount.amount)`.
When `input.description` is `undefined`, the body SHALL default
`description` to the string `"Payment"`. When `input.returnUrl` is
`undefined`, the body SHALL default `redirectUrl` to the string
`"https://localhost/return"`. When `input.metadata` is provided, the
body SHALL include `metadata` verbatim. The response SHALL be parsed
into a `PaymentIntent` via `mapMollieStatus` for `status` and
`parseMollieAmount` for `amount.value`.
#### Scenario: amount marshalled to two-decimal string
- **WHEN** `createIntent({ amount: { amount: 5000n, currency: "EUR" } })` is invoked
- **THEN** the recorded request body's `amount.value === "50.00"` and `amount.currency === "EUR"`
#### Scenario: defaults applied when description / returnUrl omitted
- **WHEN** `createIntent({ amount: { amount: 1000n, currency: "EUR" } })` is invoked with no `description` or `returnUrl`
- **THEN** the recorded request body's `description === "Payment"` and `redirectUrl === "https://localhost/return"`
**Verified by:** `src/mollie/mollie.test.ts::"marshals amount to two-decimal string on createIntent"` AND `src/mollie/mollie.test.ts::"applies default description and redirectUrl when omitted"` AND `src/mollie/mollie.test.ts::"forwards metadata on createIntent"`
---
### Requirement: confirmIntent and captureIntent are read-throughs
`MollieProvider.confirmIntent(intentId)` and
`MollieProvider.captureIntent(intentId)` SHALL both delegate to
`getIntent(intentId)`. Mollie auto-captures, so there is no separate
confirm / capture step at the protocol level.
#### Scenario: confirm dispatches a GET to /payments/{id}
- **WHEN** `confirmIntent("tr_123")` is invoked
- **THEN** exactly one request is recorded with method `GET` and URL ending `/payments/tr_123`
#### Scenario: capture dispatches a GET to /payments/{id}
- **WHEN** `captureIntent("tr_123")` is invoked on a Mollie payment with status `paid`
- **THEN** the returned intent's `status === "captured"`
**Verified by:** `src/mollie/mollie.test.ts::"confirmIntent reads through to getIntent"` AND `src/mollie/mollie.test.ts::"captureIntent reads through to getIntent"`
---
### Requirement: cancelIntent issues HTTP DELETE
`MollieProvider.cancelIntent(intentId)` SHALL dispatch an HTTP `DELETE`
request to `${baseUrl}/payments/${intentId}` and parse the response
through the standard payment-mapping pipeline.
#### Scenario: DELETE dispatched on cancel
- **WHEN** `cancelIntent("tr_123")` is invoked and Mollie returns status `canceled`
- **THEN** the recorded request method is `DELETE` and the returned intent's `status === "canceled"`
**Verified by:** `src/mollie/mollie.test.ts::"cancelIntent issues DELETE and maps canceled status"`
---
### Requirement: refund posts to /payments/{id}/refunds
`MollieProvider.refund(input)` SHALL POST to
`${baseUrl}/payments/${input.paymentIntentId}/refunds`. When
`input.amount` is provided, the body SHALL include
`amount: { currency, value: formatMollieAmount(input.amount.amount) }`;
when omitted, the body SHALL be an empty object so Mollie issues a
full refund. The response SHALL be mapped to a `Refund` with
`status` resolved through `mapMollieRefundStatus` and `amount.amount`
parsed via `parseMollieAmount`.
#### Scenario: refund with explicit amount
- **WHEN** `refund({ paymentIntentId: "tr_123", amount: { amount: 2000n, currency: "EUR" } })` is invoked
- **THEN** the recorded request body's `amount.value === "20.00"` and the returned refund's `amount.amount === 2000n`
#### Scenario: refund without explicit amount
- **WHEN** `refund({ paymentIntentId: "tr_123" })` is invoked
- **THEN** the recorded request body is `{}` (no `amount` field)
**Verified by:** `src/mollie/mollie.test.ts::"refund posts with explicit amount and round-trips through parseMollieAmount"` AND `src/mollie/mollie.test.ts::"refund without explicit amount posts empty body"`
---
### Requirement: getIntent fetches and maps payment + payment method
`MollieProvider.getIntent(intentId)` SHALL GET
`${baseUrl}/payments/${intentId}` and return a `PaymentIntent`. When
the Mollie response carries `method === "creditcard"`, the resulting
`paymentMethod.type` SHALL be `"card"` and `paymentMethod.brand` /
`paymentMethod.last4` SHALL be extracted from `details.cardLabel`
(lowercased) and the last four characters of `details.cardNumber`. When
`method` is any other defined string, the resulting
`paymentMethod.type` SHALL be `"wallet"`. When `method` is `undefined`,
`paymentMethod` SHALL be `undefined`.
#### Scenario: card payment carries brand and last4
- **WHEN** `getIntent("tr_123")` returns a Mollie payment with `method: "creditcard"`, `details: { cardNumber: "************4242", cardLabel: "Visa" }`
- **THEN** the resulting `paymentMethod.type === "card"`, `paymentMethod.brand === "visa"`, `paymentMethod.last4 === "4242"`
#### Scenario: non-card method becomes wallet without last4
- **WHEN** `getIntent("tr_123")` returns a Mollie payment with `method: "ideal"` and no `details`
- **THEN** the resulting `paymentMethod.type === "wallet"` and `paymentMethod.last4 === undefined`
#### Scenario: missing method yields undefined paymentMethod
- **WHEN** `getIntent("tr_123")` returns a Mollie payment with `method: undefined`
- **THEN** the resulting `paymentMethod === undefined`
**Verified by:** `src/mollie/mollie.test.ts::"getIntent maps a creditcard payment to card with brand and last4"` AND `src/mollie/mollie.test.ts::"getIntent maps a non-card method to wallet"` AND `src/mollie/mollie.test.ts::"getIntent yields undefined paymentMethod when method is missing"`
---
### Requirement: mapMollieStatus is exported and total
The package SHALL export `mapMollieStatus(status: string): PaymentStatus`.
The function SHALL map `open` → `created`, `pending` → `pending`,
`authorized` → `authorized`, `paid` → `captured`, `canceled` →
`canceled`, `expired` → `canceled`, `failed` → `failed`, and every
other string → `failed`.
#### Scenario: every Mollie status maps to the documented PaymentStatus
- **WHEN** `mapMollieStatus` is called with each of `open`, `pending`, `authorized`, `paid`, `canceled`, `expired`, `failed`, `unknown`
- **THEN** the results equal `created`, `pending`, `authorized`, `captured`, `canceled`, `canceled`, `failed`, `failed` respectively
**Verified by:** `src/mollie/mollie.test.ts::"mapMollieStatus covers every defined Mollie status"` AND `src/mollie/mollie.test.ts::"mapMollieStatus falls back to failed on unknown status"`
---
### Requirement: mapMollieRefundStatus is exported and total
The package SHALL export
`mapMollieRefundStatus(status: string): RefundStatus`. The function
SHALL map `refunded` → `succeeded`, `pending` / `queued` / `processing`
→ `pending`, and every other string → `failed`.
#### Scenario: every Mollie refund status maps to the documented RefundStatus
- **WHEN** `mapMollieRefundStatus` is called with each of `refunded`, `pending`, `queued`, `processing`, `failed`, `unknown`
- **THEN** the results equal `succeeded`, `pending`, `pending`, `pending`, `failed`, `failed` respectively
**Verified by:** `src/mollie/mollie.test.ts::"mapMollieRefundStatus covers every defined refund status"` AND `src/mollie/mollie.test.ts::"mapMollieRefundStatus falls back to failed on unknown status"`
---
### Requirement: formatMollieAmount and parseMollieAmount round-trip via bigint
The package SHALL export `formatMollieAmount(amount: bigint): string`
and `parseMollieAmount(value: string): bigint`. `formatMollieAmount`
SHALL produce a two-decimal string. `parseMollieAmount` SHALL accept
strings with no decimal, a single decimal place, two decimal places,
or more than two decimal places (truncating excess minor digits without
rounding). For every `bigint` `n >= 0n`,
`parseMollieAmount(formatMollieAmount(n)) === n`.
#### Scenario: one-cent amount formats with leading zero
- **WHEN** `formatMollieAmount(1n)` is called
- **THEN** the result is the string `"0.01"`
#### Scenario: exact major-unit amount formats with trailing zeros
- **WHEN** `formatMollieAmount(100n)` is called
- **THEN** the result is `"1.00"`
#### Scenario: large amount round-trips losslessly
- **WHEN** `formatMollieAmount(12345678901234n)` is then passed back through `parseMollieAmount`
- **THEN** the parsed value equals `12345678901234n`
#### Scenario: parse accepts short or missing minor digits
- **WHEN** `parseMollieAmount("10")`, `parseMollieAmount("10.5")`, and `parseMollieAmount("10.50")` are called
- **THEN** the results equal `1000n`, `1050n`, `1050n` respectively
#### Scenario: parse truncates over-precision
- **WHEN** `parseMollieAmount("10.123")` is called
- **THEN** the result is `1012n`
**Verified by:** `src/mollie/mollie.test.ts::"formatMollieAmount pads single cent to 0.01"` AND `src/mollie/mollie.test.ts::"formatMollieAmount produces two trailing zeros for exact major unit"` AND `src/mollie/mollie.test.ts::"formatMollieAmount handles large bigint values without loss"` AND `src/mollie/mollie.test.ts::"parseMollieAmount accepts no-decimal, single-decimal, and two-decimal strings"` AND `src/mollie/mollie.test.ts::"parseMollieAmount truncates over-precision without rounding"` AND `src/mollie/mollie.test.ts::"formatMollieAmount and parseMollieAmount round-trip for every minted bigint"`
---
### Requirement: failures raise PaymentProviderError only
The package SHALL NOT raise raw `Error`. Every failure path SHALL
surface a `PaymentProviderError` from `@suaveplan/payment-providers`
carrying a `PAYMENT_PROVIDER_*` code. Network failures (rejecting
`fetch`) SHALL map to `PAYMENT_PROVIDER.HTTP_ERROR`; non-2xx HTTP
responses SHALL also map to `PAYMENT_PROVIDER.HTTP_ERROR`.
#### Scenario: rejecting fetch surfaces HTTP_ERROR
- **WHEN** the injected `fetch` rejects with `new TypeError("network down")` during `getIntent`
- **THEN** the call rejects with a `PaymentProviderError` whose `code === "PAYMENT_PROVIDER.HTTP_ERROR"` and whose `provider === "mollie"`
#### Scenario: non-2xx response surfaces HTTP_ERROR
- **WHEN** Mollie responds with HTTP 422 during `createIntent`
- **THEN** the call rejects with `PaymentProviderError(PAYMENT_PROVIDER.HTTP_ERROR)` and `httpStatus === 422`
**Verified by:** `src/mollie/mollie.test.ts::"surfaces a PaymentProviderError when fetch rejects"` AND `src/mollie/mollie.test.ts::"surfaces HTTP_ERROR when Mollie responds with 422"`
---
### Requirement: subpath exports and tier purity
The package SHALL ship subpath exports for the package root (`.`) and
the feature module (`./mollie/mollie`) in `package.json#exports`,
matching the binance sibling layout. The package SHALL be universal-tier
— no `node:*` imports, no browser-only globals — and SHALL list all
runtime deps (`@suaveplan/payment`, `@suaveplan/payment-providers`,
`@suaveplan/error`, `tslib`) in `vite.config.ts#rollupOptions.external`.
#### Scenario: subpath exports count
- **WHEN** `package.json#exports` is inspected
- **THEN** the entries are exactly `./package.json`, `.`, and `./mollie/mollie`
#### Scenario: no Node-specific imports
- **WHEN** `src/` is scanned for `node:` import specifiers
- **THEN** no matches are reported
**Verified by:** `package.json#exports` AND `src/mollie/mollie.ts` (file content)

View File

@@ -0,0 +1,118 @@
# Tasks: add-payment-provider-mollie
## 1. Scaffold package shell
- [x] 1.1 Create `packages/universal/payment-provider-mollie/` with
`package.json` (name `@suaveplan/payment-provider-mollie`, version
`0.1.0`, `sideEffects: false`, type `module`, MIT, author
`Suaveplan Team`, deps `@suaveplan/payment` + `@suaveplan/payment-providers`
- `@suaveplan/error` + `tslib`, devDeps `@suaveplan/testing` +
`@types/node` + `@vitest/coverage-istanbul` + `typescript` + `vite` +
`vite-plugin-dts` + `vitest`, all via `workspace:^` / `catalog:` /
`catalog:build` / `catalog:testing` — zero bare versions).
- [x] 1.2 Add `tsconfig.json` and `tsconfig.lib.json` mirroring the
binance sibling.
- [x] 1.3 Add `vite.config.ts` with `preserveModules`, `lib.entry =
src/index.ts`, externals matching the deps.
- [x] 1.4 Add `vitest.config.ts` with `pool: "forks"`, `istanbul`
coverage at 100/100/100/100, `testTimeout: 30000`,
`setupFiles: ["./tests/setup/polyfills.ts"]`.
- [x] 1.5 Add `tests/setup/polyfills.ts` (no-op placeholder mirroring
the binance pattern).
## 2. Implement MollieProvider + helpers
- [x] 2.1 Create `src/mollie/mollie.ts` exporting:
- `MOLLIE_DEFAULT_BASE_URL` constant
- `MOLLIE_PROVIDER_NAME` constant
- `formatMollieAmount(amount: bigint): string`
- `parseMollieAmount(value: string): bigint`
- `mapMollieStatus(status: string): PaymentStatus`
- `mapMollieRefundStatus(status: string): RefundStatus`
- `MollieProvider` class implementing `PaymentProvider`
- [x] 2.2 Import all foundation symbols from `@suaveplan/payment-providers`
per the contract: `httpRequest`, `composeAbortSignal`, `FetchLike`,
`ApiKeyConfig`, `PaymentProviderError`, `PAYMENT_PROVIDER_CODES`,
`httpError`, `parseError`.
- [x] 2.3 Import payment types from `@suaveplan/payment` (verbatim
re-import of the same symbols upstream used).
- [x] 2.4 Use `Authorization: Bearer <apiKey>` header on every call.
- [x] 2.5 Marshal Money amounts via `formatMollieAmount` outbound and
`parseMollieAmount` inbound; preserve the `{ currency, value }`
shape on the wire.
- [x] 2.6 Raise ONLY `PaymentProviderError` — every failure path
bubbles through `httpRequest` which raises typed errors.
- [x] 2.7 Keep `mollie.ts` under 300 lines.
## 3. Tests + docs
- [x] 3.1 Create `src/mollie/mollie.test.ts` covering the full plan
in `design.md`. All imports go through `@suaveplan/testing/runner`;
no `vi.spyOn(globalThis, "fetch")`; recording `FetchLike` closures
injected via constructor. Every `it` has `{ timeout: 5000 }`.
- [x] 3.2 Cover amount-marshalling edge cases (1 cent, exact major
unit, large, over-precision parse, no-decimal parse, single-decimal
parse).
- [x] 3.3 Cover the full status-map matrix and refund-status-map
matrix.
- [x] 3.4 Cover the payment-method map (creditcard / ideal / undefined).
- [x] 3.5 Cover the HTTP_ERROR surface (rejecting fetch + non-2xx
response).
- [x] 3.6 Create `src/mollie/mollie.md` with the §4 mandatory sections
(Purpose ≥2 sentences, Features, Basic + Advanced usage, API
Reference for every export, Implementation Notes), ≥200 words excl.
code.
- [x] 3.7 Create `src/mollie/index.ts` with explicit named exports
(rule 6a).
- [x] 3.8 Create `src/index.ts` with explicit named package-root
exports (rule 6a).
- [x] 3.9 Create root `README.md` with all §5 mandatory sections
(H1, description, install, quick-start, Modules linking
`src/mollie/mollie.md`).
## 4. Subpath exports
- [x] 4.1 Add `./mollie/mollie` subpath export to `package.json#exports`
mirroring the binance sibling format. Final exports count: 2 entries
(`.` + `./mollie/mollie`) + `./package.json`.
## 5. Verification gates (§13)
- [ ] 5.1 Step 0 — spec reconciliation: walk every `### Requirement`
in the delta and confirm every named export + every `**Verified by:**`
test name exists in source.
- [ ] 5.2 Step 1 — `bunx turbo lint typecheck test --filter=@suaveplan/payment-provider-mollie`.
- [ ] 5.3 Step 2 — `bunx turbo test:coverage --filter=@suaveplan/payment-provider-mollie`
reports 100/100/100/100.
- [ ] 5.4 Step 3 — `bunx turbo build --filter=@suaveplan/payment-provider-mollie`.
- [ ] 5.5 Step 4 — banned-pattern scan returns zero output.
- [ ] 5.6 Step 4a — barrel-shape scan returns zero output (no
`export *` in any `<feature>/index.ts`).
- [ ] 5.7 Step 4b — test-runner import scan returns zero output (no
direct `vitest` / `bun:test` imports).
- [ ] 5.8 Step 5 — every `.ts` (excl. `.test.ts`, `.types.ts`,
`index.ts`) has a sibling `.md`.
- [ ] 5.9 Step 6 — `README.md` exists.
- [ ] 5.10 Step 7 — no bare versions in `package.json`.
- [ ] 5.11 Step 8 — subpath-export count = 2 (`.` + `./mollie/mollie`).
- [ ] 5.12 Step 9 — security scan returns zero output.
- [ ] 5.13 Step 10 — dependent sweep:
`bunx turbo test --filter=...@suaveplan/payment-provider-mollie`
green (only the package itself).
## 6. Root-doc refresh (§13b)
- [ ] 6.1 Run `bun scripts/folder-reorg/generate-manifest.ts` to refresh
`MANIFEST.md`.
- [ ] 6.2 Run `bun scripts/generate-missing.ts` to refresh `MISSING.yml`.
- [ ] 6.3 Hand-edit `STATUS.md` to add a row for
`@suaveplan/payment-provider-mollie` (✅ shipped).
- [ ] 6.4 Hand-edit `SUMMARY.md` paragraph for the new package.
- [ ] 6.5 `bunx markdownlint-cli2 --fix` across touched docs.
## 7. Fold + archive (§14)
- [ ] 7.1 Fold `specs/payment-provider-mollie/spec.md` into
`openspec/specs/payment-provider-mollie/spec.md`.
- [ ] 7.2 Move `openspec/changes/add-payment-provider-mollie/` →
`openspec/archive/2026-05-17-add-payment-provider-mollie/`.