Skip to content

Commit

Permalink
Basket price extra dynamic pricing, include complements price
Browse files Browse the repository at this point in the history
This patch allows to define a dynamic price for a basket, based also on
the complements price. This is useful for example to when the basket
price is a percentage increase/discount on the basket and complements
price.

The patch also fixes a bug on the live calculation of the basket price
when the extra price is negative (discount).

As the basket price extra can now include the complements, the extra is
place after the complements in the member forms, invoices pdf, and
membership pricing description.

Finally, this patch enforces the presence of a basket price extra when
creating a member, to ensure that the extra is explicitly set by the
new member.
  • Loading branch information
thibaudgg committed Nov 10, 2023
1 parent dd94bda commit d93cef3
Show file tree
Hide file tree
Showing 14 changed files with 93 additions and 37 deletions.
12 changes: 6 additions & 6 deletions app/admin/membership.rb
Original file line number Diff line number Diff line change
Expand Up @@ -485,12 +485,6 @@
row(:basket_sizes_price) {
display_price_description(m.basket_sizes_price, basket_sizes_price_info(m, m.baskets))
}
if Current.acp.feature?('basket_price_extra')
row(:basket_price_extra_title) {
description = baskets_price_extra_info(m, m.baskets, highlight_current: true)
display_price_description(m.baskets_price_extra, description).html_safe
}
end
row(:baskets_annual_price_change) {
cur(m.baskets_annual_price_change)
}
Expand All @@ -504,6 +498,12 @@
cur(m.basket_complements_annual_price_change)
}
end
if Current.acp.feature?('basket_price_extra')
row(:basket_price_extra_title) {
description = baskets_price_extra_info(m, m.baskets, highlight_current: true)
display_price_description(m.baskets_price_extra, description).html_safe
}
end
row(:depots_price) {
display_price_description(m.depots_price, depots_price_info(m.baskets))
}
Expand Down
3 changes: 2 additions & 1 deletion app/models/acp.rb
Original file line number Diff line number Diff line change
Expand Up @@ -324,14 +324,15 @@ def mailchimp?
credentials(:mailchimp).present?
end

def calculate_basket_price_extra(extra, basket_price, basket_size_id, deliveries_count)
def calculate_basket_price_extra(extra, basket_price, basket_size_id, complements_price, deliveries_count)
return extra unless basket_price_extra_dynamic_pricing?

template = Liquid::Template.parse(basket_price_extra_dynamic_pricing)
template.render(
'extra' => extra.to_f,
'basket_price' => basket_price.to_f,
'basket_size_id' => basket_size_id,
'complements_price' => complements_price.to_f,
'deliveries_count' => deliveries_count.to_f
).to_f
end
Expand Down
6 changes: 3 additions & 3 deletions app/models/basket.rb
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,7 @@ def complements_description(public_name: false)
end

def complements_price
baskets_basket_complements
.sum('baskets_basket_complements.quantity * baskets_basket_complements.price')
baskets_basket_complements.sum { |bbc| bbc.quantity * bbc.price }
end

def empty?
Expand Down Expand Up @@ -157,12 +156,13 @@ def set_calculated_price_extra

def calculate_price_extra
return 0 unless Current.acp.feature?('basket_price_extra')
return 0 if basket_price.zero?
return 0 if basket_price.zero? && complements_price.zero?

Current.acp.calculate_basket_price_extra(
price_extra,
basket_price,
basket_size_id,
complements_price,
Current.acp.deliveries_count(membership.fy_year))
end
end
2 changes: 1 addition & 1 deletion app/models/member.rb
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ class Member < ApplicationRecord
validates :address, :city, :zip, :country_code, presence: true, unless: :inactive?
validates :waiting_basket_size, inclusion: { in: proc { BasketSize.all }, allow_nil: true }, on: :create
validates :waiting_basket_size_id, presence: true, if: :waiting_depot, on: :create
validates :waiting_basket_price_extra, numericality: { greater_than_or_equal_to: 0, allow_nil: true }
validates :waiting_basket_price_extra, presence: true, if: -> { Current.acp.feature?('basket_price_extra') && waiting_depot }
validates :waiting_depot, inclusion: { in: proc { Depot.all }, allow_nil: true }, on: :create
validates :waiting_depot_id, presence: true, if: :waiting_basket_size, on: :create
validates :waiting_deliveries_cycle, inclusion: { in: ->(m) { m.waiting_depot&.deliveries_cycles } }, if: :waiting_depot, on: :create
Expand Down
14 changes: 10 additions & 4 deletions app/models/membership_pricing.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,22 +46,28 @@ def baskets_prices

