solidus_mollie 0.1.0 → 0.9.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.
Files changed (64) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +21 -23
  3. data/README.md +88 -56
  4. data/app/controllers/solidus_mollie/callbacks_controller.rb +43 -0
  5. data/app/decorators/solidus_mollie/spree/checkout_controller_decorator.rb +60 -0
  6. data/app/models/solidus_mollie/client.rb +66 -0
  7. data/app/models/solidus_mollie/mollie_source.rb +31 -0
  8. data/app/models/solidus_mollie/payment_method.rb +106 -0
  9. data/app/models/solidus_mollie/response.rb +33 -0
  10. data/app/services/solidus_mollie/create_order_payment.rb +76 -0
  11. data/app/services/solidus_mollie/process_webhook.rb +68 -0
  12. data/app/views/spree/checkout/payment/_mollie.html.erb +3 -0
  13. data/config/locales/en.yml +5 -6
  14. data/config/routes.rb +6 -15
  15. data/db/migrate/20260620143922_create_solidus_mollie_sources.rb +15 -0
  16. data/lib/generators/solidus_mollie/install/install_generator.rb +24 -16
  17. data/lib/generators/solidus_mollie/install/templates/initializer.rb +5 -4
  18. data/lib/solidus_mollie/engine.rb +26 -14
  19. data/lib/solidus_mollie/factories.rb +16 -0
  20. data/lib/solidus_mollie/version.rb +1 -1
  21. data/lib/solidus_mollie.rb +9 -1
  22. metadata +47 -81
  23. data/.circleci/config.yml +0 -41
  24. data/.gem_release.yml +0 -5
  25. data/.github/stale.yml +0 -17
  26. data/.github_changelog_generator +0 -2
  27. data/.gitignore +0 -20
  28. data/.rspec +0 -2
  29. data/.rubocop.yml +0 -5
  30. data/CHANGELOG.md +0 -1
  31. data/Gemfile +0 -33
  32. data/Rakefile +0 -6
  33. data/app/assets/javascripts/spree/backend/solidus_mollie.js +0 -2
  34. data/app/assets/javascripts/spree/frontend/solidus_mollie.js +0 -2
  35. data/app/assets/stylesheets/spree/backend/solidus_mollie.css +0 -4
  36. data/app/assets/stylesheets/spree/frontend/solidus_mollie.css +0 -4
  37. data/app/controllers/spree/api/mollie_controller.rb +0 -31
  38. data/app/controllers/spree/mollie_controller.rb +0 -53
  39. data/app/decorators/models/line_item_decorator.rb +0 -21
  40. data/app/decorators/models/payment_create_decorator.rb +0 -22
  41. data/app/models/spree/gateway/mollie_gateway.rb +0 -155
  42. data/app/models/spree/mollie/order_serializer.rb +0 -169
  43. data/app/models/spree/mollie/payment_state_updater.rb +0 -62
  44. data/app/models/spree/mollie_logger.rb +0 -16
  45. data/app/models/spree/mollie_payment_source.rb +0 -57
  46. data/app/models/spree/payment_method/mollie.rb +0 -26
  47. data/app/views/spree/admin/payments/source_forms/_mollie.html.erb +0 -8
  48. data/app/views/spree/admin/payments/source_views/_mollie.html.erb +0 -27
  49. data/app/views/spree/api/payments/show.json.jbuilder +0 -5
  50. data/app/views/spree/api/payments/source_views/_mollie.json.jbuilder +0 -2
  51. data/bin/console +0 -17
  52. data/bin/rails +0 -7
  53. data/bin/rails-engine +0 -13
  54. data/bin/rails-sandbox +0 -16
  55. data/bin/rake +0 -7
  56. data/bin/sandbox +0 -86
  57. data/bin/setup +0 -8
  58. data/db/migrate/20201110194821_create_solidus_mollie_payment_source.rb +0 -14
  59. data/lib/solidus_mollie/configuration.rb +0 -21
  60. data/lib/solidus_mollie/testing_support/factories.rb +0 -4
  61. data/solidus_mollie.code-workspace +0 -8
  62. data/solidus_mollie.gemspec +0 -35
  63. data/spec/solidus_mollie_gateway_spec.rb +0 -5
  64. data/spec/spec_helper.rb +0 -31
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b93ec5f4b4a21514882eb71bbe99ba37826ad0cbbd83421b3661b2251cc2e589
4
- data.tar.gz: af024b49050bbeaf8e186b0b4b338e8e8893fe507087d369a417e152aaa052b2
3
+ metadata.gz: 9dc0df8101384051b947487223e3b8aac8092e7da9dcbc394cb4bcfc5cac77a2
4
+ data.tar.gz: 32b058d3cdd52adac6b85b98e9eb2b267e688a111f29447879a654812951ea9f
5
5
  SHA512:
