spree-bank_payments 5.2.0 → 5.3.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 +65 -0
- data/README.md +89 -2
- data/app/controllers/spree/admin/bank_transfers_controller.rb +7 -1
- data/app/jobs/spree/bank_payments/poll_job.rb +59 -0
- data/app/models/spree/bank_payments/account_data.rb +8 -3
- data/app/models/spree/bank_payments/gateway.rb +111 -3
- data/app/models/spree/bank_payments/reconciler_state.rb +10 -0
- data/app/models/spree/bank_payments/reconcilers/base.rb +26 -1
- data/app/models/spree/bank_payments/reconcilers/manual.rb +4 -2
- data/app/services/spree/bank_payments/health_reporter.rb +122 -0
- data/app/services/spree/bank_payments/sync_accounts.rb +2 -1
- data/app/views/spree/admin/bank_accounts/index.html.erb +14 -1
- data/app/views/spree/admin/bank_transfers/index.html.erb +16 -0
- data/db/migrate/20260817000007_add_health_to_reconciler_states.rb +7 -0
- data/db/migrate/20260817000008_add_pooled_to_bank_accounts.rb +5 -0
- data/lib/spree/bank_payments/testing_support/reconciler_shared_examples.rb +4 -0
- data/lib/spree/bank_payments/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6a51b19c1448a4d27b386e8e65276fb900e18528f4e7bfd4f539e33b559414fa
|
|
4
|
+
data.tar.gz: 51ed0b549ce5d0bf543118430c45cd49cb6c5a66646a031ef94980c6d6c6035c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 228b83f83281f96655f08ff8e9f0258f4a7031ac3c618643e5d962ecc5d999d9a4e4d4a0d1e4d952fca49d3e26649eaa766269d92a40551e806e3169aa0505f1
|
|
7
|
+
data.tar.gz: 7c295fe395cd41e19d60fadefa0da4467d4873b12cd4658e7c13d4b1fb595b729d93490182fe6f23d85f8ef7104c87e097e30b441b6c5a9cdeb3d77c004fc16a
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,71 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project are documented in this file.
|
|
4
4
|
|
|
5
|
+
## 5.3.0
|
|
6
|
+
|
|
7
|
+
### Upgrading from 5.2.0
|
|
8
|
+
|
|
9
|
+
Adds columns, so this is not gem-bump-only:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
bin/rails spree_bank_payments:install:migrations
|
|
13
|
+
bin/rails db:migrate
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
**Run the migration before or with the deploy, never after.** `Gateway#health`
|
|
17
|
+
reads `reconciler_state.health_status`, and it is now on the checkout path.
|
|
18
|
+
In a rolling deploy, any pod running 5.3.0 against the 5.2.0 schema raises
|
|
19
|
+
`NoMethodError` on `health_status` for every non-Manual gateway — a checkout
|
|
20
|
+
500 for the whole window between the code going out and the migration landing.
|
|
21
|
+
The columns are additive and nullable, so 5.2.0 pods are perfectly happy
|
|
22
|
+
against the 5.3.0 schema; migrate first and the window does not exist.
|
|
23
|
+
|
|
24
|
+
**A revoked consent means unpaid orders stop expiring.** `ExpireSessionsJob`
|
|
25
|
+
gates on `reconciler_healthy?` and refuses to cancel while it cannot see the
|
|
26
|
+
bank, which is correct — cancelling orders customers have already paid for is
|
|
27
|
+
the worse failure. But `:consent_revoked` is by definition a state that never
|
|
28
|
+
resolves on its own, so from the moment it is reported, unpaid orders and the
|
|
29
|
+
stock they reserve accumulate until a human re-authorises the provider. Alert
|
|
30
|
+
on `reason="consent_revoked"` and treat it as a page, not a ticket.
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
|
|
34
|
+
**Three-state reconciler health.** `Reconcilers::Base#health` returns `:ok`,
|
|
35
|
+
`:transient` or `:consent_revoked`. A boolean could not separate a retryable
|
|
36
|
+
provider outage from a dead authorisation, and the two need different responses:
|
|
37
|
+
`:transient` keeps offering at checkout, because those transfers still arrive
|
|
38
|
+
and reconcile once the provider returns, while `:consent_revoked` withdraws the
|
|
39
|
+
payment method, because nothing will ever reconcile against it.
|
|
40
|
+
|
|
41
|
+
**Backward compatible.** A reconciler written against 5.1.1 or 5.2.0, overriding
|
|
42
|
+
only `#healthy?`, works unmodified — `#health` derives from it. A reconciler
|
|
43
|
+
overriding only `#health` gets `#healthy?` derived in turn.
|
|
44
|
+
|
|
45
|
+
**Health transition logging, owned by core.** Logged on transition and at most
|
|
46
|
+
hourly thereafter, with a stable `event=` key so alert rules never depend on
|
|
47
|
+
prose. `:transient` logs WARN, `:consent_revoked` logs ERROR, recovery logs INFO.
|
|
48
|
+
`bank_transfer.reconciler_health.unhealthy` and
|
|
49
|
+
`bank_transfer.reconciler_health.recovered` are also published through
|
|
50
|
+
`Spree::Events`. They sit under the same `bank_transfer.` prefix as every other
|
|
51
|
+
event this gem publishes, so `Spree::Events.subscribe('bank_transfer.*', …)` —
|
|
52
|
+
the pattern the README documents — receives them.
|
|
53
|
+
|
|
54
|
+
Note these are distinct from the flat `bank_transfer.reconciler_unhealthy`
|
|
55
|
+
published by `ExpireSessionsJob`, which is unchanged: that one says the expiry
|
|
56
|
+
job declined to cancel anything, this pair reports the reconciler's own health.
|
|
57
|
+
|
|
58
|
+
The `reason` is drawn from a closed enum — `ok`, `provider_error`,
|
|
59
|
+
`consent_revoked`, `unknown` — and unrecognised values collapse to `unknown`.
|
|
60
|
+
It is never built from an exception message or a response body. The enum is
|
|
61
|
+
deliberately only what core can actually emit: widening it later is
|
|
62
|
+
non-breaking, narrowing it is not.
|
|
63
|
+
|
|
64
|
+
**`pooled` on bank accounts.** Carried from `AccountData` through `SyncAccounts`.
|
|
65
|
+
A pooled account shares its coordinates with other customers of the provider, so
|
|
66
|
+
the payment reference is the only thing separating two payers. Auto-apply has
|
|
67
|
+
always required an exact reference match; `spec/services/spree/bank_payments/pooled_account_matching_spec.rb`
|
|
68
|
+
now locks that invariant explicitly.
|
|
69
|
+
|
|
5
70
|
## 5.2.0
|
|
6
71
|
|
|
7
72
|
### Upgrading from 5.1.1
|
data/README.md
CHANGED
|
@@ -99,6 +99,18 @@ every order regardless of currency.
|
|
|
99
99
|
`sync_accounts` raises, times out, or returns an empty array while
|
|
100
100
|
accounts already exist, the whole sync aborts with no writes — an auth
|
|
101
101
|
failure must never be read as "every account disappeared."
|
|
102
|
+
- **`pooled` marks an account whose coordinates are shared.** Some providers
|
|
103
|
+
hand every customer the same IBAN and separate the payers by reference
|
|
104
|
+
alone. A provider reports this through `AccountData#pooled` (default
|
|
105
|
+
`false`, so a provider that never sets it behaves exactly as before) and
|
|
106
|
+
it is shown as a **Pooled** badge on the bank accounts screen. It changes
|
|
107
|
+
nothing about auto-apply — that has always required an exact normalized
|
|
108
|
+
reference match, on every account — but it changes how a human must read
|
|
109
|
+
the unmatched queue. Suggestions there are ranked partly on fuzzy
|
|
110
|
+
payer-name similarity, and on a pooled account a plausible name is not
|
|
111
|
+
evidence of who paid: two unrelated customers arrive on identical
|
|
112
|
+
coordinates. The queue therefore warns when a transfer landed on a pooled
|
|
113
|
+
account, and the reference is the thing to verify before applying.
|
|
102
114
|
|
|
103
115
|
### Writing a provider's `sync_accounts`
|
|
104
116
|
|
|
@@ -221,6 +233,15 @@ of installing this gem, not as an afterthought.
|
|
|
221
233
|
|
|
222
234
|
## The health gate
|
|
223
235
|
|
|
236
|
+
There are two health mechanisms in this gem and they decide different things.
|
|
237
|
+
This one — `reconciler_healthy?` — gates **expiry**: whether
|
|
238
|
+
`ExpireSessionsJob` is allowed to cancel anything. The other, described under
|
|
239
|
+
[`#health`](#health), gates **checkout**: whether bank transfer is offered at
|
|
240
|
+
all. Checkout availability is decided by `#health` and only by `#health`; a
|
|
241
|
+
stale poll never removes the payment method from the storefront, and a
|
|
242
|
+
reconciler can be perfectly healthy here while `#health` reports
|
|
243
|
+
`:consent_revoked`, or the reverse.
|
|
244
|
+
|
|
224
245
|
`ExpireSessionsJob` refuses to cancel orders when the reconciler has not
|
|
225
246
|
polled successfully within three poll intervals (`poll_interval_minutes`).
|
|
226
247
|
This matters because expiry and reconciliation are decoupled: if a bank
|
|
@@ -255,9 +276,16 @@ The gem never sends notifications directly — it publishes to
|
|
|
255
276
|
| `bank_transfer.reminder_due` | An unpaid session is approaching its expiry deadline |
|
|
256
277
|
| `bank_transfer.expired` | `ExpireSessionsJob` cancels an unpaid order |
|
|
257
278
|
| `bank_transfer.session_superseded` | An open session is closed because its order was already paid another way — the order is left alone |
|
|
258
|
-
| `bank_transfer.reconciler_unhealthy` | The health gate trips
|
|
279
|
+
| `bank_transfer.reconciler_unhealthy` | The [health gate](#the-health-gate) trips — `ExpireSessionsJob` declined to cancel anything because the reconciler is stale |
|
|
280
|
+
| `bank_transfer.reconciler_health.unhealthy` | `PollJob` reports a *transition* into `:transient` or `:consent_revoked` — and at most hourly while it persists. Carries `status`, `reason` and `consecutive_failures` (see [`#health`](#health)) |
|
|
281
|
+
| `bank_transfer.reconciler_health.recovered` | The same reporter sees health return to `:ok` |
|
|
259
282
|
| `bank_transfer.expiry_failed` | `ExpireSessionsJob` hits an error cancelling a specific session |
|
|
260
283
|
|
|
284
|
+
The two `reconciler` events are not the same signal:
|
|
285
|
+
`reconciler_unhealthy` says the expiry job stood down, while the
|
|
286
|
+
`reconciler_health.*` pair reports the reconciler's own state and is what
|
|
287
|
+
decides whether checkout offers bank transfer at all.
|
|
288
|
+
|
|
261
289
|
Payloads contain serializable primitives only — never AR objects — because
|
|
262
290
|
subscribers may run async via ActiveJob. See
|
|
263
291
|
`Spree::PaymentSessions::BankTransfer#notification_payload` for the exact
|
|
@@ -316,13 +344,72 @@ contract methods:
|
|
|
316
344
|
|
|
317
345
|
- `#poll(since:)` — returns an `Array<Spree::BankPayments::TransferData>`
|
|
318
346
|
- `#parse_webhook(raw_body, headers)` — returns `TransferData` or `nil`
|
|
319
|
-
- `#healthy?` —
|
|
347
|
+
- `#health` or `#healthy?` — see below; `#health` decides checkout
|
|
348
|
+
availability, `#healthy?` feeds the [health gate](#the-health-gate) that
|
|
349
|
+
decides expiry
|
|
320
350
|
- `#configured?` — boolean; whether credentials/settings are complete
|
|
321
351
|
|
|
322
352
|
`Base` also provides `#sync_accounts`, returning `[]` by default — override
|
|
323
353
|
it if your provider can enumerate accounts, returning an
|
|
324
354
|
`Array<Spree::BankPayments::AccountData>`.
|
|
325
355
|
|
|
356
|
+
### `#health`
|
|
357
|
+
|
|
358
|
+
Override either `#health` or `#healthy?` — never both, and never neither.
|
|
359
|
+
Each derives from the other, so a reconciler written before 5.3.0 that only
|
|
360
|
+
implements `#healthy?` keeps working unmodified, and a reconciler that only
|
|
361
|
+
implements `#health` gets `#healthy?` derived from it. Overriding neither
|
|
362
|
+
raises `NotImplementedError` rather than recursing.
|
|
363
|
+
|
|
364
|
+
`#health` returns one of three symbols. A boolean cannot separate a retryable
|
|
365
|
+
provider outage from a dead authorisation, and checkout has to treat them
|
|
366
|
+
differently:
|
|
367
|
+
|
|
368
|
+
| State | Meaning | Withdraws from checkout? |
|
|
369
|
+
|---|---|---|
|
|
370
|
+
| `:ok` | Reconciling normally | No |
|
|
371
|
+
| `:transient` | A provider outage or similar — expected to recover on its own | No — those transfers still arrive and reconcile once the provider returns |
|
|
372
|
+
| `:consent_revoked` | The authorisation is dead; nothing will ever reconcile against it | Yes |
|
|
373
|
+
|
|
374
|
+
**This is the gate that decides whether checkout offers bank transfer**, and
|
|
375
|
+
the only one. The [health gate](#the-health-gate) above is a separate
|
|
376
|
+
mechanism reading a separate signal (`reconciler_healthy?`, driven by poll
|
|
377
|
+
staleness) and it only ever decides whether `ExpireSessionsJob` may cancel —
|
|
378
|
+
it never changes what a customer is shown.
|
|
379
|
+
|
|
380
|
+
`Gateway#health` is the persisted view `available_for_order?` reads on every
|
|
381
|
+
checkout render — it never calls the reconciler directly, so a provider's live
|
|
382
|
+
check never lands on the storefront's critical path. `PollJob` is what
|
|
383
|
+
refreshes it, reporting health after every poll through `HealthReporter`,
|
|
384
|
+
which logs the transition and then at most hourly while the condition
|
|
385
|
+
persists (WARN for `:transient`, ERROR for `:consent_revoked`, INFO on
|
|
386
|
+
recovery) and publishes `bank_transfer.reconciler_health.unhealthy` /
|
|
387
|
+
`.recovered` through `Spree::Events`.
|
|
388
|
+
|
|
389
|
+
Query the unhealthy log line with LogQL:
|
|
390
|
+
|
|
391
|
+
```logql
|
|
392
|
+
{namespace=~"your-ns-.*"} |= "bank_transfer.reconciler_health.unhealthy" | logfmt
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
`reason=` is drawn from a closed enum, never from an exception message or a
|
|
396
|
+
response body — that is how a bearer token reaches a log aggregator. The whole
|
|
397
|
+
vocabulary is four values:
|
|
398
|
+
|
|
399
|
+
| `reason` | Emitted when |
|
|
400
|
+
|---|---|
|
|
401
|
+
| `ok` | The poll succeeded — carried on the recovery line |
|
|
402
|
+
| `consent_revoked` | The reconciler reported `:consent_revoked` after a failed poll |
|
|
403
|
+
| `provider_error` | Any other poll failure |
|
|
404
|
+
| `unknown` | A reason outside this enum was passed in and was discarded |
|
|
405
|
+
|
|
406
|
+
`reason="consent_revoked"` warrants an immediate page — nothing will
|
|
407
|
+
reconcile until a human re-authorises it, and unpaid orders will not expire
|
|
408
|
+
while the gem is blind (see [The health gate](#the-health-gate)). Any other
|
|
409
|
+
reason should only alert after roughly thirty minutes sustained, since a brief
|
|
410
|
+
provider blip recovers on its own and paging on every transient hiccup trains
|
|
411
|
+
people to ignore the alert.
|
|
412
|
+
|
|
326
413
|
Register it:
|
|
327
414
|
|
|
328
415
|
```ruby
|
|
@@ -14,8 +14,14 @@ module Spree
|
|
|
14
14
|
before_action :load_recordable_payment_methods, only: %i[new create]
|
|
15
15
|
|
|
16
16
|
def index
|
|
17
|
+
# includes(:bank_account) because the view asks every row whether its
|
|
18
|
+
# account is pooled. The association is declared `-> { with_deleted }`,
|
|
19
|
+
# which preloads fine, and it must stay that way: a soft-deleted
|
|
20
|
+
# account would otherwise resolve to nil and silently drop the pooled
|
|
21
|
+
# warning on exactly the rows still quoting its coordinates.
|
|
17
22
|
@pagy, @transfers = pagy(
|
|
18
|
-
Spree::BankPayments::IncomingTransfer.unmatched.
|
|
23
|
+
Spree::BankPayments::IncomingTransfer.unmatched.
|
|
24
|
+
includes(:bank_account).order(occurred_at: :desc)
|
|
19
25
|
)
|
|
20
26
|
|
|
21
27
|
@suggestions = @transfers.each_with_object({}) do |transfer, acc|
|
|
@@ -25,6 +25,7 @@ module Spree
|
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
state.record_success!
|
|
28
|
+
report_health(payment_method, :ok, :ok)
|
|
28
29
|
rescue StandardError => e
|
|
29
30
|
# Never re-raise: one misconfigured payment method must not stop the
|
|
30
31
|
# others, and the recorded failure is what flips the health gate.
|
|
@@ -33,6 +34,64 @@ module Spree
|
|
|
33
34
|
# here can't escape and wedge every payment method after this one.
|
|
34
35
|
state&.record_failure!(e.message)
|
|
35
36
|
Rails.error.report(e, source: 'spree_bank_payments.poll')
|
|
37
|
+
report_failure(payment_method)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Deliberately takes no exception: the reason is drawn from a closed enum
|
|
41
|
+
# and is never derived from what was raised, because an exception message
|
|
42
|
+
# can carry a bearer token straight into a log aggregator.
|
|
43
|
+
#
|
|
44
|
+
# The outer rescue is a backstop only. Reporting health must not depend on
|
|
45
|
+
# the reconciler being answerable -- see #failure_status.
|
|
46
|
+
def report_failure(payment_method)
|
|
47
|
+
status = failure_status(payment_method)
|
|
48
|
+
reason = status == :consent_revoked ? :consent_revoked : :provider_error
|
|
49
|
+
|
|
50
|
+
report_health(payment_method, status, reason)
|
|
51
|
+
rescue StandardError => e
|
|
52
|
+
Rails.error.report(e, source: 'spree_bank_payments.health')
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Ask the reconciler what kind of failure this was. A provider that knows
|
|
56
|
+
# its consent is dead says :consent_revoked; everything else is
|
|
57
|
+
# :transient, including "we could not ask".
|
|
58
|
+
#
|
|
59
|
+
# Asking is best-effort, and its failure must never cost us the report.
|
|
60
|
+
# #health is a real network call for most providers, and an unregistered
|
|
61
|
+
# reconciler key raises out of Reconcilers::Base.build before #health is
|
|
62
|
+
# even reached -- which is precisely the case an operator most needs told
|
|
63
|
+
# about, because Gateway#health deliberately keeps offering bank transfer
|
|
64
|
+
# in that state. Letting the raise reach report_failure's rescue meant a
|
|
65
|
+
# store whose provider gem had gone took transfers indefinitely with no
|
|
66
|
+
# event, no log line and no persisted health_status: the documented
|
|
67
|
+
# reason=provider_error alert, the thesis of this release, never fired.
|
|
68
|
+
def failure_status(payment_method)
|
|
69
|
+
status = begin
|
|
70
|
+
payment_method.reconciler.health
|
|
71
|
+
rescue StandardError => e
|
|
72
|
+
Rails.error.report(e, source: 'spree_bank_payments.health')
|
|
73
|
+
:transient
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
return :transient unless Reconcilers::Base::HEALTH_STATES.include?(status)
|
|
77
|
+
|
|
78
|
+
status == :ok ? :transient : status
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Reporting health is bookkeeping about the poll, not part of it. On the
|
|
82
|
+
# success path the call sits inside poll_one's rescue region, so a
|
|
83
|
+
# reporter that raised -- a logger blowing up, an event subscriber
|
|
84
|
+
# exploding -- would have recorded a failure for a poll that fully
|
|
85
|
+
# succeeded, and then reported health a second time from report_failure.
|
|
86
|
+
#
|
|
87
|
+
# An `else` clause on poll_one would move the call out of the rescue
|
|
88
|
+
# region, but then the exception escapes poll_one entirely and aborts the
|
|
89
|
+
# find_each over every remaining payment method. Its own rescue is what
|
|
90
|
+
# contains it, mirroring what report_failure already does.
|
|
91
|
+
def report_health(payment_method, status, reason)
|
|
92
|
+
HealthReporter.call(payment_method: payment_method, status: status, reason: reason)
|
|
93
|
+
rescue StandardError => e
|
|
94
|
+
Rails.error.report(e, source: 'spree_bank_payments.health')
|
|
36
95
|
end
|
|
37
96
|
end
|
|
38
97
|
end
|
|
@@ -3,9 +3,14 @@ module Spree
|
|
|
3
3
|
# One account as reported by a provider, in the gem's normalised shape.
|
|
4
4
|
# The reconciler maps its provider's response into this -- the database and
|
|
5
5
|
# views never see provider-specific schemas.
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
#
|
|
7
|
+
# `pooled` marks an account whose coordinates are shared with other
|
|
8
|
+
# customers of the provider, so the payment reference is the only thing
|
|
9
|
+
# separating two payers. Defaults to false: every provider written before
|
|
10
|
+
# 5.3.0 omits it, and a dedicated account is the safe assumption.
|
|
11
|
+
AccountData = Data.define(:provider_account_id, :currency, :details, :pooled) do
|
|
12
|
+
def initialize(details: [], pooled: false, **rest)
|
|
13
|
+
super(details: details, pooled: pooled, **rest)
|
|
9
14
|
end
|
|
10
15
|
end
|
|
11
16
|
end
|
|
@@ -15,6 +15,7 @@ module Spree
|
|
|
15
15
|
|
|
16
16
|
validate :discount_percent_within_bounds
|
|
17
17
|
validate :expiry_days_positive
|
|
18
|
+
validate :reconciler_registered
|
|
18
19
|
|
|
19
20
|
has_many :bank_accounts,
|
|
20
21
|
class_name: 'Spree::BankPayments::BankAccount',
|
|
@@ -100,23 +101,61 @@ module Spree
|
|
|
100
101
|
'spree_bank_payments'
|
|
101
102
|
end
|
|
102
103
|
|
|
104
|
+
# Memoized because #health calls it twice and #health runs on every
|
|
105
|
+
# checkout render for a non-Manual gateway -- unmemoized that was two
|
|
106
|
+
# round trips per render, and on a gateway that has never polled, two
|
|
107
|
+
# find_or_create_by! calls.
|
|
103
108
|
def reconciler_state
|
|
104
|
-
|
|
109
|
+
@reconciler_state ||= find_or_create_reconciler_state
|
|
105
110
|
end
|
|
106
111
|
|
|
107
112
|
def reconciler
|
|
108
113
|
@reconciler ||= Reconcilers::Base.build(payment_method: self)
|
|
109
114
|
end
|
|
110
115
|
|
|
116
|
+
# Memoized state must not outlive an explicit reload of the record.
|
|
117
|
+
def reload(*)
|
|
118
|
+
@reconciler_state = nil
|
|
119
|
+
@reconciler = nil
|
|
120
|
+
super
|
|
121
|
+
end
|
|
122
|
+
|
|
111
123
|
# Gate on both the reconciler's own opinion and our recorded poll history.
|
|
112
124
|
# The Manual reconciler is always healthy because it never polls.
|
|
125
|
+
#
|
|
126
|
+
# An unbuildable reconciler is unhealthy rather than an exception. Two
|
|
127
|
+
# callers make that matter: ExpireSessionsJob, which must not cancel
|
|
128
|
+
# anything while we cannot see the bank, and the payment method's
|
|
129
|
+
# configuration-guide partial, which Spree renders on the **edit form** --
|
|
130
|
+
# so raising here locked an admin out of the one screen where an
|
|
131
|
+
# uninstalled provider gem can be switched back to 'manual'.
|
|
113
132
|
def reconciler_healthy?
|
|
114
|
-
|
|
115
|
-
return
|
|
133
|
+
built = safely_built_reconciler
|
|
134
|
+
return false if built.nil?
|
|
135
|
+
return false unless built.healthy?
|
|
136
|
+
return true if built.instance_of?(Reconcilers::Manual)
|
|
116
137
|
|
|
117
138
|
reconciler_state.healthy?(preferred_poll_interval_minutes)
|
|
118
139
|
end
|
|
119
140
|
|
|
141
|
+
# The persisted view of health, safe to call on the checkout hot path.
|
|
142
|
+
#
|
|
143
|
+
# Deliberately does NOT call `reconciler.health`: available_for_order? runs
|
|
144
|
+
# on every checkout render, and a provider's live check is an HTTP request.
|
|
145
|
+
# The poll job is what refreshes this.
|
|
146
|
+
#
|
|
147
|
+
# #reconciler_healthy? is left alone -- it gates the expiry job from a
|
|
148
|
+
# background worker where a live check is fine, and changing it here would
|
|
149
|
+
# alter behaviour this task has no reason to touch.
|
|
150
|
+
def health
|
|
151
|
+
return :ok if manual_reconciler?
|
|
152
|
+
|
|
153
|
+
persisted = reconciler_state.health_status.presence&.to_sym
|
|
154
|
+
return :consent_revoked if persisted == :consent_revoked
|
|
155
|
+
|
|
156
|
+
reconciler_state.healthy?(preferred_poll_interval_minutes) ? :ok : :transient
|
|
157
|
+
end
|
|
158
|
+
|
|
120
159
|
# Called by Spree::Api::V3::Webhooks::PaymentsController. Signature
|
|
121
160
|
# verification happens inside the reconciler and raises
|
|
122
161
|
# Spree::PaymentMethod::WebhookSignatureError, which the controller turns
|
|
@@ -156,6 +195,11 @@ module Spree
|
|
|
156
195
|
def available_for_order?(order)
|
|
157
196
|
return false unless super
|
|
158
197
|
|
|
198
|
+
# Nothing will ever reconcile against a dead consent, so quoting bank
|
|
199
|
+
# details would take money we cannot match to an order. :transient is
|
|
200
|
+
# deliberately not gated here.
|
|
201
|
+
return false if health == :consent_revoked
|
|
202
|
+
|
|
159
203
|
return true if offered_account_for(order.currency).present?
|
|
160
204
|
|
|
161
205
|
# An order that already quoted this gateway must be able to settle
|
|
@@ -204,6 +248,70 @@ module Spree
|
|
|
204
248
|
|
|
205
249
|
private
|
|
206
250
|
|
|
251
|
+
# The first-ever concurrent checkout render on a gateway is a real race:
|
|
252
|
+
# two requests both miss the SELECT, both INSERT, and the unique index on
|
|
253
|
+
# payment_method_id makes the loser raise. PollJob already reasons about
|
|
254
|
+
# this race (its rescue guards a nil `state`); the checkout path did not.
|
|
255
|
+
# By the time the loser is here the winner has committed a row, so
|
|
256
|
+
# re-reading it is the whole recovery.
|
|
257
|
+
def find_or_create_reconciler_state
|
|
258
|
+
Spree::BankPayments::ReconcilerState.find_or_create_by!(payment_method_id: id)
|
|
259
|
+
rescue ActiveRecord::RecordNotUnique
|
|
260
|
+
Spree::BankPayments::ReconcilerState.find_by!(payment_method_id: id)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# #health is reached from available_for_order?, which runs on every
|
|
264
|
+
# checkout render for every customer. Reconcilers::Base.build raises for
|
|
265
|
+
# a key that is not in the registry -- a typo'd preference, or a provider
|
|
266
|
+
# gem uninstalled while a gateway still names it -- and before 5.3.0 that
|
|
267
|
+
# only ever surfaced inside PollJob and ExpireSessionsJob, both of which
|
|
268
|
+
# rescue per payment method. Letting it escape here would turn a config
|
|
269
|
+
# mistake into a storefront 500.
|
|
270
|
+
#
|
|
271
|
+
# An unbuildable reconciler is therefore simply "not the Manual one", so
|
|
272
|
+
# #health falls through to the persisted state and reads :transient for a
|
|
273
|
+
# gateway that has never polled: the payment method keeps being offered
|
|
274
|
+
# and nothing reconciles, which is exactly what a misconfigured gateway
|
|
275
|
+
# did before this release. Withdrawing checkout on a config typo would be
|
|
276
|
+
# a new and much louder failure mode than the one being fixed.
|
|
277
|
+
def manual_reconciler?
|
|
278
|
+
safely_built_reconciler.instance_of?(Reconcilers::Manual)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# The rescue is deliberately tight around the build itself, and catches
|
|
282
|
+
# only ArgumentError -- the one Reconcilers::Base.build raises for an
|
|
283
|
+
# unregistered key. A provider's own constructor blowing up is a bug that
|
|
284
|
+
# should still surface, not something to fold into "unhealthy".
|
|
285
|
+
def safely_built_reconciler
|
|
286
|
+
reconciler
|
|
287
|
+
rescue ArgumentError
|
|
288
|
+
nil
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Catch the typo where it is made, rather than at the next poll. Skipped
|
|
292
|
+
# when the value is unchanged on an already-persisted record: if a
|
|
293
|
+
# provider gem is uninstalled, an admin still has to be able to save this
|
|
294
|
+
# gateway -- deactivating it, or switching it back to 'manual', is the
|
|
295
|
+
# recovery, and a validation that refused every save would lock the one
|
|
296
|
+
# record they need to fix.
|
|
297
|
+
#
|
|
298
|
+
# Saving is only half of that recovery: the admin also has to be able to
|
|
299
|
+
# *open* the edit form, whose configuration-guide partial calls
|
|
300
|
+
# #reconciler_healthy?. That is why that method tolerates an unbuildable
|
|
301
|
+
# reconciler too -- without it this escape hatch would let an admin save a
|
|
302
|
+
# screen they could never reach.
|
|
303
|
+
def reconciler_registered
|
|
304
|
+
key = preferred_reconciler.to_s
|
|
305
|
+
return if Reconcilers::Base.registry.key?(key)
|
|
306
|
+
return if persisted? && persisted_reconciler_key == key
|
|
307
|
+
|
|
308
|
+
errors.add(:preferred_reconciler, :inclusion)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def persisted_reconciler_key
|
|
312
|
+
(preferences_in_database || {}).with_indifferent_access[:reconciler].to_s
|
|
313
|
+
end
|
|
314
|
+
|
|
207
315
|
def discount_percent_within_bounds
|
|
208
316
|
percent = preferred_discount_percent.to_d
|
|
209
317
|
return if percent >= 0 && percent <= 100
|
|
@@ -21,6 +21,16 @@ module Spree
|
|
|
21
21
|
def record_failure!(error)
|
|
22
22
|
update!(last_error: error.to_s.truncate(1000), consecutive_failures: consecutive_failures + 1)
|
|
23
23
|
end
|
|
24
|
+
|
|
25
|
+
# @param status [Symbol] a member of Reconcilers::Base::HEALTH_STATES
|
|
26
|
+
# @param reason [Symbol] a member of HealthReporter::REASONS
|
|
27
|
+
# @param logged [Boolean] whether this report was actually emitted
|
|
28
|
+
def record_health!(status:, reason:, logged:)
|
|
29
|
+
attrs = { health_status: status.to_s, health_reason: reason.to_s }
|
|
30
|
+
attrs[:health_reported_at] = Time.current if logged
|
|
31
|
+
|
|
32
|
+
update!(attrs)
|
|
33
|
+
end
|
|
24
34
|
end
|
|
25
35
|
end
|
|
26
36
|
end
|
|
@@ -41,9 +41,34 @@ module Spree
|
|
|
41
41
|
raise NotImplementedError, "#{self.class} must implement #parse_webhook"
|
|
42
42
|
end
|
|
43
43
|
|
|
44
|
+
HEALTH_STATES = %i[ok transient consent_revoked].freeze
|
|
45
|
+
|
|
46
|
+
# @return [Symbol] one of HEALTH_STATES.
|
|
47
|
+
#
|
|
48
|
+
# :transient still offers at checkout -- a brief provider outage must
|
|
49
|
+
# not pull bank transfer off the storefront, because the money still
|
|
50
|
+
# arrives and reconciles once the provider returns. Only
|
|
51
|
+
# :consent_revoked withdraws it, because in that state nothing will
|
|
52
|
+
# ever reconcile.
|
|
53
|
+
#
|
|
54
|
+
# Providers written against <= 5.2 implement #healthy? only, so the
|
|
55
|
+
# default derives from it.
|
|
56
|
+
def health
|
|
57
|
+
healthy? ? :ok : :transient
|
|
58
|
+
end
|
|
59
|
+
|
|
44
60
|
# @return [Boolean] false means the expiry job must not cancel anything
|
|
61
|
+
#
|
|
62
|
+
# Providers written against >= 5.3 may implement #health only, so the
|
|
63
|
+
# default derives from it. The two defaults are mutually recursive by
|
|
64
|
+
# construction; the owner check breaks the cycle and turns "overrode
|
|
65
|
+
# neither" into a clear contract error instead of a SystemStackError.
|
|
45
66
|
def healthy?
|
|
46
|
-
|
|
67
|
+
if method(:health).owner == Spree::BankPayments::Reconcilers::Base
|
|
68
|
+
raise NotImplementedError, "#{self.class} must implement #health or #healthy?"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
health == :ok
|
|
47
72
|
end
|
|
48
73
|
|
|
49
74
|
# @return [Boolean] whether credentials and settings are complete
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module BankPayments
|
|
3
|
+
# Owns the health log line and its event, for every provider.
|
|
4
|
+
#
|
|
5
|
+
# This lives in core rather than in each provider gem on purpose: health
|
|
6
|
+
# already gates checkout and the expiry job, so if providers each logged
|
|
7
|
+
# their own way, every new provider would reinvent it and every downstream
|
|
8
|
+
# alert rule would need another clause.
|
|
9
|
+
class HealthReporter
|
|
10
|
+
RELOG_AFTER = 1.hour
|
|
11
|
+
|
|
12
|
+
# A closed set. A provider hands us one of these symbols; anything else
|
|
13
|
+
# becomes :unknown. Reasons are NEVER built from an exception message or
|
|
14
|
+
# a response body -- that is how a bearer token reaches a log aggregator.
|
|
15
|
+
#
|
|
16
|
+
# Deliberately only the four values core can actually produce. Widening a
|
|
17
|
+
# published enum later is non-breaking; narrowing it is not, and every
|
|
18
|
+
# value here is a promise to alert rules that key on reason=. Nothing
|
|
19
|
+
# hands a provider a way to supply its own reason yet, so a richer
|
|
20
|
+
# vocabulary would be advertising states no code path can reach.
|
|
21
|
+
REASONS = %i[ok provider_error consent_revoked unknown].freeze
|
|
22
|
+
|
|
23
|
+
def self.call(payment_method:, status:, reason:)
|
|
24
|
+
new(payment_method: payment_method, status: status, reason: reason).call
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(payment_method:, status:, reason:)
|
|
28
|
+
@payment_method = payment_method
|
|
29
|
+
@status = clamp(status.to_s.to_sym, Reconcilers::Base::HEALTH_STATES, :transient)
|
|
30
|
+
@reason = clamp(reason.to_s.to_sym, REASONS, :unknown)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def call
|
|
34
|
+
logged = should_log?
|
|
35
|
+
emit if logged
|
|
36
|
+
state.record_health!(status: status, reason: reason, logged: logged)
|
|
37
|
+
logged
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
attr_reader :payment_method, :status, :reason
|
|
43
|
+
|
|
44
|
+
# `reason` was already clamped; `status` was not, so an unrecognised one
|
|
45
|
+
# fell through the severity ternary to WARN and was persisted verbatim
|
|
46
|
+
# into health_status -- where Gateway#health then reads it. :transient is
|
|
47
|
+
# the fallback because it preserves that de-facto WARN severity while
|
|
48
|
+
# keeping the persisted value inside HEALTH_STATES. :ok would claim a
|
|
49
|
+
# health we have no evidence for, and :consent_revoked would withdraw the
|
|
50
|
+
# payment method from checkout on nothing more than a typo.
|
|
51
|
+
def clamp(value, permitted, fallback)
|
|
52
|
+
permitted.include?(value) ? value : fallback
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def state
|
|
56
|
+
@state ||= payment_method.reconciler_state
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def previous
|
|
60
|
+
@previous ||= state.health_status.presence&.to_sym
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def changed?
|
|
64
|
+
previous.present? && previous != status
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def due?
|
|
68
|
+
state.health_reported_at.nil? || state.health_reported_at < RELOG_AFTER.ago
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def should_log?
|
|
72
|
+
return changed? if status == :ok
|
|
73
|
+
|
|
74
|
+
changed? || due?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def emit
|
|
78
|
+
status == :ok ? emit_recovered : emit_unhealthy
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def emit_unhealthy
|
|
82
|
+
severity = status == :consent_revoked ? :error : :warn
|
|
83
|
+
|
|
84
|
+
Rails.logger.public_send(severity, <<~LINE.squish)
|
|
85
|
+
[spree-bank_payments] reconciler unhealthy
|
|
86
|
+
event=bank_transfer.reconciler_health.unhealthy
|
|
87
|
+
reconciler=#{payment_method.preferred_reconciler}
|
|
88
|
+
payment_method_id=#{payment_method.id}
|
|
89
|
+
status=#{status}
|
|
90
|
+
reason=#{reason}
|
|
91
|
+
consecutive_failures=#{state.consecutive_failures}
|
|
92
|
+
last_success_at=#{state.last_successful_run_at&.iso8601 || 'never'}
|
|
93
|
+
LINE
|
|
94
|
+
|
|
95
|
+
publish('bank_transfer.reconciler_health.unhealthy')
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def emit_recovered
|
|
99
|
+
Rails.logger.info(<<~LINE.squish)
|
|
100
|
+
[spree-bank_payments] reconciler recovered
|
|
101
|
+
event=bank_transfer.reconciler_health.recovered
|
|
102
|
+
reconciler=#{payment_method.preferred_reconciler}
|
|
103
|
+
payment_method_id=#{payment_method.id}
|
|
104
|
+
previous_status=#{previous}
|
|
105
|
+
LINE
|
|
106
|
+
|
|
107
|
+
publish('bank_transfer.reconciler_health.recovered')
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Serializable primitives only: subscribers run async through ActiveJob
|
|
111
|
+
# and an ActiveRecord object cannot survive the trip.
|
|
112
|
+
def publish(event)
|
|
113
|
+
Spree::Events.publish(event,
|
|
114
|
+
payment_method_id: payment_method.id,
|
|
115
|
+
reconciler: payment_method.preferred_reconciler.to_s,
|
|
116
|
+
status: status.to_s,
|
|
117
|
+
reason: reason.to_s,
|
|
118
|
+
consecutive_failures: state.consecutive_failures)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -84,6 +84,7 @@ module Spree
|
|
|
84
84
|
provider_account_id: data.provider_account_id,
|
|
85
85
|
currency: data.currency,
|
|
86
86
|
details: data.details,
|
|
87
|
+
pooled: data.pooled,
|
|
87
88
|
offered: false,
|
|
88
89
|
active: true,
|
|
89
90
|
synced_at: Time.current
|
|
@@ -92,7 +93,7 @@ module Spree
|
|
|
92
93
|
|
|
93
94
|
prepared[:update].each do |data|
|
|
94
95
|
account = payment_method.bank_accounts.find_by(provider_account_id: data.provider_account_id)
|
|
95
|
-
account.update!(currency: data.currency, details: data.details,
|
|
96
|
+
account.update!(currency: data.currency, details: data.details, pooled: data.pooled,
|
|
96
97
|
active: true, synced_at: Time.current)
|
|
97
98
|
end
|
|
98
99
|
|
|
@@ -35,7 +35,12 @@
|
|
|
35
35
|
|
|
36
36
|
<table class="table">
|
|
37
37
|
<thead>
|
|
38
|
-
|
|
38
|
+
<%# `Pooled` is a provider-reported fact, not an admin choice, so it renders
|
|
39
|
+
as a plain cell like `Active` rather than a toggle. It is shown because
|
|
40
|
+
it changes how a human must read the hand-match queue: on a pooled
|
|
41
|
+
account the coordinates are shared with other customers of the
|
|
42
|
+
provider, so the reference is the only thing identifying the payer. %>
|
|
43
|
+
<tr><th>Currency</th><th>Account</th><th>Source</th><th>Offered</th><th>Active</th><th>Pooled</th><th></th></tr>
|
|
39
44
|
</thead>
|
|
40
45
|
<tbody>
|
|
41
46
|
<% @bank_accounts.each do |account| %>
|
|
@@ -55,6 +60,14 @@
|
|
|
55
60
|
class: account.offered? ? 'btn btn-sm btn-success' : 'btn btn-sm btn-outline-secondary' %>
|
|
56
61
|
</td>
|
|
57
62
|
<td><%= account.active? ? 'Yes' : 'No' %></td>
|
|
63
|
+
<td>
|
|
64
|
+
<% if account.pooled? %>
|
|
65
|
+
<span class="badge bg-warning text-dark"
|
|
66
|
+
title="These coordinates are shared with other customers of the provider. The payment reference is the only thing identifying the payer.">Pooled</span>
|
|
67
|
+
<% else %>
|
|
68
|
+
No
|
|
69
|
+
<% end %>
|
|
70
|
+
</td>
|
|
58
71
|
<td>
|
|
59
72
|
<% unless account.synced? %>
|
|
60
73
|
<%= link_to 'Edit', spree.edit_admin_payment_method_bank_account_path(@payment_method, account) %>
|
|
@@ -23,6 +23,22 @@
|
|
|
23
23
|
<td><%= transfer.payer_name %></td>
|
|
24
24
|
<td><code><%= transfer.reference_raw.presence || '—' %></code></td>
|
|
25
25
|
<td>
|
|
26
|
+
<%# Suggestions are ranked partly on fuzzy pg_trgm payer-name
|
|
27
|
+
similarity, so a plausible-looking name is not evidence of who
|
|
28
|
+
paid. On a pooled account it is not evidence at all: every
|
|
29
|
+
customer of the provider sends to the same coordinates, so two
|
|
30
|
+
unrelated payers land here indistinguishable except by the
|
|
31
|
+
reference they quoted. Say so on the screen where the admin is
|
|
32
|
+
about to click, not only on the accounts screen. %>
|
|
33
|
+
<% if transfer.bank_account&.pooled? %>
|
|
34
|
+
<div class="alert alert-warning py-1 px-2 mb-2">
|
|
35
|
+
<strong>Pooled account.</strong> These coordinates are shared
|
|
36
|
+
with other customers of the provider, so the reference is the
|
|
37
|
+
only thing identifying who paid. Check the reference above
|
|
38
|
+
against the order before applying — a suggestion here is a
|
|
39
|
+
guess, not a match.
|
|
40
|
+
</div>
|
|
41
|
+
<% end %>
|
|
26
42
|
<% Array(@suggestions[transfer.id]).each do |suggestion| %>
|
|
27
43
|
<%# Currency compared case-insensitively, matching the controller's
|
|
28
44
|
own casecmp guard -- otherwise 'gbp' vs 'GBP' (the same
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
class AddHealthToReconcilerStates < ActiveRecord::Migration[7.2]
|
|
2
|
+
def change
|
|
3
|
+
add_column :spree_bank_payments_reconciler_states, :health_status, :string
|
|
4
|
+
add_column :spree_bank_payments_reconciler_states, :health_reason, :string
|
|
5
|
+
add_column :spree_bank_payments_reconciler_states, :health_reported_at, :datetime
|
|
6
|
+
end
|
|
7
|
+
end
|
|
@@ -30,6 +30,10 @@ RSpec.shared_examples 'a bank transfer reconciler' do
|
|
|
30
30
|
expect([true, false]).to include(reconciler.healthy?)
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
+
it 'answers #health with a member of the published enum' do
|
|
34
|
+
expect(Spree::BankPayments::Reconcilers::Base::HEALTH_STATES).to include(reconciler.health)
|
|
35
|
+
end
|
|
36
|
+
|
|
33
37
|
it 'answers #configured? with a boolean' do
|
|
34
38
|
expect([true, false]).to include(reconciler.configured?)
|
|
35
39
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: spree-bank_payments
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 5.
|
|
4
|
+
version: 5.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Aypex
|
|
@@ -98,6 +98,7 @@ files:
|
|
|
98
98
|
- app/models/spree/payment_sessions/bank_transfer.rb
|
|
99
99
|
- app/services/spree/bank_payments/apply_discount.rb
|
|
100
100
|
- app/services/spree/bank_payments/apply_transfer.rb
|
|
101
|
+
- app/services/spree/bank_payments/health_reporter.rb
|
|
101
102
|
- app/services/spree/bank_payments/ingest_transfer.rb
|
|
102
103
|
- app/services/spree/bank_payments/migrate_legacy_accounts.rb
|
|
103
104
|
- app/services/spree/bank_payments/reference_generator.rb
|
|
@@ -128,6 +129,8 @@ files:
|
|
|
128
129
|
- db/migrate/20260817000003_exclude_soft_deleted_from_bank_account_uniqueness.rb
|
|
129
130
|
- db/migrate/20260817000004_migrate_legacy_account_preferences.rb
|
|
130
131
|
- db/migrate/20260817000006_add_bank_account_id_to_sessions_and_transfers.rb
|
|
132
|
+
- db/migrate/20260817000007_add_health_to_reconciler_states.rb
|
|
133
|
+
- db/migrate/20260817000008_add_pooled_to_bank_accounts.rb
|
|
131
134
|
- lib/generators/spree/bank_payments/install/install_generator.rb
|
|
132
135
|
- lib/spree-bank_payments.rb
|
|
133
136
|
- lib/spree/bank_payments.rb
|