Skip to content

Commit b30be82

Browse files
committed
Add support for volume pricing based on bands
This allows discounts to be set on the actual quantities which end up in the specific range, with the rest calculated on the full price (or the respective discount set up for that band)
1 parent 7a89643 commit b30be82

10 files changed

Lines changed: 178 additions & 57 deletions

File tree

.circleci/config.yml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ jobs:
3333
lint-code:
3434
executor:
3535
name: solidusio_extensions/sqlite
36-
ruby_version: "3.0"
36+
ruby_version: "3.1"
3737
steps:
3838
- solidusio_extensions/lint-code
3939

@@ -43,15 +43,15 @@ workflows:
4343
- run-specs:
4444
name: &name "run-specs-solidus-<< matrix.solidus >>-ruby-<< matrix.ruby >>-db-<< matrix.db >>"
4545
matrix:
46-
parameters: { solidus: ["main"], ruby: ["3.2"], db: ["postgres"] }
46+
parameters: { solidus: ["main"], ruby: ["3.3"], db: ["postgres"] }
4747
- run-specs:
4848
name: *name
4949
matrix:
50-
parameters: { solidus: ["current"], ruby: ["3.1"], db: ["mysql"] }
50+
parameters: { solidus: ["current"], ruby: ["3.2"], db: ["mysql"] }
5151
- run-specs:
5252
name: *name
5353
matrix:
54-
parameters: { solidus: ["older"], ruby: ["3.0"], db: ["sqlite"] }
54+
parameters: { solidus: ["older"], ruby: ["3.1"], db: ["sqlite"] }
5555
- lint-code
5656

5757
"Weekly run specs against main":
@@ -70,4 +70,4 @@ workflows:
7070
- run-specs:
7171
name: *name
7272
matrix:
73-
parameters: { solidus: ["current"], ruby: ["3.1"], db: ["mysql"] }
73+
parameters: { solidus: ["current"], ruby: ["3.1"], db: ["mysql"] }

README.md

Lines changed: 72 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,22 @@ Each VolumePrice contains the following values:
1515
1. **Variant:** Each VolumePrice is associated with a _Variant_, which is used to link products to
1616
particular prices.
1717
2. **Name:** The human readable representation of the quantity range (Ex. 10-100). (Optional)
18-
3. **Discount Type** The type of discount to apply. **Price:** sets price to the amount specified.
19-
* **Dollar:** subtracts specified amount from the Variant price.
20-
* **Percent:** subtracts the specified amounts percentage from the Variant price.
18+
3. **Discount Type** The type of discount to apply.
19+
* **Price:** sets price to the amount specified for all items
20+
* **Dollar:** subtracts specified amount from the Variant price for all items
21+
* **Percent:** subtracts the specified amounts percentage from the Variant price for all items
22+
* **Banded Price:** sets price to the amount specified, but only for items within the range. For items outside the range it will consult that band to determine price
23+
* **Banded Dollar:** subtracts specified amount from the Variant price, but only for items within the range. For items outside the range it will consult that band to determine price
24+
* **Banded Percent:** subtracts the specified amounts percentage from the Variant price, but only for items within the range. For items outside the range it will consult that band to determine price
2125
4. **Range:** The quantity range for which the price is valid (See Below for Examples of Valid
2226
Ranges.)
2327
5. **Amount:** The price of the product if the line item quantity falls within the specified range.
2428
6. **Position:** Integer value for `acts_as_list` (Helps keep the volume prices in a defined order.)
2529

30+
Note: when using percentage based or banded discounts then first the system will calculate a per-item price, and then get the total by multiplying the per-item price with the quantity. Due to rounding this can differ if the price would have been calculated on the total.
31+
32+
Example: Percent discount is 10%, Original price is 9.99, Ordering 100 pieces. Per-item price is rounded down to $8.99 (from $8.991), so total will be $899.00 instead of $899.10
33+
2634
## Install
2735

2836
The extension contains a rails generator that will add the necessary migrations and give you the
@@ -99,12 +107,68 @@ Cart Contents:
99107
| ------- | -------- | ----- | ----- |
100108
| Rails T-Shirt | 20 | 17.99 | 359.80 |
101109

