Skip to content

Commit

Permalink
Add support for volume pricing based on bands
Browse files Browse the repository at this point in the history
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)
  • Loading branch information
sztupy committed Nov 20, 2024
1 parent 7a89643 commit b30be82
Show file tree
Hide file tree
Showing 10 changed files with 178 additions and 57 deletions.
10 changes: 5 additions & 5 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ jobs:
lint-code:
executor:
name: solidusio_extensions/sqlite
ruby_version: "3.0"
ruby_version: "3.1"
steps:
- solidusio_extensions/lint-code

Expand All @@ -43,15 +43,15 @@ workflows:
- run-specs:
name: &name "run-specs-solidus-<< matrix.solidus >>-ruby-<< matrix.ruby >>-db-<< matrix.db >>"
matrix:
parameters: { solidus: ["main"], ruby: ["3.2"], db: ["postgres"] }
parameters: { solidus: ["main"], ruby: ["3.3"], db: ["postgres"] }
- run-specs:
name: *name
matrix:
parameters: { solidus: ["current"], ruby: ["3.1"], db: ["mysql"] }
parameters: { solidus: ["current"], ruby: ["3.2"], db: ["mysql"] }
- run-specs:
name: *name
matrix:
parameters: { solidus: ["older"], ruby: ["3.0"], db: ["sqlite"] }
parameters: { solidus: ["older"], ruby: ["3.1"], db: ["sqlite"] }
- lint-code

"Weekly run specs against main":
Expand All @@ -70,4 +70,4 @@ workflows:
- run-specs:
name: *name
matrix:
parameters: { solidus: ["current"], ruby: ["3.1"], db: ["mysql"] }
parameters: { solidus: ["current"], ruby: ["3.1"], db: ["mysql"] }
80 changes: 72 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,22 @@ Each VolumePrice contains the following values:
1. **Variant:** Each VolumePrice is associated with a _Variant_, which is used to link products to
particular prices.
2. **Name:** The human readable representation of the quantity range (Ex. 10-100). (Optional)
3. **Discount Type** The type of discount to apply. **Price:** sets price to the amount specified.
* **Dollar:** subtracts specified amount from the Variant price.
* **Percent:** subtracts the specified amounts percentage from the Variant price.
3. **Discount Type** The type of discount to apply.
* **Price:** sets price to the amount specified for all items
* **Dollar:** subtracts specified amount from the Variant price for all items
* **Percent:** subtracts the specified amounts percentage from the Variant price for all items
* **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
* **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
* **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
4. **Range:** The quantity range for which the price is valid (See Below for Examples of Valid
Ranges.)
5. **Amount:** The price of the product if the line item quantity falls within the specified range.
6. **Position:** Integer value for `acts_as_list` (Helps keep the volume prices in a defined order.)

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.

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

## Install

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

## Additional Notes
## Banded Examples

Consider the following examples of volume prices:

| Variant | Name | Type | Range | Amount | Position |
| ------- | ---- | ---- | ----- | ------ | -------- |
| Rails T-Shirt | 1-5 | Price | (1..5) | 19.99 | 1 |
| Rails T-Shirt | 6-9 | Price | (6...10) | 18.99 | 2 |
| Rails T-Shirt | 10-19 | Banded Percent | (10-19) | 50% | 3 |
| Rails T-Shirt | 20 or more | Banded Percent | (20+) | 75% | 4 |

### Example 1

Cart Contents:

| Product | Quantity | Price | Total |
| ------- | -------- | ----- | ----- |
| Rails T-Shirt | 1 | 19.99 | 19.99 |

### Example 2

Cart Contents:

| Product | Quantity | Price | Total |
| ------- | -------- | ----- | ----- |
| Rails T-Shirt | 5 | 19.99 | 99.95 |

### Example 3

Cart Contents:

| Product | Quantity | Price | Total |
| ------- | -------- | ----- | ----- |
| Rails T-Shirt | 6 | 18.99 | 113.94 |

### Example 4

Cart Contents:

| Product | Quantity | Price | Total |
| ------- | -------- | ----- | ----- |
| Rails T-Shirt | 10 | 18.09 | 180.90 |

Items #1-9 will be priced according to the `(5..9)` rule as it is unbanded. Their price will be $170.91

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)

