solidus_mollie 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9dc0df8101384051b947487223e3b8aac8092e7da9dcbc394cb4bcfc5cac77a2
4
- data.tar.gz: 32b058d3cdd52adac6b85b98e9eb2b267e688a111f29447879a654812951ea9f
3
+ metadata.gz: f592e8878266fb1b4b38e6f42092fcb187672afff238eb443a9bab7c848070f1
4
+ data.tar.gz: 58736fe2073d3299bb7c3e2c5ad129b56b09cf1f884ab6bf72a9553a4c2f0241
5
5
  SHA512:
6
- metadata.gz: 1f3e7bdaf7b94a4235ca2cf91c4d61f056f0fd0f320300fe3c3f584227bbd9ca701bc903cefa08931fc8742959c9da1d1da318069b74af853a83af5a9111b483
7
- data.tar.gz: 4cc1d2a6542ee1b2fc401a676ee9275e6422fde82af2c510cff051b8504f89ca69e69138ddfa55b3c70384146630cba68df67b55aff0163fac86e25face74dbd
6
+ metadata.gz: 717c362a01ab15f28e0688aaef56afd03a5843dc0550820af0c8efd55e3095c10818279f5abd5e38fd72f483e6c52c26c453bf6ef5254368e5966f1e602cc113
7
+ data.tar.gz: b2d900cf170ba23ee52ffda9af13a407fecf9eb5dd508186720048578781c515194ef1cd79e0de16476809f624aa7ced8b12f13950286791d5b224d1d49f8883
data/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  BSD 2-Clause License
2
2
 
3
- Copyright (c) 2026, Kalopa Robotics
3
+ Copyright (c) 2026, Kalopa Robotics Limited.
4
4
 
5
5
  Redistribution and use in source and binary forms, with or without
6
6
  modification, are permitted provided that the following conditions are met:
data/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # solidus_mollie
2
2
 
