spree-bank_payments 5.0.0 → 5.1.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: 62489d37734d726a1b9fa5c30303ccd5165ed2679c18e1e87fe69dbddc25d446
4
- data.tar.gz: 502281b05bf1680cf13c92ac26e96fb51d5216e513830ae59e0301c66334ee17
3
+ metadata.gz: 2d27bec55fd161244f5186eb04cef396352d0216ef058579e59ef76e6c88b022
4
+ data.tar.gz: 61063e50bfa2e05415b38f7e5856fde515784c963e52d7c2cc23315162ebc27d
5
5
  SHA512:
6
- metadata.gz: 8a81d4f37d6ccf997de25c8dfadb60c73cc91a0812851bdd5477acc2b323948648e19a7469bf861201cde794984ce9eb962b1d2991aae111f59c6b3ec47ea342
7
- data.tar.gz: 16684728ff3aace5f8fabac9c2b48766a044ae9c2dd6d9dd904da5755051156af04e53369e2535e34e069641d2654db61ceec966f32e98bd014d56beb40ef28c
6
+ metadata.gz: f924f8984181cfdfdfe6c3c3df361e093df31f626d9651c72ae986ccc467d936f0fa155aececd6083ccc3b00b23c2111eb0a4a9619d5fb21744ce1ab1b68aee5
7
+ data.tar.gz: a9dae769559625ae8ad31b3beca9b30dae51e5db207d8cd8873d47500d75383890d4455b0320d9559d62a9cf6a0ce394ca4f952e7e34e3acedadf337b611bd78
data/CHANGELOG.md CHANGED
@@ -2,6 +2,41 @@
2
2
 
3
3
  All notable changes to this project are documented in this file.
4
4
 
5
+ ## 5.1.0
6
+
7
+ **The bank-transfer discount is now tax-aware.**
8
+
9
+ `discount_percent` was applied as a single order-level `Spree::Adjustment`.
10
+ Order-level adjustments never reach Spree's `taxable_adjustment_total`, so on a
11
+ tax-inclusive (VAT) store the customer paid less while the order still recorded
12
+ tax on the undiscounted price. This was documented as a known limitation; it is
13
+ now fixed.
14
+
15
+ The discount is applied as one adjustment **per line item**, allocated
16
+ proportionally to each line's amount with largest-remainder rounding so the
17
+ parts sum to exactly `-(item_total * pct / 100)` — naive per-line rounding
18
+ drifts by a cent, and `order.total` must match the amount quoted on the payment
19
+ session or auto-apply's exact-equality check sends every payment to the manual
20
+ queue. A new `Spree::BankPayments::Adjuster::Discount`, registered in
21
+ `config.spree.adjusters`, folds these into `taxable_adjustment_total` before tax
22
+ is computed, so recorded tax now falls with the discount. (It is inserted ahead
23
+ of `Spree::Adjustable::Adjuster::Tax` so the array reads in execution order, but
24
+ Spree runs the tax adjuster last by construction — the position is cosmetic.)
25
+
26
+ The type filters match subclassed gateways, consistently with `is_a?`-based
27
+ detection: a store subclassing `Spree::BankPayments::Gateway` gets the same tax
28
+ treatment and the same cleanup on a payment-method switch.
29
+
30
+ The discount **stacks with promotions** — it is not a competing promo
31
+ adjustment, so neither side is marked ineligible. A 20% promotion plus a 3%
32
+ transfer discount is 23% off. Set `discount_percent` accordingly.
33
+
34
+ `remove_existing` now searches every adjustment carrying the order's id rather
35
+ than only order-adjustable ones, so a payment-method switch cannot orphan the
36
+ line-item adjustments (and still cleans up adjustments written by 5.0.x).
37
+
38
+ Minor, not patch: this changes recorded tax on existing tax-inclusive stores.
39
+
5
40
  ## 5.0.0
6
41
 
7
42
  First public release on RubyGems, as `spree-bank_payments`.
data/README.md CHANGED
@@ -147,24 +147,28 @@ handler that calls out to Postmark/Resend/etc. rather than using
147
147
  ActionMailer — should disable the default mailer and subscribe to the events
148
148
  directly instead of fighting the bundled one.
149
149
 