def baskets_price_extras
extra = @params[:waiting_basket_price_extra].to_f
return [0, 0] unless extra.positive?
return [0, 0] if extra.zero?

comp_prices = [0, 0]
complements_prices.each { |p|
comp_prices = comp_prices.zip(p.map(&:round_to_five_cents)).map(&:sum)
}

[
deliveries_counts.min * calculate_price_extra(extra, basket_size, deliveries_counts.min),
deliveries_counts.max * calculate_price_extra(extra, basket_size, deliveries_counts.max)
deliveries_counts.min * calculate_price_extra(extra, basket_size, comp_prices.min / deliveries_counts.min, deliveries_counts.min),
deliveries_counts.max * calculate_price_extra(extra, basket_size, comp_prices.max / deliveries_counts.max, deliveries_counts.max)
]
end

def calculate_price_extra(extra, basket_size, deliveries_count)
def calculate_price_extra(extra, basket_size, complements_price, deliveries_count)
return 0 unless Current.acp.feature?('basket_price_extra')
return 0 unless basket_size

Current.acp.calculate_basket_price_extra(
extra,
basket_size.price,
basket_size.id,
complements_price,
deliveries_count)
end

Expand Down
12 changes: 6 additions & 6 deletions app/models/pdf/invoice.rb
Original file line number Diff line number Diff line change
Expand Up @@ -115,12 +115,6 @@ def content(items, last_page:)
]
end
end
if Current.acp.feature?('basket_price_extra') && !entity.baskets_price_extra.zero?
data << [
membership_baskets_price_extra_description,
_cur(entity.baskets_price_extra)
]
end
unless entity.baskets_annual_price_change.zero?
data << [
t('baskets_annual_price_change'),
Expand All @@ -144,6 +138,12 @@ def content(items, last_page:)
_cur(entity.basket_complements_annual_price_change)
]
end
if Current.acp.feature?('basket_price_extra') && !entity.baskets_price_extra.zero?
data << [
membership_baskets_price_extra_description,
_cur(entity.baskets_price_extra)
]
end
entity.depots.uniq.each do |depot|
price = entity.depot_total_price(depot)
if price.positive?
Expand Down
14 changes: 8 additions & 6 deletions app/views/members/members/form_modes/_membership.html.slim
Original file line number Diff line number Diff line change
Expand Up @@ -22,20 +22,22 @@ div data-controller="form-disabler form-min-value-enforcer"
- if Current.acp.share?
= f.input :desired_acp_shares_number, as: :numeric, required: true, input_html: { min: 1, max: 20, value: [@member.desired_acp_shares_number, 1].max, data: { form_min_value_enforcer_target: 'input', action: 'form-pricing#refresh' }, class: 'mt-1 dark:bg-black w-20 px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-400 dark:placeholder-gray-600 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-green-500 focus:border-green-500 focus:z-10' }, hint: t('.desired_acp_shares_number_hint'), label_html: { class: 'inline-block w-full text-sm font-medium text-gray-700 dark:text-gray-300' }, hint_html: { class: 'inline-block w-full text-sm'}

- if @member.members_basket_complements.any?
= f.input BasketComplement.model_name.human(count: 2), label_html: { class: 'inline-block w-full text-sm font-medium text-gray-700 dark:text-gray-300' }, wrapper_html: { class: ('disabled' if support_checked), data: { form_disabler_target: 'label' } } do
= f.simple_fields_for :members_basket_complements, data: { form_disabler_target: 'label' } do |ff|
= ff.input :basket_complement_id, as: :hidden
= ff.input :quantity, as: :numeric, label: basket_complement_label(ff.object.basket_complement), required: false, disabled: support_checked, wrapper_html: { class: ('disabled' if support_checked), data: { form_disabler_target: 'label' } }, input_html: { min: 0, data: { form_disabler_target: 'input', default_value: '0', action: 'form-pricing#refresh' }, class: 'order-1 dark:bg-black w-16 px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-400 dark:placeholder-gray-600 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-green-500 focus:border-green-500 focus:z-10' }, label_html: { class: 'order-2 ml-4' }, wrapper_class: 'mt-2 flex items-center'

