From d93cef3a6fac6831ea1918eb4cb933611eb38ebd Mon Sep 17 00:00:00 2001 From: Thibaud Guillaume-Gentil Date: Fri, 10 Nov 2023 18:12:18 +0100 Subject: [PATCH] Basket price extra dynamic pricing, include complements price 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. --- app/admin/membership.rb | 12 +++++------ app/models/acp.rb | 3 ++- app/models/basket.rb | 6 +++--- app/models/member.rb | 2 +- app/models/membership_pricing.rb | 14 +++++++++---- app/models/pdf/invoice.rb | 12 +++++------ .../members/form_modes/_membership.html.slim | 14 +++++++------ .../members/membership_renewals/new.html.slim | 14 ++++++------- config/locales/formtastic.yml | 6 +++--- spec/factories/acps.rb | 9 ++++++++ spec/factories/members.rb | 3 +++ spec/models/basket_spec.rb | 21 +++++++++++++++++++ spec/models/member_spec.rb | 9 ++++++++ spec/system/members/members_spec.rb | 5 +++++ 14 files changed, 93 insertions(+), 37 deletions(-) diff --git a/app/admin/membership.rb b/app/admin/membership.rb index ff4d8fc63..498d7715d 100644 --- a/app/admin/membership.rb +++ b/app/admin/membership.rb @@ -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) } @@ -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)) } diff --git a/app/models/acp.rb b/app/models/acp.rb index 5cdb8187b..a328cb85d 100644 --- a/app/models/acp.rb +++ b/app/models/acp.rb @@ -324,7 +324,7 @@ 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) @@ -332,6 +332,7 @@ def calculate_basket_price_extra(extra, basket_price, basket_size_id, deliveries '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 diff --git a/app/models/basket.rb b/app/models/basket.rb index cfcfc1b04..a1528d977 100644 --- a/app/models/basket.rb +++ b/app/models/basket.rb @@ -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? @@ -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 diff --git a/app/models/member.rb b/app/models/member.rb index 6b85e70e2..780484a75 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -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 diff --git a/app/models/membership_pricing.rb b/app/models/membership_pricing.rb index 3ce1e8d91..9564de017 100644 --- a/app/models/membership_pricing.rb +++ b/app/models/membership_pricing.rb @@ -46,15 +46,20 @@ 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 @@ -62,6 +67,7 @@ def calculate_price_extra(extra, basket_size, deliveries_count) extra, basket_size.price, basket_size.id, + complements_price, deliveries_count) end diff --git a/app/models/pdf/invoice.rb b/app/models/pdf/invoice.rb index f11e313ee..60ee6752b 100644 --- a/app/models/pdf/invoice.rb +++ b/app/models/pdf/invoice.rb @@ -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'), @@ -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? diff --git a/app/views/members/members/form_modes/_membership.html.slim b/app/views/members/members/form_modes/_membership.html.slim index 6c19cfdab..613ba5d6b 100644 --- a/app/views/members/members/form_modes/_membership.html.slim +++ b/app/views/members/members/form_modes/_membership.html.slim @@ -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, diff --git a/app/views/members/membership_renewals/new.html.slim b/app/views/members/membership_renewals/new.html.slim index 54c91d507..5f7ab8124 100644 --- a/app/views/members/membership_renewals/new.html.slim +++ b/app/views/members/membership_renewals/new.html.slim @@ -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" diff --git a/config/locales/formtastic.yml b/config/locales/formtastic.yml index 4b395e54d..ab1ffe661 100644 --- a/config/locales/formtastic.yml +++ b/config/locales/formtastic.yml @@ -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.
Unterstützt die Liquid-Syntax 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.
Supporte la syntaxe Liquid 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.
Supporta la sintassi Liquid 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.
Unterstützt die Liquid-Syntax 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.
Supporte la syntaxe Liquid 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.
Supporta la sintassi Liquid con gli oggetti "{{ extra }}", "{{ basket_size_id }}", "{{ basket_price }}", "{{ complements_price }} e "{{ deliveries_count }}". basket_price_extra_label_details_html: _de: Unterstützt die Liquid-Syntax mit den Objekten "{{ extra }}" und "{{ full_year_price }}". _fr: Supporte la syntaxe Liquid avec les objects "{{ extra }}" et "{{ full_year_price }}". diff --git a/spec/factories/acps.rb b/spec/factories/acps.rb index c50046b59..fcaa4749d 100644 --- a/spec/factories/acps.rb +++ b/spec/factories/acps.rb @@ -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 diff --git a/spec/factories/members.rb b/spec/factories/members.rb index fa3bc0afb..73909bad0 100644 --- a/spec/factories/members.rb +++ b/spec/factories/members.rb @@ -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 diff --git a/spec/models/basket_spec.rb b/spec/models/basket_spec.rb index afd8edc54..00776f465 100644 --- a/spec/models/basket_spec.rb +++ b/spec/models/basket_spec.rb @@ -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) diff --git a/spec/models/member_spec.rb b/spec/models/member_spec.rb index b15cec59e..bc6cdfbd4 100644 --- a/spec/models/member_spec.rb +++ b/spec/models/member_spec.rb @@ -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), diff --git a/spec/system/members/members_spec.rb b/spec/system/members/members_spec.rb index 7482489e8..f1b7dfa2b 100644 --- a/spec/system/members/members_spec.rb +++ b/spec/system/members/members_spec.rb @@ -131,6 +131,8 @@ choose 'Eveil PUBLIC' + choose '2.-/panier' + choose 'Vélo PUBLIC' choose 'Semaines impaires PUBLIC', visible: false @@ -147,6 +149,7 @@ expect(member.waiting_basket_size.name).to eq 'Eveil' expect(member.waiting_depot.name).to eq 'Vélo' expect(member.waiting_deliveries_cycle.name).to eq 'Semaines impaires' + expect(member.waiting_basket_price_extra).to eq 2 end it 'creates a new member with membership and alternative depots' do @@ -187,6 +190,8 @@ choose 'Eveil PUBLIC' + choose 'Tarif de base' + within '.member_waiting_depot_id' do choose 'Neuchâtel PUBLIC' end