150
- ## Known limitations
151
-
152
- **The discount does not affect tax on a tax-inclusive (VAT) store.**
153
-
154
- `discount_percent` is applied as a single order-level `Spree::Adjustment`
155
- against `order.item_total`. Spree computes `taxable_adjustment_total` by
156
- summing tax-relevant adjustments on *line items and shipments only* — an
157
- order-level adjustment is never included in that sum. The practical effect:
158
- on a VAT store, the customer pays less, but the order still records tax as
159
- if calculated on the undiscounted price. The discount and the tax figure
160
- disagree.
161
-
162
- This is not a bug we plan to quietly patch later — fixing it properly means
163
- moving the discount onto line-item adjustments, the way Spree's own
164
- promotions do, which is a bigger change than this gem currently makes. If
165
- your store is VAT/tax-inclusive, know this before you set a
166
- `discount_percent`, and evaluate whether the mismatch is acceptable for your
167
- accounting.
150
+ ## Tax, and stacking with promotions
151
+
152
+ The discount is applied as one `Spree::Adjustment` per **line item** (sourced
153
+ from the payment method, not a promotion action), allocated proportionally to
154
+ each line's amount and reconciled with largest-remainder rounding so the parts
155
+ sum to exactly `-(item_total * discount_percent / 100)` — to the cent.
156
+
157
+ Because they are line-item adjustments, they reach Spree's
158
+ `taxable_adjustment_total`, so **recorded tax falls with the discount**. On a
159
+ tax-inclusive (VAT) store a 3% discount on a £100 line reduces recorded VAT
160
+ from £16.67 to £16.17. The pre-5.1 order-level adjustment did not do this — the
161
+ customer paid less while the order still recorded tax on the undiscounted
162
+ price. That is fixed.
163
+
164
+ **The discount stacks with promotions.** It is deliberately not a competing
165
+ promotion: Spree picks a single winner among competing promo adjustments, but
166
+ this is a separate concession for paying by transfer, so it is never marked
167
+ ineligible and never makes a promotion ineligible. A 20% promotion plus a 3%
168
+ transfer discount is 23% off. Set `discount_percent` with that in mind.
169
+
170
+ Base is always `item_total`, never `order.total` — the discount never applies
171
+ to shipping or tax.
168
172
 
169
173
  ## The instructions partial ships no CSS
170
174
 
@@ -0,0 +1,55 @@
1
+ module Spree
2
+ module BankPayments
3
+ module Adjuster
4
+ # Folds this gem's line-item discount adjustments into
5
+ # +taxable_adjustment_total+ so the discount actually reduces recorded
6
+ # tax on a tax-inclusive (VAT) store.
7
+ #
8
+ # Must be registered in `config.spree.adjusters` (see
9
+ # config/initializers/spree.rb): AdjustmentsUpdater runs every registered
10
+ # non-tax adjuster, persists the running totals, and only then runs the
11
+ # tax adjuster -- which recomputes each tax adjustment from
12
+ # LineItem#taxable_basis, itself derived from taxable_amount
13
+ # (= amount + taxable_adjustment_total). Unregistered, the discount never
14
+ # reaches that total and tax stays on the undiscounted price.
15
+ #
16
+ # Position within the adjusters array is not significant: the updater
17
+ # pulls the tax adjuster out by name and always runs it last.
18
+ #
19
+ # Deliberately NOT modelled on the winner-picking half of
20
+ # Spree::Adjustable::Adjuster::Promotion. That adjuster marks every
21
+ # competing promo but the best one `eligible: false`, because only one
22
+ # promotion may apply. A payment-method discount is not competing with
23
+ # promotions -- it is a separate concession for paying by transfer, and
24
+ # must stack (a 20% promo plus a 3% transfer discount is 23% off). So
25
+ # this adjuster only sums; it never touches `eligible`, and this gem's
26
+ # adjustments are excluded from `competing_promos` (which is hardcoded
27
+ # to source_type 'Spree::PromotionAction') so Spree's promo adjuster
28
+ # cannot mark them ineligible either.
29
+ #
30
+ # Nor does it call Adjustment#update! the way the promotion adjuster
31
+ # does: the source here is a Spree::PaymentMethod, which has no
32
+ # `compute_amount`. The amounts are computed once, exactly, by
33
+ # ApplyDiscount and are authoritative.
34
+ class Discount < Spree::Adjustable::Adjuster::Base
35
+ def update
36
+ @totals[:taxable_adjustment_total] += bank_transfer_discount_total
37
+ end
38
+
39
+ private
40
+
41
+ # The type filter covers subclassed gateways. ApplyDiscount decides with
42
+ # `is_a?`, so a store subclassing the gateway would otherwise get
43
+ # adjustments created that this adjuster ignores -- VAT silently
44
+ # unfixed, which is the bug this class exists to prevent.
45
+ def bank_transfer_discount_total
46
+ adjustments.
47
+ eligible.
48
+ where(source_type: 'Spree::PaymentMethod',
49
+ source_id: Spree::BankPayments.gateway_scope.select(:id)).
50
+ sum(:amount)
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -27,15 +27,19 @@ module Spree
27
27
  return unless bank_transfer?