3
+ [![Gem Version](https://badge.fury.io/rb/solidus_mollie.svg)](https://badge.fury.io/rb/solidus_mollie)
4
+
3
5
  Accept [Mollie](https://www.mollie.com) payments in your [Solidus](https://solidus.io)
4
6
  store using Mollie's **hosted checkout** (the buyer is redirected to Mollie to pay)
5
7
  and **webhooks** for settlement.
@@ -57,23 +59,53 @@ The key prefix (`test_`/`live_`) determines test vs live mode automatically.
57
59
 
58
60
  ## Frontend
59
61
 
60
- The gem ships a `spree/checkout/payment/_mollie` partial (an informational note)
61
- and prepends a `Spree::CheckoutController#update` override that performs the
62
- redirect. This works with `solidus_starter_frontend` out of the box.
62
+ Solidus stores come in three shapes and the gem detects which one it is running in
63
+ (see `SolidusMollie::Frontend`):
64
+
65
+ | Store | Checkout hook | Buyer returns to |
66
+ |--------------------------------------------------|--------------------------------------|-------------------------------------------|
67
+ | **Starter frontend** (copied into your app) | `CheckoutsController#update` | `token_order_url(order, token)` |
68
+ | **Legacy `solidus_frontend`** | `Spree::CheckoutController#update` | `spree.order_url(order, token:)` |
69
+ | **Headless / API** | none - you call the service yourself | wherever your `order_url` lambda says |
70
+
71
+ Payment-step and order-page partials are shipped for both storefronts
72
+ (`checkouts/payment/_mollie`, `orders/payment_info/_mollie` and the `spree/...`
73
+ equivalents), plus the admin `source_views`/`source_forms` partials so an order's
74
+ Mollie payment is viewable in the backend.
75
+
76
+ Anything the auto-detection gets wrong can be overridden in an initializer:
77
+
78
+ ```ruby
79
+ SolidusMollie.configure do |config|
80
+ config.checkout_controller = 'MyCheckoutController' # or false to disable the hook
81
+ config.order_url = ->(controller, order) { controller.main_app.my_order_url(order) }
82
+ config.payment_step_path = ->(controller) { controller.main_app.my_payment_path }
83
+ config.root_path = ->(controller) { '/' }
84
+ end
85
+ ```
86
+
87
+ ### Headless / API stores
63
88
 
64
- If you run a **headless / API frontend**, the controller override is skipped.
65
- Trigger the redirect yourself after confirm:
89
+ There is no checkout controller to hook, so trigger the redirect yourself once
90
+ the order reaches `confirm`:
66
91
 
67
92
  ```ruby
68
93
  result = SolidusMollie::CreateOrderPayment.call(
69
94
  order: order,
70
95
  payment: order.payments.detect { |p| p.checkout? && p.payment_method.is_a?(SolidusMollie::PaymentMethod) },
71
- redirect_url: mollie_return_url(order_number: order.number, token: order.token),
72
- webhook_url: mollie_webhook_url
96
+ redirect_url: spree.mollie_return_url(order_number: order.number, token: order.guest_token),
97
+ webhook_url: spree.mollie_webhook_url
73
98
  )
74
99
  # => redirect the buyer to result.checkout_url
75
100
  ```
76
101
 
102
+ ### Abandoned payments
103
+
104
+ If a buyer comes back from Mollie without paying and presses "Place order" again,
105
+ the stale `pending` payment is voided (and cancelled at Mollie when still
106
+ cancelable) and a fresh Mollie payment is started, so an order can never complete
107
+ against an unpaid Mollie payment.
108
+
77
109
  ## Refunds & cancellation
78
110
 
79
111
  Refunds from the Solidus admin call `PaymentMethod#credit`, which creates a Mollie
@@ -99,9 +131,21 @@ Bundled specs to build on:
99
131
  failed / idempotent / unknown-id). This is the behaviour most worth covering.
100
132
  - `spec/requests/solidus_mollie/callbacks_spec.rb` — webhook + return endpoints.
101
133
  - `spec/models/solidus_mollie/payment_method_spec.rb` — test-mode detection, refunds.
134
+ - `spec/decorators/solidus_mollie/checkout_controller_decorator_spec.rb` — the
135
+ confirm-step redirect, run against a stand-in checkout controller.
136
+ - `spec/solidus_mollie/frontend_spec.rb` — storefront detection and overrides.
102
137
  - `spec/support/mollie_stubs.rb` — helpers to fake the Mollie API so specs never
103
138
  hit the network (`stub_mollie_get`, `stub_mollie_create`).
104
139
 
140
+ The dummy app is generated against the Solidus branch in `SOLIDUS_BRANCH`, and
141
+ `bundle` re-reads that variable every time, so export it once per shell:
142
+
143
+ ```bash
144
+ export SOLIDUS_BRANCH=v4.6
145
+ bin/setup
146
+ bundle exec rspec
147
+ ```
148
+
105
149
  ### Testing against Mollie for real
106
150
 
107
151
  Mollie has no separate sandbox URL — use a **test API key** (`test_…`) on the
@@ -115,8 +159,9 @@ run a tunnel (`ngrok http 3000`) and use the public host.
115
159
 
116
160
  - No one-click / reusable mandates (sources are non-reusable in this version).
117
161
  - No partial-payment handling beyond a single payment per order.
118
- - A buyer can technically re-enter checkout while an order sits in `confirm`
119
- awaiting a Mollie result; revisit if this matters for your flow.
162
+ - Mollie's webhook is unauthenticated by design (it only carries an id and the
163
+ gem re-fetches the payment from Mollie), so it must stay that way; do not put
164
+ it behind auth.
120
165
 
121
166
  ## License
122
167
 
@@ -4,7 +4,10 @@ module SolidusMollie
4
4
  # Handles the two requests Mollie sends us:
5
5
  # * #webhook - server-to-server POST with the payment id (source of truth)
6
6
  # * #return - the buyer's browser coming back from Mollie's hosted page
7
- class CallbacksController < ::Spree::StoreController
7
+ #
8
+ # Inherits from Spree::BaseController (solidus_core) rather than a storefront
9
+ # controller so it loads regardless of which frontend the store uses.
10
+ class CallbacksController < ::Spree::BaseController
8
11
  skip_before_action :verify_authenticity_token, only: :webhook
9
12
 
10
13
  # POST /mollie/webhook (body: id=tr_xxxxx)
@@ -15,7 +18,7 @@ module SolidusMollie
15
18
  # 422 so Mollie retries (covers the rare webhook-before-persist race).
16
19
  Rails.logger.warn("[solidus_mollie] webhook payment not found: #{e.message}")
17
20
  head :unprocessable_entity
18
- rescue StandardError => e
21
+ rescue => e
19
22
  Rails.logger.error("[solidus_mollie] webhook error: #{e.message}")
20
23
  head :internal_server_error
21
24
  end
@@ -23,21 +26,27 @@ module SolidusMollie
23
26
  # GET /mollie/return?order_number=R123&token=abc
24
27
  def return
25
28
  order = ::Spree::Order.find_by!(number: params[:order_number])
26
-
27
- if params[:token].present? && order.token.present? &&
28
- !ActiveSupport::SecurityUtils.secure_compare(params[:token].to_s, order.token.to_s)
29
- raise ActiveRecord::RecordNotFound
30
- end
29
+ raise ActiveRecord::RecordNotFound unless token_matches?(order)
31
30
 
32
31
  unless order.completed?
33
32
  flash[:notice] = I18n.t('solidus_mollie.confirming_payment',
34
- default: "Thanks! We're confirming your payment with Mollie.")
33
+ default: "Thanks! We're confirming your payment with Mollie.")
35
34
  end
