Initial Commit
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
# Design: add-payment-provider-mollie
|
||||
|
||||
This design doc captures the non-normative rationale for the
|
||||
`@suaveplan/payment-provider-mollie` sibling adapter. The cross-cutting
|
||||
architecture for the `@suaveplan/payment-provider*` family — the
|
||||
foundation-with-sibling-plugins shape, the slim core, the registry
|
||||
contract, the shared HTTP helpers — lives in
|
||||
[`../../archive/2026-05-17-slim-payment-providers/design.md`](../../archive/2026-05-17-slim-payment-providers/design.md).
|
||||
This file references that document by relative path and does not
|
||||
duplicate it.
|
||||
|
||||
## File layout
|
||||
|
||||
```text
|
||||
packages/universal/payment-provider-mollie/
|
||||
package.json
|
||||
README.md
|
||||
vite.config.ts
|
||||
vitest.config.ts
|
||||
tsconfig.json
|
||||
tsconfig.lib.json
|
||||
src/
|
||||
index.ts # explicit named barrel (rule 6a)
|
||||
mollie/
|
||||
index.ts # explicit named feature barrel
|
||||
mollie.ts # MollieProvider class + helpers
|
||||
mollie.md # sibling docs (rule 4)
|
||||
mollie.test.ts # vitest, runner from @suaveplan/testing/runner
|
||||
tests/
|
||||
setup/
|
||||
polyfills.ts # no-op (universal tier; placeholder)
|
||||
```
|
||||
|
||||
The `mollie/` feature folder mirrors the binance / ecb sibling layout
|
||||
(`packages/finance/currency-provider-binance/`). A single feature folder
|
||||
is appropriate because the entire adapter is one class plus four pure
|
||||
helpers — splitting further would manufacture surface area without
|
||||
clarifying responsibility.
|
||||
|
||||
## Public surface
|
||||
|
||||
```ts
|
||||
// from "@suaveplan/payment-provider-mollie"
|
||||
export class MollieProvider implements PaymentProvider {
|
||||
readonly name: "mollie";
|
||||
constructor(config: ApiKeyConfig);
|
||||
createIntent(input: CreatePaymentIntentInput): Promise<PaymentIntent>;
|
||||
confirmIntent(intentId: string): Promise<PaymentIntent>;
|
||||
captureIntent(intentId: string): Promise<PaymentIntent>;
|
||||
cancelIntent(intentId: string): Promise<PaymentIntent>;
|
||||
refund(input: RefundInput): Promise<Refund>;
|
||||
getIntent(intentId: string): Promise<PaymentIntent>;
|
||||
}
|
||||
|
||||
export const MOLLIE_DEFAULT_BASE_URL: "https://api.mollie.com/v2";
|
||||
export const MOLLIE_PROVIDER_NAME: "mollie";
|
||||
|
||||
export function formatMollieAmount(amount: bigint): string;
|
||||
export function parseMollieAmount(value: string): bigint;
|
||||
export function mapMollieStatus(status: string): PaymentStatus;
|
||||
export function mapMollieRefundStatus(status: string): RefundStatus;
|
||||
```
|
||||
|
||||
`formatMollieAmount` and `parseMollieAmount` are exported (not
|
||||
file-locals as in the upstream) because:
|
||||
|
||||
1. They are the package's contribution to the family — every other
|
||||
sibling marshals integer minor-units to a numeric field; only Mollie
|
||||
has the string-`{currency,value}` shape.
|
||||
2. Pure helpers are individually unit-testable for the carry / padding /
|
||||
large-value edges that bit hand-rolled implementations elsewhere
|
||||
(see e.g. the upstream's silent loss of cents on values >= 10^15
|
||||
when implemented with floating-point arithmetic — this package uses
|
||||
`bigint` end-to-end and proves it under test).
|
||||
3. Future Mollie-adjacent siblings (e.g. a hypothetical Mollie
|
||||
Customers / Subscriptions adapter) can reuse the helpers without
|
||||
re-implementation.
|
||||
|
||||
The status maps (`mapMollieStatus`, `mapMollieRefundStatus`) are
|
||||
exported for the same reason: they are pure, total, and individually
|
||||
testable without invoking the network path.
|
||||
|
||||
## Amount marshalling
|
||||
|
||||
Mollie wire shape (REST API v2):
|
||||
|
||||
```json
|
||||
{ "amount": { "currency": "EUR", "value": "10.00" } }
|
||||
```
|
||||
|
||||
The `value` is always a decimal-formatted string with **exactly** the
|
||||
currency's minor-unit precision (2 for most ISO 4217 fiat). The genesis
|
||||
`Money` model is `bigint` minor-units:
|
||||
|
||||
```ts
|
||||
{ amount: 1000n, currency: "EUR" } // 10.00 EUR
|
||||
```
|
||||
|
||||
`formatMollieAmount`:
|
||||
|
||||
- `0n` → `"0.00"`
|
||||
- `1n` → `"0.01"` (single cent — pad-left to 2)
|
||||
- `100n` → `"1.00"`
|
||||
- `12345n` → `"123.45"`
|
||||
- `12345678901234n` → `"123456789012.34"` (large; bigint avoids overflow)
|
||||
|
||||
`parseMollieAmount`:
|
||||
|
||||
- `"0.00"` → `0n`
|
||||
- `"0.01"` → `1n`
|
||||
- `"10"` → `1000n` (no decimal point — treat minor-units as zero)
|
||||
- `"10.5"` → `1050n` (single decimal — pad-right to 2)
|
||||
- `"10.123"` → `1012n` (over-precision — truncate, do not round; Mollie
|
||||
emits exactly minor-unit precision, so over-precision is upstream
|
||||
garbage and the safest action is to ignore the excess)
|
||||
- `"123456789012.34"` → `12345678901234n` (large; bigint round-trip)
|
||||
|
||||
The two helpers are inverses for every `bigint` produced from a Mollie
|
||||
response. For minted values the round-trip is exact.
|
||||
|
||||
The package targets the two-decimal currencies that Mollie supports
|
||||
(EUR, USD, GBP, CAD, AUD, CHF, DKK, NOK, SEK, PLN, HUF, CZK, BGN, RON,
|
||||
HRK, ISK, JPY-via-Mollie's special handling, etc.). Mollie's API
|
||||
documentation defines minor-unit precision per currency; for the
|
||||
currencies Mollie currently accepts, all use 2 decimals. JPY is
|
||||
**not** in Mollie's supported list as of API v2, so the universal
|
||||
2-decimal assumption is currently lossless. If Mollie adds a 0-decimal
|
||||
currency in the future the helpers can be parameterised — out of scope
|
||||
for this change.
|
||||
|
||||
## Tests
|
||||
|
||||
Tests inject a recording `FetchLike` closure into the constructor
|
||||
instead of monkey-patching `globalThis.fetch`. This:
|
||||
|
||||
1. Satisfies rule 8 (no `vi.spyOn(globalThis, "fetch")`).
|
||||
2. Lets every test assert on the exact `url` + `init` the provider
|
||||
produced — including the `Authorization` header and the JSON body
|
||||
shape.
|
||||
3. Runs identically under `bun test` and `vitest run` because no
|
||||
runtime globals are mutated.
|
||||
|
||||
The recording adapter pattern is taken verbatim from
|
||||
`@suaveplan/payment-providers`'s own `http.test.ts` — see the
|
||||
`recorder(handler)` helper there. This package re-implements it locally
|
||||
(small, file-local, and the testing package does not own the shape).
|
||||
|
||||
Edge cases the test plan covers explicitly:
|
||||
|
||||
- 1-cent amount (`1n`) — proves leading-zero padding.
|
||||
- Exact major-unit amount (`100n`, `1000n`) — proves trailing-zero
|
||||
padding.
|
||||
- Large amount (`12345678901234n`) — proves `bigint` end-to-end with
|
||||
no floating-point loss.
|
||||
- Round-trip: every minted intent's `amount.amount === bigint(input)`.
|
||||
- Round-trip: refund parsed from Mollie response equals the bigint that
|
||||
was sent.
|
||||
- `parseMollieAmount` with a minor-only value (`"0.99"`), a
|
||||
single-decimal value (`"5.5"`), a no-decimal value (`"10"`), and an
|
||||
over-precision value (`"10.123"`).
|
||||
- Status mapping for every defined Mollie status + an arbitrary unknown
|
||||
string.
|
||||
- Refund-status mapping for every defined Mollie refund status + an
|
||||
arbitrary unknown string.
|
||||
- Payment-method mapping for `creditcard` / `ideal` / undefined.
|
||||
- Network failure (`fetch` throws) surfaces as a `PaymentProviderError`
|
||||
with code `PAYMENT_PROVIDER.HTTP_ERROR` — this path is exercised by
|
||||
the slim core's `httpRequest` and is covered transitively by passing
|
||||
a rejecting `fetch` into the constructor.
|
||||
- Non-OK HTTP response (e.g. 422 from Mollie) surfaces as
|
||||
`PaymentProviderError(PAYMENT_PROVIDER.HTTP_ERROR)`.
|
||||
|
||||
## Errors
|
||||
|
||||
Every failure surfaces as `PaymentProviderError` from the slim core:
|
||||
|
||||
- `PAYMENT_PROVIDER.HTTP_ERROR` — Mollie returns non-2xx, or `fetch`
|
||||
itself throws a non-`AbortError`.
|
||||
- `PAYMENT_PROVIDER.TIMEOUT` — the per-call `timeoutMs` elapsed.
|
||||
- `PAYMENT_PROVIDER.PARSE_ERROR` — Mollie returns a non-JSON body
|
||||
(unexpected at the protocol level but defensive).
|
||||
|
||||
These all originate inside `httpRequest`. The adapter never throws
|
||||
raw `Error`.
|
||||
|
||||
## Tier purity
|
||||
|
||||
Universal-tier — no Node-specific imports (`node:fs`, `node:crypto`,
|
||||
`Buffer`, `process.env`), no browser-only globals (`window`,
|
||||
`document`, `localStorage`). The package imports:
|
||||
|
||||
- `@suaveplan/payment` — types only (`PaymentProvider`, `PaymentIntent`,
|
||||
`PaymentStatus`, `RefundStatus`, `CreatePaymentIntentInput`,
|
||||
`RefundInput`, `Refund`).
|
||||
- `@suaveplan/payment-providers` — `httpRequest`, `composeAbortSignal`,
|
||||
`FetchLike`, `ApiKeyConfig`, `PaymentProviderError`,
|
||||
`PAYMENT_PROVIDER_CODES`, `httpError`, `parseError`.
|
||||
- `tslib` — runtime helpers (catalog dep).
|
||||
|
||||
`composeAbortSignal` is imported per the task brief; it is not used in
|
||||
the production path (the slim-core `httpRequest` already composes the
|
||||
caller signal with its internal timeout) but is re-exported so future
|
||||
adapter additions (e.g. webhook fetch, listing fetch) can wire it.
|
||||
Re-export is via the package barrel only — the implementation does not
|
||||
hold a reference, so tree-shaking remains exact.
|
||||
|
||||
## Versioning + publish
|
||||
|
||||
`0.1.0` — every fresh genesis package starts at `0.1.0`. Promotion to
|
||||
`1.0.0` is gated on the broader payment-provider family graduation.
|
||||
|
||||
`publishConfig.registry` points at the SuavePlan Verdaccio
|
||||
(`https://npmjs.fq.io/`) per the sibling pattern.
|
||||
@@ -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`.
|
||||
@@ -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)
|
||||
118
openspec/archive/2026-05-17-add-payment-provider-mollie/tasks.md
Normal file
118
openspec/archive/2026-05-17-add-payment-provider-mollie/tasks.md
Normal 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/`.
|
||||
Reference in New Issue
Block a user