From b30be825d109e58183c01c8497355f46d32d7bd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zsolt=20Sz=2E=20Sztup=C3=A1k?= Date: Wed, 20 Nov 2024 18:58:11 +0000 Subject: [PATCH] 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) --- .circleci/config.yml | 10 +-- README.md | 80 +++++++++++++++++-- app/models/solidus_volume_pricing/pricer.rb | 70 ++++++++-------- app/models/spree/volume_price.rb | 3 +- .../_volume_price_fields.html.erb | 5 +- config/locales/en.yml | 9 ++- ...manage_volume_price_models_feature_spec.rb | 2 +- .../manage_volume_prices_feature_spec.rb | 2 +- .../solidus_volume_pricing/pricer_spec.rb | 49 ++++++++++++ spec/models/spree/volume_price_spec.rb | 5 +- 10 files changed, 178 insertions(+), 57 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 9761924..0a6f3b3 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -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 @@ -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": @@ -70,4 +70,4 @@ workflows: - run-specs: name: *name matrix: - parameters: { solidus: ["current"], ruby: ["3.1"], db: ["mysql"] } \ No newline at end of file + parameters: { solidus: ["current"], ruby: ["3.1"], db: ["mysql"] } diff --git a/README.md b/README.md index 931b44e..cada17c 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/app/models/solidus_volume_pricing/pricer.rb b/app/models/solidus_volume_pricing/pricer.rb index 05d15a2..0bf7ad3 100644 --- a/app/models/solidus_volume_pricing/pricer.rb +++ b/app/models/solidus_volume_pricing/pricer.rb @@ -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 @@ -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 diff --git a/app/models/spree/volume_price.rb b/app/models/spree/volume_price.rb index 27f071f..4e73339 100644 --- a/app/models/spree/volume_price.rb +++ b/app/models/spree/volume_price.rb @@ -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 @@ -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(/\(|\)/, '') diff --git a/app/views/spree/admin/volume_prices/_volume_price_fields.html.erb b/app/views/spree/admin/volume_prices/_volume_price_fields.html.erb index be2c848..86fadf0 100644 --- a/app/views/spree/admin/volume_prices/_volume_price_fields.html.erb +++ b/app/views/spree/admin/volume_prices/_volume_price_fields.html.erb @@ -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' %> diff --git a/config/locales/en.yml b/config/locales/en.yml index 252c752..6fc2d0c 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -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 diff --git a/spec/features/manage_volume_price_models_feature_spec.rb b/spec/features/manage_volume_price_models_feature_spec.rb index 75c3cb6..2f699e2 100644 --- a/spec/features/manage_volume_price_models_feature_spec.rb +++ b/spec/features/manage_volume_price_models_feature_spec.rb @@ -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 diff --git a/spec/features/manage_volume_prices_feature_spec.rb b/spec/features/manage_volume_prices_feature_spec.rb index 1ff3e70..7a00cab 100644 --- a/spec/features/manage_volume_prices_feature_spec.rb +++ b/spec/features/manage_volume_prices_feature_spec.rb @@ -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' diff --git a/spec/models/solidus_volume_pricing/pricer_spec.rb b/spec/models/solidus_volume_pricing/pricer_spec.rb index 0d8cabb..40a1dc6 100644 --- a/spec/models/solidus_volume_pricing/pricer_spec.rb +++ b/spec/models/solidus_volume_pricing/pricer_spec.rb @@ -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+)') diff --git a/spec/models/spree/volume_price_spec.rb b/spec/models/spree/volume_price_spec.rb index 344c338..162f0a9 100644 --- a/spec/models/spree/volume_price_spec.rb +++ b/spec/models/spree/volume_price_spec.rb @@ -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) }