28
28
  return if percent.zero?
29
29
 
30
- order.adjustments.create!(
31
- adjustable: order,
32
- order: order,
33
- source: payment_method,
34
- amount: discount_amount,
35
- label: label,
36
- eligible: true,
37
- included: false
38
- )
30
+ allocation.each do |line_item, amount|
31
+ next if amount.zero?
32
+
33
+ Spree::Adjustment.create!(
34
+ adjustable: line_item,
35
+ order: order,
36
+ source: payment_method,
37
+ amount: amount,
38
+ label: label,
39
+ eligible: true,
40
+ included: false
41
+ )
42
+ end
39
43
 
40
44
  order.update_with_updater!
41
45
  end
@@ -57,30 +61,88 @@ module Spree
57
61
  # Base is item_total, never order.total: order.total is gross and would
58
62
  # also discount shipping.
59
63
  #
60
- # NOTE: this is an ORDER-level adjustment, so it does NOT reduce recorded
61
- # VAT. included_tax_total is summed from line items and shipments; an
62
- # order-level adjustment never reaches taxable_adjustment_total. On a
63
- # tax-inclusive store the customer pays less while the order still
64
- # records tax on the undiscounted price. Making the discount tax-aware
65
- # would require line-item adjustments (as Spree promotions use) -- a
66
- # deliberate open design question, not an oversight.
64
+ # This is the authoritative figure. The per-line allocation below must
65
+ # sum to exactly this, to the cent.
67
66
  def discount_amount
68
67
  -(order.item_total * percent / 100).round(2)
69
68
  end
70
69
 
70
+ # The discount is spread across LINE ITEMS rather than sitting on the
71
+ # order, so that it reaches Spree::LineItem#taxable_adjustment_total and
72
+ # actually reduces recorded tax on a tax-inclusive (VAT) store. An
73
+ # order-level adjustment never reaches that sum, which is why the
74
+ # pre-5.1 behaviour left the order recording VAT on the undiscounted
75
+ # price while the customer paid less.
76
+ #
77
+ # Largest-remainder allocation, NOT naive per-line rounding. Rounding
78
+ # each line independently drifts: 3 items at 33.33 with 2.5% gives
79
+ # 0.83 x 3 = 2.49 against an intended 2.50. That cent matters far more
80
+ # than it looks -- order.total must equal the amount quoted on the
81
+ # payment session, and auto-apply requires exact amount equality, so a
82
+ # single cent of drift diverts every payment into the manual admin
83
+ # queue.
84
+ #
85
+ # @return [Array<Array(Spree::LineItem, BigDecimal)>]
86
+ def allocation
87
+ line_items = order.line_items.to_a
88
+ weights = line_items.map { |line_item| line_item.amount.to_d }
89
+ weight_total = weights.sum
90
+
91
+ target_cents = (discount_amount.abs * 100).round.to_i
92
+ return [] if target_cents.zero?
93
+
94
+ cents =
95
+ if weight_total <= 0
96
+ # Degenerate (all-zero or negative line items): nothing sensible to
97
+ # weight by, so spread evenly and let the remainder rule finish it.
98
+ largest_remainder(Array.new(line_items.size, 1.to_d), line_items.size.to_d, target_cents)
99
+ else
100
+ largest_remainder(weights, weight_total, target_cents)
101
+ end
102
+
103
+ line_items.zip(cents.map { |c| -(c.to_d / 100) })
104
+ end
105
+
106
+ # Floor every share, then hand the leftover cents out one at a time to
107
+ # the largest fractional remainders (ties broken by original order, so
108
+ # the result is deterministic). Sums to `target_cents` by construction.
109
+ def largest_remainder(weights, weight_total, target_cents)
110
+ exact = weights.map { |weight| target_cents * weight / weight_total }
111
+ floors = exact.map(&:floor)
112
+ leftover = target_cents - floors.sum
113
+
114
+ ranked = exact.each_with_index.
115
+ sort_by { |value, index| [-(value - value.floor), index] }.
116
+ map(&:last)
117
+
118
+ ranked.first(leftover).each { |index| floors[index] += 1 }
119
+ floors
120
+ end
121
+
71
122
  def label