36
35
 
37
- redirect_to spree.order_url(order, token: order.token)
36
+ redirect_to SolidusMollie::Frontend.order_url(self, order)
38
37
  rescue ActiveRecord::RecordNotFound
39
38
  flash[:error] = I18n.t('solidus_mollie.order_not_found', default: 'Order not found.')
40
- redirect_to spree.root_path
39
+ redirect_to SolidusMollie::Frontend.root_path(self)
40
+ end
41
+
42
+ private
43
+
44
+ # The guest token is what lets the buyer see their order page, so the
45
+ # return URL must carry the right one. Never fall through on a blank token.
46
+ def token_matches?(order)
47
+ token = params[:token].to_s
48
+ token.present? && order.guest_token.present? &&
49
+ ActiveSupport::SecurityUtils.secure_compare(token, order.guest_token.to_s)
41
50
  end
42
51
  end
43
52
  end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusMollie
4
+ # Prepended to the storefront checkout controller (see SolidusMollie::Frontend
5
+ # for which one). When the buyer places an order that is paying with Mollie,
6
+ # they are redirected to Mollie's hosted checkout instead of the order being
7
+ # completed synchronously; the webhook finishes the order later
8
+ # (SolidusMollie::ProcessWebhook).
9
+ #
10
+ # Works with both the starter frontend (CheckoutsController#update) and the
11
+ # legacy solidus_frontend (Spree::CheckoutController#update): both expose
12
+ # +@order+, +params[:state]+ and the +spree+ route proxy.
13
+ module CheckoutControllerDecorator
14
+ def update
15
+ return redirect_to_mollie if divert_to_mollie?
16
+
17
+ super
18
+ end
19
+
20
+ private
21
+
22
+ # Both frontends route "Place order" to update with params[:state] ==
23
+ # 'confirm' (and set @order.state from it), so the param is the reliable
24
+ # signal; an order that merely *is* in confirm must not be diverted while
25
+ # the buyer is editing an earlier step.
26
+ def divert_to_mollie?
27
+ return false unless @order
28
+ return false unless params[:state].to_s == 'confirm'
29
+
30
+ mollie_payment.present?
31
+ end
32
+
33
+ # The Mollie payment to send the buyer off with. Normally the payment the
34
+ # buyer created at the payment step (state +checkout+). If an earlier
35
+ # attempt is still +pending+ (the buyer came back from Mollie without
36
+ # paying and pressed "Place order" again) it is voided and replaced, so the
37
+ # order can never complete against an unpaid Mollie payment.
38
+ def mollie_payment
39
+ return @mollie_payment if defined?(@mollie_payment)
40
+
41
+ payments = @order.payments.select { |p| p.payment_method.is_a?(SolidusMollie::PaymentMethod) }
42
+ @mollie_payment = payments.find(&:checkout?) || replace_pending_mollie_payment(payments.find(&:pending?))
43
+ end
44
+
45
+ def replace_pending_mollie_payment(pending)
46
+ return nil unless pending
47
+
48
+ pending.payment_method.try_void(pending) if pending.response_code.present?
49
+ pending.void!
50
+ @order.payments.create!(payment_method: pending.payment_method, amount: @order.total)
51
+ end
52
+
53
+ def redirect_to_mollie
54
+ result = SolidusMollie::CreateOrderPayment.call(
55
+ order: @order,
56
+ payment: mollie_payment,
57
+ redirect_url: spree.mollie_return_url(order_number: @order.number, token: @order.guest_token),
58
+ webhook_url: spree.mollie_webhook_url
59
+ )
60
+
61
+ if result.success?
62
+ redirect_to result.checkout_url, allow_other_host: true
63
+ else
64
+ flash[:error] = I18n.t('solidus_mollie.payment_error',
65
+ message: result.error,
66
+ default: "We couldn't start your Mollie payment.")
67
+ redirect_to SolidusMollie::Frontend.payment_step_path(self)
68
+ end
69
+ end
70
+ end
71
+ end
@@ -15,9 +15,9 @@ module SolidusMollie
15
15
 