102-
## Additional Notes
110+
## Banded Examples
111+
112+
Consider the following examples of volume prices:
113+
114+
| Variant | Name | Type | Range | Amount | Position |
115+
| ------- | ---- | ---- | ----- | ------ | -------- |
116+
| Rails T-Shirt | 1-5 | Price | (1..5) | 19.99 | 1 |
117+
| Rails T-Shirt | 6-9 | Price | (6...10) | 18.99 | 2 |
118+
| Rails T-Shirt | 10-19 | Banded Percent | (10-19) | 50% | 3 |
119+
| Rails T-Shirt | 20 or more | Banded Percent | (20+) | 75% | 4 |
120+
121+
### Example 1
122+
123+
Cart Contents:
124+
125+
| Product | Quantity | Price | Total |
126+
| ------- | -------- | ----- | ----- |
127+
| Rails T-Shirt | 1 | 19.99 | 19.99 |
128+
129+
### Example 2
130+
131+
Cart Contents:
132+
133+
| Product | Quantity | Price | Total |
134+
| ------- | -------- | ----- | ----- |
135+
| Rails T-Shirt | 5 | 19.99 | 99.95 |
136+
137+
### Example 3
138+
139+
Cart Contents:
140+
141+
| Product | Quantity | Price | Total |
142+
| ------- | -------- | ----- | ----- |
143+
| Rails T-Shirt | 6 | 18.99 | 113.94 |
144+
145+
### Example 4
146+
147+
Cart Contents:
148+
149+
| Product | Quantity | Price | Total |
150+
| ------- | -------- | ----- | ----- |
151+
| Rails T-Shirt | 10 | 18.09 | 180.90 |
152+
153+
Items #1-9 will be priced according to the `(5..9)` rule as it is unbanded. Their price will be $170.91
154+
155+
Item #10 will be priced according to the `(10+)` rule, which describes a 50% reduction to the base price, which is $9.99 (rounded down)
156+
157+
### Example 5
158+
159+
Cart Contents:
160+
161+
| Product | Quantity | Price | Total |
162+
| ------- | -------- | ----- | ----- |
163+
| Rails T-Shirt | 20 | 13.79 | 275.80 |
164+
165+
Items #1-9 will be priced according to the `(5..9)` rule as it is unbanded. Their price will be $170.91
166+
167+
Items #10-19 will be priced according to the `(10-19)` rule, which describes a 50% reduction to the base price. This equates to $9.99 per item (rounded down)
168+
169+
Item #20 will be priced according to the `(20+)` rule, which describes a 75% reduction to the base price. This would be $4.99 (rounded down)
103170

104-
* The volume price is applied based on the total quantity ordered for a particular variant. It does
105-
not apply different prices for the portion of the quantity that falls within a particular range.
106-
Only the one price is used (although this would be an interesting configurable option if someone
107-
wanted to write a patch.)
171+
A per-item price of $13.79 is calculated (rounded down from $13.792875), then multiplied by the quantity getting $275.80. Note: this is $0.05 lower than what you would get if you total up the items separately, see notes above.
108172

109173
## License
110174

app/models/solidus_volume_pricing/pricer.rb

Lines changed: 34 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,17 @@ def self.pricing_options_class
1010

1111
def price_for(pricing_options)
1212
extract_options(pricing_options)
13-
::Spree::Money.new(computed_price)
13+
::Spree::Money.new(computed_price(pricing_options.quantity))
1414
end
1515

1616
def earning_amount(pricing_options)
1717
extract_options(pricing_options)
18-
::Spree::Money.new(computed_earning)
18+
::Spree::Money.new(computed_earning(pricing_options.quantity))
1919
end
2020

2121
def earning_percent(pricing_options)
2222
extract_options(pricing_options)
23-
computed_earning_percent.round
23+
computed_earning_percent(pricing_options.quantity).round
2424
end
2525

2626
private
@@ -46,50 +46,48 @@ def volume_prices
4646
::Spree::VolumePrice.for_variant(variant, user: user)
4747
end
4848

49-
def volume_price
49+
def get_volume_price_for(quantity)
5050
volume_prices.detect do |volume_price|
5151
volume_price.include?(quantity)
5252
end
5353
end
5454

