spree-bank_payments 5.1.1 → 5.2.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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +120 -0
  3. data/README.md +137 -8
  4. data/app/controllers/spree/admin/bank_accounts_controller.rb +134 -0
  5. data/app/mailers/spree/bank_payments/instructions_mailer.rb +2 -2
  6. data/app/models/spree/bank_payments/account_data.rb +12 -0
  7. data/app/models/spree/bank_payments/bank_account.rb +80 -0
  8. data/app/models/spree/bank_payments/detail_set.rb +62 -0
  9. data/app/models/spree/bank_payments/gateway.rb +63 -8
  10. data/app/models/spree/bank_payments/incoming_transfer.rb +6 -0
  11. data/app/models/spree/bank_payments/reconcilers/base.rb +8 -0
  12. data/app/models/spree/bank_payments/reconcilers/manual.rb +6 -0
  13. data/app/models/spree/bank_payments/transfer_data.rb +5 -3
  14. data/app/models/spree/payment_sessions/bank_transfer.rb +36 -0
  15. data/app/services/spree/bank_payments/ingest_transfer.rb +19 -1
  16. data/app/services/spree/bank_payments/migrate_legacy_accounts.rb +50 -0
  17. data/app/services/spree/bank_payments/sync_accounts.rb +116 -0
  18. data/app/views/spree/admin/bank_accounts/_form.html.erb +33 -0
  19. data/app/views/spree/admin/bank_accounts/edit.html.erb +3 -0
  20. data/app/views/spree/admin/bank_accounts/index.html.erb +71 -0
  21. data/app/views/spree/admin/bank_accounts/new.html.erb +3 -0
  22. data/app/views/spree/admin/payment_methods/configuration_guides/_spree_bank_payments.html.erb +8 -0
  23. data/app/views/spree/bank_payments/_order_instructions.html.erb +26 -17
  24. data/app/views/spree/bank_payments/instructions_mailer/instructions.html.erb +16 -7
  25. data/app/views/spree/bank_payments/instructions_mailer/reminder.html.erb +16 -7
  26. data/config/locales/en.yml +1 -0
  27. data/config/routes.rb +7 -0
  28. data/db/migrate/20260817000001_create_spree_bank_payments_bank_accounts.rb +30 -0
  29. data/db/migrate/20260817000002_add_deleted_at_to_spree_bank_payments_bank_accounts.rb +12 -0
  30. data/db/migrate/20260817000003_exclude_soft_deleted_from_bank_account_uniqueness.rb +45 -0
  31. data/db/migrate/20260817000004_migrate_legacy_account_preferences.rb +18 -0
  32. data/db/migrate/20260817000006_add_bank_account_id_to_sessions_and_transfers.rb +26 -0
  33. data/lib/spree/bank_payments/factories.rb +29 -0
  34. data/lib/spree/bank_payments/testing_support/reconciler_shared_examples.rb +25 -0
  35. data/lib/spree/bank_payments/version.rb +1 -1
  36. metadata +16 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 853a5591445f34c158831ce573209a2d0bd300c01f107576eecbab2d5c1336d2
4
- data.tar.gz: 33614d35672725463dbbb13c1a70398d843135cc824e76ec4e3b3f8ff8b843e9
3
+ metadata.gz: b7bb0454adf07aad2127a114af121975941265c60b04ba329aa870d5bed31243
4
+ data.tar.gz: 887a05355e75ae225476c219f4b355dae87354c0e026e5f92bbe1bf56a46bf14
5
5
  SHA512:
6
- metadata.gz: e35bb75965fe3896265cec76c1f93f117e52b23e2058d9105fdfef973a0c859fa3c0be94ec9842fa023c01f400f4714208ef19289f7d4f8a751918b77082f3d0
7
- data.tar.gz: 0d4456c3a1f35e6d8a28e7dbe6b54be40b78dd9ac1919b9b2c919e6294e31f6f0d6c4d24f984873f5df7177ec1cc126794aab6cee59ef31ab76effde706eed9f
6
+ metadata.gz: d44fd3f4f89ad61d1a75e8393c0cb7f62a104ad4a710584e4c789b189a9a66d6154f23e6a49d3d075c16c7acf81740e18c9a40b86b4a9a046d6176b6c0266639
7
+ data.tar.gz: 96092e1b5ff5e6fd9cbd5d92a604ae0624ba506a6b0e8f76d9a9465ac819d8e9748c4754481f59160d622716821f795d07fbcd60f0b09530adcb47f3139e8da8
data/CHANGELOG.md CHANGED
@@ -2,6 +2,126 @@
2
2
 