16
16
  # amount: a BigDecimal/Numeric in MAJOR units (e.g. 10.00 for €10).
17
17
  def create_payment(amount:, currency:, description:, redirect_url:, webhook_url:,
18
- method: nil, metadata: {})
18
+ method: nil, metadata: {})
19
19
  Mollie::Payment.create(
20
- amount: { value: self.class.format_amount(amount, currency), currency: currency },
20
+ amount: {value: self.class.format_amount(amount, currency), currency: currency},
21
21
  description: description,
22
22
  redirect_url: redirect_url,
23
23
  webhook_url: webhook_url,
@@ -38,7 +38,7 @@ module SolidusMollie
38
38
  def create_refund(payment_id:, amount:, currency:)
39
39
  Mollie::Payment::Refund.create(
40
40
  payment_id: payment_id,
41
- amount: { value: self.class.format_amount(amount, currency), currency: currency },
41
+ amount: {value: self.class.format_amount(amount, currency), currency: currency},
42
42
  api_key: @api_key
43
43
  )
44
44
  end
@@ -59,7 +59,7 @@ module SolidusMollie
59
59
 
60
60
  def self.currency_exponent(currency)
61
61
  ::Money::Currency.new(currency).exponent
62
- rescue StandardError
62
+ rescue
63
63
  2
64
64
  end
65
65
  end
@@ -7,6 +7,8 @@ module SolidusMollie
7
7
  class MollieSource < ::Spree::PaymentSource
8
8
  self.table_name = 'solidus_mollie_sources'
9
9
 
10
+ belongs_to :user, class_name: ::Spree::UserClassHandle.new, optional: true
11
+
10
12
  # Off-site payments are not reusable for one-click in this version.
11
13
  def reusable?
12
14
  false
@@ -23,8 +23,12 @@ module SolidusMollie
23
23
  'mollie'
24
24
  end
25
25
 
26
+ # No card data is collected in the store: the MollieSource is created when
27
+ # the buyer is redirected (CreateOrderPayment), not at the payment step.
28
+ # Returning false lets every frontend (starter, legacy, API) create the
29
+ # payment with just a payment_method_id.
26
30
  def source_required?
27
- true
31
+ false
28
32
  end
29
33
 
30
34
  def payment_profiles_supported?
@@ -75,7 +79,7 @@ module SolidusMollie
75
79
  currency: currency
76
80
  )
77
81
  SolidusMollie::Response.success('Mollie refund created', authorization: response_code)
78
- rescue StandardError => e
82
+ rescue => e
79
83
  Rails.logger.error("[solidus_mollie] refund failed: #{e.message}")
80
84
  SolidusMollie::Response.failure(e.message)
81
85
  end
@@ -89,7 +93,7 @@ module SolidusMollie
89
93
 
90
94
  client.cancel_payment(payment.response_code)
91
95
  SolidusMollie::Response.success('Mollie payment canceled', authorization: payment.response_code)
92
- rescue StandardError => e
96
+ rescue => e
93
97
  Rails.logger.error("[solidus_mollie] void failed: #{e.message}")
94
98
  false
95
99
  end
@@ -31,12 +31,12 @@ module SolidusMollie
31
31
  redirect_url: @redirect_url,
32
32
  webhook_url: @webhook_url,
33
33
  method: payment_method.preferred_mollie_method,
34
- metadata: { order_number: @order.number, payment_number: @payment.number }
34
+ metadata: {order_number: @order.number, payment_number: @payment.number}
35
35
  )
36
36
 
37
37
  persist(mollie)
38
38
  Result.new(checkout_url: mollie.checkout_url)
39
- rescue StandardError => e
39
+ rescue => e
40
40
  Rails.logger.error("[solidus_mollie] failed to create payment for #{@order.number}: #{e.message}")