72
123
  Spree.t('bank_payments.discount_label', percent: payment_method.formatted_discount_percent)
73
124
  end
74
125
 
126
+ # Type filter covers subclasses (see gateway_type_names): `bank_transfer?`
127
+ # matches them with `is_a?`, so every SQL filter here must too.
75
128
  def settled_by_bank_transfer?
76
129
  order.payments.completed.joins(:payment_method).
77
- where(spree_payment_methods: { type: 'Spree::BankPayments::Gateway' }).exists?
130
+ where(spree_payment_methods: { type: Spree::BankPayments.gateway_type_names }).exists?
78
131
  end
79
132
 
133
+ # Scoped by `order.all_adjustments` (every adjustment carrying this
134
+ # order_id, whatever it is adjustable to) rather than `order.adjustments`
135
+ # (order-adjustable only). Since 5.1 the discount lives on line items, so
136
+ # the narrower scope would find nothing and silently orphan the whole
137
+ # discount on a payment-method switch -- the customer keeps a transfer
138
+ # discount while paying by card. The wider scope also still catches the
139
+ # order-level adjustments written by earlier versions of this gem.
140
+ #
141
+ # Matches subclassed gateways too, for the same reason as above.
80
142
  def remove_existing
81
- existing = order.adjustments.where(source_type: 'Spree::PaymentMethod').
82
- joins("INNER JOIN spree_payment_methods ON spree_payment_methods.id = spree_adjustments.source_id").
83
- where(spree_payment_methods: { type: 'Spree::BankPayments::Gateway' })
143
+ existing = order.all_adjustments.
144
+ where(source_type: 'Spree::PaymentMethod',
145
+ source_id: Spree::BankPayments.gateway_scope.select(:id))
84
146
 
85
147
  return if existing.empty?
86
148
 
@@ -1,6 +1,16 @@
1
1
  Rails.application.config.after_initialize do
2
2
  Rails.application.config.spree.payment_methods << Spree::BankPayments::Gateway
3
3
 
4
+ # Must be here rather than in `to_prepare` below: spree_core ASSIGNS
5
+ # `config.spree.adjusters = [Promotion, Tax]` in its own after_initialize,
6
+ # and `to_prepare` callbacks run earlier in boot, so a to_prepare-only
7
+ # registration would be silently wiped. Engine initializers (this file
8
+ # included) are loaded after spree_core's engine registers its hook, so this
9
+ # block runs after that assignment. See
10
+ # Spree::BankPayments.register_discount_adjuster! for why the ordering
11
+ # within the array matters.
12
+ Spree::BankPayments.register_discount_adjuster!
13
+
4
14
  # I4: without this the unmatched-transfers queue (and the "record a
5
15
  # received transfer" form that is the only ingress under the default
6
16
  # Manual reconciler) is reachable only by typing the URL.
@@ -52,4 +62,13 @@ Rails.application.config.to_prepare do
52
62
  # Subscribers must re-register per reload too: spree_core's own to_prepare hook
53
63
  # calls Spree::Events.reset!, which drops Proc-based subscribers registered at boot.
54
64
  Spree::BankPayments.register_default_mailer_subscribers! unless Spree::BankPayments::Config.disable_default_mailer
65
+
66
+ # Also re-registered per reload, for the Zeitwerk reason above rather than
67
+ # the Spree::Events one: Adjuster::Discount autoloads from app/models, so
68
+ # after a reload the adjusters array holds a stale, unloaded class object.
69
+ # register_discount_adjuster! rejects by class NAME and re-inserts, so this
70
+ # swaps in the fresh constant without ever stacking duplicates. On the very
71
+ # first boot the array is not seeded yet and this call is a no-op; the
72
+ # after_initialize call above does the real registration.
73
+ Spree::BankPayments.register_discount_adjuster!
55
74
  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.0.0'.freeze
4
+ VERSION = '5.1.0'.freeze
5
5
  end
6
6
  end
@@ -18,6 +18,73 @@ module Spree
18
18
  'spree_bank_payments_'
19
19
  end
20
20
 