3
3
  All notable changes to this project are documented in this file.
4
4
 
5
+ ## 5.2.0
6
+
7
+ ### Upgrading from 5.1.1
8
+
9
+ This release adds tables and columns, so the upgrade is not gem-bump-only.
10
+ From the host app, after bumping the gem:
11
+
12
+ ```sh
13
+ bin/rails spree_bank_payments:install:migrations
14
+ bin/rails db:migrate
15
+ ```
16
+
17
+ `db:migrate` runs the legacy preference migration described under
18
+ "Legacy preference migration" below, which folds your existing `account_*`
19
+ preferences into a `BankAccount` so the store keeps quoting exactly what it
20
+ quoted before. Then read the BREAKING CHANGE below: any host code calling
21
+ `Gateway#bank_details` needs a change.
22
+
23
+ ### BREAKING CHANGE
24
+
25
+ **`Gateway#bank_details` now returns `Array<Spree::BankPayments::DetailSet>`,
26
+ not a `Hash`.** The method was kept — nothing was renamed or removed — but a
27
+ host reading it as a `Hash` will get a `TypeError`, not a missing-method
28
+ error, the moment it upgrades:
29
+
30
+ ```ruby
31
+ # 5.1.1
32
+ gateway.bank_details[:iban] #=> "GB00REVO00000000000000"
33
+
34
+ # 5.2.0
35
+ gateway.bank_details[:iban] #=> TypeError: no implicit conversion of Symbol into Integer
36
+ gateway.bank_details[0].fields #=> [["IBAN", "GB00REVO00000000000000"], ["BIC", "REVOGB21"]]
37
+ ```
38
+
39
+ Two alternatives were considered and rejected:
40
+
41
+ - **Back-mapping the first detail set onto the old five keys** (`:name`,
42
+ `:iban`, `:bic`, `:sort_code`, `:number`). Rejected: detail-set labels are
43
+ arbitrary per country (a US account has a routing number, not a sort
44
+ code), so there is no general mapping back onto five fixed keys. A
45
+ silently wrong `Hash` — one that quietly drops or mislabels fields — is
46
+ worse than a loud `TypeError` a host discovers in CI.
47
+ - **Bumping to 6.0.0 instead of 5.2.0.** Rejected: this gem's major version
48
+ tracks Spree's major version (`spree-bank_payments` 5.x supports Spree
49
+ 5.x). A 6.0.0 release would falsely advertise Spree 6 support that
50
+ doesn't exist.
51
+
52
+ **Fix:** call `bank_details_for(currency)` instead, and iterate the returned
53
+ detail sets:
54
+
55
+ ```ruby
56
+ gateway.bank_details_for(order.currency).each do |detail_set|
57
+ detail_set.label #=> "UK payments"
58
+ detail_set.fields #=> [["Sort code", "04-00-75"], ["Account number", "12345678"]]
59
+ end
60
+ ```
61
+
62
+ `#bank_details` itself is unchanged in one respect: it still quotes the
63
+ *offered* account for the store's default currency (`Spree::Config[:currency]`),
64
+ via `bank_details_for`, and still emits a one-time deprecation warning. Only
65
+ its return type changed, to keep it truthful about what an account now is:
66
+ a store can hold more than one detail set (local + international) per
67
+ currency, and a `Hash` had no way to represent that.
68
+
69
+ ### Added
70
+
71
+ **Multi-currency bank accounts.** A store can now configure one bank
72
+ account per currency (`Spree::BankPayments::BankAccount`), instead of a
73
+ single flat set of `account_*` preferences shared by every currency. See
74
+ the README's "Bank accounts and currencies" section for the full model —
75
+ briefly:
76
+
77
+ - Customers are always shown every detail set on the offered account for
78
+ their order's currency (local and international both) — never inferred
79
+ from billing country.
80
+ - The admin checklist allows at most one *offered* account per currency,
81
+ enforced by a partial unique database index — not just a form validation.
82
+ - Every synced account is watched (polled/webhooked) regardless of whether
83
+ it is offered, so switching which account is offered for a currency never
84
+ strands a transfer already in flight against the old one.
85
+ - A currency with no offered account is not presented at checkout for that
86
+ currency (`available_for_order?` returns `false`), unless the order
87
+ already holds an open same-currency session against this gateway.
88
+ - Accounts can be synced from a provider (`Reconciler#sync_accounts`) or
89
+ entered by hand in the admin. Sync never sets `offered` and never rewrites
90
+ `bank_account_id` on an existing session — both are left to a human and to
91
+ history respectively.
92
+ - A soft-deleted account is not silently resurrected by a later sync; it is
93
+ reported as skipped in the sync diff instead.
94
+
95
+ **Legacy preference migration.** A data migration
96
+ (`db/migrate/20260817000004_migrate_legacy_account_preferences.rb`) folds
97
+ any existing `account_*` preferences into one `BankAccount`, marked
98
+ `offered`, for the store's default currency. An upgrading install keeps
99
+ quoting exactly what it quoted before — no manual step required.
100
+
101
+ ### Reconciler contract additions
102
+
103
+ All additions are backward compatible with the contract published in 5.1.1.
104
+ **A reconciler gem built against 5.1.1 keeps working unmodified against
105
+ 5.2.0** — nothing already implemented changed shape, and everything new is
106
+ either optional to override or defaults to `[]`/`nil`.
107
+
108
+ - **`sync_accounts` → `Array<Spree::BankPayments::AccountData>`.** New,
109
+ optional override point on `Reconcilers::Base`; the default and `Manual`
110
+ both return `[]`. `AccountData` carries `provider_account_id`, `currency`,
111
+ and `details` in the normalised detail-set shape (the same shape
112
+ `BankAccount#details` stores).
113
+ - **`TransferData` gains `provider_account_id`**, defaulting to `nil`. The
114
+ initializer is keyword-only, so this is additive: existing construction
115
+ calls are unaffected.
116
+ - **`poll(since:)` is unchanged.** Per-account fetching stays the
117
+ provider's business.
118
+ - The exported shared example groups gain a third group,
119
+ `'a bank transfer reconciler that returns accounts'` — provider authors
120
+ must run it alongside the existing two (`'a bank transfer reconciler'` and
121
+ `'a bank transfer reconciler that returns transfers'`) if their reconciler
122
+ implements `sync_accounts`. It exists because the base group cannot check
123
+ element types against an empty result.
124
+
5
125
  ## 5.1.1
