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,271 @@
<!-- markdownlint-disable MD024 -->
# 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 <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)