55-
def computed_price
56-
case volume_price&.discount_type
57-
when 'price'
58-
volume_price.amount
59-
when 'dollar'
60-
variant.price - volume_price.amount
61-
when 'percent'
62-
variant.price * (1 - volume_price.amount)
55+
def computed_price(quantity)
56+
volume_price = get_volume_price_for(quantity)
57+
band_price = case volume_price&.discount_type
58+
when /price/
59+
volume_price.amount
60+
when /dollar/
61+
variant.price - volume_price.amount
62+
when /percent/
63+
variant.price * (1 - volume_price.amount)
64+
else
65+
variant.price
66+
end
67+
68+
if volume_price&.discount_type&.starts_with?('banded_')
69+
range_start = volume_price.begin
70+
amount = quantity - range_start + 1
71+
band_total = amount * band_price
72+
if range_start > 1
73+
band_total += computed_price(range_start - 1) * (range_start - 1)
74+
end
75+
band_total / quantity
6376
else
64-
variant.price
77+
band_price
6578
end
6679
end
6780

68-
def computed_earning
69-
case volume_price&.discount_type
70-
when 'price'
71-
variant.price - volume_price.amount
72-
when 'dollar'
73-
volume_price.amount
74-
when 'percent'
75-
variant.price - (variant.price * (1 - volume_price.amount))
76-
else
77-
0
78-
end
81+
def computed_earning(quantity)
82+
total_with_discount = computed_price(quantity) * quantity
83+
total_without_discount = variant.price * quantity
84+
(total_without_discount - total_with_discount) / quantity
7985
end
8086

81-
def computed_earning_percent
82-
case volume_price&.discount_type
83-
when 'price'
84-
diff = variant.price - volume_price.amount
85-
diff * 100 / variant.price
86-
when 'dollar'
87-
volume_price.amount * 100 / variant.price
88-
when 'percent'
89-
volume_price.amount * 100
90-
else
91-
0
92-
end
87+
def computed_earning_percent(quantity)
88+
total_with_discount = computed_price(quantity) * quantity
89+
total_without_discount = variant.price * quantity
90+
100 - (total_with_discount * 100 / total_without_discount)
9391
end
9492
end
9593
end

app/models/spree/volume_price.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ class VolumePrice < ApplicationRecord
1111
validates :discount_type,
1212
presence: true,
1313
inclusion: {
14-
in: %w(price dollar percent)
14+
in: %w(price dollar percent banded_price banded_dollar banded_percent)
1515
}
1616

1717
validate :range_format
@@ -31,6 +31,7 @@ def self.for_variant(variant, user: nil)
3131
end
3232

3333
delegate :include?, to: :range_from_string
34+
delegate :begin, to: :range_from_string
3435

3536
def display_range
3637
range.gsub(/\.+/, "-").gsub(/\(|\)/, '')

app/views/spree/admin/volume_prices/_volume_price_fields.html.erb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@
88
<%= f.select :discount_type, [
99
[t('spree.total_price'), 'price'],
1010
[t('spree.percent_discount'), 'percent'],
11-
[t('spree.price_discount'), 'dollar']
11+
[t('spree.price_discount'), 'dollar'],
12+
[t('spree.banded_total_price'), 'banded_price'],
13+
[t('spree.banded_percent_discount'), 'banded_percent'],
14+
[t('spree.banded_price_discount'), 'banded_dollar']
1215
], { include_blank: true }, class: 'custom-select' %>
1316
</td>
1417
<td>

config/locales/en.yml

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,13 @@ en:
1111
new_volume_price_model: New Volume Price Model
1212
volume_price_model_edit: Edit Volume Price Model
1313
volume_pricing: Volume Pricing
14-
price_discount: Price Discount
15-
percent_discount: Percent Discount
1614
bulk_discount: Bulk Discount
17-
total_price: Total price
15+
price_discount: Price Discount (All items)
16+
percent_discount: Percent Discount (All items)
17+
total_price: Total price (All items)
18+
banded_price_discount: Price Discount (Banded)
19+
banded_percent_discount: Percent Discount (Banded)
20+
banded_total_price: Total price (Banded)
1821
admin:
1922
tab:
2023
volume_price_models: Volume Price Models

