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.
Files changed (50) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +61 -0
  3. data/LICENSE +9 -0
  4. data/README.md +213 -0
  5. data/Rakefile +36 -0
  6. data/app/controllers/spree/admin/bank_transfers_controller.rb +287 -0
  7. data/app/jobs/spree/bank_payments/expire_sessions_job.rb +102 -0
  8. data/app/jobs/spree/bank_payments/poll_job.rb +39 -0
  9. data/app/jobs/spree/bank_payments/send_reminders_job.rb +50 -0
  10. data/app/mailers/spree/bank_payments/instructions_mailer.rb +30 -0
  11. data/app/models/spree/bank_payments/base.rb +12 -0
  12. data/app/models/spree/bank_payments/gateway.rb +166 -0
  13. data/app/models/spree/bank_payments/incoming_transfer.rb +46 -0
  14. data/app/models/spree/bank_payments/payment_decorator.rb +22 -0
  15. data/app/models/spree/bank_payments/reconciler_state.rb +26 -0
  16. data/app/models/spree/bank_payments/reconcilers/base.rb +56 -0
  17. data/app/models/spree/bank_payments/reconcilers/manual.rb +25 -0
  18. data/app/models/spree/bank_payments/transfer_data.rb +21 -0
  19. data/app/models/spree/payment_sessions/bank_transfer.rb +48 -0
  20. data/app/services/spree/bank_payments/apply_discount.rb +92 -0
  21. data/app/services/spree/bank_payments/apply_transfer.rb +75 -0
  22. data/app/services/spree/bank_payments/ingest_transfer.rb +71 -0
  23. data/app/services/spree/bank_payments/reference_generator.rb +41 -0
  24. data/app/services/spree/bank_payments/suggest_matches.rb +66 -0
  25. data/app/views/spree/admin/bank_transfers/index.html.erb +75 -0
  26. data/app/views/spree/admin/bank_transfers/new.html.erb +46 -0
  27. data/app/views/spree/admin/payment_methods/configuration_guides/_spree_bank_payments.html.erb +44 -0
  28. data/app/views/spree/admin/payment_methods/descriptions/_spree_bank_payments.html.erb +4 -0
  29. data/app/views/spree/bank_payments/_order_instructions.html.erb +73 -0
  30. data/app/views/spree/bank_payments/admin/_order_panel.html.erb +46 -0
  31. data/app/views/spree/bank_payments/instructions_mailer/instructions.html.erb +18 -0
  32. data/app/views/spree/bank_payments/instructions_mailer/reminder.html.erb +20 -0
  33. data/app/views/spree/checkout/payment/_spree_bank_payments.html.erb +13 -0
  34. data/config/initializers/spree.rb +55 -0
  35. data/config/locales/en.yml +18 -0
  36. data/config/routes.rb +14 -0
  37. data/db/migrate/20260815000001_create_spree_bank_payments_tables.rb +42 -0
  38. data/db/migrate/20260815000002_add_external_id_normalized_to_payment_sessions.rb +20 -0
  39. data/db/migrate/20260815000003_enable_pg_trgm.rb +45 -0
  40. data/db/migrate/20260816000001_add_payment_method_id_to_spree_bank_payments_incoming_transfers.rb +8 -0
  41. data/lib/generators/spree/bank_payments/install/install_generator.rb +40 -0
  42. data/lib/spree/bank_payments/configuration.rb +7 -0
  43. data/lib/spree/bank_payments/engine.rb +26 -0
  44. data/lib/spree/bank_payments/factories.rb +38 -0
  45. data/lib/spree/bank_payments/subscribers.rb +42 -0
  46. data/lib/spree/bank_payments/testing_support/reconciler_shared_examples.rb +50 -0
  47. data/lib/spree/bank_payments/version.rb +6 -0
  48. data/lib/spree/bank_payments.rb +31 -0
  49. data/lib/spree-bank_payments.rb +8 -0
  50. 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