- if show_basket_price_extras?
div data-form-disabler-target='label'
label class="text-sm font-medium text-gray-700 dark:text-gray-300"
= Current.acp.basket_price_extra_public_title
= " "
abbr title=t('simple_form.required.text') = t('simple_form.required.mark')
div class="my-1 text-sm text-justify"
= Current.acp.basket_price_extra_text&.html_safe
= f.input :waiting_basket_price_extra, as: :radio_buttons, collection: basket_prices_extra_collection(data: { form_disabler_target: 'input', action: 'form-pricing#refresh' }), label: false, wrapper_html: { class: ('disabled' if support_checked), data: { form_disabler_target: 'label' } }, input_html: { disabled: support_checked, class: 'mr-4 flex-none h-6 w-6 border-2 border-gray-300 dark:border-gray-700 text-green-500 dark:bg-black focus:outline-none focus:ring-green-500 focus:border-green-500 cursor-pointer' }, item_label_class: 'border-gray-200 dark:border-gray-800 border rounded px-4 py-2 flex flex-row flex-nowrap items-center cursor-pointer', wrapper_class: 'flex flex-col space-y-2'

- if @member.members_basket_complements.any?
= f.input BasketComplement.model_name.human(count: 2), label_html: { class: 'inline-block w-full text-sm font-medium text-gray-700 dark:text-gray-300' }, wrapper_html: { class: ('disabled' if support_checked), data: { form_disabler_target: 'label' } } do
= f.simple_fields_for :members_basket_complements, data: { form_disabler_target: 'label' } do |ff|
= ff.input :basket_complement_id, as: :hidden
= ff.input :quantity, as: :numeric, label: basket_complement_label(ff.object.basket_complement), required: false, disabled: support_checked, wrapper_html: { class: ('disabled' if support_checked), data: { form_disabler_target: 'label' } }, input_html: { min: 0, data: { form_disabler_target: 'input', default_value: '0', action: 'form-pricing#refresh' }, class: 'order-1 dark:bg-black w-16 px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-400 dark:placeholder-gray-600 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-green-500 focus:border-green-500 focus:z-10' }, label_html: { class: 'order-2 ml-4' }, wrapper_class: 'mt-2 flex items-center'

div data-controller="form-choice-excluder form-choices-limiter"
div class='space-y-6'
= f.input :waiting_depot_id, as: :radio_buttons, collection: depots_collection(data: { form_disabler_target: 'input', action: 'form-choice-excluder#excludeChoice form-choices-limiter#limitChoices form-pricing#refresh' }), label: Depot.model_name.human, required: true,
Expand Down
14 changes: 7 additions & 7 deletions app/views/members/membership_renewals/new.html.slim
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,19 @@ div class="mt-4"
- when 'renew'
= f.input :basket_size_id, as: :radio_buttons, collection: basket_sizes_collection(no_basket_option: false), required: true, label: BasketSize.model_name.human, label_html: { class: 'text-sm font-medium text-gray-700 dark:text-gray-300' }, input_html: { class: 'mr-4 flex-none h-6 w-6 border-2 border-gray-300 dark:border-gray-700 text-green-500 dark:bg-black focus:outline-none focus:ring-green-500 focus:border-green-500 cursor-pointer' }, item_label_class: 'border-gray-200 dark:border-gray-800 border rounded px-4 py-2 flex flex-row flex-nowrap items-center cursor-pointer', wrapper_class: 'flex flex-col space-y-2'

- if @membership.memberships_basket_complements.any?
= f.input BasketComplement.model_name.human(count: 2), label_html: { class: 'inline-block w-full text-sm font-medium text-gray-700 dark:text-gray-300' } do
= f.simple_fields_for :memberships_basket_complements, data: { form_disabler_target: 'label' } do |ff|
= ff.input :basket_complement_id, as: :hidden
= ff.input :quantity, as: :numeric, label: basket_complement_label(ff.object.basket_complement), required: false, input_html: { min: 0, class: 'order-1 dark:bg-black w-16 px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-400 dark:placeholder-gray-600 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-green-500 focus:border-green-500 focus:z-10' }, label_html: { class: 'order-2 ml-4' }, wrapper_class: 'mt-2 flex items-center'