6
- metadata.gz: f3ad564dcc8d5ea425b5cfe5c9887a74deb903acba47bcc4f30cc40600243dc42d8a433350a1533875bf180f3adc14c723582f495592ed747a28ab76876ebc6e
7
- data.tar.gz: 89a485930ff1261aa2d49bb24061305a45eb7f5ea6a00116d6c00aa82759c175385017ff5bbf0a193176b208650960e20ab088b9b8f1f891449dce5df5a8ae74
6
+ metadata.gz: 1f3e7bdaf7b94a4235ca2cf91c4d61f056f0fd0f320300fe3c3f584227bbd9ca701bc903cefa08931fc8742959c9da1d1da318069b74af853a83af5a9111b483
7
+ data.tar.gz: 4cc1d2a6542ee1b2fc401a676ee9275e6422fde82af2c510cff051b8504f89ca69e69138ddfa55b3c70384146630cba68df67b55aff0163fac86e25face74dbd
data/LICENSE CHANGED
@@ -1,26 +1,24 @@
1
- Copyright (c) 2021 [name of plugin creator]
2
- All rights reserved.
1
+ BSD 2-Clause License
3
2
 
4
- Redistribution and use in source and binary forms, with or without modification,
5
- are permitted provided that the following conditions are met:
3
+ Copyright (c) 2026, Kalopa Robotics
6
4
 
7
- * Redistributions of source code must retain the above copyright notice,
8
- this list of conditions and the following disclaimer.
9
- * Redistributions in binary form must reproduce the above copyright notice,
10
- this list of conditions and the following disclaimer in the documentation
11
- and/or other materials provided with the distribution.
12
- * Neither the name Solidus nor the names of its contributors may be used to
13
- endorse or promote products derived from this software without specific
14
- prior written permission.
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
15
7
 
16
- THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
17
- "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
18
- LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
19
- A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
20
- CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
21
- EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
22
- PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
23
- PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
24
- LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
25
- NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
26
- SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
16
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
17
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
18
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
19
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
20
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
21
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
22
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
23
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
24
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
data/README.md CHANGED
@@ -1,91 +1,123 @@
1
- # Solidus Mollie
1
+ # solidus_mollie
2
2
 
