solidus_mollie
Accept Mollie payments in your Solidus store using Mollie's hosted checkout (the buyer is redirected to Mollie to pay) and webhooks for settlement.
Tested against the stack it was written for: Solidus 4.6 / Rails 7.1 / Ruby 3.2,
with mollie-api-ruby ~> 4.
How it works (read this first)
Mollie is not a synchronous credit-card gateway, so this is not a normal
ActiveMerchant-style authorize/capture extension. The flow is:
- Buyer selects Mollie at the checkout payment step. A
Spree::Paymentis created with aSolidusMollie::MollieSource(no card data is collected). - At the confirm step, instead of completing the order synchronously, the
buyer is redirected to Mollie's hosted page. The payment is moved to
pending. (SeeSolidusMollie::CreateOrderPaymentand theSpree::CheckoutControlleroverride.) - The buyer pays on Mollie and is sent back to
/mollie/return, which forwards them to their order page. - Mollie also POSTs to
/mollie/webhook— this is the source of truth.SolidusMollie::ProcessWebhookre-fetches the authoritative status, marks theSpree::Paymentcomplete/failed, and completes the order. It is idempotent because Mollie may call the webhook more than once.
Because the webhook is what completes the order, Mollie must be able to reach your app over a public HTTPS URL, including in development. Use a tunnel such as
ngrokorcloudflaredlocally — Mollie will not calllocalhost.
Installation
Add to your store's Gemfile:
gem 'solidus_mollie'Then:
bundle install
bin/rails generate solidus_mollie:install
bin/rails db:migrateConfiguration
- In the admin, go to Configuration → Payment Methods → New Payment Method.
- Choose
SolidusMollie::PaymentMethodas the provider. - Paste your Mollie API key (
test_…for testing,live_…for production). - Optionally set Mollie method to force a single method (e.g.
ideal,creditcard,bancontact). Leave blank to let the buyer choose on Mollie's page.
The key prefix (test_/live_) determines test vs live mode automatically.
Frontend
Solidus stores come in three shapes and the gem detects which one it is running in
(see SolidusMollie::Frontend):
| Store | Checkout hook | Buyer returns to |
|---|---|---|
| Starter frontend (copied into your app) | CheckoutsController#update |
token_order_url(order, token) |
Legacy solidus_frontend |
Spree::CheckoutController#update |
spree.order_url(order, token:) |
| Headless / API | none - you call the service yourself | wherever your order_url lambda says |
Payment-step and order-page partials are shipped for both storefronts
(checkouts/payment/_mollie, orders/payment_info/_mollie and the spree/...
equivalents), plus the admin source_views/source_forms partials so an order's
Mollie payment is viewable in the backend.
Anything the auto-detection gets wrong can be overridden in an initializer:
SolidusMollie.configure do |config|
config.checkout_controller = 'MyCheckoutController' # or false to disable the hook
config.order_url = ->(controller, order) { controller.main_app.my_order_url(order) }
config.payment_step_path = ->(controller) { controller.main_app.my_payment_path }
config.root_path = ->(controller) { '/' }
endHeadless / API stores
There is no checkout controller to hook, so trigger the redirect yourself once
the order reaches confirm:
result = SolidusMollie::CreateOrderPayment.call(
order: order,
payment: order.payments.detect { |p| p.checkout? && p.payment_method.is_a?(SolidusMollie::PaymentMethod) },
redirect_url: spree.mollie_return_url(order_number: order.number, token: order.guest_token),
webhook_url: spree.mollie_webhook_url
)
# => redirect the buyer to result.checkout_urlAbandoned payments
If a buyer comes back from Mollie without paying and presses "Place order" again,
the stale pending payment is voided (and cancelled at Mollie when still
cancelable) and a fresh Mollie payment is started, so an order can never complete
against an unpaid Mollie payment.
Refunds & cancellation
Refunds from the Solidus admin call PaymentMethod#credit, which creates a Mollie
refund. Cancelling a still-cancelable Mollie payment is attempted via try_void;
otherwise Solidus falls back to a refund.
Development & tests
The extension is scaffolded with solidus_dev_support, which generates a dummy
Solidus app under spec/dummy to test against.
bin/setup # bundle + generate the dummy app
bin/rake # run the full spec suite (extension:specs)
bundle exec rspec spec/services/solidus_mollie/process_webhook_spec.rb # one file
SOLIDUS_BRANCH=v4.6 bin/setup # pin a specific Solidus version
bin/sandbox # build a runnable sandbox store under ./sandboxBundled specs to build on:
-
spec/services/solidus_mollie/process_webhook_spec.rb— settlement (paid / failed / idempotent / unknown-id). This is the behaviour most worth covering. -
spec/requests/solidus_mollie/callbacks_spec.rb— webhook + return endpoints. -
spec/models/solidus_mollie/payment_method_spec.rb— test-mode detection, refunds. -
spec/decorators/solidus_mollie/checkout_controller_decorator_spec.rb— the confirm-step redirect, run against a stand-in checkout controller. -
spec/solidus_mollie/frontend_spec.rb— storefront detection and overrides. -
spec/support/mollie_stubs.rb— helpers to fake the Mollie API so specs never hit the network (stub_mollie_get,stub_mollie_create).
The dummy app is generated against the Solidus branch in SOLIDUS_BRANCH, and
bundle re-reads that variable every time, so export it once per shell:
export SOLIDUS_BRANCH=v4.6
bin/setup
bundle exec rspecTesting against Mollie for real
Mollie has no separate sandbox URL — use a test API key (test_…) on the
payment method. Test payments are fully isolated from live data, and Mollie's
hosted page is replaced by a screen where you choose the resulting status
(Paid / Failed / Expired / Cancelled). Because settlement is webhook-driven,
Mollie must reach /mollie/webhook over a public HTTPS URL even in test mode —
run a tunnel (ngrok http 3000) and use the public host.
Known limitations / TODO
- No one-click / reusable mandates (sources are non-reusable in this version).
- No partial-payment handling beyond a single payment per order.
- Mollie's webhook is unauthenticated by design (it only carries an id and the gem re-fetches the payment from Mollie), so it must stay that way; do not put it behind auth.
License
BSD-2-Clause.