41
41
  Result.new(error: e.message)
42
42
  end
@@ -0,0 +1,5 @@
1
+ <%# Starter frontend: rendered inside the payment-method card at the payment step.
2
+ Buyers enter nothing here - they are redirected to Mollie when they place the order. %>
3
+ <div class="mollie-payment-method" data-payment-method-type="mollie">
4
+ <p><%= t('solidus_mollie.redirect_notice') %></p>
5
+ </div>
@@ -0,0 +1,5 @@
1
+ <%# Starter frontend: order page / confirm step summary of a Mollie payment. %>
2
+ <%= payment.payment_method.name %>
3
+ <% if payment.source.respond_to?(:mollie_method) && payment.source.mollie_method.present? %>
4
+ (<%= payment.source.mollie_method.to_s.titleize %>)
5
+ <% end %>
@@ -0,0 +1,3 @@
1
+ <%# Admin: "New payment" form. Mollie payments can only be started by the buyer
2
+ (they must be redirected to Mollie), so there is nothing to fill in here. %>
3
+ <p class="note"><%= t('solidus_mollie.admin.cannot_create') %></p>
@@ -0,0 +1,28 @@
1
+ <%# Admin: Orders -> Payments -> a Mollie payment. %>
2
+ <fieldset class="no-border-bottom">
3
+ <legend align="center"><%= t('solidus_mollie.admin.legend') %></legend>
4
+ <table>
5
+ <tbody>
6
+ <tr>
7
+ <th><%= t('solidus_mollie.admin.mollie_payment_id') %></th>
8
+ <td>
9
+ <% if payment.source&.mollie_payment_id.present? %>
10
+ <%= link_to payment.source.mollie_payment_id,
11
+ "https://my.mollie.com/dashboard/payments/#{payment.source.mollie_payment_id}",
12
+ target: '_blank', rel: 'noopener' %>
13
+ <% else %>
14
+ <%= t('solidus_mollie.admin.not_started') %>
15
+ <% end %>
16
+ </td>
17
+ </tr>
18
+ <tr>
19
+ <th><%= t('solidus_mollie.admin.mollie_method') %></th>
20
+ <td><%= payment.source&.mollie_method.presence || '-' %></td>
21
+ </tr>
22
+ <tr>
23
+ <th><%= t('solidus_mollie.admin.mollie_status') %></th>
24
+ <td><%= payment.source&.status.presence || '-' %></td>
25
+ </tr>
26
+ </tbody>
27
+ </table>
28
+ </fieldset>
@@ -1,3 +1,5 @@
1
+ <%# Buyers enter no card data here – they choose Mollie and are redirected to
2
+ Mollie's hosted checkout when they place the order. %>
1
3
  <div class="mollie-payment-method" data-payment-method-type="mollie">
2
4
  <p><%= t('solidus_mollie.redirect_notice') %></p>
3
5
  </div>
@@ -0,0 +1,5 @@
1
+ <%# Legacy solidus_frontend: order page / confirm step summary of a Mollie payment. %>
2
+ <%= payment.payment_method.name %>
3
+ <% if payment.source.respond_to?(:mollie_method) && payment.source.mollie_method.present? %>
4
+ (<%= payment.source.mollie_method.to_s.titleize %>)
5
+ <% end %>
@@ -4,3 +4,10 @@ en:
4
4
  confirming_payment: "Thanks! We're confirming your payment with Mollie."
5
5
  order_not_found: "Order not found."
6
6
  redirect_notice: "You'll be redirected to Mollie to complete your payment securely."
7
+ admin:
8
+ legend: "Mollie"
9
+ mollie_payment_id: "Mollie payment"
10
+ mollie_method: "Method"
11
+ mollie_status: "Mollie status"
12
+ not_started: "Not started (buyer has not been redirected to Mollie yet)"
13
+ cannot_create: "Mollie payments are started by the customer during checkout and cannot be created from the admin."
data/config/routes.rb CHANGED
@@ -2,7 +2,11 @@
2
2
 
3
3
  # Mount onto Spree's engine so the helpers are available as
4
4
  # spree.mollie_webhook_url / spree.mollie_return_url.
5
+ #
6
+ # The leading slash on the controller is essential: Spree::Core::Engine is an
7
+ # isolated engine, so a bare 'solidus_mollie/callbacks' would resolve to
8
+ # Spree::SolidusMollie::CallbacksController.
5
9
  ::Spree::Core::Engine.routes.draw do