3
- [![CircleCI](https://circleci.com/gh/solidusio-contrib/solidus_mollie.svg?style=shield)](https://circleci.com/gh/solidusio-contrib/solidus_mollie)
4
- [![codecov](https://codecov.io/gh/solidusio-contrib/solidus_mollie/branch/master/graph/badge.svg)](https://codecov.io/gh/solidusio-contrib/solidus_mollie)
3
+ Accept [Mollie](https://www.mollie.com) payments in your [Solidus](https://solidus.io)
4
+ store using Mollie's **hosted checkout** (the buyer is redirected to Mollie to pay)
5
+ and **webhooks** for settlement.
6
+
7
+ Tested against the stack it was written for: **Solidus 4.6 / Rails 7.1 / Ruby 3.2**,
8
+ with `mollie-api-ruby ~> 4`.
9
+
10
+ ## How it works (read this first)
11
+
12
+ Mollie is **not** a synchronous credit-card gateway, so this is not a normal
13
+ ActiveMerchant-style `authorize`/`capture` extension. The flow is:
14
+
15
+ 1. Buyer selects Mollie at the checkout **payment** step. A `Spree::Payment` is
16
+ created with a `SolidusMollie::MollieSource` (no card data is collected).
17
+ 2. At the **confirm** step, instead of completing the order synchronously, the
18
+ buyer is redirected to Mollie's hosted page. The payment is moved to
19
+ `pending`. (See `SolidusMollie::CreateOrderPayment` and the
20
+ `Spree::CheckoutController` override.)
21
+ 3. The buyer pays on Mollie and is sent back to `/mollie/return`, which forwards
22
+ them to their order page.
23
+ 4. Mollie also POSTs to `/mollie/webhook` — **this is the source of truth.**
24
+ `SolidusMollie::ProcessWebhook` re-fetches the authoritative status, marks the
25
+ `Spree::Payment` complete/failed, and completes the order. It is idempotent
26
+ because Mollie may call the webhook more than once.
27
+
28
+ > Because the webhook is what completes the order, **Mollie must be able to reach
29
+ > your app over a public HTTPS URL**, including in development. Use a tunnel such
30
+ > as `ngrok` or `cloudflared` locally — Mollie will not call `localhost`.
5
31
 
6
32
  ## Installation
7
33
 
8
- Add solidus\_mollie to your Gemfile:
34
+ Add to your store's `Gemfile`:
9
35
 
10
36
  ```ruby
11
37
  gem 'solidus_mollie'
12
38
  ```
13
39
 
14
- Bundle your dependencies and run the installation generator:
40
+ Then:
15
41
 
16
- ```shell
42
+ ```bash
43
+ bundle install
17
44
  bin/rails generate solidus_mollie:install
45
+ bin/rails db:migrate
18
46
  ```
19
47
 
20
- ## Usage
21
-
22
- <!-- Explain how to use your extension once it's been installed. -->
23
-
24
- ## Development
25
-
26
- ### Testing the extension
48
+ ## Configuration
27
49
 
28
- First bundle your dependencies, then run `bin/rake`. `bin/rake` will default to building the dummy
29
- app if it does not exist, then it will run specs. The dummy app can be regenerated by using
30
- `bin/rake extension:test_app`.
50
+ 1. In the admin, go to **Configuration Payment Methods New Payment Method**.
51
+ 2. Choose **`SolidusMollie::PaymentMethod`** as the provider.
52
+ 3. Paste your Mollie **API key** (`test_…` for testing, `live_…` for production).
53
+ 4. Optionally set **Mollie method** to force a single method (e.g. `ideal`,
54
+ `creditcard`, `bancontact`). Leave blank to let the buyer choose on Mollie's page.
31
55
 
32
- ```shell
33
- bin/rake
34
- ```
56
+ The key prefix (`test_`/`live_`) determines test vs live mode automatically.
35
57
 
36
- To run [Rubocop](https://github.com/bbatsov/rubocop) static code analysis run
58
+ ## Frontend
37
59
 
38
- ```shell
39
- bundle exec rubocop
40
- ```
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.
41
63
 
42
- When testing your application's integration with this extension you may use its factories.
43
- Simply add this require statement to your `spec/spec_helper.rb`:
64
+ If you run a **headless / API frontend**, the controller override is skipped.
65
+ Trigger the redirect yourself after confirm:
44
66
 
45
67
  ```ruby
46
- require 'solidus_mollie/testing_support/factories'
68
+ result = SolidusMollie::CreateOrderPayment.call(
69
+ order: order,
70
+ 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
73
+ )
74
+ # => redirect the buyer to result.checkout_url
47
75
  ```
48
76
 
49
- Or, if you are using `FactoryBot.definition_file_paths`, you can load Solidus core
50
- factories along with this extension's factories using this statement:
51
-
52
- ```ruby
53
- SolidusDevSupport::TestingSupport::Factories.load_for(SolidusMollie::Engine)
54
- ```
77
+ ## Refunds & cancellation
55
78
 
56
- ### Running the sandbox
79
+ Refunds from the Solidus admin call `PaymentMethod#credit`, which creates a Mollie
80
+ refund. Cancelling a still-cancelable Mollie payment is attempted via `try_void`;
81
+ otherwise Solidus falls back to a refund.
57
82
 
58
- To run this extension in a sandboxed Solidus application, you can run `bin/sandbox`. The path for
59
- the sandbox app is `./sandbox` and `bin/rails` will forward any Rails commands to
60
- `sandbox/bin/rails`.
83
+ ## Development & tests
61
84
 
62
- Here's an example:
85
+ The extension is scaffolded with `solidus_dev_support`, which generates a dummy
86
+ Solidus app under `spec/dummy` to test against.
63
87
 
64
- ```
65
- $ bin/rails server
66
- => Booting Puma
67
- => Rails 6.0.2.1 application starting in development
68
- * Listening on tcp://127.0.0.1:3000
69
- Use Ctrl-C to stop
88
+ ```bash
89
+ bin/setup # bundle + generate the dummy app
90
+ bin/rake # run the full spec suite (extension:specs)
91
+ bundle exec rspec spec/services/solidus_mollie/process_webhook_spec.rb # one file
92
+ SOLIDUS_BRANCH=v4.6 bin/setup # pin a specific Solidus version
93
+ bin/sandbox # build a runnable sandbox store under ./sandbox
70
94
  ```
71
95
 
72
- ### Updating the changelog
96
+ Bundled specs to build on:
73
97
 
74
- Before and after releases the changelog should be updated to reflect the up-to-date status of
75
- the project:
98
+ - `spec/services/solidus_mollie/process_webhook_spec.rb` settlement (paid /
99
+ failed / idempotent / unknown-id). This is the behaviour most worth covering.
100
+ - `spec/requests/solidus_mollie/callbacks_spec.rb` — webhook + return endpoints.
101
+ - `spec/models/solidus_mollie/payment_method_spec.rb` — test-mode detection, refunds.
102
+ - `spec/support/mollie_stubs.rb` — helpers to fake the Mollie API so specs never
103
+ hit the network (`stub_mollie_get`, `stub_mollie_create`).
76
104
 
77
- ```shell
78
- bin/rake changelog
79
- git add CHANGELOG.md
80
- git commit -m "Update the changelog"
81
- ```
105
+ ### Testing against Mollie for real
106
+
107
+ Mollie has no separate sandbox URL — use a **test API key** (`test_…`) on the
108
+ payment method. Test payments are fully isolated from live data, and Mollie's
109
+ hosted page is replaced by a screen where you choose the resulting status
110
+ (Paid / Failed / Expired / Cancelled). Because settlement is webhook-driven,
111
+ Mollie must reach `/mollie/webhook` over a public HTTPS URL even in test mode —
112
+ run a tunnel (`ngrok http 3000`) and use the public host.
82
113
 
83
- ### Releasing new versions
114
+ ## Known limitations / TODO
84
115
 
85
- Please refer to the dedicated [page](https://github.com/solidusio/solidus/wiki/How-to-release-extensions) on Solidus wiki.
116
+ - No one-click / reusable mandates (sources are non-reusable in this version).
117
+ - 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.
86
120
 
87
121
  ## License
88
122
 
89
- Copyright (c) 2021 Victor ter Hark.
90
- Copyright (c) 2026 Kalopa Robotics Limited.
91
- Released under the New BSD License.
123
+ BSD-2-Clause.
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusMollie
4
+ # Handles the two requests Mollie sends us:
5
+ # * #webhook - server-to-server POST with the payment id (source of truth)
6
+ # * #return - the buyer's browser coming back from Mollie's hosted page
7
+ class CallbacksController < ::Spree::StoreController
8
+ skip_before_action :verify_authenticity_token, only: :webhook
9
+
10
+ # POST /mollie/webhook (body: id=tr_xxxxx)
11
+ def webhook
12
+ SolidusMollie::ProcessWebhook.call(mollie_payment_id: params[:id])
13
+ head :ok
14
+ rescue SolidusMollie::ProcessWebhook::PaymentNotFound => e
15
+ # 422 so Mollie retries (covers the rare webhook-before-persist race).
16
+ Rails.logger.warn("[solidus_mollie] webhook payment not found: #{e.message}")
17
+ head :unprocessable_entity
18
+ rescue StandardError => e
19
+ Rails.logger.error("[solidus_mollie] webhook error: #{e.message}")
20
+ head :internal_server_error
21
+ end
22
+
23
+ # GET /mollie/return?order_number=R123&token=abc
24
+ def return
25
+ 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
31
+
32
+ unless order.completed?
33
+ flash[:notice] = I18n.t('solidus_mollie.confirming_payment',
34
+ default: "Thanks! We're confirming your payment with Mollie.")
35
+ end
36
+
37
+ redirect_to spree.order_url(order, token: order.token)
38
+ rescue ActiveRecord::RecordNotFound
39
+ flash[:error] = I18n.t('solidus_mollie.order_not_found', default: 'Order not found.')
40
+ redirect_to spree.root_path
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,60 @@
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
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bigdecimal'
4
+
5
+ module SolidusMollie
6
+ # Thin wrapper around the mollie-api-ruby gem. Centralises API-key handling
7
+ # and the (fiddly) amount formatting Mollie requires: a string value with
8
+ # exactly the right number of decimals for the currency.
9
+ class Client
10
+ def initialize(api_key:)
11
+ raise ArgumentError, 'Mollie API key is blank' if api_key.to_s.empty?
12
+
13
+ @api_key = api_key
14
+ end
15
+
16
+ # amount: a BigDecimal/Numeric in MAJOR units (e.g. 10.00 for €10).
17
+ def create_payment(amount:, currency:, description:, redirect_url:, webhook_url:,
18
+ method: nil, metadata: {})
19
+ Mollie::Payment.create(
20
+ amount: { value: self.class.format_amount(amount, currency), currency: currency },
21
+ description: description,
22
+ redirect_url: redirect_url,
23
+ webhook_url: webhook_url,
24
+ method: method.presence,
25
+ metadata: metadata,
26
+ api_key: @api_key
27
+ )
28
+ end
29
+
30
+ def get_payment(payment_id)
31
+ Mollie::Payment.get(payment_id, api_key: @api_key)
32
+ end
33
+
34
+ def cancel_payment(payment_id)
35
+ Mollie::Payment.delete(payment_id, api_key: @api_key)
36
+ end
37
+
38
+ def create_refund(payment_id:, amount:, currency:)
39
+ Mollie::Payment::Refund.create(
40
+ payment_id: payment_id,
41
+ amount: { value: self.class.format_amount(amount, currency), currency: currency },
42
+ api_key: @api_key
43
+ )
44
+ end
45
+
46
+ # --- formatting helpers --------------------------------------------------
47
+
48
+ # Format a major-unit amount into the string Mollie expects, honouring the
49
+ # currency's decimal places (EUR -> "10.00", JPY -> "10").
50
+ def self.format_amount(amount, currency)
51
+ format("%.#{currency_exponent(currency)}f", BigDecimal(amount.to_s))
52
+ end
53
+
54
+ # Convert a cents/minor-unit integer (as Solidus passes to #credit) into a
55
+ # major-unit BigDecimal.
56
+ def self.cents_to_major(cents, currency)
57
+ BigDecimal(cents.to_s) / (10**currency_exponent(currency))
58
+ end
59
+
60
+ def self.currency_exponent(currency)
61
+ ::Money::Currency.new(currency).exponent
62
+ rescue StandardError
63
+ 2
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusMollie
4
+ # Persists the Mollie payment id and last-known status alongside a
5
+ # Spree::Payment. The buyer enters no card data here (that happens on Mollie's
6
+ # hosted page), so this source has no validated fields.
7
+ class MollieSource < ::Spree::PaymentSource
8
+ self.table_name = 'solidus_mollie_sources'
9
+
10
+ # Off-site payments are not reusable for one-click in this version.
11
+ def reusable?
12
+ false
13
+ end
14
+
15
+ def actions
16
+ %w[void credit]
17
+ end
18
+
19
+ def can_void?(payment)
20
+ payment.pending? || payment.checkout?
21
+ end
22
+
23
+ def can_credit?(payment)
24
+ payment.completed? && payment.credit_allowed.positive?
25
+ end
26
+
27
+ def paid?
28
+ SolidusMollie::PAID_STATUSES.include?(status)
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusMollie
4
+ # A Solidus payment method backed by Mollie's hosted (redirect) checkout.
5
+ #
6
+ # Unlike credit-card gateways, Mollie does not authorise/capture
7
+ # synchronously. The buyer is redirected to Mollie, and the *webhook* is the
8
+ # source of truth for whether payment succeeded. See
9
+ # SolidusMollie::CreateOrderPayment (redirect) and
10
+ # SolidusMollie::ProcessWebhook (settlement).
11
+ class PaymentMethod < ::Spree::PaymentMethod
12
+ preference :api_key, :string
13
+ # Optional. Force a single Mollie method (e.g. "ideal", "creditcard",
14
+ # "bancontact"). Leave blank to let the buyer choose on Mollie's page.
15
+ preference :mollie_method, :string
16
+
17
+ def payment_source_class
18
+ SolidusMollie::MollieSource
19
+ end
20
+
21
+ # Frontend/admin look for spree/checkout/payment/_mollie and friends.
22
+ def partial_name
23
+ 'mollie'
24
+ end
25
+
26
+ def source_required?
27
+ true
28
+ end
29
+
30
+ def payment_profiles_supported?
31
+ false
32
+ end
33
+
34
+ # Money is moved on Mollie's side, so there is nothing to auto-capture here.
35
+ def auto_capture?
36
+ false
37
+ end
38
+
39
+ def test_mode?
40
+ preferred_api_key.to_s.start_with?('test_')
41
+ end
42
+
43
+ def client
44
+ SolidusMollie::Client.new(api_key: preferred_api_key)
45
+ end
46
+
47
+ # --- Solidus gateway interface ------------------------------------------
48
+ # These are deliberately defensive. The normal checkout flow never calls
49
+ # purchase/authorize/capture (we redirect instead), but implementing them
50
+ # keeps Solidus happy if Order#process_payments! is ever invoked.
51
+
52
+ def authorize(_amount, _source, _gateway_options = {})
53
+ SolidusMollie::Response.pending('Awaiting Mollie redirect')
54
+ end
55
+
56
+ def purchase(_amount, _source, _gateway_options = {})
57
+ SolidusMollie::Response.pending('Awaiting Mollie redirect')
58
+ end
59
+
60
+ def capture(_amount, _response_code, _gateway_options = {})
61
+ SolidusMollie::Response.success('Captured by Mollie webhook')
62
+ end
63
+
64
+ # Refund. Solidus' Spree::Refund#perform! has varied across versions; accept
65
+ # both (amount, response_code, options) and (amount, source, response_code,
66
+ # options).
67
+ def credit(amount_cents, *rest)
68
+ options = rest.last.is_a?(Hash) ? rest.last : {}
69
+ response_code = rest.reverse.find { |arg| arg.is_a?(String) } || options[:response_code]
70
+ currency = refund_currency(options)
71
+
72
+ client.create_refund(
73
+ payment_id: response_code,
74
+ amount: SolidusMollie::Client.cents_to_major(amount_cents, currency),
75
+ currency: currency
76
+ )
77
+ SolidusMollie::Response.success('Mollie refund created', authorization: response_code)
78
+ rescue StandardError => e
79
+ Rails.logger.error("[solidus_mollie] refund failed: #{e.message}")
80
+ SolidusMollie::Response.failure(e.message)
81
+ end
82
+
83
+ # Solidus calls try_void before issuing a refund. Cancel the Mollie payment
84
+ # if it is still cancelable; otherwise return false so Solidus falls back to
85
+ # a refund.
86
+ def try_void(payment)
87
+ remote = client.get_payment(payment.response_code)
88
+ return false unless remote.respond_to?(:cancelable?) ? remote.cancelable? : remote.try(:cancelable)
89
+
90
+ client.cancel_payment(payment.response_code)
91
+ SolidusMollie::Response.success('Mollie payment canceled', authorization: payment.response_code)
92
+ rescue StandardError => e
93
+ Rails.logger.error("[solidus_mollie] void failed: #{e.message}")
94
+ false
95
+ end
96
+
97
+ private
98
+
99
+ def refund_currency(options)
100
+ options[:currency] ||
101
+ options[:originator]&.try(:payment)&.try(:currency) ||
102
+ ::Spree::Config.try(:currency) ||
103
+ 'EUR'
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusMollie
4
+ # Solidus serialises gateway responses into Spree::LogEntry as YAML and expects
5
+ # an ActiveMerchant::Billing::Response-shaped object. We reuse that class
6
+ # (active_merchant is a solidus_core dependency) so logging, #success? and
7
+ # #authorization all behave as Solidus expects.
8
+ module Response
9
+ module_function
10
+
11
+ def success(message, authorization: nil, params: {})
12
+ build(true, message, params, authorization: authorization)
13
+ end
14
+
15
+ def failure(message, params: {})
16
+ build(false, message, params)
17
+ end
18
+
19
+ def pending(message, params: {})
20
+ build(true, message, params.merge('pending' => true))
21
+ end
22
+
23
+ def build(success, message, params, authorization: nil)
24
+ ::ActiveMerchant::Billing::Response.new(
25
+ success,
26
+ message,
27
+ params,
28
+ authorization: authorization,
29
+ test: false
30
+ )
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidusMollie
4
+ # Creates a Mollie payment for an order and returns the hosted checkout URL the
5
+ # buyer should be redirected to. The associated Spree::Payment is moved to
6
+ # `pending` so Solidus knows it is awaiting an off-site result.
7
+ class CreateOrderPayment
8
+ Result = Struct.new(:checkout_url, :error, keyword_init: true) do
9
+ def success?
10
+ error.nil?
11
+ end
12
+ end
13
+
14
+ def self.call(**kwargs)
15
+ new(**kwargs).call
16
+ end
17
+
18
+ def initialize(order:, payment:, redirect_url:, webhook_url:)
19
+ @order = order
20
+ @payment = payment
21
+ @redirect_url = redirect_url
22
+ @webhook_url = webhook_url
23
+ end
24
+
25
+ def call
26
+ payment_method = @payment.payment_method
27
+ mollie = payment_method.client.create_payment(
28
+ amount: @order.total,
29
+ currency: @order.currency,
30
+ description: description,
31
+ redirect_url: @redirect_url,
32
+ webhook_url: @webhook_url,
33
+ method: payment_method.preferred_mollie_method,
34
+ metadata: { order_number: @order.number, payment_number: @payment.number }
35
+ )
36
+
37
+ persist(mollie)
38
+ Result.new(checkout_url: mollie.checkout_url)
39
+ rescue StandardError => e
40
+ Rails.logger.error("[solidus_mollie] failed to create payment for #{@order.number}: #{e.message}")
41
+ Result.new(error: e.message)
42
+ end
43
+
44
+ private
45
+
46
+ def persist(mollie)
47
+ source = mollie_source
48
+ source.update!(
49
+ mollie_payment_id: mollie.id,
50
+ mollie_method: mollie.try(:method),
51
+ status: mollie.status
52
+ )
53
+
54
+ @payment.update!(response_code: mollie.id)
55
+ @payment.pend! if @payment.checkout?
56
+ end
57
+
58
+ def mollie_source
59
+ if @payment.source.is_a?(SolidusMollie::MollieSource)
60
+ @payment.source
61
+ else
62
+ source = SolidusMollie::MollieSource.create!(
63
+ payment_method: @payment.payment_method,
64
+ user: @order.user
65
+ )
66
+ @payment.update!(source: source)
67
+ source
68
+ end
69
+ end
70
+
71
+ def description
72
+ store_name = @order.try(:store)&.name || ::Spree::Store.try(:default)&.name || 'Order'
73
+ "#{store_name} ##{@order.number}"
74
+ end
75
+ end
76
+ end