### Example 5

Cart Contents:

| Product | Quantity | Price | Total |
| ------- | -------- | ----- | ----- |
| Rails T-Shirt | 20 | 13.79 | 275.80 |

Items #1-9 will be priced according to the `(5..9)` rule as it is unbanded. Their price will be $170.91

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)

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)

* The volume price is applied based on the total quantity ordered for a particular variant. It does
not apply different prices for the portion of the quantity that falls within a particular range.
Only the one price is used (although this would be an interesting configurable option if someone
wanted to write a patch.)
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.

## License

Expand Down
70 changes: 34 additions & 36 deletions app/models/solidus_volume_pricing/pricer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,17 @@ def self.pricing_options_class

def price_for(pricing_options)
extract_options(pricing_options)
::Spree::Money.new(computed_price)
::Spree::Money.new(computed_price(pricing_options.quantity))
end

def earning_amount(pricing_options)
extract_options(pricing_options)
::Spree::Money.new(computed_earning)
::Spree::Money.new(computed_earning(pricing_options.quantity))
end

def earning_percent(pricing_options)
extract_options(pricing_options)
computed_earning_percent.round
computed_earning_percent(pricing_options.quantity).round
end

private
Expand All @@ -46,50 +46,48 @@ def volume_prices
::Spree::VolumePrice.for_variant(variant, user: user)
end

def volume_price
def get_volume_price_for(quantity)
volume_prices.detect do |volume_price|
volume_price.include?(quantity)
end
end

def computed_price
case volume_price&.discount_type
when 'price'
volume_price.amount
when 'dollar'
variant.price - volume_price.amount
when 'percent'
variant.price * (1 - volume_price.amount)
def computed_price(quantity)
volume_price = get_volume_price_for(quantity)
band_price = case volume_price&.discount_type
when /price/
volume_price.amount
when /dollar/
variant.price - volume_price.amount
when /percent/
variant.price * (1 - volume_price.amount)
else
variant.price
end

if volume_price&.discount_type&.starts_with?('banded_')
range_start = volume_price.begin
amount = quantity - range_start + 1
band_total = amount * band_price
if range_start > 1
band_total += computed_price(range_start - 1) * (range_start - 1)
end
band_total / quantity
else
variant.price
band_price
end
end

def computed_earning
case volume_price&.discount_type
when 'price'
variant.price - volume_price.amount
when 'dollar'
volume_price.amount
when 'percent'
variant.price - (variant.price * (1 - volume_price.amount))
else
0
end
def computed_earning(quantity)
total_with_discount = computed_price(quantity) * quantity
total_without_discount = variant.price * quantity
(total_without_discount - total_with_discount) / quantity
end

def computed_earning_percent
case volume_price&.discount_type
when 'price'
diff = variant.price - volume_price.amount
diff * 100 / variant.price
when 'dollar'
volume_price.amount * 100 / variant.price
when 'percent'
volume_price.amount * 100
else
0
end
def computed_earning_percent(quantity)
total_with_discount = computed_price(quantity) * quantity
total_without_discount = variant.price * quantity
100 - (total_with_discount * 100 / total_without_discount)
end
end
end
3 changes: 2 additions & 1 deletion app/models/spree/volume_price.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ class VolumePrice < ApplicationRecord
validates :discount_type,
presence: true,
inclusion: {
in: %w(price dollar percent)
in: %w(price dollar percent banded_price banded_dollar banded_percent)
}

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

delegate :include?, to: :range_from_string
delegate :begin, to: :range_from_string

