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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b7bb0454adf07aad2127a114af121975941265c60b04ba329aa870d5bed31243
4
- data.tar.gz: 887a05355e75ae225476c219f4b355dae87354c0e026e5f92bbe1bf56a46bf14
3
+ metadata.gz: 6a51b19c1448a4d27b386e8e65276fb900e18528f4e7bfd4f539e33b559414fa
4
+ data.tar.gz: 51ed0b549ce5d0bf543118430c45cd49cb6c5a66646a031ef94980c6d6c6035c
5
5
  SHA512:
6
- metadata.gz: d44fd3f4f89ad61d1a75e8393c0cb7f62a104ad4a710584e4c789b189a9a66d6154f23e6a49d3d075c16c7acf81740e18c9a40b86b4a9a046d6176b6c0266639
7
- data.tar.gz: 96092e1b5ff5e6fd9cbd5d92a604ae0624ba506a6b0e8f76d9a9465ac819d8e9748c4754481f59160d622716821f795d07fbcd60f0b09530adcb47f3139e8da8
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 (see above) |
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?` — boolean; feeds the health gate above
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.order(occurred_at: :desc)
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
- AccountData = Data.define(:provider_account_id, :currency, :details) do
7
- def initialize(details: [], **rest)
8
- super(details: details, **rest)
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
- Spree::BankPayments::ReconcilerState.find_or_create_by!(payment_method_id: id)
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
- return false unless reconciler.healthy?
115
- return true if reconciler.instance_of?(Reconcilers::Manual)
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
- raise NotImplementedError, "#{self.class} must implement #healthy?"
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
@@ -12,8 +12,10 @@ module Spree
12
12
  nil
13
13
  end
14
14
 
15
- def healthy?
16
- true
15
+ # The manual reconciler never talks to anything, so it cannot be
16
+ # unhealthy.
17
+ def health
18
+ :ok
17
19
  end
18
20
 
19
21
  def configured?
@@ -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
- <tr><th>Currency</th><th>Account</th><th>Source</th><th>Offered</th><th>Active</th><th></th></tr>
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
@@ -0,0 +1,5 @@
1
+ class AddPooledToBankAccounts < ActiveRecord::Migration[7.2]
2
+ def change
3
+ add_column :spree_bank_payments_bank_accounts, :pooled, :boolean, null: false, default: false
4
+ end
5
+ 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
@@ -1,6 +1,6 @@
1
1
  module Spree
2
2
  module BankPayments
3
3
  # Major version tracks Spree's major version: 5.x supports Spree 5.x.
4
- VERSION = '5.2.0'.freeze
4
+ VERSION = '5.3.0'.freeze
5
5
  end
6
6
  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.2.0
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