# Capability Spec: payment-provider-mollie **Package**: `@suaveplan/payment-provider-mollie` **Status**: SHIPPED (2026-05-17) > Folded from > `openspec/archive/2026-05-17-add-payment-provider-mollie/specs/payment-provider-mollie/spec.md`. ## 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 `. #### 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 "` **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)