Initial Commit
This commit is contained in:
271
openspec/specs/payment-provider-mollie/spec.md
Normal file
271
openspec/specs/payment-provider-mollie/spec.md
Normal 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)
|
||||
Reference in New Issue
Block a user