6
126
 
7
127
  ### Fixed
data/README.md CHANGED
@@ -32,7 +32,128 @@ Add a Bank Transfer payment method in the Spree admin and set:
32
32
  | `expiry_days` | Days before an unpaid order is cancelled and restocked |
33
33
  | `discount_percent` | Percentage off `item_total` for paying by transfer |
34
34
  | `poll_interval_minutes` | How often the reconciler polls; drives the health gate |
35
- | `account_*` | Bank details shown to the customer |
35
+ | `account_*` | **Deprecated.** Pre-5.2 flat bank details. On upgrade these are folded automatically into one offered `BankAccount` for the store's default currency (see below) — do not set them on a fresh 5.2+ install; use the bank accounts screen instead. |
36
+
37
+ ## Bank accounts and currencies
38
+
39
+ As of 5.2.0, bank details live on `Spree::BankPayments::BankAccount` rows —
40
+ one per currency you accept, not one flat set of preferences shared by
41
+ every order regardless of currency.
42
+
43
+ - **One account per currency.** Each account records a `currency` and one
44
+ or more *detail sets* (a local scheme and an international/SWIFT set are
45
+ both common). Multiple accounts can exist for the same currency, but at
46
+ most one can be **offered** at a time — the customer-facing one.
47
+ - **Customers always see every detail set on the offered account** — local
48
+ and international both, side by side. The gem deliberately does **not**
49
+ infer which one a customer needs from their billing country: that would
50
+ require a maintained SEPA-membership list (and it changes), and guessing
51
+ wrong hides the details the customer actually needed, costing them a
52
+ bounced transfer or a correspondent fee with no explanation. Let them
53
+ choose; they know where they bank.
54
+ - **Admin checklist:** the bank accounts screen under the payment method
55
+ lets an admin toggle at most one *offered* account per currency. This is
56
+ enforced by a partial unique database index
57
+ (`index_bp_bank_accounts_on_pm_and_currency_offered`), not just a form
58
+ validation — a second offered row for the same currency cannot exist,
59
+ even written directly against the database.
60
+ - **Every synced account stays watched (polled or webhooked) regardless of
61
+ whether it is offered.** This is what makes switching the offered account
62
+ safe: quote a customer against GBP account A, later switch the offered
63
+ GBP account to B — new sessions quote B, but A is still polled, so a
64
+ transfer that arrives late into A still reconciles automatically against
65
+ the session it was actually quoted on. No cutover window, no "don't
66
+ switch until the last order clears."
67
+ - **A currency with no offered account means bank transfer is not
68
+ available for that currency.** `available_for_order?` returns `false`
69
+ and the method drops out of checkout for that currency — unless the
70
+ order already holds an open session against this gateway in the same
71
+ currency, in which case availability is kept so that order can still be
72
+ paid. This is easy to reach by accident: **the first sync leaves every
73
+ account unchecked**, so bank transfer is unavailable in every currency
74
+ until an admin offers at least one account. The admin screen states this
75
+ plainly.
76
+ - **Two sync triggers:**
77
+ 1. A **"Sync from &lt;provider&gt;"** button in the payment method admin,
78
+ which always shows a diff for confirmation before applying it.
79
+ 2. **Consent re-approval**, for providers whose credentials need periodic
80
+ re-authorisation (e.g. an OAuth consent that expires every ~90 days).
81
+ A provider gem can call `SyncAccounts.new(payment_method:).apply!(additive_only: true)`
82
+ from its callback handler; `additive_only` applies new accounts and
83
+ refreshed details immediately but always skips deactivations, since a
84
+ currency should never be withdrawn from the storefront mid-redirect
85
+ with nobody looking at a diff.
86
+
87
+ In both cases, **sync never sets `offered`** (that decision stays with a
88
+ human, including on the very first sync) and **never touches
89
+ `bank_account_id` on an existing session** — a historical quote is
90
+ immutable, because changing what a customer was told after the fact
91
+ makes a dispute unwinnable.
92
+ - **Soft-deleted accounts are not resurrected by sync.** `BankAccount` is
93
+ `acts_as_paranoid`; deleting one from the admin is a soft delete, so a
94
+ session that already quoted it keeps rendering the coordinates. If a
95
+ later sync reports the same provider account again, it is **not**
96
+ silently recreated — it's surfaced as `skipped` in the sync diff, visible
97
+ rather than silently reappearing behind an admin's back.
98
+ - **Failed or empty sync leaves every account untouched.** If
99
+ `sync_accounts` raises, times out, or returns an empty array while
100
+ accounts already exist, the whole sync aborts with no writes — an auth
101
+ failure must never be read as "every account disappeared."
102
+
103
+ ### Writing a provider's `sync_accounts`
104
+
105
+ `sync_accounts` returns `Array<Spree::BankPayments::AccountData>`, each
106
+ carrying `provider_account_id`, `currency`, and `details` in the same
107
+ normalised detail-set shape `BankAccount#details` stores — never the
108
+ provider's raw response shape.
109
+
110
+ `details` is an ordered list of detail-set **objects**. Each has an optional
111
+ `label`, `schemes`, and `beneficiary_name`, plus `fields`: an ordered list of
112
+ `{ 'label' => …, 'value' => … }` **objects** (not `[label, value]` pairs —
113
+ each field is a hash with a `label` key and a `value` key). Fields are
114
+ label/value rather than named keys because bank coordinates are not
115
+ standardised: a sort code in the UK, a routing number in the US, something
116
+ else again in Poland. The views render them generically, so a market the gem
117
+ has never heard of needs no code change.
118
+
119
+ ```ruby
120
+ def sync_accounts
121
+ [
122
+ Spree::BankPayments::AccountData.new(
123
+ provider_account_id: 'acct_9f2c',
124
+ currency: 'GBP',
125
+ details: [
126
+ {
127
+ 'label' => 'UK payments',
128
+ 'schemes' => ['faster_payments'],
129
+ 'beneficiary_name' => 'Example Store Ltd',
130
+ 'fields' => [
131
+ { 'label' => 'Sort code', 'value' => '04-00-75' },
132
+ { 'label' => 'Account number', 'value' => '12345678' }
133
+ ]
134
+ },
135
+ {
136
+ 'label' => 'International',
137
+ 'schemes' => ['swift'],
138
+ 'fields' => [
139
+ { 'label' => 'IBAN', 'value' => 'GB00REVO00000000000000' },
140
+ { 'label' => 'BIC', 'value' => 'REVOGB21' }
141
+ ]
142
+ }
143
+ ]
144
+ )
145
+ ]
146
+ end
147
+ ```
148
+
149
+ `provider_account_id` must be present and stable — it is the key sync
150
+ reconciles on. A report with a blank id is skipped rather than applied,
151
+ because it cannot be matched to a row without risking overwriting a
152
+ hand-created account. An account whose detail sets contain no usable field
153
+ is skipped too; both appear under `skipped` in the sync diff.
154
+
155
+ See "Writing a reconciler" below for the full contract, including the shared
156
+ example group that exercises this method's return type.
36
157
 
