Files
2026-05-27 23:29:53 +01:00

13 KiB

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)