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.
- checksums.yaml +4 -4
- data/LICENSE +21 -23
- data/README.md +88 -56
- data/app/controllers/solidus_mollie/callbacks_controller.rb +43 -0
- data/app/decorators/solidus_mollie/spree/checkout_controller_decorator.rb +60 -0
- data/app/models/solidus_mollie/client.rb +66 -0
- data/app/models/solidus_mollie/mollie_source.rb +31 -0
- data/app/models/solidus_mollie/payment_method.rb +106 -0
- data/app/models/solidus_mollie/response.rb +33 -0
- data/app/services/solidus_mollie/create_order_payment.rb +76 -0
- data/app/services/solidus_mollie/process_webhook.rb +68 -0
- data/app/views/spree/checkout/payment/_mollie.html.erb +3 -0
- data/config/locales/en.yml +5 -6
- data/config/routes.rb +6 -15
- data/db/migrate/20260620143922_create_solidus_mollie_sources.rb +15 -0
- data/lib/generators/solidus_mollie/install/install_generator.rb +24 -16
- data/lib/generators/solidus_mollie/install/templates/initializer.rb +5 -4
- data/lib/solidus_mollie/engine.rb +26 -14
- data/lib/solidus_mollie/factories.rb +16 -0
- data/lib/solidus_mollie/version.rb +1 -1
- data/lib/solidus_mollie.rb +9 -1
- metadata +47 -81
- data/.circleci/config.yml +0 -41
- data/.gem_release.yml +0 -5
- data/.github/stale.yml +0 -17
- data/.github_changelog_generator +0 -2
- data/.gitignore +0 -20
- data/.rspec +0 -2
- data/.rubocop.yml +0 -5
- data/CHANGELOG.md +0 -1
- data/Gemfile +0 -33
- data/Rakefile +0 -6
- data/app/assets/javascripts/spree/backend/solidus_mollie.js +0 -2
- data/app/assets/javascripts/spree/frontend/solidus_mollie.js +0 -2
- data/app/assets/stylesheets/spree/backend/solidus_mollie.css +0 -4
- data/app/assets/stylesheets/spree/frontend/solidus_mollie.css +0 -4
- data/app/controllers/spree/api/mollie_controller.rb +0 -31
- data/app/controllers/spree/mollie_controller.rb +0 -53
- data/app/decorators/models/line_item_decorator.rb +0 -21
- data/app/decorators/models/payment_create_decorator.rb +0 -22
- data/app/models/spree/gateway/mollie_gateway.rb +0 -155
- data/app/models/spree/mollie/order_serializer.rb +0 -169
- data/app/models/spree/mollie/payment_state_updater.rb +0 -62
- data/app/models/spree/mollie_logger.rb +0 -16
- data/app/models/spree/mollie_payment_source.rb +0 -57
- data/app/models/spree/payment_method/mollie.rb +0 -26
- data/app/views/spree/admin/payments/source_forms/_mollie.html.erb +0 -8
- data/app/views/spree/admin/payments/source_views/_mollie.html.erb +0 -27
- data/app/views/spree/api/payments/show.json.jbuilder +0 -5
- data/app/views/spree/api/payments/source_views/_mollie.json.jbuilder +0 -2
- data/bin/console +0 -17
- data/bin/rails +0 -7
- data/bin/rails-engine +0 -13
- data/bin/rails-sandbox +0 -16
- data/bin/rake +0 -7
- data/bin/sandbox +0 -86
- data/bin/setup +0 -8
- data/db/migrate/20201110194821_create_solidus_mollie_payment_source.rb +0 -14
- data/lib/solidus_mollie/configuration.rb +0 -21
- data/lib/solidus_mollie/testing_support/factories.rb +0 -4
- data/solidus_mollie.code-workspace +0 -8
- data/solidus_mollie.gemspec +0 -35
- data/spec/solidus_mollie_gateway_spec.rb +0 -5
- data/spec/spec_helper.rb +0 -31
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9dc0df8101384051b947487223e3b8aac8092e7da9dcbc394cb4bcfc5cac77a2
|
|
4
|
+
data.tar.gz: 32b058d3cdd52adac6b85b98e9eb2b267e688a111f29447879a654812951ea9f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1f3e7bdaf7b94a4235ca2cf91c4d61f056f0fd0f320300fe3c3f584227bbd9ca701bc903cefa08931fc8742959c9da1d1da318069b74af853a83af5a9111b483
|
|
7
|
+
data.tar.gz: 4cc1d2a6542ee1b2fc401a676ee9275e6422fde82af2c510cff051b8504f89ca69e69138ddfa55b3c70384146630cba68df67b55aff0163fac86e25face74dbd
|
data/LICENSE
CHANGED
|
@@ -1,26 +1,24 @@
|
|
|
1
|
-
|
|
2
|
-
All rights reserved.
|
|
1
|
+
BSD 2-Clause License
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
are permitted provided that the following conditions are met:
|
|
3
|
+
Copyright (c) 2026, Kalopa Robotics
|
|
6
4
|
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
#
|
|
1
|
+
# solidus_mollie
|
|
2
2
|
|
|
3
|
-
[
|
|
4
|
-
|
|
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
|
|
34
|
+
Add to your store's `Gemfile`:
|
|
9
35
|
|
|
10
36
|
```ruby
|
|
11
37
|
gem 'solidus_mollie'
|
|
12
38
|
```
|
|
13
39
|
|
|
14
|
-
|
|
40
|
+
Then:
|
|
15
41
|
|
|
16
|
-
```
|
|
42
|
+
```bash
|
|
43
|
+
bundle install
|
|
17
44
|
bin/rails generate solidus_mollie:install
|
|
45
|
+
bin/rails db:migrate
|
|
18
46
|
```
|
|
19
47
|
|
|
20
|
-
##
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
`
|
|
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
|
-
|
|
33
|
-
bin/rake
|
|
34
|
-
```
|
|
56
|
+
The key prefix (`test_`/`live_`) determines test vs live mode automatically.
|
|
35
57
|
|
|
36
|
-
|
|
58
|
+
## Frontend
|
|
37
59
|
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
96
|
+
Bundled specs to build on:
|
|
73
97
|
|
|
74
|
-
|
|
75
|
-
the
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
114
|
+
## Known limitations / TODO
|
|
84
115
|
|
|
85
|
-
|
|
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
|
-
|
|
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
|