spec/features/manage_volume_price_models_feature_spec.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
fill_in 'Name', with: 'Discount'
1414
within '#volume_prices' do
1515
fill_in 'volume_price_model_volume_prices_attributes_0_name', with: '5 pieces discount'
16-
select 'Total price', from: 'volume_price_model_volume_prices_attributes_0_discount_type'
16+
select 'Total price (All items)', from: 'volume_price_model_volume_prices_attributes_0_discount_type'
1717
fill_in 'volume_price_model_volume_prices_attributes_0_range', with: '1..5'
1818
fill_in 'volume_price_model_volume_prices_attributes_0_amount', with: '1'
1919
end

spec/features/manage_volume_prices_feature_spec.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
expect(page).to have_content('Volume Prices')
1414

1515
fill_in 'variant_volume_prices_attributes_0_name', with: '5 pieces discount'
16-
select 'Total price', from: 'variant_volume_prices_attributes_0_discount_type'
16+
select 'Total price (All items)', from: 'variant_volume_prices_attributes_0_discount_type'
1717
fill_in 'variant_volume_prices_attributes_0_range', with: '1..5'
1818
fill_in 'variant_volume_prices_attributes_0_amount', with: '1'
1919
click_on 'Update'

spec/models/solidus_volume_pricing/pricer_spec.rb

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,55 @@
222222
end
223223
end
224224

225+
context 'discount_type is banded' do
226+
before do
227+
variant.volume_prices.create!(amount: 7, discount_type: 'price', range: '(10...20)')
228+
variant.volume_prices.create!(amount: 4, discount_type: 'banded_dollar', range: '(20...30)')
229+
variant.volume_prices.create!(amount: 0.5, discount_type: 'banded_percent', range: '(30...40)')
230+
variant.volume_prices.create!(amount: 1, discount_type: 'banded_price', range: '(40+)')
231+
end
232+
233+
context 'when quantity does not match the range' do
234+
it_behaves_like 'having the variant price'
235+
end
236+
237+
context 'when quantity matches the first (dollar) band' do
238+
let(:quantity) { 25 }
239+
240+
it 'uses discount based calculation, but for the quantity inside the band only' do
241+
# Pre-band: 19 * 7.00 +
242+
# Band 1: 6 * (10.00 - 4.00)
243+
# Divided by 25
244+
expect(subject).to eq('$6.76')
245+
end
246+
end
247+
248+
context 'when quantity matches the second (percent) band' do
249+
let(:quantity) { 35 }
250+
251+
it 'uses percent based calculation, but for the quantity inside the band only' do
252+
# Pre-band: 19 * 7.00 +
253+
# Band 1: 10 * (10.00 - 4.00) +
254+
# Band 2: 6 * (10.00 * 0.5)
255+
# Divided by 35
256+
expect(subject).to eq('$6.37')
257+
end
258+
end
259+
260+
context 'when quantity matches the third (price) band' do
261+
let(:quantity) { 45 }
262+
263+
it 'uses the set volume price, but for the quantity inside the band only' do
264+
# Pre-band: 19 * 7.00 +
265+
# Band 1: 10 * (10.00 - 4.00) +
266+
# Band 2: 10 * (10.00 * 0.5) +
267+
# Band 3: 6 * 1.00
268+
# Divided by 45
269+
expect(subject).to eq('$5.53')
270+
end
271+
end
272+
end
273+
225274
context 'discount_type is unknown' do
226275
before do
227276
variant.volume_prices.create(amount: 7, discount_type: 'foo', range: '(10+)')

spec/models/spree/volume_price_spec.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@
1111
it { is_expected.to validate_presence_of(:discount_type) }
1212
it { is_expected.to validate_presence_of(:amount) }
1313

14-
it { is_expected.to validate_inclusion_of(:discount_type).in_array(%w[price dollar percent]) }
14+
it {
15+
expect(subject).to validate_inclusion_of(:discount_type).in_array(%w[price dollar percent banded_price banded_dollar
16+
banded_percent])
17+
}
1518

1619
describe '.for_variant' do
1720
subject { described_class.for_variant(variant, user: user) }

0 commit comments

Comments
 (0)