spree-bank_payments 5.0.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 +7 -0
- data/CHANGELOG.md +61 -0
- data/LICENSE +9 -0
- data/README.md +213 -0
- data/Rakefile +36 -0
- data/app/controllers/spree/admin/bank_transfers_controller.rb +287 -0
- data/app/jobs/spree/bank_payments/expire_sessions_job.rb +102 -0
- data/app/jobs/spree/bank_payments/poll_job.rb +39 -0
- data/app/jobs/spree/bank_payments/send_reminders_job.rb +50 -0
- data/app/mailers/spree/bank_payments/instructions_mailer.rb +30 -0
- data/app/models/spree/bank_payments/base.rb +12 -0
- data/app/models/spree/bank_payments/gateway.rb +166 -0
- data/app/models/spree/bank_payments/incoming_transfer.rb +46 -0
- data/app/models/spree/bank_payments/payment_decorator.rb +22 -0
- data/app/models/spree/bank_payments/reconciler_state.rb +26 -0
- data/app/models/spree/bank_payments/reconcilers/base.rb +56 -0
- data/app/models/spree/bank_payments/reconcilers/manual.rb +25 -0
- data/app/models/spree/bank_payments/transfer_data.rb +21 -0
- data/app/models/spree/payment_sessions/bank_transfer.rb +48 -0
- data/app/services/spree/bank_payments/apply_discount.rb +92 -0
- data/app/services/spree/bank_payments/apply_transfer.rb +75 -0
- data/app/services/spree/bank_payments/ingest_transfer.rb +71 -0
- data/app/services/spree/bank_payments/reference_generator.rb +41 -0
- data/app/services/spree/bank_payments/suggest_matches.rb +66 -0
- data/app/views/spree/admin/bank_transfers/index.html.erb +75 -0
- data/app/views/spree/admin/bank_transfers/new.html.erb +46 -0
- data/app/views/spree/admin/payment_methods/configuration_guides/_spree_bank_payments.html.erb +44 -0
- data/app/views/spree/admin/payment_methods/descriptions/_spree_bank_payments.html.erb +4 -0
- data/app/views/spree/bank_payments/_order_instructions.html.erb +73 -0
- data/app/views/spree/bank_payments/admin/_order_panel.html.erb +46 -0
- data/app/views/spree/bank_payments/instructions_mailer/instructions.html.erb +18 -0
- data/app/views/spree/bank_payments/instructions_mailer/reminder.html.erb +20 -0
- data/app/views/spree/checkout/payment/_spree_bank_payments.html.erb +13 -0
- data/config/initializers/spree.rb +55 -0
- data/config/locales/en.yml +18 -0
- data/config/routes.rb +14 -0
- data/db/migrate/20260815000001_create_spree_bank_payments_tables.rb +42 -0
- data/db/migrate/20260815000002_add_external_id_normalized_to_payment_sessions.rb +20 -0
- data/db/migrate/20260815000003_enable_pg_trgm.rb +45 -0
- data/db/migrate/20260816000001_add_payment_method_id_to_spree_bank_payments_incoming_transfers.rb +8 -0
- data/lib/generators/spree/bank_payments/install/install_generator.rb +40 -0
- data/lib/spree/bank_payments/configuration.rb +7 -0
- data/lib/spree/bank_payments/engine.rb +26 -0
- data/lib/spree/bank_payments/factories.rb +38 -0
- data/lib/spree/bank_payments/subscribers.rb +42 -0
- data/lib/spree/bank_payments/testing_support/reconciler_shared_examples.rb +50 -0
- data/lib/spree/bank_payments/version.rb +6 -0
- data/lib/spree/bank_payments.rb +31 -0
- data/lib/spree-bank_payments.rb +8 -0
- metadata +149 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 62489d37734d726a1b9fa5c30303ccd5165ed2679c18e1e87fe69dbddc25d446
|
|
4
|
+
data.tar.gz: 502281b05bf1680cf13c92ac26e96fb51d5216e513830ae59e0301c66334ee17
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 8a81d4f37d6ccf997de25c8dfadb60c73cc91a0812851bdd5477acc2b323948648e19a7469bf861201cde794984ce9eb962b1d2991aae111f59c6b3ec47ea342
|
|
7
|
+
data.tar.gz: 16684728ff3aace5f8fabac9c2b48766a044ae9c2dd6d9dd904da5755051156af04e53369e2535e34e069641d2654db61ceec966f32e98bd014d56beb40ef28c
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
## 5.0.0
|
|
6
|
+
|
|
7
|
+
First public release on RubyGems, as `spree-bank_payments`.
|
|
8
|
+
|
|
9
|
+
This gem was developed under the working names `aypex_bank_transfer`,
|
|
10
|
+
`spree_bank_transfer` and `spree_bank_payments`, but was never published under any
|
|
11
|
+
of them.
|
|
12
|
+
|
|
13
|
+
Every spelling of `bank_transfer` turned out to be unavailable. Two different
|
|
14
|
+
owners hold the underscore and hyphen forms — `spree_bank_transfer` (vinsol, 2013)
|
|
15
|
+
and `spree-bank-transfer` (Mohit Bansal, 2014) — and `spree-bank_transfer` is
|
|
16
|
+
rejected too, because RubyGems refuses a new name that is too similar to an
|
|
17
|
+
existing one and treats `-` and `_` as equivalent. An API 404 on a gem name means
|
|
18
|
+
"no exact match", not "publishable".
|
|
19
|
+
|
|
20
|
+
`spree-bank_payments` is free, and the spelling is correct on its own terms: a dash
|
|
21
|
+
denotes a gem under another gem's namespace while an underscore joins words within
|
|
22
|
+
one level, so the gem name, the require path (`spree/bank_payments`) and the Ruby
|
|
23
|
+
namespace (`Spree::BankPayments`) all agree.
|
|
24
|
+
|
|
25
|
+
The *domain* vocabulary is still "bank transfer" — that is what the payment
|
|
26
|
+
instrument is. Only the gem namespace is `BankPayments`.
|
|
27
|
+
|
|
28
|
+
**Versioning:** the major version tracks Spree's major version — `spree-bank_payments`
|
|
29
|
+
5.x supports Spree 5.x. The gemspec requires `spree >= 5.6.0`.
|
|
30
|
+
|
|
31
|
+
If you tracked this repository from git before 5.0.0, note:
|
|
32
|
+
|
|
33
|
+
- **Gem name** is `spree-bank_payments`. `gem 'spree-bank_payments'` is all a host
|
|
34
|
+
app needs — `Bundler.require` resolves through a shim at `lib/spree-bank_payments.rb`.
|
|
35
|
+
- **Require path** is now `spree/bank_payments`.
|
|
36
|
+
- **Ruby namespace** is now `Spree::BankPayments` (was the top-level `SpreeBankPayments`).
|
|
37
|
+
Note the nesting change: these constants now live under `Spree`.
|
|
38
|
+
- **Install generator** is now `rails g spree:bank_payments:install`.
|
|
39
|
+
- **Database tables** are `spree_bank_payments_incoming_transfers` and
|
|
40
|
+
`spree_bank_payments_reconciler_states`. The migrations were edited in place
|
|
41
|
+
rather than shipped as renames, which is safe only because no host application
|
|
42
|
+
had ever run them. There is no upgrade path from an earlier checkout that had
|
|
43
|
+
already migrated — drop the old tables and re-run.
|
|
44
|
+
- **Unchanged:** `Spree::PaymentSessions::BankTransfer`,
|
|
45
|
+
`Spree::Admin::BankTransfersController`, `IncomingTransfer`, `TransferData`, and
|
|
46
|
+
the admin routes.
|
|
47
|
+
|
|
48
|
+
`engine_name` is `spree_bank_payments`: it generates route helper prefixes and the
|
|
49
|
+
`spree_bank_payments:install:migrations` rake task, so it must be a valid Ruby
|
|
50
|
+
identifier and cannot take a dash.
|
|
51
|
+
|
|
52
|
+
### Features
|
|
53
|
+
|
|
54
|
+
- Bank transfer payment method with a configurable checkout discount, expiry
|
|
55
|
+
window, and unique per-order payment references.
|
|
56
|
+
- Pluggable reconciliation of incoming transfers, with a manual reconciler that
|
|
57
|
+
works out of the box and a documented interface for bank-specific backends.
|
|
58
|
+
- Admin queue for transfers that cannot be matched automatically, with match
|
|
59
|
+
suggestions backed by `pg_trgm` similarity.
|
|
60
|
+
- Instruction and reminder mailers, session expiry, and polling jobs.
|
|
61
|
+
- PostgreSQL only: `jsonb`, partial unique indexes and `pg_trgm` are load-bearing.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aypex
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# spree-bank_payments
|
|
2
|
+
|
|
3
|
+
Bank transfer checkout for Spree 5.6+, with pluggable reconciliation of
|
|
4
|
+
incoming payments.
|
|
5
|
+
|
|
6
|
+
## Requirements
|
|
7
|
+
|
|
8
|
+
PostgreSQL. The gem uses `jsonb`, partial unique indexes, and `pg_trgm`.
|
|
9
|
+
|
|
10
|
+
## Installation
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
gem 'spree-bank_payments'
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
bundle install
|
|
18
|
+
bin/rails g spree:bank_transfer:install
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The generator copies the migrations and then asks whether to run them. Pass
|
|
22
|
+
`--auto-run-migrations` to skip the prompt (useful in scripted installs).
|
|
23
|
+
|
|
24
|
+
## Configuration
|
|
25
|
+
|
|
26
|
+
Add a Bank Transfer payment method in the Spree admin and set:
|
|
27
|
+
|
|
28
|
+
| Preference | Purpose |
|
|
29
|
+
|---|---|
|
|
30
|
+
| `reconciler` | `manual` by default; `revolut` with `spree-bank_payments_revolut` installed |
|
|
31
|
+
| `reference_prefix` | Prefix on generated references, e.g. `TKF-` |
|
|
32
|
+
| `expiry_days` | Days before an unpaid order is cancelled and restocked |
|
|
33
|
+
| `discount_percent` | Percentage off `item_total` for paying by transfer |
|
|
34
|
+
| `poll_interval_minutes` | How often the reconciler polls; drives the health gate |
|
|
35
|
+
| `account_*` | Bank details shown to the customer |
|
|
36
|
+
|
|
37
|
+
## The admin queue and the manual workflow
|
|
38
|
+
|
|
39
|
+
The gem adds a **Bank transfers** entry to the Spree admin sidebar, pointing
|
|
40
|
+
at `/admin/bank_transfers`. That screen is the operational heart of the gem:
|
|
41
|
+
|
|
42
|
+
- **The unmatched queue** (`/admin/bank_transfers`) lists every observed
|
|
43
|
+
transfer that hasn't been matched to an order, with suggested sessions
|
|
44
|
+
ranked by exact amount first and fuzzy payer-name similarity second. Apply
|
|
45
|
+
a suggestion in one click, or ignore the transfer with a reason.
|
|
46
|
+
- **Record a received transfer** (`/admin/bank_transfers/new`) is how money
|
|
47
|
+
gets into the system when you're not running a provider integration. This
|
|
48
|
+
is **required reading if you use the default `manual` reconciler**: it has
|
|
49
|
+
nothing to poll and no webhook, so this form is the only way a transfer can
|
|
50
|
+
ever be recorded. You enter the payment method, amount, currency, payer
|
|
51
|
+
name, the reference as the customer quoted it, and the date received; the
|
|
52
|
+
entry then goes through exactly the same matching path as a
|
|
53
|
+
provider-delivered transfer. An exact match (reference, amount, currency,
|
|
54
|
+
against a single open session on an unpaid order) applies immediately and
|
|
55
|
+
marks the order paid. Anything else lands in the queue above.
|
|
56
|
+
|
|
57
|
+
Submitting the same form twice is safe: the transfer's identity is derived
|
|
58
|
+
from what you typed, so a resubmission is recognised as the same transfer
|
|
59
|
+
and nothing is credited twice.
|
|
60
|
+
|
|
61
|
+
- **Applying to a mismatched order** takes two deliberate steps. The first
|
|
62
|
+
click is refused with both amounts spelled out; only then does an explicit
|
|
63
|
+
"Yes — apply … anyway" control appear for that specific pairing, behind a
|
|
64
|
+
confirmation dialog. A mismatch is credited for the amount that actually
|
|
65
|
+
arrived, so the order lands in `balance_due`/`credit_owed` rather than a
|
|
66
|
+
false `paid`.
|
|
67
|
+
|
|
68
|
+
### The order panel partial
|
|
69
|
+
|
|
70
|
+
`spree/bank_payments/admin/_order_panel` renders the bank-transfer state for
|
|
71
|
+
a single order — reference, amount, status, expiry, and the matched transfer
|
|
72
|
+
if there is one. The gem does **not** inject it anywhere; render it from your
|
|
73
|
+
admin order view where it makes sense for your store:
|
|
74
|
+
|
|
75
|
+
```erb
|
|
76
|
+
<%= render 'spree/bank_payments/admin/order_panel', order: @order %>
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Scheduling
|
|
80
|
+
|
|
81
|
+
These scheduled jobs are mandatory, not optional — without them nothing
|
|
82
|
+
expires and no reminders send:
|
|
83
|
+
|
|
84
|
+
| Job | Frequency | Purpose |
|
|
85
|
+
|---|---|---|
|
|
86
|
+
| `Spree::BankPayments::ExpireSessionsJob` | Hourly | Cancels and restocks orders whose payment window has lapsed |
|
|
87
|
+
| `Spree::BankPayments::SendRemindersJob` | Daily | Sends payment reminders as the expiry deadline approaches |
|
|
88
|
+
| `Spree::BankPayments::PollJob` | Every `poll_interval_minutes` (default 15) | Polls the configured reconciler for new transfers; a successful run is what arms the health gate below |
|
|
89
|
+
|
|
90
|
+
Wire all three into your scheduler (`sidekiq-cron`, `whenever`, etc.) as part
|
|
91
|
+
of installing this gem, not as an afterthought.
|
|
92
|
+
|
|
93
|
+
## The health gate
|
|
94
|
+
|
|
95
|
+
`ExpireSessionsJob` refuses to cancel orders when the reconciler has not
|
|
96
|
+
polled successfully within three poll intervals (`poll_interval_minutes`).
|
|
97
|
+
This matters because expiry and reconciliation are decoupled: if a bank
|
|
98
|
+
credential lapses or a webhook silently stops arriving, the reconciler stops
|
|
99
|
+
confirming payments, but orders keep aging past their expiry window. Without
|
|
100
|
+
the gate, `ExpireSessionsJob` would start cancelling and restocking orders
|
|
101
|
+
for customers who have already paid — just because the gem couldn't see the
|
|
102
|
+
payment. Instead, a stale reconciler raises an alert and the job cancels
|
|
103
|
+
nothing.
|
|
104
|
+
|
|
105
|
+
Subscribe to `bank_transfer.reconciler_unhealthy` and route it somewhere a
|
|
106
|
+
human will see it. A lapsed credential is an operational incident, not
|
|
107
|
+
background noise.
|
|
108
|
+
|
|
109
|
+
`ExpireSessionsJob` also leaves alone any order that has already been paid by
|
|
110
|
+
other means — a customer who abandons the transfer and pays by card keeps a
|
|
111
|
+
stale open session, and cancelling it would cancel and restock a paid order.
|
|
112
|
+
Those sessions are closed as `canceled` and
|
|
113
|
+
`bank_transfer.session_superseded` is published instead. For the same reason
|
|
114
|
+
a transfer that arrives against an already-paid order is never auto-applied:
|
|
115
|
+
it queues for a human, because a second payment on a settled order is a
|
|
116
|
+
decision, not a reconciliation.
|
|
117
|
+
|
|
118
|
+
## Events
|
|
119
|
+
|
|
120
|
+
The gem never sends notifications directly — it publishes to
|
|
121
|
+
`Spree::Events`, and subscribers decide what to do:
|
|
122
|
+
|
|
123
|
+
| Event | Fired when |
|
|
124
|
+
|---|---|
|
|
125
|
+
| `bank_transfer.instructions_ready` | A payment session is created and the customer needs the reference/bank details |
|
|
126
|
+
| `bank_transfer.reminder_due` | An unpaid session is approaching its expiry deadline |
|
|
127
|
+
| `bank_transfer.expired` | `ExpireSessionsJob` cancels an unpaid order |
|
|
128
|
+
| `bank_transfer.session_superseded` | An open session is closed because its order was already paid another way — the order is left alone |
|
|
129
|
+
| `bank_transfer.reconciler_unhealthy` | The health gate trips (see above) |
|
|
130
|
+
| `bank_transfer.expiry_failed` | `ExpireSessionsJob` hits an error cancelling a specific session |
|
|
131
|
+
|
|
132
|
+
Payloads contain serializable primitives only — never AR objects — because
|
|
133
|
+
subscribers may run async via ActiveJob. See
|
|
134
|
+
`Spree::PaymentSessions::BankTransfer#notification_payload` for the exact
|
|
135
|
+
shape. Subscribe with `Spree::Events.subscribe('bank_transfer.*', MyHandler)`
|
|
136
|
+
or to individual event names.
|
|
137
|
+
|
|
138
|
+
A bundled mailer subscribes to `instructions_ready` and `reminder_due` as one
|
|
139
|
+
optional subscriber among possibly several. Disable it with:
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
Spree::BankPayments::Config.disable_default_mailer = true
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Stores that deliver mail another way — for example a storefront webhook
|
|
146
|
+
handler that calls out to Postmark/Resend/etc. rather than using
|
|
147
|
+
ActionMailer — should disable the default mailer and subscribe to the events
|
|
148
|
+
directly instead of fighting the bundled one.
|
|
149
|
+
|
|
150
|
+
## Known limitations
|
|
151
|
+
|
|
152
|
+
**The discount does not affect tax on a tax-inclusive (VAT) store.**
|
|
153
|
+
|
|
154
|
+
`discount_percent` is applied as a single order-level `Spree::Adjustment`
|
|
155
|
+
against `order.item_total`. Spree computes `taxable_adjustment_total` by
|
|
156
|
+
summing tax-relevant adjustments on *line items and shipments only* — an
|
|
157
|
+
order-level adjustment is never included in that sum. The practical effect:
|
|
158
|
+
on a VAT store, the customer pays less, but the order still records tax as
|
|
159
|
+
if calculated on the undiscounted price. The discount and the tax figure
|
|
160
|
+
disagree.
|
|
161
|
+
|
|
162
|
+
This is not a bug we plan to quietly patch later — fixing it properly means
|
|
163
|
+
moving the discount onto line-item adjustments, the way Spree's own
|
|
164
|
+
promotions do, which is a bigger change than this gem currently makes. If
|
|
165
|
+
your store is VAT/tax-inclusive, know this before you set a
|
|
166
|
+
`discount_percent`, and evaluate whether the mismatch is acceptable for your
|
|
167
|
+
accounting.
|
|
168
|
+
|
|
169
|
+
## The instructions partial ships no CSS
|
|
170
|
+
|
|
171
|
+
`spree/bank_payments/_order_instructions` renders the payment reference and
|
|
172
|
+
bank details with no styling of its own. Making the reference visually
|
|
173
|
+
prominent — the single most impactful thing you can do for match rates — is
|
|
174
|
+
the host store's responsibility. Customers who don't notice or don't copy
|
|
175
|
+
the reference correctly produce transfers reconciliation can't match
|
|
176
|
+
automatically, which means manual admin work. Style it like the most
|
|
177
|
+
important line on the page, because for a bank-transfer checkout, it is.
|
|
178
|
+
|
|
179
|
+
## Writing a reconciler
|
|
180
|
+
|
|
181
|
+
Subclass `Spree::BankPayments::Reconcilers::Base` and implement the four
|
|
182
|
+
contract methods:
|
|
183
|
+
|
|
184
|
+
- `#poll(since:)` — returns an `Array<Spree::BankPayments::TransferData>`
|
|
185
|
+
- `#parse_webhook(raw_body, headers)` — returns `TransferData` or `nil`
|
|
186
|
+
- `#healthy?` — boolean; feeds the health gate above
|
|
187
|
+
- `#configured?` — boolean; whether credentials/settings are complete
|
|
188
|
+
|
|
189
|
+
Register it:
|
|
190
|
+
|
|
191
|
+
```ruby
|
|
192
|
+
Spree::BankPayments::Reconcilers::Base.register('my_bank', MyBank::Reconciler)
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
and run **both** shared example groups against it — not just the first one:
|
|
196
|
+
|
|
197
|
+
```ruby
|
|
198
|
+
require 'spree/bank_payments/testing_support/reconciler_shared_examples'
|
|
199
|
+
|
|
200
|
+
RSpec.describe MyBank::Reconciler do
|
|
201
|
+
let(:payment_method) { create(:bank_transfer_gateway) }
|
|
202
|
+
|
|
203
|
+
it_behaves_like 'a bank transfer reconciler'
|
|
204
|
+
it_behaves_like 'a bank transfer reconciler that returns transfers'
|
|
205
|
+
end
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
The second group exists because the first can't check element types: a
|
|
209
|
+
reconciler with nothing to poll legitimately returns `[]`, and any
|
|
210
|
+
`all(be_a(...))` assertion passes vacuously against an empty array. The
|
|
211
|
+
second group requires `#poll` to return at least one real `TransferData` and
|
|
212
|
+
checks its shape — that's the only way to actually exercise the type
|
|
213
|
+
contract.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
require 'bundler'
|
|
2
|
+
Bundler::GemHelper.install_tasks
|
|
3
|
+
|
|
4
|
+
require 'rspec/core/rake_task'
|
|
5
|
+
require 'spree/testing_support/extension_rake'
|
|
6
|
+
|
|
7
|
+
RSpec::Core::RakeTask.new
|
|
8
|
+
|
|
9
|
+
task :default do
|
|
10
|
+
if Dir['spec/dummy'].empty?
|
|
11
|
+
Rake::Task[:test_app].invoke
|
|
12
|
+
Dir.chdir('../../')
|
|
13
|
+
end
|
|
14
|
+
Rake::Task[:spec].invoke
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
desc 'Generates a dummy app for testing'
|
|
18
|
+
task :test_app do
|
|
19
|
+
# Must be the require path, not the gem name: spree_core's common:test_app does a
|
|
20
|
+
# literal `require ENV['LIB_NAME']`, templates the same string into the generated
|
|
21
|
+
# dummy app, and constantizes "#{LIB_NAME.camelize}::Generators::InstallGenerator"
|
|
22
|
+
# -- which only resolves to Spree::BankPayments::Generators::InstallGenerator when
|
|
23
|
+
# this is the slash form.
|
|
24
|
+
ENV['LIB_NAME'] = 'spree/bank_payments'
|
|
25
|
+
# This gem is PostgreSQL-only: jsonb, partial indexes and pg_trgm are all
|
|
26
|
+
# load-bearing. Default the harness accordingly so a bare `rake test_app`
|
|
27
|
+
# cannot silently build a SQLite app on which none of them are real.
|
|
28
|
+
ENV['DB'] ||= 'postgres'
|
|
29
|
+
# extension:test_app (spree_core) generates the dummy app, migrates Spree's own
|
|
30
|
+
# schema, then auto-loads generators/spree/bank_payments/install/install_generator
|
|
31
|
+
# and runs it with --auto-run-migrations. That generator copies *only* this gem's
|
|
32
|
+
# migrations (scoped via `rake spree_bank_payments:install:migrations`, not the
|
|
33
|
+
# unscoped `railties:install:migrations`) and runs db:migrate, so a fresh
|
|
34
|
+
# `rake test_app` needs no manual migration step.
|
|
35
|
+
Rake::Task['extension:test_app'].execute(install_storefront: true, install_admin: true)
|
|
36
|
+
end
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Admin
|
|
3
|
+
class BankTransfersController < Spree::Admin::BaseController
|
|
4
|
+
include Pagy::Method
|
|
5
|
+
|
|
6
|
+
# Synthetic provider for transfers an admin typed in by hand. Keeps
|
|
7
|
+
# them distinguishable from anything a real reconciler delivered, and
|
|
8
|
+
# gives the (provider, provider_transaction_id) uniqueness index its
|
|
9
|
+
# own namespace so a hand-entered row can never collide with a
|
|
10
|
+
# provider-issued transaction id.
|
|
11
|
+
MANUAL_PROVIDER = 'manual'.freeze
|
|
12
|
+
|
|
13
|
+
before_action :load_transfer, only: %i[apply ignore]
|
|
14
|
+
before_action :load_recordable_payment_methods, only: %i[new create]
|
|
15
|
+
|
|
16
|
+
def index
|
|
17
|
+
@pagy, @transfers = pagy(
|
|
18
|
+
Spree::BankPayments::IncomingTransfer.unmatched.order(occurred_at: :desc)
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
@suggestions = @transfers.each_with_object({}) do |transfer, acc|
|
|
22
|
+
acc[transfer.id] = Spree::BankPayments::SuggestMatches.new(transfer: transfer).call
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Set only by #apply's mismatch refusal (see I3). Identifies the one
|
|
26
|
+
# transfer/session pair the admin has already been shown the numbers
|
|
27
|
+
# for and may now confirm.
|
|
28
|
+
@confirm_transfer_id = params[:confirm_transfer_id].presence&.to_i
|
|
29
|
+
@confirm_payment_session_id = params[:confirm_payment_session_id].presence&.to_i
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# "Record a received transfer". The Manual reconciler -- the default,
|
|
33
|
+
# and the only one this gem ships -- returns [] from #poll and nil from
|
|
34
|
+
# #parse_webhook, so nothing else can ever create an IncomingTransfer.
|
|
35
|
+
# Without this form a store on the shipped configuration takes a
|
|
36
|
+
# customer's money and has no action available to record it, and
|
|
37
|
+
# ExpireSessionsJob cancels the order a few days later.
|
|
38
|
+
def new
|
|
39
|
+
@transfer_form = blank_transfer_form
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Deliberately builds a TransferData and hands it to IngestTransfer
|
|
43
|
+
# rather than writing an IncomingTransfer directly: matching lives in
|
|
44
|
+
# exactly one place, so a hand-recorded transfer behaves identically to
|
|
45
|
+
# a provider-delivered one -- exact match auto-applies, anything else
|
|
46
|
+
# lands in the queue for a human.
|
|
47
|
+
def create
|
|
48
|
+
@transfer_form = transfer_form_params
|
|
49
|
+
|
|
50
|
+
error = transfer_form_error(@transfer_form)
|
|
51
|
+
if error
|
|
52
|
+
flash.now[:error] = error
|
|
53
|
+
return render :new, status: :unprocessable_entity
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
payment_method = @payment_methods.detect { |pm| pm.id.to_s == @transfer_form[:payment_method_id].to_s }
|
|
57
|
+
transaction_id = manual_transaction_id(payment_method, @transfer_form)
|
|
58
|
+
|
|
59
|
+
# The idempotency guard is IngestTransfer's find_or_create_by! on
|
|
60
|
+
# (provider, provider_transaction_id). A hand-typed transfer has no
|
|
61
|
+
# provider-issued id to key on, so we derive a deterministic one from
|
|
62
|
+
# the submitted facts: an admin who double-submits the form (double
|
|
63
|
+
# click, browser back-and-resubmit) reproduces the same digest, hits
|
|
64
|
+
# the existing row, and applies nothing a second time. The trade is
|
|
65
|
+
# that two genuinely distinct but byte-identical transfers on the
|
|
66
|
+
# same day collapse into one -- rare, and far safer than the
|
|
67
|
+
# alternative of crediting an order twice.
|
|
68
|
+
already_recorded = Spree::BankPayments::IncomingTransfer.exists?(
|
|
69
|
+
provider: MANUAL_PROVIDER, provider_transaction_id: transaction_id
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
transfer = Spree::BankPayments::IngestTransfer.new(
|
|
73
|
+
payment_method: payment_method,
|
|
74
|
+
transfer_data: Spree::BankPayments::TransferData.new(
|
|
75
|
+
provider: MANUAL_PROVIDER,
|
|
76
|
+
provider_transaction_id: transaction_id,
|
|
77
|
+
amount: BigDecimal(@transfer_form[:amount].to_s),
|
|
78
|
+
currency: @transfer_form[:currency].to_s.strip.upcase,
|
|
79
|
+
reference: @transfer_form[:reference].to_s.strip,
|
|
80
|
+
payer_name: @transfer_form[:payer_name].to_s.strip.presence,
|
|
81
|
+
occurred_at: parse_occurred_at(@transfer_form[:occurred_at]),
|
|
82
|
+
raw: {
|
|
83
|
+
'source' => 'admin_manual_entry',
|
|
84
|
+
'recorded_by_id' => try_spree_current_user&.id
|
|
85
|
+
}
|
|
86
|
+
)
|
|
87
|
+
).call
|
|
88
|
+
|
|
89
|
+
flash[:success] =
|
|
90
|
+
if already_recorded
|
|
91
|
+
'That transfer was already recorded — nothing was applied a second time.'
|
|
92
|
+
elsif transfer.applied?
|
|
93
|
+
"Transfer recorded and applied to order #{transfer.payment_session&.order&.number}."
|
|
94
|
+
else
|
|
95
|
+
'Transfer recorded. It is waiting in the queue below for a match.'
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
redirect_to spree.admin_bank_transfers_path
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def apply
|
|
102
|
+
if @transfer.applied?
|
|
103
|
+
flash[:error] = 'That transfer has already been applied.'
|
|
104
|
+
return redirect_to spree.admin_bank_transfers_path
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
payment_session = find_bank_transfer_payment_session(params[:payment_session_id])
|
|
108
|
+
|
|
109
|
+
if payment_session.nil?
|
|
110
|
+
flash[:error] = 'That payment session could not be found.'
|
|
111
|
+
return redirect_to spree.admin_bank_transfers_path
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
if gateway_mismatch?(payment_session)
|
|
115
|
+
flash[:error] = 'That transfer was received on a different bank-transfer gateway ' \
|
|
116
|
+
'and cannot be applied to this session.'
|
|
117
|
+
return redirect_to spree.admin_bank_transfers_path
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# I3: the refusal now hands back the pair that needs confirming, and
|
|
121
|
+
# the queue renders a distinct, explicitly-labelled confirm button
|
|
122
|
+
# only for that pair. Confirmation is therefore a genuine second
|
|
123
|
+
# step -- a deliberate act after seeing the numbers -- rather than
|
|
124
|
+
# something the view pre-granted before the admin looked at anything.
|
|
125
|
+
if money_mismatch?(payment_session) && !confirmed_mismatch?
|
|
126
|
+
flash[:error] = "Amount/currency mismatch: the transfer is #{@transfer.money}, " \
|
|
127
|
+
"the session expects #{payment_session.money}. " \
|
|
128
|
+
'Confirm to apply anyway.'
|
|
129
|
+
return redirect_to spree.admin_bank_transfers_path(
|
|
130
|
+
confirm_transfer_id: @transfer.id,
|
|
131
|
+
confirm_payment_session_id: payment_session.id
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
Spree::BankPayments::ApplyTransfer.call(
|
|
136
|
+
transfer: @transfer,
|
|
137
|
+
payment_session: payment_session,
|
|
138
|
+
applied_by: try_spree_current_user
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
flash[:success] = 'Payment applied.'
|
|
142
|
+
redirect_to spree.admin_bank_transfers_path
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def ignore
|
|
146
|
+
if @transfer.applied?
|
|
147
|
+
flash[:error] = 'That transfer has already been applied and cannot be ignored.'
|
|
148
|
+
return redirect_to spree.admin_bank_transfers_path
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
reason = params[:reason].to_s.strip
|
|
152
|
+
if reason.blank?
|
|
153
|
+
flash[:error] = 'A reason is required to ignore a transfer.'
|
|
154
|
+
return redirect_to spree.admin_bank_transfers_path
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
@transfer.update!(state: 'ignored', ignored_reason: reason)
|
|
158
|
+
|
|
159
|
+
flash[:success] = 'Transfer ignored.'
|
|
160
|
+
redirect_to spree.admin_bank_transfers_path
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
private
|
|
164
|
+
|
|
165
|
+
def load_transfer
|
|
166
|
+
@transfer = Spree::BankPayments::IncomingTransfer.find(params[:id])
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Same store guard as the apply path: an admin must not be able to
|
|
170
|
+
# record a transfer against another store's gateway.
|
|
171
|
+
def load_recordable_payment_methods
|
|
172
|
+
@payment_methods = Spree::BankPayments::Gateway.where(store_id: current_store.id).order(:name).to_a
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def blank_transfer_form
|
|
176
|
+
{
|
|
177
|
+
payment_method_id: @payment_methods.first&.id,
|
|
178
|
+
amount: nil,
|
|
179
|
+
currency: current_store.default_currency,
|
|
180
|
+
payer_name: nil,
|
|
181
|
+
reference: nil,
|
|
182
|
+
occurred_at: Time.zone.today.to_s
|
|
183
|
+
}
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def transfer_form_params
|
|
187
|
+
params.
|
|
188
|
+
fetch(:bank_transfer, {}).
|
|
189
|
+
permit(:payment_method_id, :amount, :currency, :payer_name, :reference, :occurred_at).
|
|
190
|
+
to_h.
|
|
191
|
+
symbolize_keys
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def transfer_form_error(form)
|
|
195
|
+
return 'No bank-transfer payment method is configured for this store.' if @payment_methods.empty?
|
|
196
|
+
|
|
197
|
+
unless @payment_methods.any? { |pm| pm.id.to_s == form[:payment_method_id].to_s }
|
|
198
|
+
return 'Choose which bank-transfer payment method received this money.'
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
amount = begin
|
|
202
|
+
BigDecimal(form[:amount].to_s)
|
|
203
|
+
rescue ArgumentError, TypeError
|
|
204
|
+
nil
|
|
205
|
+
end
|
|
206
|
+
return 'Enter the amount that arrived, as a number.' if amount.nil?
|
|
207
|
+
return 'The amount that arrived must be greater than zero.' unless amount.positive?
|
|
208
|
+
|
|
209
|
+
return 'Enter the currency the money arrived in.' if form[:currency].to_s.strip.blank?
|
|
210
|
+
return 'Enter the date the money was received.' if parse_occurred_at(form[:occurred_at]).nil?
|
|
211
|
+
|
|
212
|
+
nil
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def parse_occurred_at(value)
|
|
216
|
+
return nil if value.to_s.strip.blank?
|
|
217
|
+
|
|
218
|
+
Time.zone.parse(value.to_s)
|
|
219
|
+
rescue ArgumentError
|
|
220
|
+
nil
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Deterministic, so a resubmitted form is a no-op rather than a second
|
|
224
|
+
# credit. Reference is normalised the same way IncomingTransfer
|
|
225
|
+
# normalises it, and payer name is case/whitespace folded, so trivial
|
|
226
|
+
# retyping differences still collapse onto the same transfer.
|
|
227
|
+
def manual_transaction_id(payment_method, form)
|
|
228
|
+
digest = Digest::SHA256.hexdigest(
|
|
229
|
+
[
|
|
230
|
+
payment_method.id,
|
|
231
|
+
BigDecimal(form[:amount].to_s).to_s('F'),
|
|
232
|
+
form[:currency].to_s.strip.upcase,
|
|
233
|
+
Spree::BankPayments::IncomingTransfer.normalize_reference(form[:reference]),
|
|
234
|
+
form[:payer_name].to_s.strip.downcase,
|
|
235
|
+
parse_occurred_at(form[:occurred_at]).to_date.iso8601
|
|
236
|
+
].join('|')
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
"#{MANUAL_PROVIDER}-#{digest[0, 32]}"
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Scoped to the concrete bank-transfer STI subtype and to orders in the
|
|
243
|
+
# current store, so a `payment_session_id` for another payment method
|
|
244
|
+
# (credit card, PayPal, ...) or another store cannot be handed to
|
|
245
|
+
# ApplyTransfer -- that would complete an arbitrary payment/session
|
|
246
|
+
# pair with real money moving behind it.
|
|
247
|
+
def find_bank_transfer_payment_session(id)
|
|
248
|
+
::Spree::PaymentSessions::BankTransfer.
|
|
249
|
+
joins(:order).
|
|
250
|
+
where(spree_orders: { store_id: current_store.id }).
|
|
251
|
+
find_by(id: id)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# The auto-apply path (IngestTransfer) guards on payment_method_id at
|
|
255
|
+
# the query level, since it starts from a known gateway. The admin
|
|
256
|
+
# path starts from a payment_session_id typed by a human, so it has
|
|
257
|
+
# to check the reverse direction explicitly: a transfer received on
|
|
258
|
+
# one bank-transfer gateway (e.g. a different `provider`) must not be
|
|
259
|
+
# applicable to a session belonging to another gateway in the same
|
|
260
|
+
# store. A transfer with no known payment_method (legacy/manual data)
|
|
261
|
+
# can't be checked and is allowed through -- that's an existing gap,
|
|
262
|
+
# not a new one.
|
|
263
|
+
def gateway_mismatch?(payment_session)
|
|
264
|
+
@transfer.payment_method_id.present? &&
|
|
265
|
+
@transfer.payment_method_id != payment_session.payment_method_id
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# A human is allowed to hand-match a transfer to a session for a
|
|
269
|
+
# different amount/currency (typos happen, partial payments happen)
|
|
270
|
+
# but never silently -- ApplyTransfer now credits the payment with
|
|
271
|
+
# the transfer's own amount, so a confirmed mismatch produces a real
|
|
272
|
+
# balance_due/credit_owed rather than a false 'paid', but the admin
|
|
273
|
+
# still has to see and confirm the mismatch before it happens.
|
|
274
|
+
# Currency codes are compared case-insensitively so 'gbp' vs 'GBP'
|
|
275
|
+
# (same currency, different casing) doesn't force a spurious
|
|
276
|
+
# confirmation on an otherwise exact match.
|
|
277
|
+
def money_mismatch?(payment_session)
|
|
278
|
+
@transfer.amount != payment_session.amount ||
|
|
279
|
+
@transfer.currency.to_s.casecmp(payment_session.currency.to_s) != 0
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def confirmed_mismatch?
|
|
283
|
+
ActiveModel::Type::Boolean.new.cast(params[:confirm_mismatch])
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end
|