37
158
  ## The admin queue and the manual workflow
38
159
 
@@ -198,13 +319,18 @@ contract methods:
198
319
  - `#healthy?` — boolean; feeds the health gate above
199
320
  - `#configured?` — boolean; whether credentials/settings are complete
200
321
 
322
+ `Base` also provides `#sync_accounts`, returning `[]` by default — override
323
+ it if your provider can enumerate accounts, returning an
324
+ `Array<Spree::BankPayments::AccountData>`.
325
+
201
326
  Register it:
202
327
 
203
328
  ```ruby
204
329
  Spree::BankPayments::Reconcilers::Base.register('my_bank', MyBank::Reconciler)
205
330
  ```
206
331
 
207
- and run **both** shared example groups against it — not just the first one:
332
+ and run the shared example groups against it — the base group plus
333
+ whichever "returns ..." group(s) apply to your reconciler:
208
334
 
209
335
  ```ruby
210
336
  require 'spree/bank_payments/testing_support/reconciler_shared_examples'
@@ -214,12 +340,15 @@ RSpec.describe MyBank::Reconciler do
214
340
 
215
341
  it_behaves_like 'a bank transfer reconciler'
216
342
  it_behaves_like 'a bank transfer reconciler that returns transfers'
343
+ it_behaves_like 'a bank transfer reconciler that returns accounts'
217
344
  end
218
345
  ```
