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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +120 -0
- data/README.md +137 -8
- data/app/controllers/spree/admin/bank_accounts_controller.rb +134 -0
- data/app/mailers/spree/bank_payments/instructions_mailer.rb +2 -2
- data/app/models/spree/bank_payments/account_data.rb +12 -0
- data/app/models/spree/bank_payments/bank_account.rb +80 -0
- data/app/models/spree/bank_payments/detail_set.rb +62 -0
- data/app/models/spree/bank_payments/gateway.rb +63 -8
- data/app/models/spree/bank_payments/incoming_transfer.rb +6 -0
- data/app/models/spree/bank_payments/reconcilers/base.rb +8 -0
- data/app/models/spree/bank_payments/reconcilers/manual.rb +6 -0
- data/app/models/spree/bank_payments/transfer_data.rb +5 -3
- data/app/models/spree/payment_sessions/bank_transfer.rb +36 -0
- data/app/services/spree/bank_payments/ingest_transfer.rb +19 -1
- data/app/services/spree/bank_payments/migrate_legacy_accounts.rb +50 -0
- data/app/services/spree/bank_payments/sync_accounts.rb +116 -0
- data/app/views/spree/admin/bank_accounts/_form.html.erb +33 -0
- data/app/views/spree/admin/bank_accounts/edit.html.erb +3 -0
- data/app/views/spree/admin/bank_accounts/index.html.erb +71 -0
- data/app/views/spree/admin/bank_accounts/new.html.erb +3 -0
- data/app/views/spree/admin/payment_methods/configuration_guides/_spree_bank_payments.html.erb +8 -0
- data/app/views/spree/bank_payments/_order_instructions.html.erb +26 -17
- data/app/views/spree/bank_payments/instructions_mailer/instructions.html.erb +16 -7
- data/app/views/spree/bank_payments/instructions_mailer/reminder.html.erb +16 -7
- data/config/locales/en.yml +1 -0
- data/config/routes.rb +7 -0
- data/db/migrate/20260817000001_create_spree_bank_payments_bank_accounts.rb +30 -0
- data/db/migrate/20260817000002_add_deleted_at_to_spree_bank_payments_bank_accounts.rb +12 -0
- data/db/migrate/20260817000003_exclude_soft_deleted_from_bank_account_uniqueness.rb +45 -0
- data/db/migrate/20260817000004_migrate_legacy_account_preferences.rb +18 -0
- data/db/migrate/20260817000006_add_bank_account_id_to_sessions_and_transfers.rb +26 -0
- data/lib/spree/bank_payments/factories.rb +29 -0
- data/lib/spree/bank_payments/testing_support/reconciler_shared_examples.rb +25 -0
- data/lib/spree/bank_payments/version.rb +1 -1
- metadata +16 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b7bb0454adf07aad2127a114af121975941265c60b04ba329aa870d5bed31243
|
|
4
|
+
data.tar.gz: 887a05355e75ae225476c219f4b355dae87354c0e026e5f92bbe1bf56a46bf14
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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_*` |
|
|
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 <provider>"** 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
|
|
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
|
|
221
|
-
reconciler with nothing to poll legitimately returns `[]`,
|
|
222
|
-
`all(be_a(...))` assertion passes vacuously against an empty array.
|
|
223
|
-
|
|
224
|
-
checks its shape — that's the only way to actually
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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
|