6
- post '/mollie/webhook', to: 'solidus_mollie/callbacks#webhook', as: :mollie_webhook
7
- get '/mollie/return', to: 'solidus_mollie/callbacks#return', as: :mollie_return
10
+ post '/mollie/webhook', to: '/solidus_mollie/callbacks#webhook', as: :mollie_webhook
11
+ get '/mollie/return', to: '/solidus_mollie/callbacks#return', as: :mollie_return
8
12
  end
@@ -19,7 +19,7 @@ module SolidusMollie
19
19
 
20
20
  def run_migrations
21
21
  run_migrations = options[:auto_run_migrations] ||
22
- ['', 'y', 'Y'].include?(ask('Run migrations now? [Yn]'))
22
+ ['', 'y', 'Y'].include?(ask('Run migrations now? [Yn]'))
23
23
  if run_migrations
24
24
  rake 'db:migrate'
25
25
  else
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusMollie
4
+ # Global settings. Everything here has a sensible default that is detected
5
+ # from the host application, so most stores never need to touch it:
6
+ #
7
+ # SolidusMollie.configure do |config|
8
+ # config.checkout_controller = 'MyCheckoutController'
9
+ # config.order_url = ->(controller, order) { controller.main_app.my_order_url(order) }
10
+ # end
11
+ class Configuration
12
+ # Name of the storefront controller whose #update action places the order
13
+ # at the confirm step. nil = auto-detect (see SolidusMollie::Frontend).
14
+ # Set to false to disable the override entirely (headless stores).
15
+ attr_accessor :checkout_controller
16
+
17
+ # Optional lambdas, each called with (controller, order) / (controller),
18
+ # returning where the buyer should be sent. nil = auto-detect.
19
+ attr_accessor :order_url, :payment_step_path, :root_path
20
+ end
21
+
22
+ class << self
23
+ def config
24
+ @config ||= Configuration.new
25
+ end
26
+
27
+ def configure
28
+ yield config
29
+ end
30
+ end
31
+ end
@@ -10,29 +10,31 @@ module SolidusMollie
10
10
  # so helpers resolve as spree.mollie_webhook_url / spree.mollie_return_url.
11
11
 
12
12
  # Register the payment method so it appears in the admin "New Payment Method"
13
- # provider dropdown.
14
- initializer 'solidus_mollie.register_payment_method' do |app|
15
- app.reloader.to_prepare do
16
- methods = Rails.application.config.spree.payment_methods
17
- methods << SolidusMollie::PaymentMethod unless methods.include?(SolidusMollie::PaymentMethod)
18
- rescue StandardError => e
19
- Rails.logger.warn("[solidus_mollie] could not auto-register payment method: #{e.message}")
20
- end
13
+ # provider dropdown. Runs right after core populates its defaults; the string
14
+ # form avoids autoloading the model during boot and survives code reloads
15
+ # (config.spree.payment_methods is a ClassConstantizer::Set that constantizes
16
+ # on read and dedupes inserts).
17
+ initializer 'solidus_mollie.register_payment_method', after: 'spree.register.payment_methods' do |app|
18
+ app.config.spree.payment_methods << 'SolidusMollie::PaymentMethod'
21
19
  end
22
20
 
23
- # Prepend our checkout override so confirm-step orders paying with Mollie are
24
- # redirected to Mollie's hosted checkout instead of completing synchronously.
21
+ # Hook the storefront's checkout controller so confirm-step orders paying
22
+ # with Mollie are redirected to Mollie's hosted checkout instead of being
23
+ # completed synchronously. Re-runs on every code reload in development.
25
24
  config.to_prepare do
26
25
  SolidusMollie::Engine.prepare_decorators
27
26
  end
28
27
 
29
28
  def self.prepare_decorators
30
- Spree::CheckoutController.prepend(SolidusMollie::Spree::CheckoutControllerDecorator)
31
- rescue NameError
32
- Rails.logger.info(
33
- '[solidus_mollie] Spree::CheckoutController not found; skipping checkout override. ' \
34
- 'For an API/headless frontend, call SolidusMollie::CreateOrderPayment yourself.'
35
- )
29
+ controller = SolidusMollie::Frontend.checkout_controller
30
+ if controller
31
+ controller.prepend(SolidusMollie::CheckoutControllerDecorator)
32
+ else
33
+ Rails.logger.info(
34
+ '[solidus_mollie] no storefront checkout controller found; skipping checkout override. ' \
35
+ 'For an API/headless frontend, call SolidusMollie::CreateOrderPayment yourself.'
36
+ )
37
+ end
36
38
  end
