From 304b1246e4b45e48e67f430f8ab470e0de434918 Mon Sep 17 00:00:00 2001 From: Thibaud Guillaume-Gentil Date: Fri, 11 Aug 2023 10:32:52 +0200 Subject: [PATCH] Shop, add automatic invoicing setting This patch adds a new setting to the shop, allowing to automatically invoice orders after/before a certain delay following/preceding the delivery date. Close https://github.com/acp-admin/acp-admin/issues/100 --- app/admin/acp.rb | 2 + app/admin/shop/order.rb | 20 +++++- app/assets/stylesheets/active_admin.css.erb | 1 + .../billing/shop_order_auto_invoicer_job.rb | 9 +++ ...job.rb => billing_members_invoicer_job.rb} | 2 +- .../billing_shop_orders_auto_invoicer_job.rb | 12 ++++ app/models/acp.rb | 2 + app/models/shop/order.rb | 10 +++ app/views/handbook/shop.md.erb | 4 +- config/locales/activerecord.yml | 4 ++ config/locales/formtastic.yml | 4 ++ config/locales/mail_template.yml | 24 +++---- config/locales/shop.yml | 8 +++ config/sidekiq.yml | 8 ++- ...order_automatic_invoicing_delay_in_days.rb | 5 ++ db/schema.rb | 3 +- spec/factories/shop/orders.rb | 3 + spec/models/shop/order_spec.rb | 64 +++++++++++++++++++ 18 files changed, 167 insertions(+), 18 deletions(-) create mode 100644 app/jobs/billing/shop_order_auto_invoicer_job.rb rename app/jobs/scheduled/{billing_invoicer_job.rb => billing_members_invoicer_job.rb} (83%) create mode 100644 app/jobs/scheduled/billing_shop_orders_auto_invoicer_job.rb create mode 100644 db/migrate/20230810153313_add_shop_order_automatic_invoicing_delay_in_days.rb diff --git a/app/admin/acp.rb b/app/admin/acp.rb index c02196420..7fdfe9cd6 100644 --- a/app/admin/acp.rb +++ b/app/admin/acp.rb @@ -23,6 +23,7 @@ :shop_order_maximum_weight_in_kg, :shop_order_minimal_amount, :shop_member_percentages, :shop_delivery_open_delay_in_days, :shop_delivery_open_last_day_end_time, + :shop_order_automatic_invoicing_delay_in_days, :recurring_billing_wday, :currency_code, :send_closed_invoice, :open_renewal_reminder_sent_after_in_days, @@ -317,6 +318,7 @@ f.input :shop_delivery_open_last_day_end_time, as: :time_picker, input_html: { value: f.object.shop_delivery_open_last_day_end_time&.strftime('%H:%M') } + f.input :shop_order_automatic_invoicing_delay_in_days translated_input(f, :shop_invoice_infos, hint: t('formtastic.hints.acp.shop_invoice_info'), required: false) diff --git a/app/admin/shop/order.rb b/app/admin/shop/order.rb index 393bd141a..8500e2a99 100644 --- a/app/admin/shop/order.rb +++ b/app/admin/shop/order.rb @@ -110,9 +110,9 @@ end end - sidebar t('active_admin.sidebars.shop_status'), if: -> { params.dig(:q, :delivery_id_eq).present? }, only: :index do + sidebar t('active_admin.sidebars.shop_status'), if: -> { params.dig(:q, :_delivery_gid_eq).present? }, only: :index do div class: 'content' do - delivery = Delivery.find(params[:q][:delivery_id_eq]) + delivery = GlobalID::Locator.locate(params[:q][:_delivery_gid_eq]) if delivery == Delivery.shop_open.next if delivery.shop_open? span t('active_admin.sidebars.shop_open_until_html', date: l(delivery.date, format: :long), end_date: l(delivery.shop_closing_at, format: :long)) @@ -127,6 +127,22 @@ end end + sidebar t('active_admin.sidebars.billing'), if: -> { params.dig(:q, :_delivery_gid_eq).present? }, only: :index do + div class: 'actions' do + handbook_icon_link('shop', anchor: 'facturation') + end + + div class: 'content' do + if delay = Current.acp.shop_order_automatic_invoicing_delay_in_days + delivery = GlobalID::Locator.locate(params[:q][:_delivery_gid_eq]) + date = delivery.date + delay.days + span t('shop.orders_automatic_invoicing', date: l(date, format: :long)) + else + span t('shop.orders_manual_invoicing') + end + end + end + sidebar_handbook_link('shop#commandes') show do |order| diff --git a/app/assets/stylesheets/active_admin.css.erb b/app/assets/stylesheets/active_admin.css.erb index 542f69be6..fcb885b6b 100644 --- a/app/assets/stylesheets/active_admin.css.erb +++ b/app/assets/stylesheets/active_admin.css.erb @@ -356,6 +356,7 @@ span.delivery_note input[type="submit"] { font-size: 16px; font-weight: 600; margin: 30px 0 0 0; + scroll-margin-top: 60px; } .handbook #main_content p { diff --git a/app/jobs/billing/shop_order_auto_invoicer_job.rb b/app/jobs/billing/shop_order_auto_invoicer_job.rb new file mode 100644 index 000000000..d552562d1 --- /dev/null +++ b/app/jobs/billing/shop_order_auto_invoicer_job.rb @@ -0,0 +1,9 @@ +module Billing + class ShopOrderAutoInvoicerJob < ApplicationJob + queue_as :low + + def perform(order) + order.auto_invoice! + end + end +end diff --git a/app/jobs/scheduled/billing_invoicer_job.rb b/app/jobs/scheduled/billing_members_invoicer_job.rb similarity index 83% rename from app/jobs/scheduled/billing_invoicer_job.rb rename to app/jobs/scheduled/billing_members_invoicer_job.rb index 711a5ecf5..4437108c3 100644 --- a/app/jobs/scheduled/billing_invoicer_job.rb +++ b/app/jobs/scheduled/billing_members_invoicer_job.rb @@ -1,5 +1,5 @@ module Scheduled - class BillingInvoicerJob < BaseJob + class BillingMembersInvoicerJob < BaseJob def perform return unless Current.acp.recurring_billing_wday == Date.current.wday diff --git a/app/jobs/scheduled/billing_shop_orders_auto_invoicer_job.rb b/app/jobs/scheduled/billing_shop_orders_auto_invoicer_job.rb new file mode 100644 index 000000000..7fcdf2a10 --- /dev/null +++ b/app/jobs/scheduled/billing_shop_orders_auto_invoicer_job.rb @@ -0,0 +1,12 @@ +module Scheduled + class BillingShopOrdersAutoInvoicerJob < BaseJob + def perform + return unless Current.acp.feature?('shop') + return unless Current.acp.shop_order_automatic_invoicing_delay_in_days + + Shop::Order.pending.find_each do |order| + Billing::ShopOrderInvoicerJob.perform_later(order) + end + end + end +end diff --git a/app/models/acp.rb b/app/models/acp.rb index bfd8ae9be..8a84401ae 100644 --- a/app/models/acp.rb +++ b/app/models/acp.rb @@ -94,6 +94,8 @@ class ACP < ApplicationRecord numericality: { greater_than_or_equal_to: 1, allow_nil: true } validates :shop_order_minimal_amount, numericality: { greater_than_or_equal_to: 1, allow_nil: true } + validates :shop_order_automatic_invoicing_delay_in_days, + numericality: { only_integer: true, allow_nil: true } validates :member_form_mode, presence: true, inclusion: { in: MEMBER_FORM_MODES } validates :member_profession_form_mode, presence: true, inclusion: { in: INPUT_FORM_MODES } validates :member_come_from_form_mode, presence: true, inclusion: { in: INPUT_FORM_MODES } diff --git a/app/models/shop/order.rb b/app/models/shop/order.rb index e79a83ecb..7c6225306 100644 --- a/app/models/shop/order.rb +++ b/app/models/shop/order.rb @@ -137,6 +137,16 @@ def unconfirm! end end + def auto_invoice! + delay = Current.acp.shop_order_automatic_invoicing_delay_in_days + return unless delay + return unless can_invoice? + + if (Date.today - delivery.date).to_i >= delay + invoice! + end + end + def invoice! invalid_transition(:invoice!) unless can_invoice? diff --git a/app/views/handbook/shop.md.erb b/app/views/handbook/shop.md.erb index 4abccabd1..248bc82ca 100644 --- a/app/views/handbook/shop.md.erb +++ b/app/views/handbook/shop.md.erb @@ -49,7 +49,9 @@ Un bulletin de livraison (PDF), n'incluant pas le prix des produits, est disponi ### Facturation -Une fois les commandes préparées, les commandes doivent être **manuellement** facturées (possibilité de toutes les facturer en un coup via une action groupée). La facture est automatiquement envoyée par email au membre. +Par défaut, une fois les commandes préparées, les commandes doivent être **manuellement** facturées (possibilité de toutes les facturer en un coup via une action groupée). La facture est ensuite automatiquement envoyée par email au membre. + +Il est également possible de configurer la **facturation automatique** des commandes, depuis [les paramètres de l'épicerie](/settings#shop), après ou avant un certain nombre de jours suivant ou précédant la date de livraison. > Si une commande déjà facturée est modifiée, la facture existante est automatiquement annulée et une nouvelle facture devra être générée et envoyée. diff --git a/config/locales/activerecord.yml b/config/locales/activerecord.yml index 3c8938956..37723b45b 100644 --- a/config/locales/activerecord.yml +++ b/config/locales/activerecord.yml @@ -259,6 +259,10 @@ _: _de: Verfügbare Prozentsätze für Mitglieder _fr: Pourcentages disponibles pour les membres _it: Percentuali disponibili per i soci + shop_order_automatic_invoicing_delay_in_days: + _de: Automatische Rechnungsstellung vor/nach der Lieferung (in Tagen) + _fr: Facturation automatique avant/après la livraison (en jours) + _it: Fatturazione automatica prima/dopo la consegna (in giorni) shop_order_maximum_weight_in_kg: _de: Maximales Gewicht einer Bestellung (in kg) _fr: Poids maximal d'une commande (en kg) diff --git a/config/locales/formtastic.yml b/config/locales/formtastic.yml index 1cdf1ce62..ae3814c88 100644 --- a/config/locales/formtastic.yml +++ b/config/locales/formtastic.yml @@ -178,6 +178,10 @@ _: _de: Durch Komma getrennt. Ermöglicht es Mitgliedern, den Endpreis seiner Bestellung selbst um die verfügbaren Prozentsätze zu erhöhen (Unterstützung) oder zu senken (Rabatt). _fr: Séparés par une virgule. Permet aux membres d'augmenter (soutien) ou de diminuer (rabais) lui-même le prix final de sa commande par les pourcentages disponibles. _it: Separati da una virgola. Consente ai soci di aumentare (supporto) o diminuire (sconto) il prezzo finale dell'ordine in base alle percentuali disponibili. + shop_order_automatic_invoicing_delay_in_days: + _de: Ermöglicht es Ihnen, die Bestellungen X Tage nach oder vor (-X) dem Lieferdatum automatisch zu fakturieren. Lassen Sie das Feld leer, um die Bestellungen manuell zu fakturieren. + _fr: Permet de facturer automatiquement, durant la nuit, les commandes X jours après ou avant (-X) la date de livraison. Laisser vide pour facturer manuellement les commandes. + _it: Permette di fatturare automaticamente, durante la notte, gli ordini X giorni dopo o prima (-X) la data di consegna. Lasciare vuoto per fatturare manualmente gli ordini. shop_order_maximum_weight_in_kg: _de: Leer lassen, wenn keine Begrenzung. _fr: Laisser vide si aucune limite. diff --git a/config/locales/mail_template.yml b/config/locales/mail_template.yml index 6fa2b7c0e..d96b68db9 100644 --- a/config/locales/mail_template.yml +++ b/config/locales/mail_template.yml @@ -13,14 +13,14 @@ _: _de: "Bestätigte Aktivität \U0001F389" _fr: "Activité validée \U0001F389" _it: "Attività confermata \U0001F389" - invoice_created: - _de: 'Neue Rechnung #{{ invoice.number }}' - _fr: 'Nouvelle facture #{{ invoice.number }}' - _it: 'Nuova fattura #{{ invoice.number }}' invoice_cancelled: _de: 'Stornierte Rechnung #{{ invoice.number }}' _fr: 'Facture annulée #{{ invoice.number }}' _it: 'Fattura annullata #{{ invoice.number }}' + invoice_created: + _de: 'Neue Rechnung #{{ invoice.number }}' + _fr: 'Nouvelle facture #{{ invoice.number }}' + _it: 'Nuova fattura #{{ invoice.number }}' invoice_overdue_notice: _de: "Mahnung #{{ invoice.overdue_notices_count }} der Rechnung #{{ invoice.number }} \U0001F62C" _fr: "Rappel #{{ invoice.overdue_notices_count }} de la facture #{{ invoice.number }} \U0001F62C" @@ -58,14 +58,14 @@ _: _de: Wird gesendet, wenn die Teilnahme an einer Aktivität bestätigt wird. _fr: Envoyé quand une participation à une activité est validée. _it: Inviato quando viene convalidata una partecipazione a un'attività. - invoice_created: - _de: Wird gesendet, wenn eine Rechnung automatisch erstellt wird oder wenn die Schaltfläche "Senden" einer manuell erstellten Rechnung gedrückt wird. - _fr: Envoyé lors de la création automatique d'une facture ou quand le bouton "Envoyer" d'une facture créée manuellement est pressé. - _it: Inviato durante la creazione automatica di una fattura o quando si preme il pulsante "Invia" di una fattura creata manualmente. invoice_cancelled: _de: Wird gesendet, wenn eine noch offene Rechnung storniert wird. _fr: Envoyé quand une facture encore ouverte est annulée. _it: Inviato quando una fattura ancora aperta viene annullata. + invoice_created: + _de: Wird gesendet, wenn eine Rechnung automatisch erstellt wird oder wenn die Schaltfläche "Senden" einer manuell erstellten Rechnung gedrückt wird. + _fr: Envoyé lors de la création automatique d'une facture ou quand le bouton "Envoyer" d'une facture créée manuellement est pressé. + _it: Inviato durante la creazione automatica di una fattura o quando si preme il pulsante "Invia" di una fattura creata manualmente. invoice_overdue_notice: _de: Wird automatisch gesendet, wenn eine Rechnung 35 Tage nach ihrer Erstellung oder letzten Mahnung noch offen ist.
Deaktiviert, wenn die Zahlungen nicht automatisch verarbeitet werden (EBICS). _fr: Envoyé automatiquement quand une facture est toujours ouverte 35 jours après sa création ou son dernier rappel.
Désactivé quand les paiements ne sont pas traités automatiquement (EBICS). @@ -103,14 +103,14 @@ _: _de: Bestätigte Aktivität _fr: Activité validée _it: Attività confermata - invoice_created: - _de: Neue Rechnung - _fr: Nouvelle facture - _it: Nuova fattura invoice_cancelled: _de: Stornierte Rechnung _fr: Facture annulée _it: Fattura annullata + invoice_created: + _de: Neue Rechnung + _fr: Nouvelle facture + _it: Nuova fattura invoice_overdue_notice: _de: Mahnung der Rechnung _fr: Rappel facture diff --git a/config/locales/shop.yml b/config/locales/shop.yml index 7e28a0267..55e0e9968 100644 --- a/config/locales/shop.yml +++ b/config/locales/shop.yml @@ -22,6 +22,14 @@ _: _de: keine Bestellung _fr: aucune commande _it: nessun ordine + orders_automatic_invoicing: + _de: Die zu bereitenden Bestellungen werden in der Nacht vom %{date} automatisch fakturiert. + _fr: Les commandes à préparer seront automatiquement facturées, durant la nuit, à partir du %{date}. + _it: Gli ordini da preparare verranno fatturati automaticamente, durante la notte, a partire dal %{date}. + orders_manual_invoicing: + _de: Automatische Rechnungsstellung ist nicht konfiguriert, Bestellungen müssen manuell fakturiert werden. + _fr: La facturation automatique n'est pas paramétrée, les commandes à préparer doivent être facturées manuellement. + _it: La fatturazione automatica non è configurata, gli ordini devono essere fatturati manualmente. percentage: negative: _de: Rabatt %{percentage}% diff --git a/config/sidekiq.yml b/config/sidekiq.yml index 5bc7d66bd..3a7e2f650 100644 --- a/config/sidekiq.yml +++ b/config/sidekiq.yml @@ -8,8 +8,14 @@ cron: '0 0 5 * * *' queue: low class: ScheduledJob - args: Scheduled::BillingInvoicerJob + args: Scheduled::BillingMembersInvoicerJob description: "Automatically create and send new invoices" + billing_shop_orders: + cron: '0 45 4 * * *' + queue: low + class: ScheduledJob + args: Scheduled::BillingShopOrdersAutoInvoicerJob + description: "Automatically invoice shop orders" billing_payments: cron: '0 30 4 * * *' queue: low diff --git a/db/migrate/20230810153313_add_shop_order_automatic_invoicing_delay_in_days.rb b/db/migrate/20230810153313_add_shop_order_automatic_invoicing_delay_in_days.rb new file mode 100644 index 000000000..f36bf7c25 --- /dev/null +++ b/db/migrate/20230810153313_add_shop_order_automatic_invoicing_delay_in_days.rb @@ -0,0 +1,5 @@ +class AddShopOrderAutomaticInvoicingDelayInDays < ActiveRecord::Migration[7.0] + def change + add_column :acps, :shop_order_automatic_invoicing_delay_in_days, :integer + end +end diff --git a/db/schema.rb b/db/schema.rb index 98499764d..567310f7d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2023_07_07_091231) do +ActiveRecord::Schema[7.0].define(version: 2023_08_10_153313) do # These are extensions that must be enabled in order to support this database enable_extension "hstore" enable_extension "plpgsql" @@ -110,6 +110,7 @@ t.string "basket_complements_member_order_mode", default: "deliveries_count_desc", null: false t.string "depots_member_order_mode", default: "price_asc", null: false t.string "deliveries_cycles_member_order_mode", default: "deliveries_count_desc", null: false + t.integer "shop_order_automatic_invoicing_delay_in_days" t.index ["host"], name: "index_acps_on_host" t.index ["tenant_name"], name: "index_acps_on_tenant_name" end diff --git a/spec/factories/shop/orders.rb b/spec/factories/shop/orders.rb index 7b6dc417e..02d230493 100644 --- a/spec/factories/shop/orders.rb +++ b/spec/factories/shop/orders.rb @@ -19,5 +19,8 @@ trait :pending do state { Shop::Order::PENDING_STATE } end + trait :invoiced do + state { Shop::Order::INVOICED_STATE } + end end end diff --git a/spec/models/shop/order_spec.rb b/spec/models/shop/order_spec.rb index 031c91d81..b037eb4b3 100644 --- a/spec/models/shop/order_spec.rb +++ b/spec/models/shop/order_spec.rb @@ -309,6 +309,70 @@ end end + describe '#auto_invoice!' do + specify 'auto invoice after delivery date' do + delivery = create(:delivery, date: 3.days.from_now) + order = create(:shop_order, :pending, delivery_gid: delivery.gid) + Current.acp.update!(shop_order_automatic_invoicing_delay_in_days: 3) + + travel 5.days do + expect { order.auto_invoice! }.not_to change { order.reload.state } + end + + travel 6.days do + expect { + order.auto_invoice! + }.to change { order.reload.state }.from('pending').to('invoiced') + end + end + + specify 'auto invoice before delivery date' do + delivery = create(:delivery, date: Date.today) + order = create(:shop_order, :pending, delivery_gid: delivery.gid) + Current.acp.update!(shop_order_automatic_invoicing_delay_in_days: -2) + + travel -3.days do + expect { order.auto_invoice! }.not_to change { order.reload.state } + end + + travel -2.days do + expect { order.auto_invoice! } + .to change { order.reload.state }.from('pending').to('invoiced') + end + end + + specify 'auto invoice the delivery date' do + delivery = create(:delivery, date: Date.today) + order = create(:shop_order, :pending, delivery_gid: delivery.gid) + Current.acp.update!(shop_order_automatic_invoicing_delay_in_days: 0) + + expect { order.auto_invoice! } + .to change { order.reload.state }.from('pending').to('invoiced') + end + + specify 'do nothing when no delay configured' do + delivery = create(:delivery, date: Date.today) + order = create(:shop_order, :pending, delivery_gid: delivery.gid) + Current.acp.update!(shop_order_automatic_invoicing_delay_in_days: nil) + + expect { order.auto_invoice! }.not_to change { order.reload.state } + end + + specify 'do nothing for cart order' do + Current.acp.update!(shop_order_automatic_invoicing_delay_in_days: 0) + + order = create(:shop_order, :cart) + expect { order.auto_invoice! }.not_to change { order.reload.state } + end + + specify 'do nothing for invoiced order' do + Current.acp.update!(shop_order_automatic_invoicing_delay_in_days: 0) + + order = create(:shop_order, :invoiced) + expect { order.auto_invoice! }.not_to change { order.reload.state } + end + end + describe '#invoice!' do specify 'create an invoice and set state to invoiced' do product = create(:shop_product,