21
+ # Registers Spree::BankPayments::Adjuster::Discount in
22
+ # `Rails.application.config.spree.adjusters`.
23
+ #
24
+ # Being in that array at all is what matters: AdjustmentsUpdater runs every
25
+ # registered non-tax adjuster and persists the resulting totals onto the
26
+ # adjustable BEFORE computing tax, so an unregistered adjuster means the
27
+ # line-item discounts never reach taxable_adjustment_total and tax is still
28
+ # computed on the undiscounted price.
29
+ #
30
+ # Position within the array is NOT significant. AdjustmentsUpdater picks the
31
+ # tax adjuster out by name (`adjusters - [tax_adjuster]`) and always runs it
32
+ # last, whatever the order. We insert ahead of it so the array reads in
33
+ # execution order, but appending would behave identically -- do not treat
34
+ # the insertion point as load-bearing.
35
+ #
36
+ # Called from BOTH hooks in config/initializers/spree.rb, for two different
37
+ # reasons:
38
+ #
39
+ # * `after_initialize`, because spree_core *assigns* (not appends to)
40
+ # `config.spree.adjusters` in its own after_initialize. A `to_prepare`
41
+ # registration runs earlier in boot (run_prepare_callbacks is a finisher
42
+ # ahead of finisher_hook) and would be wiped by that assignment.
43
+ # * `to_prepare`, because the adjuster autoloads from app/models: Zeitwerk
44
+ # re-creates the class on every code reload in development, leaving the
45
+ # array holding a stale, unloaded constant. Re-running swaps in the
46
+ # fresh one.
47
+ #
48
+ # Idempotent, and safe to call before spree_core has seeded the array
49
+ # (a no-op then -- the after_initialize call registers for real).
50
+ def self.register_discount_adjuster!
51
+ adjusters = Rails.application.config.spree.adjusters
52
+ return if adjusters.blank?
53
+
54
+ # Reject by NAME, not by identity: after a reload the array holds the
55
+ # previous incarnation of the class, which is not `equal?` to the fresh
56
+ # constant, so a `include?` guard would let duplicates accumulate.
57
+ adjusters.reject! { |adjuster| adjuster.name == 'Spree::BankPayments::Adjuster::Discount' }
58
+
59
+ # Cosmetic: keeps the array in execution order. AdjustmentsUpdater runs
60
+ # the tax adjuster last regardless, so `<<` would be equivalent.
61
+ tax_index = adjusters.index { |adjuster| adjuster.name == 'Spree::Adjustable::Adjuster::Tax' }
62
+ if tax_index
63
+ adjusters.insert(tax_index, Spree::BankPayments::Adjuster::Discount)
64
+ else
65
+ adjusters << Spree::BankPayments::Adjuster::Discount
66
+ end
67
+ end
68
+
69
+ # Every STI type name that counts as one of this gem's gateways.
70
+ #
71
+ # `ApplyDiscount#bank_transfer?` tests with `is_a?`, which matches
72
+ # subclasses, so a store subclassing the gateway HAS the discount applied.
73
+ # The SQL filters must agree, or those adjustments would be created but
74
+ # never counted into taxable_adjustment_total (VAT silently unfixed -- the
75
+ # exact bug 5.1.0 fixes) and never removed on a payment-method switch
76
+ # (leaking margin). Resolved at call time so it reflects whatever is loaded.
77
+ def self.gateway_type_names
78
+ ([Spree::BankPayments::Gateway] + Spree::BankPayments::Gateway.descendants).map(&:name).uniq
79
+ end
80
+
81
+ # Subquery of payment-method ids for those types. `unscoped` deliberately:
82
+ # a soft-deleted payment method must still have its stale adjustments found
83
+ # and removed.
84
+ def self.gateway_scope
85
+ Spree::PaymentMethod.unscoped.where(type: gateway_type_names)
86
+ end
87
+
21
88
  def self.pg_trgm_available?
22
89
  return @pg_trgm_available if defined?(@pg_trgm_available)
23
90
 
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.0.0
4
+ version: 5.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Aypex
@@ -82,6 +82,7 @@ files:
82
82
  - app/jobs/spree/bank_payments/poll_job.rb
83
83
  - app/jobs/spree/bank_payments/send_reminders_job.rb
84
84
  - app/mailers/spree/bank_payments/instructions_mailer.rb
85
+ - app/models/spree/bank_payments/adjuster/discount.rb
85
86
  - app/models/spree/bank_payments/base.rb
86
87
  - app/models/spree/bank_payments/gateway.rb
87
88
  - app/models/spree/bank_payments/incoming_transfer.rb
@@ -143,7 +144,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
143
144
  - !ruby/object:Gem::Version
144
145
  version: '0'
145
146
  requirements: []
146
- rubygems_version: 4.0.15
147
+ rubygems_version: 4.0.16
147
148
  specification_version: 4
148
149
  summary: Bank transfer checkout for Spree, with pluggable payment reconciliation
149
150
  test_files: []