219
346
 
220
- The second group exists because the first can't check element types: a
221
- reconciler with nothing to poll legitimately returns `[]`, and any
222
- `all(be_a(...))` assertion passes vacuously against an empty array. The
223
- second group requires `#poll` to return at least one real `TransferData` and
224
- checks its shape — that's the only way to actually exercise the type
225
- contract.
347
+ The "returns ..." groups exist because the base group can't check element
348
+ types: a reconciler with nothing to poll or sync legitimately returns `[]`,
349
+ and any `all(be_a(...))` assertion passes vacuously against an empty array.
350
+ Each "returns ..." group requires the corresponding method to return at
351
+ least one real value and checks its shape — that's the only way to actually
352
+ exercise the type contract. Omit the accounts group if your reconciler
353
+ genuinely can't enumerate accounts (e.g. a manual/no-op reconciler) — forcing
354
+ it there would mean stubbing the class under test, which tests nothing.
@@ -0,0 +1,134 @@
1
+ module Spree
2
+ module Admin
3
+ class BankAccountsController < Spree::Admin::BaseController
4
+ before_action :load_payment_method
5
+ before_action :load_bank_account, only: %i[edit update destroy toggle_offered]
6
+
7
+ def index
8
+ @bank_accounts = @payment_method.bank_accounts.order(:currency, :id)
9
+ end
10
+
11
+ def new
12
+ @bank_account = @payment_method.bank_accounts.new
13
+ end
14
+
15
+ def create
16
+ @bank_account = @payment_method.bank_accounts.new(bank_account_params)
17
+
18
+ if @bank_account.save
19
+ redirect_to_index 'Bank account created.'
20
+ else
21
+ render :new
22
+ end
23
+ rescue JSON::ParserError
24
+ @bank_account = @payment_method.bank_accounts.new(raw_bank_account_params.except(:details))
25
+ @bank_account.errors.add(:details, 'must contain valid JSON')
26
+ render :new
27
+ end
28
+
29
+ def edit; end
30
+
31
+ def update
32
+ # Synced accounts are the provider's record, not ours -- editing their
33
+ # currency or details here would silently diverge from the account
34
+ # actually watched. Checked against the *raw*, unparsed params so a
35
+ # malformed `details` payload can't slip past this guard by raising
36
+ # JSON::ParserError before it runs (see the JSON.parse call in
37
+ # #bank_account_params).
38
+ if @bank_account.synced? && synced_field_present?
39
+ flash[:error] = 'Synced accounts cannot be edited. Re-sync to refresh them.'
40
+ return redirect_to_index
41
+ end
42
+
43
+ if @bank_account.update(bank_account_params)
44
+ redirect_to_index 'Bank account updated.'
45
+ else
46
+ render :edit
47
+ end
48
+ rescue JSON::ParserError
49
+ @bank_account.errors.add(:details, 'must contain valid JSON')
50
+ render :edit
51
+ end
52
+
53
+ def destroy
54
+ if @bank_account.synced?
55
+ flash[:error] = 'Synced accounts cannot be deleted. Deactivate instead.'
56
+ else
57
+ @bank_account.destroy
58
+ end
59
+
60
+ redirect_to_index
61
+ end
62
+
63
+ def toggle_offered
64
+ Spree::BankPayments::BankAccount.transaction do
65
+ @payment_method.bank_accounts.
66
+ for_currency(@bank_account.currency).offered.
67
+ where.not(id: @bank_account.id).
68
+ update_all(offered: false)
69
+
70
+ @bank_account.update!(offered: !@bank_account.offered?)
71
+ end
72
+
73
+ redirect_to_index
74
+ end
75
+
76
+ # Never builds and holds a plan across the request: `apply!` with no
77
+ # argument derives and validates the plan itself, including the abort
78
+ # guard against a provider auth failure returning `[]` (which `plan`
79
+ # would otherwise read as "every account disappeared" and deactivate
80
+ # all of them). Passing a pre-built plan here would bypass that guard
81
+ # entirely -- see SyncAccounts#apply! and #plan.
82
+ #
83
+ # Rescues only EmptyResponseError -- the one operational failure
84
+ # SyncAccounts itself distinguishes from a programming error. A bare
85
+ # `rescue StandardError` here would also swallow a NoMethodError or
86
+ # similar bug and report it to the admin as an unremarkable "Sync
87
+ # failed", hiding it instead of surfacing it as the 500 it should be.
88
+ def sync
89
+ Spree::BankPayments::SyncAccounts.new(payment_method: @payment_method).apply!
90
+ redirect_to_index 'Accounts synced.'
91
+ rescue Spree::BankPayments::SyncAccounts::EmptyResponseError => e
92
+ flash[:error] = "Sync failed: #{e.message}. No accounts were changed."
93
+ redirect_to_index
94
+ end
95
+
96
+ private
97
+
98
+ def load_payment_method
99
+ @payment_method = Spree::BankPayments::Gateway.find(params[:payment_method_id])
100
+ end
101
+
102
+ def load_bank_account
103
+ @bank_account = @payment_method.bank_accounts.find(params[:id])
104
+ end
105
+
106
+ def redirect_to_index(notice = nil)
107
+ flash[:success] = notice if notice
108
+ redirect_to spree.admin_payment_method_bank_accounts_path(@payment_method)
109
+ end
110
+
111
+ # `active` is deliberately NOT permitted. It is sync's field, not the
112
+ # admin's: it records whether the provider still reports the account,
113
+ # and the form offers no control for it. Permitting it meant a
114
+ # hand-crafted PUT could set `active: false` on the offered account,
115
+ # which withdraws that currency from checkout entirely
116
+ # (Gateway#offered_account_for scopes by `active`) until the next sync
117
+ # flips it back. The admin's lever is `offered` (toggle_offered).
118
+ def raw_bank_account_params
119
+ params.require(:bank_account).permit(:currency, :offered, :details)
120
+ end
121
+
122
+ def synced_field_present?
123
+ raw = raw_bank_account_params
124
+ raw.key?(:currency) || raw.key?(:details)
125
+ end
126
+
127
+ def bank_account_params
128
+ permitted = raw_bank_account_params
129
+ permitted[:details] = JSON.parse(permitted[:details]) if permitted[:details].is_a?(String)
130
+ permitted
131
+ end
132
+ end
133
+ end
134
+ end
@@ -4,7 +4,7 @@ module Spree
4
4
  def instructions(payment_session_id)