- if show_basket_price_extras?
div data-form-disabler-target='label'
label class="text-sm font-medium text-gray-700 dark:text-gray-300"
= Current.acp.basket_price_extra_public_title
div class="my-1 text-sm text-justify trix"
= Current.acp.basket_price_extra_text&.html_safe
= f.input :basket_price_extra, as: :radio_buttons, collection: basket_prices_extra_collection, label: false, input_html: {class: 'mr-4 flex-none h-6 w-6 border-2 border-gray-300 dark:border-gray-700 text-green-500 dark:bg-black focus:outline-none focus:ring-green-500 focus:border-green-500 cursor-pointer' }, item_label_class: 'border-gray-200 dark:border-gray-800 border rounded px-4 py-2 flex flex-row flex-nowrap items-center cursor-pointer', wrapper_class: 'flex flex-col space-y-2'

- if @membership.memberships_basket_complements.any?
= f.input BasketComplement.model_name.human(count: 2), label_html: { class: 'inline-block w-full text-sm font-medium text-gray-700 dark:text-gray-300' } do
= f.simple_fields_for :memberships_basket_complements, data: { form_disabler_target: 'label' } do |ff|
= ff.input :basket_complement_id, as: :hidden
= ff.input :quantity, as: :numeric, label: basket_complement_label(ff.object.basket_complement), required: false, input_html: { min: 0, class: 'order-1 dark:bg-black w-16 px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-400 dark:placeholder-gray-600 text-gray-900 dark:text-gray-100 rounded-md focus:outline-none focus:ring-green-500 focus:border-green-500 focus:z-10' }, label_html: { class: 'order-2 ml-4' }, wrapper_class: 'mt-2 flex items-center'
= f.input :basket_price_extra, as: :radio_buttons, collection: basket_prices_extra_collection, label: false, required: true, input_html: { class: 'mr-4 flex-none h-6 w-6 border-2 border-gray-300 dark:border-gray-700 text-green-500 dark:bg-black focus:outline-none focus:ring-green-500 focus:border-green-500 cursor-pointer' }, item_label_class: 'border-gray-200 dark:border-gray-800 border rounded px-4 py-2 flex flex-row flex-nowrap items-center cursor-pointer', wrapper_class: 'flex flex-col space-y-2'