37
39
  end
38
40
  end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusMollie
4
+ # Resolves the storefront-specific pieces of the Mollie flow. Solidus stores
5
+ # come in three shapes and this module papers over the differences:
6
+ #
7
+ # * the starter frontend copied into the host app (Solidus 3.2+): checkout
8
+ # lives in +CheckoutsController+ and order/checkout routes are main-app
9
+ # routes (+token_order_url+, +checkout_state_path+);
10
+ # * the legacy +solidus_frontend+ engine: +Spree::CheckoutController+ and
11
+ # +spree.order_url+ / +spree.checkout_state_path+;
12
+ # * headless / API stores: no checkout controller at all - the host app
13
+ # drives SolidusMollie::CreateOrderPayment itself.
14
+ #
15
+ # Every lookup can be overridden through SolidusMollie.config.
16
+ module Frontend
17
+ CHECKOUT_CONTROLLERS = %w[CheckoutsController Spree::CheckoutController].freeze
18
+
19
+ module_function
20
+
21
+ # The controller class to hook, or nil when there is none (headless).
22
+ def checkout_controller
23
+ configured = SolidusMollie.config.checkout_controller
24
+ return nil if configured == false
25
+ return configured.to_s.constantize if configured.present?
26
+
27
+ CHECKOUT_CONTROLLERS.lazy.map(&:safe_constantize).find(&:itself)
28
+ end
29
+
30
+ # Where to send the buyer once they come back from Mollie.
31
+ def order_url(controller, order)
32
+ custom = SolidusMollie.config.order_url
33
+ return custom.call(controller, order) if custom
34
+
35
+ main = controller.main_app
36
+ spree = controller.spree
37
+ if main.respond_to?(:token_order_url)
38
+ main.token_order_url(order, order.guest_token)
39
+ elsif spree.respond_to?(:order_url)
40
+ spree.order_url(order, token: order.guest_token)
41
+ else
42
+ root_path(controller)
43
+ end
44
+ end
45
+
46
+ # Where to send the buyer if the Mollie payment could not be created.
47
+ def payment_step_path(controller)
48
+ custom = SolidusMollie.config.payment_step_path
49
+ return custom.call(controller) if custom
50
+
51
+ routes = [controller.main_app, controller.spree].find { |r| r.respond_to?(:checkout_state_path) }
52
+ routes ? routes.checkout_state_path(:payment) : root_path(controller)
53
+ end
54
+
55
+ def root_path(controller)
56
+ custom = SolidusMollie.config.root_path
57
+ return custom.call(controller) if custom
58
+
59
+ routes = [controller.main_app, controller.spree].find { |r| r.respond_to?(:root_path) }
60
+ routes ? routes.root_path : '/'
61
+ end
62
+ end
63
+ end
@@ -5,7 +5,7 @@ FactoryBot.define do
5
5
  name { 'Mollie' }
6
6
  available_to_admin { true }
7
7
  available_to_users { true }
8
- preferences { { api_key: 'test_fakekey', mollie_method: '' } }
8
+ preferences { {api_key: 'test_fakekey', mollie_method: ''} }
9
9
  end
10
10
 
11
11
  factory :mollie_source, class: 'SolidusMollie::MollieSource' do
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SolidusMollie
4
- VERSION = '0.9.0'
4
+ VERSION = '0.10.0'
5
5
  end
@@ -3,6 +3,8 @@
3
3
  require 'mollie-api-ruby'
4
4
  require 'solidus_core'
5
5
  require 'solidus_mollie/version'
6
+ require 'solidus_mollie/configuration'
7
+ require 'solidus_mollie/frontend'
6
8
  require 'solidus_mollie/engine'
7
9
 
8
10
  module SolidusMollie
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solidus_mollie
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.0
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kalopa Robotics
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2026-06-20 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: mollie-api-ruby
@@ -83,29 +82,35 @@ files:
83
82
  - LICENSE
84
83
  - README.md
85
84
  - app/controllers/solidus_mollie/callbacks_controller.rb