5
5
  @session = ::Spree::PaymentSessions::BankTransfer.find(payment_session_id)
6
6
  @order = @session.order
7
- @bank_details = @session.payment_method.bank_details
7
+ @detail_sets = @session.bank_detail_sets
8
8
 
9
9
  with_store_locale(@order.store) do
10
10
  mail(to: @order.email, subject: Spree.t('bank_payments.reference'))
@@ -14,7 +14,7 @@ module Spree
14
14
  def reminder(payment_session_id)
15
15
  @session = ::Spree::PaymentSessions::BankTransfer.find(payment_session_id)
16
16
  @order = @session.order
17
- @bank_details = @session.payment_method.bank_details
17
+ @detail_sets = @session.bank_detail_sets
18
18
  @days_remaining = ((@session.expires_at.to_date - Date.current).to_i)
19
19
 
20
20
  with_store_locale(@order.store) do
@@ -0,0 +1,12 @@
1
+ module Spree
2
+ module BankPayments
3
+ # One account as reported by a provider, in the gem's normalised shape.
4
+ # The reconciler maps its provider's response into this -- the database and
5
+ # views never see provider-specific schemas.
6
+ AccountData = Data.define(:provider_account_id, :currency, :details) do
7
+ def initialize(details: [], **rest)
8
+ super(details: details, **rest)
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,80 @@
1
+ module Spree
2
+ module BankPayments
3
+ # One account money can arrive in. `offered` decides whether customers are
4
+ # quoted it; every active account is watched regardless, which is what makes
5
+ # switching accounts safe for orders already in flight.
6
+ class BankAccount < Base
7
+ # Spree::PaymentMethod (our belongs_to parent) is acts_as_paranoid.
8
+ # Without this, Gateway's `dependent: :destroy` on #bank_accounts would
9
+ # hard-delete these rows the moment an admin soft-deletes the gateway --
10
+ # restoring the gateway a day later would not bring the coordinates
11
+ # back. Spree::PaymentSession is paranoid for the same reason.
12
+ acts_as_paranoid
13
+
14
+ belongs_to :payment_method, class_name: 'Spree::PaymentMethod'
15
+
16
+ validates :currency, presence: true, format: { with: /\A[A-Za-z]{3}\z/ }
17
+ # Mirrors index_bp_bank_accounts_on_pm_and_currency_offered exactly
18
+ # (payment_method_id + currency, where offered AND deleted_at IS NULL --
19
+ # acts_as_paranoid's default scope already excludes soft-deleted rows
20
+ # from this uniqueness check). The database index is the real
21
+ # guarantee; this validation exists only so an admin offering a second
22
+ # account for a currency they already offer sees a form error instead
23
+ # of an unrescued RecordNotUnique.
24
+ validates :currency,
25
+ uniqueness: { scope: :payment_method_id, conditions: -> { where(offered: true) },
26
+ message: 'already has an offered account for this currency' },
27
+ if: :offered?
28
+ validate :details_is_a_list_of_objects
29
+ validate :has_a_usable_detail_set
30
+
31
+ scope :offered, -> { where(offered: true) }
32
+ scope :active, -> { where(active: true) }
33
+ scope :for_currency, ->(code) { where(currency: code.to_s.upcase) }
34
+
35
+ before_validation :normalize_currency
36
+
37
+ # @return [Array<Spree::BankPayments::DetailSet>] one per *object* entry.
38
+ # Non-object entries are skipped rather than raising: `details` is
39
+ # admin-editable JSON, and `Array(a_hash)` yields `[[key, value]]`, so
40
+ # an admin pasting `{"label": ...}` where an array belongs used to blow
41
+ # up with NoMethodError inside a validation. #details_is_a_list_of_objects
42
+ # turns that into a form error instead.
43
+ def detail_sets
44
+ Array(details).grep(Hash).map { |raw| DetailSet.new(raw) }
45
+ end
46
+
47
+ def synced?
48
+ provider_account_id.present?
49
+ end
50
+
51
+ private
52
+
53
+ def normalize_currency
54
+ self.currency = currency.to_s.upcase.presence
55
+ end
56
+
57
+ # `details` is a JSON array of detail-set objects. Anything else is a
58
+ # form error, not an exception: the admin form takes raw JSON, so a
59
+ # pasted object (`{"label": ...}`) or a bare scalar is an ordinary typo.
60
+ def details_is_a_list_of_objects
61
+ return if details.nil?
62
+ return if details.is_a?(Array) && details.all?(Hash)
63
+
64
+ errors.add(:details, 'must be a JSON array of detail set objects')
65
+ end
66
+
67
+ # An account with no payable coordinates is worse than no account: the
68
+ # customer is quoted an empty instruction block and has nowhere to send
69
+ # money.
70
+ def has_a_usable_detail_set
71
+ # Shape already reported; a second error about the same field only
72
+ # obscures the actual problem.
73
+ return if errors.key?(:details)
74
+ return if detail_sets.any?(&:usable?)
75
+
76
+ errors.add(:details, :blank)
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,62 @@
1
+ module Spree
2
+ module BankPayments
3
+ # One set of payable coordinates for an account -- a local scheme, or SWIFT.
4
+ #
5
+ # `fields` is an ordered list of label/value pairs rather than named keys
6
+ # because bank coordinates are not standardised: the UK uses a sort code and
7
+ # account number, the US a routing number, Poland's Elixir something else
8
+ # again. Named columns or fixed keys would mean a migration per market.
9
+ class DetailSet
10
+ # @param raw [Hash] anything that is not a Hash is treated as an empty
11
+ # detail set rather than raising. `details` is admin-editable JSON and
12
+ # provider-supplied data, so the shape is not guaranteed: a JSON object
13
+ # pasted where an array belongs arrives here as an `[key, value]` pair,
14
+ # and a provider following the README's old (wrong) `[[label, value]]`
15
+ # field shape arrives as an Array too. Both used to raise NoMethodError
16
+ # deep inside a validation or mid-sync. Unusable input must surface as
17
+ # a form error or a skipped account, never a 500.
18
+ def initialize(raw)
19
+ @raw = raw.is_a?(Hash) ? raw.transform_keys(&:to_s) : {}
20
+ end
21
+
22
+ def label
23
+ @raw['label'].presence
24
+ end
25
+
26
+ def schemes
27
+ Array(@raw['schemes']).map(&:to_s)
28
+ end
29
+
30
+ def beneficiary_name
31
+ @raw['beneficiary_name'].presence
32
+ end
33
+
34
+ def beneficiary_address
35
+ @raw['beneficiary_address']
36
+ end
37
+
38
+ # @return [Array<Array(String, String)>] ordered [label, value] pairs
39
+ def fields
40
+ # grep(Hash): a field entry that is not an object (e.g. the
41
+ # `[label, value]` pair the README wrongly documented until 5.2.0) is
42
+ # dropped, not raised on. An account whose every field is dropped is
43
+ # simply not `usable?`.
44
+ Array(@raw['fields']).grep(Hash).filter_map do |field|
45
+ f = field.transform_keys(&:to_s)
46
+ value = f['value'].to_s.strip
47
+ next if value.empty?
48
+
49
+ [f['label'].to_s, value]
50
+ end
51
+ end
52
+
53
+ def usable?
54
+ fields.any?
55
+ end
56
+
57
+ def to_h
58
+ @raw
59
+ end
60
+ end
61
+ end
62
+ end