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

8.9 KiB
Raw Permalink Blame History

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.

// 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 Responses. 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.