86
- - app/decorators/solidus_mollie/spree/checkout_controller_decorator.rb
85
+ - app/decorators/solidus_mollie/checkout_controller_decorator.rb
87
86
  - app/models/solidus_mollie/client.rb
88
87
  - app/models/solidus_mollie/mollie_source.rb
89
88
  - app/models/solidus_mollie/payment_method.rb
90
89
  - app/models/solidus_mollie/response.rb
91
90
  - app/services/solidus_mollie/create_order_payment.rb
92
91
  - app/services/solidus_mollie/process_webhook.rb
92
+ - app/views/checkouts/payment/_mollie.html.erb
93
+ - app/views/orders/payment_info/_mollie.html.erb
94
+ - app/views/spree/admin/payments/source_forms/_mollie.html.erb
95
+ - app/views/spree/admin/payments/source_views/_mollie.html.erb
93
96
  - app/views/spree/checkout/payment/_mollie.html.erb
97
+ - app/views/spree/orders/payment_info/_mollie.html.erb
94
98
  - config/locales/en.yml
95
99
  - config/routes.rb
96
100
  - db/migrate/20260620143922_create_solidus_mollie_sources.rb
97
101
  - lib/generators/solidus_mollie/install/install_generator.rb
98
102
  - lib/generators/solidus_mollie/install/templates/initializer.rb
99
103
  - lib/solidus_mollie.rb
104
+ - lib/solidus_mollie/configuration.rb
100
105
  - lib/solidus_mollie/engine.rb
101
- - lib/solidus_mollie/factories.rb
106
+ - lib/solidus_mollie/frontend.rb
107
+ - lib/solidus_mollie/testing_support/factories.rb
102
108
  - lib/solidus_mollie/version.rb
103
109
  homepage: https://github.com/kalopa/solidus_mollie
104
110
  licenses:
105
111
  - BSD-2-Clause
106
112
  metadata:
107
113
  rubygems_mfa_required: 'true'
108
- post_install_message:
109
114
  rdoc_options: []
110
115
  require_paths:
111
116
  - lib
@@ -120,8 +125,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
120
125
  - !ruby/object:Gem::Version
121
126
  version: '0'
122
127
  requirements: []
123
- rubygems_version: 3.4.19
124
- signing_key:
128
+ rubygems_version: 4.0.21
125
129
  specification_version: 4
126
130
  summary: Mollie payments for Solidus
127
131
  test_files: []
@@ -1,60 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module SolidusMollie
4
- module Spree
5
- # Intercepts the checkout confirm step. When the order is paying with a
6
- # Mollie payment method, the buyer is redirected to Mollie's hosted checkout
7
- # instead of the order being completed synchronously. The order is finished
8
- # later by the webhook (see SolidusMollie::ProcessWebhook).
9
- module CheckoutControllerDecorator
10
- def update
11
- return redirect_to_mollie if divert_to_mollie?
12
-
13
- super
14
- end
15
-
16
- private
17
-
18
- def divert_to_mollie?
19
- return false unless params[:state].to_s == 'confirm' || @order&.confirm?
20
-
21
- mollie_payment.present?
22
- end
23
-
24
- def mollie_payment
25
- @mollie_payment ||= @order&.payments&.detect do |payment|
26
- payment.checkout? && payment.payment_method.is_a?(SolidusMollie::PaymentMethod)
27
- end
28
- end
29
-
30
- def redirect_to_mollie
31
- result = SolidusMollie::CreateOrderPayment.call(
32
- order: @order,
33
- payment: mollie_payment,
34
- redirect_url: spree.mollie_return_url(mollie_url_options.merge(
35
- order_number: @order.number,
36
- token: @order.token
37
- )),
38
- webhook_url: spree.mollie_webhook_url(mollie_url_options)
39
- )
40
-
41
- if result.success?
42
- redirect_to result.checkout_url, allow_other_host: true
43
- else
44
- flash[:error] = I18n.t('solidus_mollie.payment_error',
45
- message: result.error,
46
- default: "We couldn't start your Mollie payment.")
47
- redirect_to spree.checkout_state_path(:payment)
48
- end
49
- end
50
-
51
- def mollie_url_options
52
- {
53
- host: request.host,
54
- port: request.optional_port,
55
- protocol: request.ssl? ? 'https' : 'http'
56
- }.compact
57
- end
58
- end
59
- end
60
- end