spree-fixed_amt_discount 0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c28a61c314d743fe8799c4128a8f6824b62a7e00a1550440d73ae672fdbe3682
4
+ data.tar.gz: b51b1d703375a40682650893927325df26e489f4af3de49006296cf6ef627c34
5
+ SHA512:
6
+ metadata.gz: a6580edf628464368d03c34970cbefb85f6ee6e02a52a09391423e6059da0c147b53a04d14460f2a4da4cc1c0aa2e3cd8691ce914461e0a4aa24e2d69667d555
7
+ data.tar.gz: f669e1e83f575cbe9b4c12a78b8781e86cd5bccbf3b2ebd77f19906f17156c08b19ad5707b073eeda520a1a2808604437e420c494941869f8de00f15d21fed77
data/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Initial release.
6
+ - Adds `Spree::Calculator::FixedAmountOnLineItems`, a promotion calculator that
7
+ spreads a single fixed discount amount across the actionable line items in
8
+ proportion to their value, so per-item tax is recalculated correctly.
9
+ - Registered automatically for per-line-item promotion adjustments.
10
+ - Requires Spree >= 5.6.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aypex
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,72 @@
1
+ # Spree::FixedAmtDiscount
2
+
3
+ Spree's built-in `FlatRate` calculator doesn't handle fixed-amount discounts
4
+ well once tax is involved. Attached to a *per-line-item* promotion adjustment,
5
+ it applies the full amount to **every** line item — a £10 promo on a
6
+ three-item basket discounts £30, not £10. Attached to a *whole-order*
7
+ adjustment instead, it lands as a single order-level adjustment that can't be
8
+ apportioned between tax categories, so a VAT-inclusive store ends up with the
9
+ wrong tax breakdown. This gem adds a calculator that spreads one fixed amount
10
+ across the basket's line items in proportion to their value, so each item's
11
+ share — and its tax — comes out correct.
12
+
13
+ ## Installation
14
+
15
+ Requires Spree >= 5.6 and Ruby >= 3.3.
16
+
17
+ ```bash
18
+ bundle add spree-fixed_amt_discount
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ In the Spree admin: **Promotion → Actions → Create per-line-item adjustment**,
24
+ then choose the **Fixed Amount (spread across line items)** calculator and set
25
+ its amount and currency.
26
+
27
+ ## Behaviour
28
+
29
+ - The discount is split across actionable line items weighted by
30
+ `price × quantity`, calculated **before** any discounts are applied — so
31
+ when this promotion stacks with another one, the split is still based on
32
+ each item's original price, not its already-discounted price.
33
+ - Because the split is proportional, the shares are rounded to the currency's
34
+ minor unit and won't always sum exactly to the target amount. The last item
35
+ (ordered by `id`) absorbs whatever rounding remainder is left, so the shares
36
+ usually sum to the full discount. In rare baskets where the rounding can't
37
+ divide cleanly, the applied total can fall a few pence short of the
38
+ configured amount — it will never go over, and a line item will never be
39
+ surcharged.
40
+ - If the discount amount is larger than the actionable total, it clamps to
41
+ that total — the order can be discounted to zero but never goes negative.
42
+ - Line items excluded by the promotion's rules (e.g. per-item rules or
43
+ promotion-category restrictions) are left out of the split entirely. Their
44
+ value isn't counted in the denominator, so they don't dilute the discount
45
+ shared among the remaining eligible items.
46
+
47
+ ## Example
48
+
49
+ A £10 discount across three line items worth £33.33, £33.33 and £33.34 splits
50
+ as £3.33 / £3.33 / £3.34 — the third item picks up the extra penny so the
51
+ shares sum to exactly £10.00.
52
+
53
+ ## Preferences
54
+
55
+ | Preference | Type | Default | Description |
56
+ |---|---|---|---|
57
+ | `amount` | decimal | — | The fixed amount to discount, before it's spread across line items. |
58
+ | `currency` | string | the default store's currency | The currency the amount is denominated in. |
59
+ | `apply_only_on_full_priced_items` | boolean | `false` | When `true`, excludes items with a compare-at price from both the split's numerator and its denominator. |
60
+
61
+ ## Development
62
+
63
+ ```bash
64
+ bundle install
65
+ bundle exec rake test_app
66
+ bundle exec rspec
67
+ ```
68
+
69
+ ## Licence
70
+
71
+ The gem is available as open source under the terms of the [MIT
72
+ Licence](LICENSE).
data/Rakefile ADDED
@@ -0,0 +1,26 @@
1
+ require 'bundler'
2
+ Bundler::GemHelper.install_tasks
3
+
4
+ require 'rspec/core/rake_task'
5
+ require 'spree/testing_support/extension_rake'
6
+
7
+ RSpec::Core::RakeTask.new
8
+
9
+ task :default do
10
+ if Dir['spec/dummy'].empty?
11
+ Rake::Task[:test_app].invoke
12
+ Dir.chdir('../../')
13
+ end
14
+ Rake::Task[:spec].invoke
15
+ end
16
+
17
+ desc 'Generates a dummy app for testing'
18
+ task :test_app do
19
+ # Must be the require path, not the gem name -- common:test_app does a literal
20
+ # `require ENV['LIB_NAME']` and templates the same string into the dummy app.
21
+ ENV['LIB_NAME'] = 'spree/fixed_amt_discount'
22
+ ENV['DB'] ||= 'postgres'
23
+ # This gem ships no admin or storefront views, so the dummy app needs neither.
24
+ # Leaving both off also skips the Tailwind install and assets:precompile.
25
+ Rake::Task['extension:test_app'].execute
26
+ end
@@ -0,0 +1,91 @@
1
+ require_dependency 'spree/calculator'
2
+
3
+ module Spree
4
+ # Spreads a single fixed discount amount across the actionable line items in
5
+ # proportion to their value, so each item carries its own share and Spree
6
+ # recalculates per-item tax against it.
7
+ class Calculator::FixedAmountOnLineItems < Calculator
8
+ preference :amount, :decimal, default: 0
9
+ preference :currency, :string, default: -> { Spree::Store.default.default_currency }
10
+ preference :apply_only_on_full_priced_items, :boolean, default: false
11
+
12
+ def self.description
13
+ Spree.t(:fixed_amount_on_line_items)
14
+ end
15
+
16
+ # Returns this line item's non-negative share of the fixed amount.
17
+ # Spree::Promotion::Actions::CreateItemAdjustments negates the result and
18
+ # applies its own guard against a negative order total.
19
+ def compute(line_item = nil)
20
+ return 0 if line_item.nil?
21
+ return 0 unless preferred_currency.to_s.casecmp(line_item.currency.to_s).zero?
22
+
23
+ items = actionable_line_items(line_item.order)
24
+ return 0 unless items.include?(line_item)
25
+
26
+ total = items.sum(&:amount)
27
+ return 0 unless total.positive?
28
+
29
+ # Clamp: a fixed amount larger than the basket discounts it to zero, never
30
+ # below. Matches Spree::Calculator::PercentOnLineItem. Rounded to 2dp so a
31
+ # >2dp preferred_amount can't leave the last item's remainder off-currency.
32
+ budget = [preferred_amount, total].min.round(2)
33
+
34
+ share =
35
+ if line_item == items.last
36
+ # The last item absorbs the rounding remainder so the shares sum to
37
+ # (at most) the budget rather than drifting by a penny or two.
38
+ budget - items[0..-2].sum { |item| pro_rata(item, budget, total) }
39
+ else
40
+ pro_rata(line_item, budget, total)
41
+ end
42
+
43
+ # Guard against a negative remainder becoming a positive (surcharge)
44
+ # adjustment once Spree core negates it, and never exceed the item's own
45
+ # amount.
46
+ share.clamp(0, line_item.amount)
47
+ end
48
+
49
+ private
50
+
51
+ def pro_rata(item, budget, total)
52
+ # Floor, not round: rounding earlier shares up can push their sum past
53
+ # the budget, forcing the last item's remainder negative -- which core
54
+ # would then negate into a positive surcharge on that line item.
55
+ # Flooring guarantees the running sum never exceeds the budget, so the
56
+ # remainder is provably non-negative (at worst it's a small shortfall).
57
+ (budget * item.amount / total).floor(2)
58
+ end
59
+
60
+ def actionable_line_items(order)
61
+ return [] if order.nil?
62
+
63
+ # Sorted by id so "the last item" -- the one absorbing the remainder --
64
+ # is the same on every call. Core only computes against persisted line
65
+ # items; the to_i keeps an unsaved one from raising on comparison.
66
+ order.line_items.to_a.
67
+ select { |item| actionable?(order, item) }.
68
+ sort_by { |item| item.id.to_i }
69
+ end
70
+
71
+ # Excluded items must leave the actionable set entirely -- dropping them
72
+ # from the numerator alone would dilute the remaining items' shares and
73
+ # apply less than the promised amount.
74
+ def actionable?(order, item)
75
+ return false if preferred_apply_only_on_full_priced_items && on_sale?(item)
76
+ return true if promotion.nil?
77
+
78
+ promotion.line_item_actionable?(order, item)
79
+ end
80
+
81
+ def on_sale?(item)
82
+ item.variant&.compare_at_amount_in(item.currency).present?
83
+ end
84
+
85
+ # `calculable` is the Spree::Promotion::Actions::CreateItemAdjustments this
86
+ # calculator belongs to. A bare calculator (no action) has none.
87
+ def promotion
88
+ calculable.try(:promotion)
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,3 @@
1
+ en:
2
+ spree:
3
+ fixed_amount_on_line_items: "Fixed Amount (spread across line items)"
@@ -0,0 +1,23 @@
1
+ module Spree
2
+ module FixedAmtDiscount
3
+ class Engine < ::Rails::Engine
4
+ require 'spree/core'
5
+ isolate_namespace Spree
6
+ engine_name 'spree_fixed_amt_discount'
7
+
8
+ config.generators do |g|
9
+ g.test_framework :rspec
10
+ end
11
+
12
+ # Must be after_initialize, not a config/initializers file: spree_core
13
+ # ASSIGNS this array with `=` in its own after_initialize, which runs
14
+ # after engine initializers and would clobber an earlier registration.
15
+ # This engine is initialized after spree_core, so this block runs after
16
+ # core's. spec/spree/fixed_amt_discount/registration_spec.rb pins it.
17
+ config.after_initialize do
18
+ Rails.application.config.spree.calculators.promotion_actions_create_item_adjustments <<
19
+ Spree::Calculator::FixedAmountOnLineItems
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,5 @@
1
+ module Spree
2
+ module FixedAmtDiscount
3
+ VERSION = '0.1.0'.freeze
4
+ end
5
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spree/core'
4
+ require 'spree/fixed_amt_discount/version'
5
+ require 'spree/fixed_amt_discount/engine'
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Bundler auto-requires a gem by its *name*, so `gem "spree-fixed_amt_discount"`
4
+ # in a host Gemfile issues `require "spree-fixed_amt_discount"`. The real entry
5
+ # point is `spree/fixed_amt_discount` (matching the Spree::FixedAmtDiscount
6
+ # namespace), so this shim keeps the default `Bundler.require` working.
7
+ require 'spree/fixed_amt_discount'
metadata ADDED
@@ -0,0 +1,84 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: spree-fixed_amt_discount
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Aypex
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: spree
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 5.6.0
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: 5.6.0
26
+ - !ruby/object:Gem::Dependency
27
+ name: spree_dev_tools
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ description: Adds a promotion calculator that spreads a single fixed discount amount
41
+ across the actionable line items in proportion to their value, so each item's tax
42
+ is recalculated correctly instead of the discount landing as one untaxed order-level
43
+ adjustment.
44
+ email: hello@aypex.io
45
+ executables: []
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - CHANGELOG.md
50
+ - LICENSE
51
+ - README.md
52
+ - Rakefile
53
+ - app/models/spree/calculator/fixed_amount_on_line_items.rb
54
+ - config/locales/en.yml
55
+ - lib/spree-fixed_amt_discount.rb
56
+ - lib/spree/fixed_amt_discount.rb
57
+ - lib/spree/fixed_amt_discount/engine.rb
58
+ - lib/spree/fixed_amt_discount/version.rb
59
+ homepage: https://github.com/aypex-io/spree-fixed_amt_discount
60
+ licenses:
61
+ - MIT
62
+ metadata:
63
+ source_code_uri: https://github.com/aypex-io/spree-fixed_amt_discount
64
+ bug_tracker_uri: https://github.com/aypex-io/spree-fixed_amt_discount/issues
65
+ changelog_uri: https://github.com/aypex-io/spree-fixed_amt_discount/blob/main/CHANGELOG.md
66
+ rubygems_mfa_required: 'true'
67
+ rdoc_options: []
68
+ require_paths:
69
+ - lib
70
+ required_ruby_version: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '3.3'
75
+ required_rubygems_version: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ version: '0'
80
+ requirements: []
81
+ rubygems_version: 4.0.16
82
+ specification_version: 4
83
+ summary: Fixed amount Spree promotion spread pro-rata across line items
84
+ test_files: []