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 +7 -0
- data/CHANGELOG.md +10 -0
- data/LICENSE +21 -0
- data/README.md +72 -0
- data/Rakefile +26 -0
- data/app/models/spree/calculator/fixed_amount_on_line_items.rb +91 -0
- data/config/locales/en.yml +3 -0
- data/lib/spree/fixed_amt_discount/engine.rb +23 -0
- data/lib/spree/fixed_amt_discount/version.rb +5 -0
- data/lib/spree/fixed_amt_discount.rb +5 -0
- data/lib/spree-fixed_amt_discount.rb +7 -0
- metadata +84 -0
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,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,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: []
|