def display_range
range.gsub(/\.+/, "-").gsub(/\(|\)/, '')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@
<%= f.select :discount_type, [
[t('spree.total_price'), 'price'],
[t('spree.percent_discount'), 'percent'],
[t('spree.price_discount'), 'dollar']
[t('spree.price_discount'), 'dollar'],
[t('spree.banded_total_price'), 'banded_price'],
[t('spree.banded_percent_discount'), 'banded_percent'],
[t('spree.banded_price_discount'), 'banded_dollar']
], { include_blank: true }, class: 'custom-select' %>
</td>
<td>
Expand Down
9 changes: 6 additions & 3 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@ en:
new_volume_price_model: New Volume Price Model
volume_price_model_edit: Edit Volume Price Model
volume_pricing: Volume Pricing
price_discount: Price Discount
percent_discount: Percent Discount
bulk_discount: Bulk Discount
total_price: Total price
price_discount: Price Discount (All items)
percent_discount: Percent Discount (All items)
total_price: Total price (All items)
banded_price_discount: Price Discount (Banded)
banded_percent_discount: Percent Discount (Banded)
banded_total_price: Total price (Banded)
admin:
tab:
volume_price_models: Volume Price Models
Expand Down
2 changes: 1 addition & 1 deletion spec/features/manage_volume_price_models_feature_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
fill_in 'Name', with: 'Discount'
within '#volume_prices' do
fill_in 'volume_price_model_volume_prices_attributes_0_name', with: '5 pieces discount'
select 'Total price', from: 'volume_price_model_volume_prices_attributes_0_discount_type'
select 'Total price (All items)', from: 'volume_price_model_volume_prices_attributes_0_discount_type'
fill_in 'volume_price_model_volume_prices_attributes_0_range', with: '1..5'
fill_in 'volume_price_model_volume_prices_attributes_0_amount', with: '1'
end
Expand Down
2 changes: 1 addition & 1 deletion spec/features/manage_volume_prices_feature_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
expect(page).to have_content('Volume Prices')

fill_in 'variant_volume_prices_attributes_0_name', with: '5 pieces discount'
select 'Total price', from: 'variant_volume_prices_attributes_0_discount_type'
select 'Total price (All items)', from: 'variant_volume_prices_attributes_0_discount_type'
fill_in 'variant_volume_prices_attributes_0_range', with: '1..5'
fill_in 'variant_volume_prices_attributes_0_amount', with: '1'
click_on 'Update'
Expand Down
49 changes: 49 additions & 0 deletions spec/models/solidus_volume_pricing/pricer_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,55 @@
end
end

context 'discount_type is banded' do
before do
variant.volume_prices.create!(amount: 7, discount_type: 'price', range: '(10...20)')
variant.volume_prices.create!(amount: 4, discount_type: 'banded_dollar', range: '(20...30)')
variant.volume_prices.create!(amount: 0.5, discount_type: 'banded_percent', range: '(30...40)')
variant.volume_prices.create!(amount: 1, discount_type: 'banded_price', range: '(40+)')
end

context 'when quantity does not match the range' do
it_behaves_like 'having the variant price'
end

context 'when quantity matches the first (dollar) band' do
let(:quantity) { 25 }

it 'uses discount based calculation, but for the quantity inside the band only' do
# Pre-band: 19 * 7.00 +
# Band 1: 6 * (10.00 - 4.00)
# Divided by 25
expect(subject).to eq('$6.76')
end
end

context 'when quantity matches the second (percent) band' do
let(:quantity) { 35 }

it 'uses percent based calculation, but for the quantity inside the band only' do
# Pre-band: 19 * 7.00 +
# Band 1: 10 * (10.00 - 4.00) +
# Band 2: 6 * (10.00 * 0.5)
# Divided by 35
expect(subject).to eq('$6.37')
end
end

context 'when quantity matches the third (price) band' do
let(:quantity) { 45 }

it 'uses the set volume price, but for the quantity inside the band only' do
# Pre-band: 19 * 7.00 +
# Band 1: 10 * (10.00 - 4.00) +
# Band 2: 10 * (10.00 * 0.5) +
# Band 3: 6 * 1.00
# Divided by 45
expect(subject).to eq('$5.53')
end
end
end

context 'discount_type is unknown' do
before do
variant.volume_prices.create(amount: 7, discount_type: 'foo', range: '(10+)')
Expand Down
5 changes: 4 additions & 1 deletion spec/models/spree/volume_price_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@
it { is_expected.to validate_presence_of(:discount_type) }
it { is_expected.to validate_presence_of(:amount) }

it { is_expected.to validate_inclusion_of(:discount_type).in_array(%w[price dollar percent]) }
it {
expect(subject).to validate_inclusion_of(:discount_type).in_array(%w[price dollar percent banded_price banded_dollar
banded_percent])
}

describe '.for_variant' do
subject { described_class.for_variant(variant, user: user) }
Expand Down

0 comments on commit b30be82

Please sign in to comment.