- if Current.acp.membership_renewal_depot_update?
div data-controller="form-choices-limiter"
Expand Down
6 changes: 3 additions & 3 deletions config/locales/formtastic.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,9 @@ _:
_fr: Permet de définir l'ordre d'affichage des compléments de paniers dans le formulaire d'inscription, après avoir pris en compte leur priorité individuelle.
_it: Permette di definire l'ordine di visualizzazione dei aggiunte di cestini nel modulo di iscrizione, dopo aver preso in considerazione la loro priorità individuale.
basket_price_extra_dynamic_pricing_html:
_de: Erlaubt es, den Preis dynamisch zu ändern, basierend auf verschiedenen Kriterien wie der Größe des Korb.<br/> Unterstützt <a href='https://shopify.github.io/liquid/' target='_blank'>die Liquid-Syntax</a> mit den Objekten "{{ extra }}", "{{ basket_size_id }}", "{{ basket_price }}" und "{{ deliveries_count }}".
_fr: Permet de changer dynamiquement le prix appliqué en fonction de différents critères comme la taille du panier.<br/> Supporte <a href='https://shopify.github.io/liquid/' target='_blank'>la syntaxe Liquid</a> avec les objects "{{ extra }}", "{{ basket_size_id }}", "{{ basket_price }}" et "{{ deliveries_count }}".
_it: Permette di cambiare dinamicamente il prezzo applicato in base a diversi criteri come la dimensione del cesto.<br/> Supporta <a href='https://shopify.github.io/liquid/' target='_blank'>la sintassi Liquid</a> con gli oggetti "{{ extra }}", "{{ basket_size_id }}", "{{ basket_price }}" e "{{ deliveries_count }}".
_de: Erlaubt es, den Preis dynamisch zu ändern, basierend auf verschiedenen Kriterien wie der Größe des Korb.<br/> Unterstützt <a href='https://shopify.github.io/liquid/' target='_blank'>die Liquid-Syntax</a> mit den Objekten "{{ extra }}", "{{ basket_size_id }}", "{{ basket_price }}", "{{ complements_price }} und "{{ deliveries_count }}".
_fr: Permet de changer dynamiquement le prix appliqué en fonction de différents critères comme la taille du panier.<br/> Supporte <a href='https://shopify.github.io/liquid/' target='_blank'>la syntaxe Liquid</a> avec les objects "{{ extra }}", "{{ basket_size_id }}", "{{ basket_price }}", "{{ complements_price }} et "{{ deliveries_count }}".
_it: Permette di cambiare dinamicamente il prezzo applicato in base a diversi criteri come la dimensione del cesto.<br/> Supporta <a href='https://shopify.github.io/liquid/' target='_blank'>la sintassi Liquid</a> con gli oggetti "{{ extra }}", "{{ basket_size_id }}", "{{ basket_price }}", "{{ complements_price }} e "{{ deliveries_count }}".
basket_price_extra_label_details_html:
_de: Unterstützt <a href='https://shopify.github.io/liquid/' target='_blank'>die Liquid-Syntax</a> mit den Objekten "{{ extra }}" und "{{ full_year_price }}".
_fr: Supporte <a href='https://shopify.github.io/liquid/' target='_blank'>la syntaxe Liquid</a> avec les objects "{{ extra }}" et "{{ full_year_price }}".
Expand Down
9 changes: 9 additions & 0 deletions spec/factories/acps.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,14 @@
terms_of_service_url { 'https://www.ragedevert.ch/s/RageDeVert-Reglement-2015.pdf' }
features { %w[absence activity basket_content basket_price_extra shop] }
feature_flags { %w[] }
basket_price_extras { "0, 1, 2, 3" }
basket_price_extra_public_title { 'Soutien' }
basket_price_extra_label { <<~LIQUID }
{% if extra == 0 %}
Tarif de base
{% else %}
+ {{ extra | ceil }}.-/panier
{% endif %}
LIQUID
end
end
3 changes: 3 additions & 0 deletions spec/factories/members.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,16 @@
validated_at { nil }
validator { nil }
waiting_basket_size { create(:basket_size) }
waiting_basket_price_extra { 0 }
waiting_depot { create(:depot) }

end

trait :waiting do
state { 'waiting' }
waiting_started_at { Time.current }
waiting_basket_size { create(:basket_size) }
waiting_basket_price_extra { 0 }
waiting_depot { create(:depot) }
end

Expand Down
21 changes: 21 additions & 0 deletions spec/models/basket_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,27 @@
expect(basket_2.send(:calculate_price_extra)).to eq 2.5
end

specify 'with dynamic pricing based on basket_size and complements prices' do
Current.acp.update!(basket_price_extra_dynamic_pricing: <<~LIQUID)
{% assign price = basket_price | plus: complements_price %}
{{ price | divided_by: 100.0 | times: extra }}
LIQUID

membership = create(:membership)
complement_1 = create(:basket_complement)
complement_2 = create(:basket_complement)
basket = membership.baskets.first
basket.update!(
basket_price: 20.5,
price_extra: -20,
baskets_basket_complements_attributes: {
'0' => { basket_complement_id: complement_1.id, quantity: 2, price: 3.3 },
'1' => { basket_complement_id: complement_2.id, quantity: 1, price: 4.2 }
})

expect(basket.calculated_price_extra).to eq ((20.5 + 2 * 3.3 + 1 * 4.2) / 100.0 * -20)
end

specify 'with dynamic pricing based on deliveries count and extra', freeze: '2022-01-01' do
create_deliveries(3)
membership = create(:membership)
Expand Down
9 changes: 9 additions & 0 deletions spec/models/member_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,15 @@
expect(member).not_to have_valid(:waiting_basket_size_id)
end

it 'validates waiting_basket_price_extra presence' do
member = build(:member,
waiting_depot: create(:depot),
waiting_basket_price_extra: nil)

expect(member).not_to be_valid
expect(member).not_to have_valid(:waiting_basket_price_extra)
end

it 'validates waiting_depot presence' do
member = build(:member,
waiting_basket_size: create(:basket_size),
Expand Down
Loading

0 comments on commit d93cef3

Please sign in to comment.