diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index db5832c72..61d969cc5 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -51,6 +51,11 @@ jobs: env: DATABASE_URL: mysql2://user:password@127.0.0.1/test RAILS_ENV: test + - name: Apply plugin migrations + run: bundle exec rake foodsoft_invoices_engine:install:migrations db:migrate + env: + DATABASE_URL: mysql2://user:password@127.0.0.1/test + RAILS_ENV: test - name: Run tests run: bundle exec rake rspec-rerun:spec env: diff --git a/Gemfile b/Gemfile index a3049cdb0..04daa1885 100644 --- a/Gemfile +++ b/Gemfile @@ -80,6 +80,7 @@ gem 'foodsoft_wiki', path: 'plugins/wiki' # gem 'foodsoft_printer', path: 'plugins/printer' # gem 'foodsoft_uservoice', path: 'plugins/uservoice' # gem 'foodsoft_mollie', path: 'plugins/mollie' +# gem 'foodsoft_invoices', path: 'plugins/invoices' group :development do gem 'listen' @@ -135,4 +136,5 @@ group :test do gem 'rswag-specs' # plugins with tests deactivated by default gem 'foodsoft_b85', path: 'plugins/b85' + gem 'foodsoft_invoices', path: 'plugins/invoices' end diff --git a/Gemfile.lock b/Gemfile.lock index f9d5189e7..490665cd4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -35,6 +35,16 @@ PATH rails ruby-filemagic +PATH + remote: plugins/invoices + specs: + foodsoft_invoices (0.1.0) + deface (~> 1.0) + prawn + prawn-table + rails + sepa_king + PATH remote: plugins/links specs: @@ -277,6 +287,7 @@ GEM i18n (>= 0.6.6, < 2) i18n-spec (0.6.0) iso + iban-tools (1.2.1) ice_cube (0.17.0) image_processing (1.13.0) mini_magick (>= 4.9.5, < 5) @@ -600,6 +611,10 @@ GEM tilt securerandom (0.4.1) select2-rails (4.0.13) + sepa_king (0.14.0) + activemodel (>= 4.2) + iban-tools + nokogiri simple-navigation (4.4.0) activesupport (>= 2.3.2) simple_form (5.3.1) @@ -739,6 +754,7 @@ DEPENDENCIES foodsoft_b85! foodsoft_discourse! foodsoft_documents! + foodsoft_invoices! foodsoft_links! foodsoft_messages! foodsoft_polls! diff --git a/app/controllers/finance/balancing_controller.rb b/app/controllers/finance/balancing_controller.rb index 3976e235c..d53c14203 100644 --- a/app/controllers/finance/balancing_controller.rb +++ b/app/controllers/finance/balancing_controller.rb @@ -82,6 +82,8 @@ def close @type = FinancialTransactionType.find_by_id(params.permit(:type)[:type]) @link = FinancialLink.new if params[:create_financial_link] @order.close!(@current_user, @type, @link, create_foodcoop_transaction: params[:create_foodcoop_transaction]) + # Allow plugins to react on order close + ActiveSupport::Notifications.instrument('foodsoft.order.closed', order: @order) redirect_to finance_order_index_url, notice: t('.notice') rescue StandardError => e redirect_to new_finance_order_url(order_id: @order.id), alert: t('.alert', message: e.message) diff --git a/app/views/admin/configs/_tab_foodcoop.html.haml b/app/views/admin/configs/_tab_foodcoop.html.haml index 141eb519c..25cbd62d9 100644 --- a/app/views/admin/configs/_tab_foodcoop.html.haml +++ b/app/views/admin/configs/_tab_foodcoop.html.haml @@ -7,4 +7,5 @@ = config_input c, :country, as: :string, input_html: {class: 'form-control'} = config_input c, :email, required: true, input_html: {class: 'form-control'} = config_input c, :phone, input_html: {class: 'form-control'} + = config_input c, :tax_number, input_html: {class: 'form-control'} = config_input form, :homepage, required: true, as: :url, input_html: {class: 'form-control'} diff --git a/app/views/finance/balancing/_order_row.html.haml b/app/views/finance/balancing/_order_row.html.haml new file mode 100644 index 000000000..89783d68e --- /dev/null +++ b/app/views/finance/balancing/_order_row.html.haml @@ -0,0 +1,14 @@ +- row_class = cycle("even","odd", :name => "order") +%tr{:class => row_class, 'data-order_id' => order.id} + %td= link_to truncate(order.name), new_finance_order_path(order_id: order.id) + %td=h format_time(order.ends) unless order.ends.nil? + %td= order.closed? ? t('finance.balancing.orders.cleared', amount: number_to_currency(order.foodcoop_result)) : t('finance.balancing.orders.ended') + %td= show_user(order.updated_by) + %td + - unless order.closed? + - if current_user.role_orders? + - unless order.stockit? + = link_to t('orders.index.action_receive'), receive_order_path(order), class: 'btn btn-default btn-sm' + - else + = link_to t('orders.index.action_receive'), '#', class: 'btn btn-sm disabled' + = link_to t('finance.balancing.orders.clear'), new_finance_order_path(order_id: order.id), class: 'btn btn-sm btn-primary' diff --git a/app/views/finance/balancing/_orders.html.haml b/app/views/finance/balancing/_orders.html.haml index eb197b52d..2a1d36b22 100644 --- a/app/views/finance/balancing/_orders.html.haml +++ b/app/views/finance/balancing/_orders.html.haml @@ -1,5 +1,5 @@ - unless @orders.empty? - - if Order.finished.count > 20 + - if @orders.total_count > 20 = items_per_page = pagination_links_remote @orders %table.table.table-striped @@ -12,18 +12,6 @@ %th %tbody - @orders.each do |order| - %tr{:class => cycle("even","odd", :name => "order")} - %td= link_to truncate(order.name), new_finance_order_path(order_id: order.id) - %td=h format_time(order.ends) unless order.ends.nil? - %td= order.closed? ? t('.cleared', amount: number_to_currency(order.foodcoop_result)) : t('.ended') - %td= show_user(order.updated_by) - %td - - unless order.closed? - - if current_user.role_orders? - - unless order.stockit? - = link_to t('orders.index.action_receive'), receive_order_path(order), class: 'btn btn-default btn-sm' - - else - = link_to t('orders.index.action_receive'), '#', class: 'btn btn-sm disabled' - = link_to t('.clear'), new_finance_order_path(order_id: order.id), class: 'btn btn-sm btn-primary' + = render partial: 'order_row', locals: { order: order } - else %i= t('.no_closed_orders') diff --git a/app/views/layouts/_header.html.haml b/app/views/layouts/_header.html.haml index 66e143550..404a1f09b 100644 --- a/app/views/layouts/_header.html.haml +++ b/app/views/layouts/_header.html.haml @@ -6,7 +6,7 @@ %meta(name="viewport" content="width=device-width, initial-scale=1.0") %title= [t('layouts.foodsoft'), yield(:title)].join(" - ") = csrf_meta_tags - = stylesheet_link_tag "application", :media => "all" + = stylesheet_link_tag *Foodsoft::AssetRegistry.stylesheets, media: "all" //%link(href="images/favicon.ico" rel="shortcut icon") = yield(:head) = foodcoop_css_tag @@ -20,7 +20,7 @@ \================================================== / Placed at the end of the document so the pages load faster = javascript_importmap_tags - = javascript_include_tag "application_legacy" + = javascript_include_tag *Foodsoft::AssetRegistry.javascripts :javascript I18n.defaultLocale = "#{I18n.default_locale}"; diff --git a/config/app_config.yml.SAMPLE b/config/app_config.yml.SAMPLE index fa439e0eb..4a3dc8cfe 100644 --- a/config/app_config.yml.SAMPLE +++ b/config/app_config.yml.SAMPLE @@ -19,6 +19,7 @@ default: &defaults country: Deutschland email: foodsoft@foodcoop.test phone: "030 323 23249" + tax_number: "12345" # Homepage homepage: http://www.foodcoop.test @@ -105,6 +106,16 @@ default: &defaults # Members of a user's groups and administrators can still see full names. use_nick: false + # When use_invoices is enabled, it is possible to generate group order invoices + # in balancing view. + use_invoices: false + group_order_invoices: + iban: DE12345 + bic: GENODE123 + creditor_identifier: DE98ZZZ09999999999 + use_automatic_invoices: false + vat_exempt: false + # Most plugins can be enabled/disabled here as well. Messages and wiki are enabled # by default and need to be set to false to disable. Most other plugins needs to # be enabled before they do anything. diff --git a/config/environments/test.rb b/config/environments/test.rb index bb76f632c..755b2425e 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -61,4 +61,7 @@ # Raise error when a before_action's only/except options reference missing actions config.action_controller.raise_on_missing_callback_actions = true + + # Add factories from plugins + config.factory_bot.definition_file_paths += Dir['plugins/**/spec/factories'] end diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb index d52cecaa7..337fe99c4 100644 --- a/config/initializers/assets.rb +++ b/config/initializers/assets.rb @@ -10,3 +10,8 @@ # application.js, application.css, and all non-JS/CSS in the app/assets # folder are already added. Rails.application.config.assets.precompile += %w[application_legacy.js jquery.min.js trix-editor-overrides.js] + +# Add registered assets after all plugins have been initialized +Rails.application.config.after_initialize do + Rails.application.config.assets.precompile += Foodsoft::AssetRegistry.precompile_assets +end diff --git a/config/locales/de.yml b/config/locales/de.yml index dbff0b4a5..0a7af0aeb 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -697,6 +697,7 @@ de: phone: Telefon street: Straße zip_code: Postleitzahl + tax_number: Steuernummer currency_space: Leerzeichen hinzufügen currency_unit: Währung custom_css: Angepasstes CSS @@ -1566,6 +1567,7 @@ de: confirm_end: Willst Du wirklich die Bestellung %{order} beenden? Es gibt kein zurück. new_order: Neue Bestellung anlegen no_open_or_finished_orders: Derzeit gibt es keine laufende oder beendete Bestellungen. + not_closed: Nicht abgerechnet orders_finished: Beendet orders_open: Laufend orders_settled: Abgerechnet @@ -1976,6 +1978,7 @@ de: cancel: Abbrechen close: Schließen confirm_delete: Willst du %{name} wirklich löschen? + confirm_mark_all: Willst du wirklich '%{name}' für alle setzen? confirm_restore: Willst du %{name} wirklich wiederherstellen? copy: Kopieren delete: Löschen @@ -2010,3 +2013,4 @@ de: time: formats: foodsoft_datetime: "%d.%m.%Y %H:%M" + file: "%Y-%d-%B" diff --git a/config/locales/en.yml b/config/locales/en.yml index b7f1c5a6d..4dbc3b3a2 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -699,6 +699,7 @@ en: phone: Phone street: Street zip_code: Postcode + tax_number: Tax number currency_space: add space currency_unit: Currency custom_css: Custom CSS @@ -1571,6 +1572,7 @@ en: confirm_end: Do you really want to close the order %{order}? There is no going back. new_order: Create new order no_open_or_finished_orders: There are currently no open or closed orders. + not_closed: Not accounted orders_finished: Closed orders_open: Open orders_settled: Settled @@ -1981,6 +1983,7 @@ en: cancel: Cancel close: Close confirm_delete: Do you really want to delete %{name}? + confirm_mark_all: Do you really want to set '%{name}' for all? confirm_restore: Do you really want to restore %{name}? copy: Copy delete: Delete @@ -2015,3 +2018,4 @@ en: time: formats: foodsoft_datetime: "%Y-%m-%d %H:%M" + file: "%Y-%d-%B" diff --git a/config/locales/es.yml b/config/locales/es.yml index fa1bd5773..69d32d31e 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -1894,3 +1894,4 @@ es: time: formats: foodsoft_datetime: "%d/%b/%Y %H:%M" + file: "%Y-%d-%B" diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 71b80743c..91c6b8f11 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -1366,3 +1366,4 @@ fr: time: formats: foodsoft_datetime: "%d/%m/%Y %H:%M" + file: "%Y-%d-%B" diff --git a/config/locales/nl.yml b/config/locales/nl.yml index 7ab1086c4..96f486d07 100644 --- a/config/locales/nl.yml +++ b/config/locales/nl.yml @@ -1487,6 +1487,7 @@ nl: confirm_end: Wil je de bestelling %{order} werkelijk sluiten? Dit kun je niet ongedaan maken. new_order: Nieuwe bestelling openen no_open_or_finished_orders: Er zijn momenteel geen open of gesloten bestellingen. + not_closed: Niet afgerekend orders_finished: Gesloten orders_open: Open orders_settled: Afgerekend @@ -1861,6 +1862,7 @@ nl: cancel: Annuleren close: Sluiten confirm_delete: Wil je %{name} echt verwijderen? + confirm_mark_all: Wil je '%{name}' echt voor iedereen instellen? confirm_restore: Wil je %{name} echt opnieuw actief maken? copy: Kopiëren delete: Verwijder @@ -1895,3 +1897,4 @@ nl: time: formats: foodsoft_datetime: "%d-%m-%Y %H:%M" + file: "%Y-%d-%B" diff --git a/config/locales/tr.yml b/config/locales/tr.yml index 00ae9ae73..6a73b691e 100644 --- a/config/locales/tr.yml +++ b/config/locales/tr.yml @@ -1892,3 +1892,4 @@ tr: time: formats: foodsoft_datetime: "%d.%b.%Y %H:%M" + file: "%Y-%d-%B" diff --git a/lib/foodsoft/asset_registry.rb b/lib/foodsoft/asset_registry.rb new file mode 100644 index 000000000..fb5e162c3 --- /dev/null +++ b/lib/foodsoft/asset_registry.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Foodsoft + class AssetRegistry + class << self + def stylesheets + @stylesheets ||= Set.new(['application']) + end + + def javascripts + @javascripts ||= Set.new(['application_legacy']) + end + + def register_stylesheet(name) + stylesheets.add(name) + end + + def register_javascript(name) + javascripts.add(name) + end + + def precompile_assets + (stylesheets.map { |s| "#{s}.css" } + javascripts.map { |j| "#{j}.js" }).to_a + end + end + end +end diff --git a/plugins/invoices/README.md b/plugins/invoices/README.md new file mode 100644 index 000000000..6e9b5626c --- /dev/null +++ b/plugins/invoices/README.md @@ -0,0 +1,98 @@ +# Foodsoft Invoices Plugin + +This plugin extends Foodsoft with a complete, production‑ready invoicing system for order groups. It covers single invoices per order group as well as collective invoices across multiple orders, and it supports SEPA direct debit and email delivery. + +## Features + +- Order group invoices (single order/order group) +- Collective orders (MultiOrders) and ordergroup invoices across multiple orders of the same order group +- PDF generation, ZIP download (all invoices of an order or a MultiOrder) +- Email delivery +- SEPA export (single and collective direct debit), including SEPA sequence types +- Automatic invoice delivery after order closing (optional) +- Administrative functions: mark as paid/SEPA downloaded, set SEPA sequence type + +## Installation + +1. Add the plugin to your Gemfile: + ```ruby + gem 'foodsoft_invoices', path: 'plugins/invoices' + ``` +2. Install dependencies: + ``` + bundle install + ``` +3. Install and run migrations: + ``` + rake foodsoft_invoices_engine:install:migrations + rake db:migrate + ``` + +Note: The required assets (foodsoft_invoices.js/.css) are registered via the Foodsoft AssetRegistry and precompiled. No further steps are required. + +## Activation + +Enable the plugin and provide a tax number (required for PDF generation) in `app_config.yml`: + +```yaml +use_invoices: true +contact: + tax_number: "DE123456789" +``` + +## Configuration + +The plugin settings are available under Admin → Settings → Payment. The following options are available (keys in parentheses): + +- Ignore minimum balance (group_order_invoices.ignore_minimum_balance) +- Automatically send invoices by email after balancing (group_order_invoices.use_automatic_invoices) +- Show deposits separately (group_order_invoices.separate_deposits) +- VAT exempt (group_order_invoices.vat_exempt) +- Payment method (group_order_invoices.payment_method) +- For SEPA export: IBAN, BIC, Creditor Identifier (group_order_invoices.iban/bic/creditor_identifier) + +SEPA account holder per order group: In Admin → Order groups you can store a SEPA account holder with IBAN, BIC, mandate ID and signature date for each group. These data are used for single/collective direct debits. + +Example configuration in `app_config.yml` (optional – settings can also be managed via the Admin UI): + +```yaml +use_invoices: true +contact: + tax_number: "DE123456789" +group_order_invoices: + use_automatic_invoices: true + vat_exempt: false + separate_deposits: false + # For SEPA export of the foodcoop (optional, if direct debit is used) + iban: "DE12 3456 7890 1234 5678 90" + bic: "GENODEF1XYZ" + creditor_identifier: "DE98ZZZ09999999999" +``` + +## Usage + +- Single order (order group invoices): In Finance → Balancing, expand the order. There you can + - generate invoices for all order groups, view/download PDFs and download all as a ZIP, + - send invoices via email, + - set SEPA sequence types and mark them as "paid"/"SEPA downloaded". +- SEPA export: Using the links "Direct debit"/"Collective direct debit" you can generate XML files for all or selected order groups. +- Collective order (MultiOrder): Merge orders on the balancing list. For the resulting MultiOrder you can create ordergroup invoices, send them in bulk and download them as a ZIP. SEPA functions and status toggles are also available here. + +## Technical notes + +- Invoice number format: YYYYMMDD + four‑digit sequential number (unique per day) +- Automation: When the option is enabled, invoices are created and sent after an order is closed (event: `foodsoft.order.closed`). +- Dependencies: deface, sepa_king +- Assets: `foodsoft_invoices.js` and `foodsoft_invoices.css` are automatically registered/precompiled. + +## Tests + +Run the plugin tests: + +``` +bundle exec rspec plugins/invoices/spec +``` + +## Acknowledgements + +Many thanks to @viehlieb for most of the original code. Port to this plugin: Robert (rw@roko.li). The plugin is part of the Foodsoft project. Origins may exist in the [Local‑IT Gitlab](https://git.local-it.org/Foodsoft/foodsoft/src/branch/automatic_group_order_invoice). \ No newline at end of file diff --git a/plugins/invoices/Rakefile b/plugins/invoices/Rakefile new file mode 100644 index 000000000..68c9a1643 --- /dev/null +++ b/plugins/invoices/Rakefile @@ -0,0 +1,20 @@ +begin + require 'bundler/setup' +rescue LoadError + puts 'You must `gem install bundler` and `bundle install` to run rake tasks' +end + +require 'rdoc/task' + +RDoc::Task.new(:rdoc) do |rdoc| + rdoc.rdoc_dir = 'rdoc' + rdoc.title = 'FoodsoftInvoices' + rdoc.options << '--line-numbers' + rdoc.rdoc_files.include('README.md') + rdoc.rdoc_files.include('lib/**/*.rb') +end + +APP_RAKEFILE = File.expand_path('../../Rakefile', __dir__) +load 'rails/tasks/engine.rake' + +Bundler::GemHelper.install_tasks diff --git a/plugins/invoices/app/assets/javascripts/foodsoft_invoices.js b/plugins/invoices/app/assets/javascripts/foodsoft_invoices.js new file mode 100644 index 000000000..30b213b1f --- /dev/null +++ b/plugins/invoices/app/assets/javascripts/foodsoft_invoices.js @@ -0,0 +1,175 @@ +$(document).on('ready turbolinks:load', function () { + $('.expand-trigger').click(function () { + var tableRow = $(this).closest('tr') + var orderId = tableRow.data('order_id'); + var multiOrderId = tableRow.data('multi_order_id'); + + if(multiOrderId != undefined){ + var expandedRow = $('#expanded-multi-row-' + multiOrderId); + console.log(multiOrderId); + } + else + { + var expandedRow = $('#expanded-row-' + orderId); + } + // Toggle visibility of the expanded row + + expandedRow.toggleClass('hidden'); + + tableRow.toggleClass('border'); + expandedRow.toggleClass('bordered'); + + return false; // Prevent the default behavior of the link + }); +}); + +$(document).on('click', '.merge-orders-btn', function () { + const url = $(this).data('url'); + const selectedOrderIds = $('input[name="order_ids_for_multi_order[]"]:checked').map(function () { + return $(this).val(); + }).get(); + + $.ajax({ + url: url, + method: 'POST', + data: { order_ids_for_multi_order: selectedOrderIds }, + success: function (response) { + window.location.reload(); + }, + }); +}); + +$(document).off('change', '[class^="ajax-update-all-link-"] select').on('change', '[class^="ajax-update-all-link-"] select', function () { + var selectedValue = $(this).val(); + var url = $(this).closest('a').attr('href'); + $.ajax({ + url: url, + method: 'PATCH', + data: { sepa_sequence_type: selectedValue }, + success: function (response) { + // Handle success response + }, + error: function (error) { + console.log(error); + } + }); +}); + +$(document).off('change', '.ajax-update-sepa-select').on('change', '.ajax-update-sepa-select', function () { + var selectedValue = $(this).val(); + var url = $(this).data('url'); + console.log(url); + console.log(selectedValue); + $.ajax({ + url: url, + method: 'PATCH', + data: { sepa_sequence_type: selectedValue }, + success: function (response) { + console.log("succeeded"); + }, + error: function (error) { + console.error(error); + } + }); +}); + +function doTheDownload(selectedGroupOrderIds, orderId, url, supplier, mode = "all") { + console.log(selectedGroupOrderIds); + if (mode == "all") { + var data = { order_id: orderId } + } + else { + var data = { multi_group_order_ids: selectedGroupOrderIds } + } + if (mode == "all" || selectedGroupOrderIds.length > 0) { + //suppress generic error warning + $.ajaxSetup({ + global: false, + }); + $.ajax({ + url: url, + method: 'GET', // You may adjust the HTTP method as needed + data: data, + dataType: 'xml', + success: function (response) { + // Handle success response + // Convert XML response to a Blob + var blob = new Blob([new XMLSerializer().serializeToString(response)], { type: 'text/xml' }); + var order_id = orderId + // Create a temporary link element + var link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + if (selectedGroupOrderIds.length > 1) { + link.download = supplier + "-" + orderId + "-Sammellastschrift.xml"; + } else { + link.download = supplier + "-" + orderId + "-Lastschrift.xml"; + } + // Append the link to the document and trigger the click event + document.body.appendChild(link); + link.click(); + + // Clean up + document.body.removeChild(link); + $("group-order-invoices-for-order-" + orderId + " .expand-trigger a").click(); + var modalSelector = "#order_" + orderId + "_modal"; + + // Update the value attribute of checkboxes with IDs starting with "sepa_downloaded" to '1' + if (selectedGroupOrderIds.length >= 1) { + selectedGroupOrderIds.forEach(function (groupOrderId) { + var modalSelector = "#group_order_" + groupOrderId; + checkbox_element = $(modalSelector + ' input[id^="sepa_downloaded"]'); + checkbox_element.val('1'); + checkbox_element.prop('checked', true); + }); + } else { + $(modalSelector + ' input[id^="sepa_downloaded"]').each(function () { + $(this).val('1'); + $(this).prop('checked', true); + }); + } + }, + error: function (error) { + // Handle error + if (error.responseJSON) { + alert('AJAX request error:' + "\n" + error.responseJSON.message); + } else { + var errorText = JSON.parse(error.responseText).error; + var alertDiv = '
' + errorText + '
'; + $('.page-header').before(alertDiv); + $('modal_') + } + } + }); + } + else { + var errorText = "Nothing selected"; + var alertDiv = '
' + errorText + '
'; + $('.page-header').before(alertDiv); + } +} + +$(document).off('click', '[id^="collective-direct-debit-link-selected-"]').on('click', '[id^="collective-direct-debit-link-selected-"]', function (e) { + e.preventDefault(); + var input = "group_order_ids_for_order_" + var orderId = $(this).data("order-id"); + var supplier = $(this).data("supplier"); + if (orderId == undefined) { + orderId = $(this).data("multi-order-id"); + input = "group_order_ids_for_multi_order_" + } + // Extract selected group_order_ids + var selectedGroupOrderIds = $('input[name^="'+ input + orderId + '"]:checked').map(function () { + return $(this).val(); + }).get(); + + var url = $(this).closest('a').attr('href'); + doTheDownload(selectedGroupOrderIds, orderId, url, supplier, "selected"); +}); + +$(document).off('click', '[id^="collective-direct-debit-link-all-"]').on('click', '[id^="collective-direct-debit-link-all-"]', function (e) { + e.preventDefault(); + var orderId = $(this).data("order-id"); + var supplier = $(this).data("supplier"); + var url = $(this).closest('a').attr('href'); + doTheDownload([], orderId, url, supplier, "all"); +}); diff --git a/plugins/invoices/app/assets/stylesheets/foodsoft_invoices.css b/plugins/invoices/app/assets/stylesheets/foodsoft_invoices.css new file mode 100644 index 000000000..7bec979d4 --- /dev/null +++ b/plugins/invoices/app/assets/stylesheets/foodsoft_invoices.css @@ -0,0 +1,69 @@ +.checkbox-icon { + display: inline-block; + width: 20px; + height: 20px; + position: relative; + cursor: pointer; +} + +.checkbox-icon::before { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border: 1px solid #000; + background-color: white; +} + +.checkbox-icon.checked::before { + content: "\2713"; /* Unicode checkmark symbol */ + text-align: center; + font-size: 14px; + line-height: 20px; /* Align the checkmark vertically */ + color: #00ff00; /* Change the color to represent a checked state */ +} + + +.expanded-row{ + td { + padding-top: 0 !important; + } +} +.hidden{ + display: none; +} +.border td{ + background-color: rgb(231, 231, 194) !important; +} + +.bordered { + .order-modal{ + background-color: lightgoldenrodyellow !important; + padding-bottom: 2em ; + } + .multi-order-modal{ + background-color: lightgoldenrodyellow !important; + padding-bottom: 2em ; + } +} + +.table.group-order-invoices-table tr{ + background-color: rgb(255, 255, 233); +} +.table.group-order-invoices-table thead tr{ + background-color: lightgoldenrodyellow; +} + +.table.group-order-invoices-table tr:nth-child(odd) > td, +.table.group-order-invoices-table tr:nth-child(even) > td{ + background-color: rgb(255, 255, 233); + padding-right: 0; + .group-order-checkbox { + margin-left: 20px; + } + .form-check-input{ + margin-left: 20px; + } +} diff --git a/plugins/invoices/app/controllers/concerns/balancing_controller_extensions.rb b/plugins/invoices/app/controllers/concerns/balancing_controller_extensions.rb new file mode 100644 index 000000000..e95a36fc8 --- /dev/null +++ b/plugins/invoices/app/controllers/concerns/balancing_controller_extensions.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module BalancingControllerExtensions + extend ActiveSupport::Concern + + included do + def index + multi_orders = MultiOrder.includes(:orders, :group_orders) + orders = Order.finished.non_multi_order + combined = (multi_orders + orders).sort_by(&:ends).reverse + @orders = Kaminari.paginate_array(combined).page(params[:page]).per(@per_page) + end + end +end diff --git a/plugins/invoices/app/controllers/concerns/orders_controller_extensions.rb b/plugins/invoices/app/controllers/concerns/orders_controller_extensions.rb new file mode 100644 index 000000000..fb29b93a1 --- /dev/null +++ b/plugins/invoices/app/controllers/concerns/orders_controller_extensions.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +module OrdersControllerExtensions + extend ActiveSupport::Concern + include InvoiceHelper + + included do # rubocop:disable Metrics/BlockLength + def collective_direct_debit + return handle_sepa_not_ready unless foodsoft_sepa_ready? + + group_orders = fetch_group_orders + return if group_orders.nil? + + @order = Order.find(params[:id]) + process_group_orders(group_orders) + end + + private + + def fetch_group_orders + case params[:mode] + when 'all' + GroupOrder.where(order_id: params[:id]) + when 'selected' + GroupOrder.where(id: params[:multi_group_order_ids]) + else + handle_invalid_mode + nil + end + end + + def process_group_orders(group_orders) + ordergroups = group_orders.map(&:ordergroup) + sepa_details = analyze_sepa_status(ordergroups, group_orders) + + if sepa_details[:export_allowed] && group_orders.present? + generate_sepa_export(group_orders, sepa_details) + else + handle_export_not_allowed(sepa_details[:not_possible_names]) + end + end + + def analyze_sepa_status(ordergroups, group_orders) + possible_names = ordergroups.map { |og| og.name if og.sepa_possible? }.compact_blank + not_possible_names = ordergroups.map(&:name) - possible_names + export_allowed = ordergroups.map(&:sepa_possible?).exclude?(false) && + group_orders.map { |go| go.group_order_invoice.present? }.exclude?(false) + + { + export_allowed: export_allowed, + possible_names: possible_names, + not_possible_names: not_possible_names + } + end + + def generate_sepa_export(group_orders, sepa_details) + respond_to do |format| + format.html { export_sepa_html(group_orders) } + format.xml { export_sepa_xml(group_orders, sepa_details) } + format.js + end + end + + def export_sepa_html(group_orders) + collective_debit = OrderCollectiveDirectDebitXml.new(group_orders) + mark_invoices_downloaded(group_orders) + send_data collective_debit.xml_string, + filename: "#{@order.name}_Sammellastschrift.xml", + type: 'text/xml' + rescue StandardError => e + handle_export_error(e, group_orders) + end + + def export_sepa_xml(group_orders, sepa_details) + mark_invoices_downloaded(group_orders) + collective_debit = OrderCollectiveDirectDebitXml.new(group_orders) + send_data collective_debit.xml_string, + filename: "#{@order.name}_Sammellastschrift.xml", + type: 'text/xml' + rescue StandardError => e + handle_xml_export_error(e, group_orders, sepa_details) + end + + def handle_sepa_not_ready + respond_to do |format| + format.html { redirect_to_finance_with_alert('activerecord.attributes.group_order_invoice.links.sepa_not_ready') } + format.xml { redirect_to_finance_with_alert('activerecord.attributes.group_order_invoice.links.sepa_not_ready') } + format.js + end + end + + def handle_invalid_mode + redirect_to_finance_with_alert('orders.collective_direct_debit.alert', ordergroup_names: '') + end + + def mark_invoices_downloaded(group_orders) + group_orders.map(&:group_order_invoice).each(&:mark_sepa_downloaded) + end + + def unmark_invoices_downloaded(group_orders) + group_orders.map(&:group_order_invoice).each(&:unmark_sepa_downloaded) + end + + def redirect_to_finance_with_alert(key, **options) + redirect_to finance_order_index_path, alert: I18n.t(key, **options) + end + + def handle_export_error(error, group_orders) + unmark_invoices_downloaded(group_orders) + if error.is_a?(SEPA::Error) + redirect_to_finance_with_alert('orders.collective_direct_debit.alert', error: error.message) + else + redirect_to finance_order_index_path, + alert: I18n.t('orders.collective_direct_debit.alert', + ordergroup_names: sepa_details[:not_possible_names].join(', '), + error: error.message) + end + end + + def handle_xml_export_error(error, group_orders, sepa_details) + unmark_invoices_downloaded(group_orders) + error_message = if error.is_a?(SEPA::Error) + { error: error.message } + else + { error: I18n.t('orders.collective_direct_debit.alert', + ordergroup_names: sepa_details[:not_possible_names].join(', '), + error: error.message) } + end + render json: error_message + end + + def handle_export_not_allowed(not_possible_names) + respond_to do |format| + format.html do + redirect_to_finance_with_alert('orders.collective_direct_debit.alert', + ordergroup_names: not_possible_names.join(', '), + error: '') + end + format.xml do + render json: { error: I18n.t('orders.collective_direct_debit.alert', + ordergroup_names: not_possible_names.join(', '), + error: '') } + end + format.js + end + end + end +end diff --git a/plugins/invoices/app/controllers/concerns/send_group_order_invoice_pdf.rb b/plugins/invoices/app/controllers/concerns/send_group_order_invoice_pdf.rb new file mode 100644 index 000000000..428ae3e66 --- /dev/null +++ b/plugins/invoices/app/controllers/concerns/send_group_order_invoice_pdf.rb @@ -0,0 +1,17 @@ +module SendGroupOrderInvoicePdf + extend ActiveSupport::Concern + + protected + + def create_invoice_pdf(group_order_invoice) + invoice_data = group_order_invoice.load_data_for_invoice + invoice_data[:title] = t('documents.group_order_invoice_pdf.title', supplier: invoice_data[:supplier]) + invoice_data[:no_footer] = true + GroupOrderInvoicePdf.new invoice_data + end + + def send_group_order_invoice_pdf(group_order_invoice) + pdf = create_invoice_pdf(group_order_invoice) + send_data pdf.to_pdf, filename: pdf.filename, type: 'application/pdf' + end +end diff --git a/plugins/invoices/app/controllers/group_order_invoices_controller.rb b/plugins/invoices/app/controllers/group_order_invoices_controller.rb new file mode 100644 index 000000000..ede5bc38f --- /dev/null +++ b/plugins/invoices/app/controllers/group_order_invoices_controller.rb @@ -0,0 +1,136 @@ +class GroupOrderInvoicesController < OrderInvoicesControllerBase + include InvoiceHelper + include SendGroupOrderInvoicePdf + + def show + @group_order_invoice = GroupOrderInvoice.find(params[:id]) + raise RecordInvalid unless FoodsoftConfig[:contact][:tax_number] + + respond_to do |format| + format.html do + send_group_order_invoice_pdf @group_order_invoice if FoodsoftConfig[:contact][:tax_number] + end + format.pdf do + send_group_order_invoice_pdf @group_order_invoice if FoodsoftConfig[:contact][:tax_number] + end + end + rescue ActiveRecord::RecordInvalid => e + redirect_back fallback_location: root_path, notice: I18n.t('errors.general'), alert: I18n.t('errors.general_msg', msg: "#{e} " + I18n.t('errors.check_tax_number')) + end + + def create + go = GroupOrder.find(params[:group_order]) + @order = go.order + begin + GroupOrderInvoice.find_or_create_by!(group_order_id: go.id) + respond_to do |format| + format.js + end + rescue StandardError => e + redirect_back fallback_location: root_path, notice: I18n.t('errors.general'), alert: I18n.t('errors.general_msg', msg: e) + end + end + + def destroy + goi = GroupOrderInvoice.find(params[:id]) + @order = goi.group_order.order + goi.destroy + respond_to do |format| + format.js + format.json { head :no_content } + end + end + + def create_multiple + invoice_date = params[:group_order_invoice][:invoice_date] + order_id = params[:group_order_invoice][:order_id] + @order = Order.find(order_id) + gos = GroupOrder.where(order_id: order_id) + gos.each do |go| + goi = GroupOrderInvoice.find_or_create_by!(group_order_id: go.id) + goi.invoice_date = invoice_date + goi.invoice_number = generate_invoice_number(goi, 1) + goi.save! + end + respond_to do |format| + format.js + end + end + + def select_all_sepa_sequence_type + @order = Order.find(params[:order_id]) + @group_order_invoices = @order.group_orders.map(&:group_order_invoice).compact + return unless params[:sepa_sequence_type] + + @sepa_sequence_type = params[:sepa_sequence_type] + @group_order_invoices.each do |goi| + goi.sepa_sequence_type = params[:sepa_sequence_type] + goi.save! + end + respond_to do |format| + format.js + end + end + + def toggle_all_paid + @order = Order.find(params[:order_id]) + @group_order_invoices = @order.group_orders.map(&:group_order_invoice).compact + @group_order_invoices.each do |goi| + goi.paid = !ActiveRecord::Type::Boolean.new.deserialize(params[:paid]) + goi.save! + end + respond_to do |format| + format.js + end + end + + def toggle_all_sepa_downloaded + @order = Order.find(params[:order_id]) + @group_order_invoices = @order.group_orders.map(&:group_order_invoice).compact + @group_order_invoices.each do |goi| + goi.sepa_downloaded = !ActiveRecord::Type::Boolean.new.deserialize(params[:sepa_downloaded]) + goi.save! + end + respond_to do |format| + format.js + end + end + + def download_all + order = Order.find(params[:order_id]) + invoices = order.group_orders.map(&:group_order_invoice) + pdf = {} + file_paths = [] + temp_file = Tempfile.new("all_invoices_for_order_#{order.id}.zip") + Zip::File.open(temp_file.path, Zip::File::CREATE) do |zipfile| + invoices.each do |invoice| + pdf = create_invoice_pdf(invoice) + file_path = File.join('tmp', pdf.filename) + File.open(file_path, 'w:ASCII-8BIT') do |file| + file.write(pdf.to_pdf) + end + file_paths << file_path + zipfile.add(pdf.filename, file_path) unless zipfile.find_entry(pdf.filename) + end + end + zip_data = File.read(temp_file.path) + file_paths.each do |file_path| + File.delete(file_path) + end + respond_to do |format| + format.html do + send_data(zip_data, type: 'application/zip', filename: "#{l order.ends, format: :file}-#{order.supplier.name}-#{order.id}.zip", disposition: 'attachment') + end + end + end + + protected + + def invoice_class + GroupOrderInvoice + end + + def related_group_order(invoice) + invoice.group_order + end +end diff --git a/plugins/invoices/app/controllers/multi_orders_controller.rb b/plugins/invoices/app/controllers/multi_orders_controller.rb new file mode 100644 index 000000000..44772fefb --- /dev/null +++ b/plugins/invoices/app/controllers/multi_orders_controller.rb @@ -0,0 +1,160 @@ +class MultiOrdersController < ApplicationController + include InvoiceHelper + before_action :set_multi_order, only: [:generate_ordergroup_invoices] + + def create + orders = Order.where(id: multi_order_params[:order_ids_for_multi_order]) + + unclosed_orders = orders.select { |order| order.closed? == false } + multi_orders = orders.select { |order| order.multi_order_id.present? } + invoiced_orders = orders.select { |order| order.group_orders.map(&:group_order_invoice).compact.present? } + + if multi_order_params[:multi_order_ids_for_multi_multi_order].present? + msg = I18n.t('multi_orders.create.no_multi_multi') + flash[:alert] = msg + respond_to do |format| + format.js + format.html { redirect_to finance_order_index_path } + end + return + end + if multi_orders.any? || unclosed_orders.any? + msg = I18n.t('multi_orders.create.invalid_orders') + flash[:alert] = msg + respond_to do |format| + format.js + format.html { redirect_to finance_order_index_path } + end + return + end + if invoiced_orders.any? + msg = I18n.t('multi_orders.create.merge_not_possible_invoices_present') + flash[:alert] = msg + respond_to do |format| + format.js + format.html { redirect_to finance_order_index_path } + end + return + end + begin + @multi_order = MultiOrder.new + @multi_order.orders = orders + @multi_order.ends = orders.map(&:ends).max + @multi_order.save! + suppliers = orders.map(&:supplier).map(&:name).join(', ') + msg = I18n.t('multi_orders.create.success', suppliers: suppliers) + respond_to do |format| + flash[:notice] = msg + format.js + format.html { redirect_to finance_order_index_path } + end + rescue ActiveRecord::RecordInvalid => e + flash[:alert] = t('errors.general_msg', msg: e.message) + respond_to do |format| + format.js + format.html { redirect_to finance_order_index_path } + end + end + end + + def destroy + @multi_order = MultiOrder.find(params[:id]) + if @multi_order.ordergroup_invoices.any? + flash[:alert] = I18n.t('multi_orders.destroy.invoices_left') + else + @multi_order.destroy + redirect_to finance_order_index_path + end + end + + def generate_ordergroup_invoices + @multi_order.group_orders.group_by(&:ordergroup_id).each_value do |group_orders| + OrdergroupInvoice.create!(group_orders: group_orders) + end + redirect_to finance_order_index_path, notice: t('finance.balancing.close.notice') + rescue StandardError => e + redirect_to finance_order_index_path, alert: t('errors.general_msg', msg: e.message) + end + + def collective_direct_debit + if foodsoft_sepa_ready? + case params[:mode] + when 'all' + multi_group_orders = MultiGroupOrder.where(multi_order_id: params[:id]) + when 'selected' + # TODO: !!! params and javascript + multi_group_orders = MultiGroupOrder.where(id: params[:multi_group_order_ids]) + else + redirect_to finance_order_index_path, alert: I18n.t('orders.collective_direct_debit.alert', ordergroup_names: '') + end + process_sepa_export(multi_group_orders) + else + respond_to do |format| + format.html do + redirect_to finance_order_index_path, alert: I18n.t('activerecord.attributes.group_order_invoice.links.sepa_not_ready') + end + format.xml do + redirect_to finance_order_index_path, alert: I18n.t('activerecord.attributes.group_order_invoice.links.sepa_not_ready') + end + format.js + end + end + end + + private + + def process_sepa_export(multi_group_orders) + @multi_order = MultiOrder.find(params[:id]) + ordergroups = multi_group_orders.flat_map(&:group_orders).map(&:ordergroup) + + export_allowed = ordergroups.map(&:sepa_possible?).exclude?(false) && multi_group_orders.map { |go| go.ordergroup_invoice.present? }.exclude?(false) + multi_group_orders.map { |mgo| mgo.id if mgo.ordergroup_invoice.present? } + + sepa_possible_ordergroup_names = ordergroups.map { |ordergroup| ordergroup.name if ordergroup.sepa_possible? }.compact_blank + sepa_not_possible_ordergroup_names = ordergroups.map(&:name) - sepa_possible_ordergroup_names + + if export_allowed && multi_group_orders.present? + generate_and_send_sepa_export(multi_group_orders, sepa_not_possible_ordergroup_names) + else + respond_to do |format| + format.html do + redirect_to finance_order_index_path, alert: I18n.t('orders.collective_direct_debit.alert', ordergroup_names: sepa_not_possible_ordergroup_names.join(', '), error: '') + end + format.xml do + render json: { error: I18n.t('orders.collective_direct_debit.alert', ordergroup_names: sepa_not_possible_ordergroup_names.join(', '), error: '') } + end + format.js + end + end + end + + def generate_and_send_sepa_export(multi_group_orders, sepa_not_possible_ordergroup_names) + respond_to do |format| + format.html do + collective_debit = OrderCollectiveDirectDebitXml.new(multi_group_orders) + send_data collective_debit.xml_string, filename: @order.name + '_' + I18n.t('multi_orders.collective_direct_debit.filename_suffix') + '.xml', type: 'text/xml' + multi_group_orders.map(&:ordergroup_invoice).each(&:mark_sepa_downloaded) + rescue StandardError => e + multi_group_orders.map(&:ordergroup_invoice).each(&:unmark_sepa_downloaded) + redirect_to finance_order_index_path, alert: I18n.t('orders.collective_direct_debit.alert', ordergroup_names: sepa_not_possible_ordergroup_names.join(', '), error: e.message) + end + format.xml do + multi_group_orders.map(&:ordergroup_invoice).each(&:mark_sepa_downloaded) + collective_debit = OrderCollectiveDirectDebitXml.new(multi_group_orders) + send_data collective_debit.xml_string, filename: @multi_order.name + '_' + I18n.t('multi_orders.collective_direct_debit.filename_suffix') + '.xml', type: 'text/xml' + rescue StandardError => e + multi_group_orders.map(&:ordergroup_invoice).each(&:unmark_sepa_downloaded) + render json: { error: I18n.t('orders.collective_direct_debit.alert', ordergroup_names: sepa_not_possible_ordergroup_names.join(', '), error: e.message) } + end + format.js + end + end + + def set_multi_order + @multi_order = MultiOrder.find(params[:id]) + end + + def multi_order_params + params.permit(:id, :foodcoop, order_ids_for_multi_order: [], multi_order_ids_for_multi_multi_order: []) + end +end diff --git a/plugins/invoices/app/controllers/order_invoices_controller_base.rb b/plugins/invoices/app/controllers/order_invoices_controller_base.rb new file mode 100644 index 000000000..a1236d388 --- /dev/null +++ b/plugins/invoices/app/controllers/order_invoices_controller_base.rb @@ -0,0 +1,44 @@ +class OrderInvoicesControllerBase < ApplicationController + before_action :authenticate_finance + + def select_sepa_sequence_type + @invoice = invoice_class.find(params[:id]) + return unless params[:sepa_sequence_type] + + @group_order = related_group_order(@invoice) + @multi_group_order = related_group_order(@invoice) + + @invoice.sepa_sequence_type = params[:sepa_sequence_type] + save_and_respond(@invoice) + end + + def toggle_paid + @invoice = invoice_class.find(params[:id]) + @invoice.paid = !@invoice.paid + save_and_respond(@invoice) + end + + def toggle_sepa_downloaded + @invoice = invoice_class.find(params[:id]) + @invoice.sepa_downloaded = !@invoice.sepa_downloaded + save_and_respond(@invoice) + end + + protected + + def save_and_respond(record) + if record.save! + respond_to { |format| format.js } + else + respond_to { |format| format.json { render json: record.errors, status: :unprocessable_entity } } + end + end + + def invoice_class + raise NotImplementedError + end + + def related_group_order(invoice) + raise NotImplementedError + end +end diff --git a/plugins/invoices/app/controllers/ordergroup_invoices_controller.rb b/plugins/invoices/app/controllers/ordergroup_invoices_controller.rb new file mode 100644 index 000000000..10ee68dd0 --- /dev/null +++ b/plugins/invoices/app/controllers/ordergroup_invoices_controller.rb @@ -0,0 +1,154 @@ +class OrdergroupInvoicesController < OrderInvoicesControllerBase + include SendGroupOrderInvoicePdf + + # def new + # @ordergroup_invoice = OrdergroupInvoice.new + # @ordergroup_invoice.payment_method = FoodsoftConfig[:ordergroup_invoices][:payment_method] || I18n.t('activerecord.attributes.ordergroup_invoice.payment_method') + # @ordergroup_invoice.sepa_sequence_type = params[:sepa_sequence_type] + # end + + def show + @ordergroup_invoice = OrdergroupInvoice.find(params[:id]) + raise RecordInvalid unless FoodsoftConfig[:contact][:tax_number] + + respond_to do |format| + format.html do + send_group_order_invoice_pdf @ordergroup_invoice if FoodsoftConfig[:contact][:tax_number] + end + format.pdf do + send_group_order_invoice_pdf @ordergroup_invoice if FoodsoftConfig[:contact][:tax_number] + end + end + rescue ActiveRecord::RecordInvalid => e + redirect_back fallback_location: root_path, notice: I18n.t('errors.general'), alert: I18n.t('errors.general_msg', msg: "#{e} " + I18n.t('errors.check_tax_number')) + end + + def create + mgo = MultiGroupOrder.find(params[:multi_group_order_id]) + @multi_order = mgo.multi_order + begin + OrdergroupInvoice.create(multi_group_order_id: mgo.id) + respond_to do |format| + format.js + end + rescue StandardError => e + redirect_back fallback_location: root_path, notice: I18n.t('errors.general'), alert: I18n.t('errors.general_msg', msg: e) + end + end + + def destroy + oi = OrdergroupInvoice.find(params[:id]) + @multi_order = oi.multi_group_order.multi_order + oi.destroy + respond_to do |format| + format.js + format.json { head :no_content } + end + end + + def create_multiple + invoice_date = params[:ordergroup_invoice][:invoice_date] + multi_order_id = params[:ordergroup_invoice][:multi_order_id] + @multi_order = MultiOrder.find(multi_order_id) + multi_group_orders = MultiGroupOrder.where(multi_order_id: multi_order_id) + multi_group_orders.each do |multi_group_order| + ordergroup_invoice = OrdergroupInvoice.find_or_create_by!(multi_group_order: multi_group_order) + ordergroup_invoice.invoice_date = invoice_date + ordergroup_invoice.invoice_number = ordergroup_invoice.generate_invoice_number(ordergroup_invoice, 1) + ordergroup_invoice.save! + end + respond_to do |format| + format.js + end + end + + def send_all + @multi_order = MultiOrder.find(params[:multi_order_id]) + @ordergroup_invoices = @multi_order.multi_group_orders.map(&:ordergroup_invoice).compact + @ordergroup_invoices.each(&:send_invoice) + respond_to do |format| + format.html do + redirect_to finance_order_index_path, notice: I18n.t('ordergroup_invoices.send_all.success') + end + end + end + + def select_all_sepa_sequence_type + @multi_order = MultiOrder.find(params[:multi_order_id]) + @ordergroup_invoices = @multi_order.multi_group_orders.map(&:ordergroup_invoice).compact + return unless params[:sepa_sequence_type] + + @sepa_sequence_type = params[:sepa_sequence_type] + @ordergroup_invoices.each do |oi| + oi.sepa_sequence_type = params[:sepa_sequence_type] + oi.save! + end + respond_to do |format| + format.js + end + end + + def toggle_all_paid + @multi_order = MultiOrder.find(params[:multi_order_id]) + @ordergroup_invoices = @multi_order.multi_group_orders.map(&:ordergroup_invoice).compact + @ordergroup_invoices.each do |oi| + oi.paid = !ActiveRecord::Type::Boolean.new.deserialize(params[:paid]) + oi.save! + end + respond_to do |format| + format.js + end + end + + def toggle_all_sepa_downloaded + @multi_order = MultiOrder.find(params[:multi_order_id]) + @ordergroup_invoices = @multi_order.multi_group_orders.map(&:ordergroup_invoice).compact + @ordergroup_invoices.each do |goi| + goi.sepa_downloaded = !ActiveRecord::Type::Boolean.new.deserialize(params[:sepa_downloaded]) + goi.save! + end + respond_to do |format| + format.js + end + end + + def download_all + multi_order = MultiOrder.find(params[:multi_order_id]) + + invoices = multi_order.multi_group_orders.map(&:ordergroup_invoice) + pdf = {} + file_paths = [] + temp_file = Tempfile.new("all_invoices_for_multi_order_#{multi_order.id}.zip") + Zip::File.open(temp_file.path, Zip::File::CREATE) do |zipfile| + invoices.each do |invoice| + pdf = create_invoice_pdf(invoice) + file_path = File.join('tmp', pdf.filename) + File.open(file_path, 'w:ASCII-8BIT') do |file| + file.write(pdf.to_pdf) + end + file_paths << file_path + zipfile.add(pdf.filename, file_path) unless zipfile.find_entry(pdf.filename) + end + end + + zip_data = File.read(temp_file.path) + file_paths.each do |file_path| + File.delete(file_path) + end + respond_to do |format| + format.html do + send_data(zip_data, type: 'application/zip', filename: "#{l multi_order.ends, format: :file}-#{multi_order.orders.first.supplier.name}-#{multi_order.id}.zip", disposition: 'attachment') + end + end + end + + protected + + def invoice_class + OrdergroupInvoice + end + + def related_group_order(invoice) + invoice.multi_group_order + end +end diff --git a/plugins/invoices/app/documents/group_order_invoice_pdf.rb b/plugins/invoices/app/documents/group_order_invoice_pdf.rb new file mode 100644 index 000000000..77e57d6a2 --- /dev/null +++ b/plugins/invoices/app/documents/group_order_invoice_pdf.rb @@ -0,0 +1,424 @@ +class GroupOrderInvoicePdf < RenderPdf + def filename + ordergroup_name = @options[:ordergroup].name || 'OrderGroup' + "#{ordergroup_name}_" + I18n.t('documents.group_order_invoice_pdf.filename', number: @options[:invoice_number]) + '.pdf' + end + + def title + I18n.t('documents.group_order_invoice_pdf.title', supplier: @options[:supplier]) + end + + def body + contact = FoodsoftConfig[:contact].symbolize_keys + ordergroup = @options[:ordergroup] + # TODO: group_by supplier, sort alphabetically + # From paragraph + bounding_box [margin_box.right - 200, margin_box.top - 20], width: 200 do + text I18n.t('documents.group_order_invoice_pdf.invoicer') + move_down 7 + text FoodsoftConfig[:name], size: fontsize(9), align: :left + move_down 5 + text contact[:street], size: fontsize(9), align: :left + move_down 5 + text "#{contact[:zip_code]} #{contact[:city]}", size: fontsize(9), align: :left + move_down 5 + if contact[:phone].present? + text "#{Supplier.human_attribute_name :phone}: #{contact[:phone]}", size: fontsize(9), align: :left + move_down 5 + end + text "#{Supplier.human_attribute_name :email}: #{contact[:email]}", size: fontsize(9), align: :left if contact[:email].present? + move_down 5 + text I18n.t('documents.group_order_invoice_pdf.tax_number', number: @options[:tax_number]), size: fontsize(9), align: :left + end + + # Receiving Ordergroup + bounding_box [margin_box.left, margin_box.top - 20], width: 200 do + text I18n.t('documents.group_order_invoice_pdf.invoicee') + move_down 7 + text I18n.t('documents.group_order_invoice_pdf.ordergroup.name', ordergroup: ordergroup.name.to_s), size: fontsize(9) + move_down 5 + if ordergroup.contact_address.present? + text I18n.t('documents.group_order_invoice_pdf.ordergroup.contact_address', contact_address: ordergroup.contact_address.to_s), size: fontsize(9) + move_down 5 + end + if ordergroup.contact_phone.present? + text I18n.t('documents.group_order_invoice_pdf.ordergroup.contact_phone', contact_phone: ordergroup.contact_phone.to_s), size: fontsize(9) + move_down 5 + end + if ordergroup.respond_to?(:customer_number) && ordergroup.customer_number.present? + text I18n.t('documents.group_order_invoice_pdf.ordergroup.customer_number', customer_number: ordergroup.customer_number.to_s), size: fontsize(9) + move_down 5 + end + end + + # invoice Date and nnvoice number + bounding_box [margin_box.right - 200, margin_box.top - 150], width: 200 do + text I18n.t('documents.group_order_invoice_pdf.invoice_number', invoice_number: @options[:invoice_number]), align: :left + move_down 5 + text I18n.t('documents.group_order_invoice_pdf.invoice_date', invoice_date: @options[:invoice_date].strftime(I18n.t('date.formats.default'))), align: :left + if @options[:pickup] + move_down 5 + text I18n.t('documents.group_order_invoice_pdf.pickup_date', invoice_date: @options[:pickup].strftime(I18n.t('date.formats.default'))) + end + end + + move_down 20 + text I18n.t('documents.group_order_invoice_pdf.payment_method', payment_method: @options[:payment_method]) + text I18n.t('documents.group_order_invoice_pdf.table_headline') + move_down 5 + + #------------- Table Data ----------------------- + + if FoodsoftConfig[:group_order_invoices][:vat_exempt] + body_for_vat_exempt + else + body_with_vat + end + end + + def body_for_vat_exempt + data = [I18n.t('documents.group_order_invoice_pdf.vat_exempt_rows')] + move_down 10 + + # Get all the articles + group_order_articles = fetch_group_order_articles + separate_deposits = FoodsoftConfig[:group_order_invoices]&.[](:separate_deposits) + + # Build data table + data, total_gross = build_vat_exempt_data_table(data, group_order_articles, separate_deposits) + + # Render data table + render_data_table(data) + + # Render sum table + move_down 5 + render_vat_exempt_sum_table(total_gross) + + # Render footer text + move_down 25 + text I18n.t('documents.group_order_invoice_pdf.small_business_regulation') + move_down 10 + end + + # Fetches all group order articles for the invoice + def fetch_group_order_articles + GroupOrderArticle.where(group_order_id: @options[:group_order_ids]) + end + + # Builds the data table for VAT exempt invoices + def build_vat_exempt_data_table(data, group_order_articles, separate_deposits) + total_gross = 0 + supplier = '' + + group_order_articles.each do |goa| + # Skip if no units received + next if goa.result.to_i == 0 + + # Add supplier header if it changed + if goa.group_order.order.supplier.name != supplier + supplier = goa.group_order.order.supplier.name + data << [supplier, '', '', ''] + end + + # Add article row + goa_total_price = separate_deposits ? goa.total_price_without_deposit : goa.total_price + data << [goa.order_article.article.name, + goa.result.to_i, + number_to_currency(goa.order_article.price.fc_price_without_deposit), + number_to_currency(goa_total_price)] + total_gross += goa_total_price + + # Add deposit row if needed + next unless separate_deposits && goa.order_article.price.deposit > 0.0 + + goa_total_deposit = goa.result * goa.order_article.price.fc_deposit_price + data << [I18n.t('documents.group_order_invoice_pdf.deposit_excluded'), + goa.result.to_i, + number_to_currency(goa.order_article.article.fc_deposit_price), + number_to_currency(goa_total_deposit)] + total_gross += goa_total_deposit + end + + [data, total_gross] + end + + # Renders the data table with proper formatting + def render_data_table(data) + table data, cell_style: { size: fontsize(8), overflow: :shrink_to_fit } do |table| + table.header = true + table.position = :center + table.cells.border_width = 1 + table.cells.border_color = '666666' + table.row(0).column(0..4).width = 80 + table.row(0).column(0).width = 180 + table.row(0).border_bottom_width = 2 + table.columns(1).align = :right + table.columns(1..6).align = :right + end + end + + # Renders the sum table for VAT exempt invoices + def render_vat_exempt_sum_table(total_gross) + sum = [] + sum << [nil, nil, I18n.t('documents.group_order_invoice_pdf.sum_to_pay_gross'), number_to_currency(total_gross)] + + table sum, cell_style: { size: fontsize(8), overflow: :shrink_to_fit } do |table| + table.header = true + table.position = :center + table.cells.border_width = 1 + table.cells.border_color = '666666' + table.row(0).columns(2..4).style(align: :bottom) + table.row(0).border_bottom_width = 2 + table.row(0..-1).columns(0..1).border_width = 0 + + table.rows(0..-1).columns(0..4).width = 80 + table.row(0).column(0).width = 180 + table.row(0).column(-1).style(font_style: :bold) + table.row(0).column(-2).style(font_style: :bold) + table.row(0).column(-1).size = fontsize(10) + table.row(0).column(-2).size = fontsize(10) + + table.columns(1).align = :right + table.columns(1..6).align = :right + end + end + + def body_with_vat + separate_deposits = FoodsoftConfig[:group_order_invoices]&.[](:separate_deposits) + marge = FoodsoftConfig[:price_markup] + + # Initialize tax hashes and totals + tax_hashes = initialize_tax_hashes(separate_deposits) + + # Build data table + data = build_vat_data_header(marge) + data, totals = build_vat_data_table(data, tax_hashes, separate_deposits) + + # Render data table + render_vat_data_table(data) + + # Build and render sum table + move_down 10 + sum = build_vat_sum_table(tax_hashes, totals, marge) + render_vat_sum_table(sum, marge) + + # Render footer text if needed + if FoodsoftConfig[:group_order_invoices][:vat_exempt] + move_down 15 + text I18n.t('documents.group_order_invoice_pdf.small_business_regulation') + end + move_down 10 + end + + # Initialize tax hashes and totals for VAT calculations + def initialize_tax_hashes(separate_deposits) + result = { + net: Hash.new(0), # for summing up article net prices grouped into vat percentage + gross: Hash.new(0), # same here with gross prices + fc: Hash.new(0), # same here with fc prices + totals: { + gross: 0, + net: 0 + } + } + + if separate_deposits + result[:deposit] = { + gross: Hash.new(0), # for summing up deposit gross prices grouped into vat percentage + net: Hash.new(0), # same here with gross prices + fc: Hash.new(0), # same here with fc prices + totals: { + deposit: 0, + deposit_gross: 0 + } + } + end + + result + end + + # Build the header row for the VAT data table + def build_vat_data_header(marge) + if marge == 0 + [I18n.t('documents.group_order_invoice_pdf.no_price_markup_rows')] + else + [I18n.t('documents.group_order_invoice_pdf.price_markup_rows', marge: marge)] + end + end + + # Build the data table for VAT included invoices + def build_vat_data_table(data, tax_hashes, separate_deposits) + group_order_articles = fetch_group_order_articles.includes(group_order: { order: :supplier }) + + # Group articles by supplier + group_order_articles.group_by { |goa| goa.group_order.order.supplier.name }.each do |supplier_name, articles| + data << [supplier_name, '', '', '', '', ''] if articles.map(&:result).sum > 0 + + # Process each article + articles.each do |goa| + next if goa.result.to_i == 0 + + # Add article row + data = add_article_row_with_vat(data, goa, tax_hashes, separate_deposits) + + # Add deposit row if needed + data = add_deposit_row_with_vat(data, goa, tax_hashes) if separate_deposits && goa.order_article.price.deposit > 0.0 + end + end + + # Extract totals from tax_hashes + totals = { + gross: tax_hashes[:totals][:gross], + deposit_gross: tax_hashes[:deposit] ? tax_hashes[:deposit][:totals][:deposit_gross] : 0 + } + + [data, totals] + end + + # Add an article row to the data table with VAT + def add_article_row_with_vat(data, goa, tax_hashes, separate_deposits) + order_article = goa.order_article + tax = order_article.price.tax + goa_total_net = goa.result * order_article.price.price + + goa_total_fc = separate_deposits ? goa.total_price_without_deposit : goa.total_price + goa_total_gross = separate_deposits ? goa.result * order_article.price.gross_price_without_deposit : goa.result * order_article.price.gross_price + + data << [order_article.article_version.name, + goa.result.to_i, + number_to_currency(order_article.price.price), + number_to_currency(goa_total_net), + tax.to_s + '%', + number_to_currency(goa_total_fc)] + + # Update tax hashes + tax_hashes[:net][tax.to_i] += goa_total_net + tax_hashes[:gross][tax.to_i] += goa_total_gross + tax_hashes[:fc][tax.to_i] += goa_total_fc + + # Update totals + tax_hashes[:totals][:net] += goa_total_net + tax_hashes[:totals][:gross] += goa_total_fc + + data + end + + # Add a deposit row to the data table with VAT + def add_deposit_row_with_vat(data, goa, tax_hashes) + order_article = goa.order_article + tax = order_article.price.tax + + goa_net_deposit = goa.result * order_article.price.net_deposit_price + goa_deposit = goa.result * order_article.price.deposit + goa_total_deposit = goa.result * order_article.price.fc_deposit_price + + data << [I18n.t('documents.group_order_invoice_pdf.deposit_excluded'), + goa.result.to_i, + number_to_currency(order_article.price.net_deposit_price), + number_to_currency(goa_net_deposit), + tax.to_s + '%', + number_to_currency(goa_total_deposit)] + + # Update deposit tax hashes + tax_hashes[:deposit][:net][tax.to_i] += goa_net_deposit + tax_hashes[:deposit][:gross][tax.to_i] += goa_deposit + tax_hashes[:deposit][:fc][tax.to_i] += goa_total_deposit + + # Update deposit totals + tax_hashes[:deposit][:totals][:deposit] += goa_deposit + tax_hashes[:deposit][:totals][:deposit_gross] += goa_total_deposit + + data + end + + # Render the data table for VAT included invoices + def render_vat_data_table(data) + table data, cell_style: { size: fontsize(8), overflow: :shrink_to_fit } do |table| + table.header = true + table.position = :center + table.cells.border_width = 1 + table.cells.border_color = '666666' + table.row(0).columns(0..6).style(background_color: 'cccccc', font_style: :bold) + table.rows(0..-1).columns(2..6).width = 80 + table.rows(0..-1).column(0).width = 170 + table.rows(0..-1).column(1).width = 40 + table.rows(0..-1).column(4).width = 60 + table.rows(0..-1).column(5).width = 90 + table.row(0).border_bottom_width = 2 + table.columns(1).align = :right + table.columns(1..6).align = :right + end + end + + # Build the sum table for VAT included invoices + def build_vat_sum_table(tax_hashes, totals, marge) + sum = if marge > 0 + [[nil, + nil, + I18n.t('documents.group_order_invoice_pdf.vat_sum_table.net'), + I18n.t('documents.group_order_invoice_pdf.vat_sum_table.tax'), + I18n.t('documents.group_order_invoice_pdf.vat_sum_table.margin'), + I18n.t('documents.group_order_invoice_pdf.vat_sum_table.gross')]] + else + [[nil, + nil, + nil, + I18n.t('documents.group_order_invoice_pdf.vat_sum_table.net'), + I18n.t('documents.group_order_invoice_pdf.vat_sum_table.tax'), + I18n.t('documents.group_order_invoice_pdf.vat_sum_table.gross')]] + end + + # Add rows for each tax rate + tax_hashes[:gross].each_key do |key| + # Add product row + sum = add_tax_sum_row(sum, key, tax_hashes, I18n.t('documents.group_order_invoice_pdf.products'), marge) + + # Add deposit row if needed + sum = add_tax_sum_row(sum, key, tax_hashes[:deposit], I18n.t('documents.group_order_invoice_pdf.deposit'), marge) if tax_hashes[:deposit] && tax_hashes[:deposit][:gross][key] > 0 + end + + # Add total row + total_amount = totals[:gross] + totals[:deposit_gross] + sum << [nil, nil, nil, nil, I18n.t('documents.group_order_invoice_pdf.sum_to_pay_gross'), number_to_currency(total_amount)] + + sum + end + + # Add a tax sum row to the sum table + def add_tax_sum_row(sum, tax_key, tax_hash, label, marge) + tmp_sum = [nil, I18n.t('documents.group_order_invoice_pdf.tax_line', label: label, tax_key: tax_key), number_to_currency(tax_hash[:net][tax_key])] + tmp_sum.unshift(nil) if marge <= 0 + tmp_sum << number_to_currency(tax_hash[:gross][tax_key] - tax_hash[:net][tax_key]) + tmp_sum << number_to_currency(tax_hash[:fc][tax_key] - tax_hash[:gross][tax_key]) if marge > 0 + tmp_sum << number_to_currency(tax_hash[:fc][tax_key]) + sum << tmp_sum + sum + end + + # Render the sum table for VAT included invoices + def render_vat_sum_table(sum, marge) + table sum, cell_style: { size: fontsize(8), overflow: :shrink_to_fit } do |table| + table.header = true + table.position = :center + table.cells.border_width = 1 + table.cells.border_color = '666666' + table.row(0).columns(2..6).style(align: :bottom) + table.row(0).border_bottom_width = 2 + table.row(0..-1).columns(0).border_width = 0 + table.row(0..-1).columns(1).border_width = 0 if marge <= 0 + table.rows(0..-1).columns(2..6).width = 80 + table.rows(0..-1).column(0).width = 110 + table.rows(0..-1).column(1).width = 100 + table.rows(0..-1).column(4).width = 60 + table.rows(0..-1).column(5).width = 90 + + table.row(-1).column(-1).style(font_style: :bold) + table.row(-1).column(-2).style(font_style: :bold) + table.row(-1).column(-1).size = fontsize(10) + table.row(-1).column(-2).size = fontsize(10) + + table.columns(1).align = :right + table.columns(1..6).align = :right + end + end +end diff --git a/plugins/invoices/app/helpers/invoice_helper.rb b/plugins/invoices/app/helpers/invoice_helper.rb new file mode 100644 index 000000000..cef4af04e --- /dev/null +++ b/plugins/invoices/app/helpers/invoice_helper.rb @@ -0,0 +1,21 @@ +module InvoiceHelper + SEPA_SEQUENCE_TYPES = { + FRST: 'First Direct Debit', + RCUR: 'Recurring Direct Debit', + OOFF: 'One-time Direct Debit', + FNAL: 'Final Direct Debit' + }.freeze + + def foodsoft_sepa_ready? + FoodsoftConfig[:group_order_invoices]&.[](:iban) && FoodsoftConfig[:group_order_invoices]&.[](:bic) && FoodsoftConfig[:name].present? && FoodsoftConfig[:group_order_invoices]&.[](:creditor_identifier).present? + end + + def generate_invoice_number(instance, count) + trailing_number = count.to_s.rjust(4, '0') + if GroupOrderInvoice.find_by(invoice_number: instance.invoice_date.strftime('%Y%m%d') + trailing_number) || OrdergroupInvoice.find_by(invoice_number: instance.invoice_date.strftime('%Y%m%d') + trailing_number) + generate_invoice_number(instance, count.to_i + 1) + else + instance.invoice_date.strftime('%Y%m%d') + trailing_number + end + end +end diff --git a/plugins/invoices/app/jobs/notify_group_order_invoice_job.rb b/plugins/invoices/app/jobs/notify_group_order_invoice_job.rb new file mode 100644 index 000000000..225b2470a --- /dev/null +++ b/plugins/invoices/app/jobs/notify_group_order_invoice_job.rb @@ -0,0 +1,10 @@ +class NotifyGroupOrderInvoiceJob < ApplicationJob + def perform(group_order_invoice) + ordergroup = group_order_invoice.group_order.ordergroup + ordergroup.users.each do |user| + InvoiceMailer.deliver_now_with_user_locale user do + InvoiceMailer.group_order_invoice(group_order_invoice, user) + end + end + end +end diff --git a/plugins/invoices/app/jobs/notify_ordergroup_invoice_job.rb b/plugins/invoices/app/jobs/notify_ordergroup_invoice_job.rb new file mode 100644 index 000000000..8ff15cb27 --- /dev/null +++ b/plugins/invoices/app/jobs/notify_ordergroup_invoice_job.rb @@ -0,0 +1,11 @@ +class NotifyOrdergroupInvoiceJob < ApplicationJob + def perform(ordergroup_invoice) + ordergroup = ordergroup_invoice.multi_group_order.ordergroup + ordergroup.users.each do |user| + ordergroup_invoice.update!(email_sent_at: Time.current) + InvoiceMailer.deliver_now_with_user_locale user do + InvoiceMailer.ordergroup_invoice(ordergroup_invoice, user) + end + end + end +end diff --git a/plugins/invoices/app/lib/order_collective_direct_debit_xml.rb b/plugins/invoices/app/lib/order_collective_direct_debit_xml.rb new file mode 100644 index 000000000..cc61f2ae1 --- /dev/null +++ b/plugins/invoices/app/lib/order_collective_direct_debit_xml.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +class OrderCollectiveDirectDebitXml + attr_reader :xml_string + + def initialize(group_orders) + batch_booking = group_orders.count > 1 + sdd = SEPA::DirectDebit.new( + # Name of the initiating party and creditor, in German: "Auftraggeber" + # String, max. 70 char + name: FoodsoftConfig[:name], + + # OPTIONAL: Business Identifier Code (SWIFT-Code) of the creditor + # String, 8 or 11 char + bic: FoodsoftConfig[:group_order_invoices][:bic], + + # International Bank Account Number of the creditor + # String, max. 34 chars + iban: FoodsoftConfig[:group_order_invoices][:iban], # remove spaces + + # Creditor Identifier, in German: Gläubiger-Identifikationsnummer + # String, max. 35 chars + creditor_identifier: FoodsoftConfig[:group_order_invoices][:creditor_identifier] + ) + group_orders.each do |group_order| + remittance_information = "#{group_order.ordergroup_invoice.invoice_number} #{group_order.multi_order.name}" if group_order.instance_of?(MultiGroupOrder) + next if group_order.price == 0 + + sdd.add_transaction( + # Name of the debtor, in German: "Zahlungspflichtiger" + # String, max. 70 char + name: group_order.ordergroup.name, + + # Ende zu Ende Referenz + reference: 'NOTPROVIDED', + + # OPTIONAL: Business Identifier Code (SWIFT-Code) of the debtor's account + # String, 8 or 11 char + bic: group_order.ordergroup.sepa_account_holder.bic.gsub(' ', ''), + + # International Bank Account Number of the debtor's account + # String, max. 34 chars + iban: group_order.ordergroup.sepa_account_holder.iban.gsub(' ', ''), + + # Amount + # Number with two decimal digit + amount: group_order.price, + + # OPTIONAL: Currency, EUR by default (ISO 4217 standard) + # String, 3 char + currency: 'EUR', + + # OPTIONAL: Instruction Identification, will not be submitted to the debtor + # String, max. 35 char + # instruction: '12345', + + # OPTIONAL: Unstructured remittance information, in German "Verwendungszweck" + # String, max. 140 char + remittance_information: remittance_information || "#{group_order.group_order_invoice.invoice_number} #{group_order.order.supplier.name}", + + # Mandate identifikation, in German "Mandatsreferenz" + # String, max. 35 char + mandate_id: group_order.ordergroup.sepa_account_holder.mandate_id, + + # Mandate Date of signature, in German "Datum, zu dem das Mandat unterschrieben wurde" + # Date + mandate_date_of_signature: group_order.ordergroup.sepa_account_holder.mandate_date_of_signature, + + # Local instrument, in German "Lastschriftart" + # One of these strings: + # 'CORE' ("Basis-Lastschrift") + # 'COR1' ("Basis-Lastschrift mit verkürzter Vorlagefrist") + # 'B2B' ("Firmen-Lastschrift") + local_instrument: 'CORE', + + # Sequence type + # One of these strings: + # 'FRST' ("Erst-Lastschrift") + # 'RCUR' ("Folge-Lastschrift") + # 'OOFF' ("Einmalige Lastschrift") + # 'FNAL' ("Letztmalige Lastschrift") + sequence_type: group_order.group_order_invoice.sepa_sequence_type || 'RCUR', + + # OPTIONAL: Requested collection date, in German "Fälligkeitsdatum der Lastschrift" + # Date + requested_date: Time.zone.today + 2.days, + + # OPTIONAL: Enables or disables batch booking, in German "Sammelbuchung / Einzelbuchung" + # True or False + batch_booking: batch_booking + ) + # Last: create XML string + end + @xml_string = sdd.to_xml # Use schema pain.008.001.02 + end +end diff --git a/plugins/invoices/app/mailers/invoice_mailer.rb b/plugins/invoices/app/mailers/invoice_mailer.rb new file mode 100644 index 000000000..25aaade40 --- /dev/null +++ b/plugins/invoices/app/mailers/invoice_mailer.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class InvoiceMailer < Mailer + # Sends automatically generated invoicesfor group orders to ordergroup members + def group_order_invoice(group_order_invoice, user) + @user = user + @group_order_invoice = group_order_invoice + @group_order = group_order_invoice.group_order + @supplier = @group_order.order.supplier.name + @group = @group_order.ordergroup + add_group_order_invoice_attachments(group_order_invoice) + mail to: user, + subject: I18n.t('mailer.group_order_invoice.subject', group: @group.name, supplier: @supplier) + end + + def ordergroup_invoice(ordergroup_invoice, user) + @user = user + @ordergroup_invoice = ordergroup_invoice + @multi_group_order = ordergroup_invoice.multi_group_order + @multi_order = @multi_group_order.multi_order + @supplier = @multi_order.orders.map(&:supplier).map(&:name).uniq.join(', ') + @group = @multi_group_order.ordergroup + add_ordergroup_invoice_attachments(ordergroup_invoice) + mail to: user, + subject: I18n.t('mailer.ordergroup_invoice.subject', group: @group.name, supplier: @supplier) + end + + def add_group_order_invoice_attachments(group_order_invoice) + attachment_name = group_order_invoice.name + '.pdf' + attachments[attachment_name] = GroupOrderInvoicePdf.new(group_order_invoice.load_data_for_invoice).to_pdf + end + + def add_ordergroup_invoice_attachments(ordergroup_invoice) + add_group_order_invoice_attachments(ordergroup_invoice) + end +end diff --git a/plugins/invoices/app/models/concerns/group_extensions.rb b/plugins/invoices/app/models/concerns/group_extensions.rb new file mode 100644 index 000000000..fde88b3ed --- /dev/null +++ b/plugins/invoices/app/models/concerns/group_extensions.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module GroupExtensions + extend ActiveSupport::Concern + + included do + has_one :sepa_account_holder, dependent: :destroy + accepts_nested_attributes_for :sepa_account_holder, allow_destroy: true + + def sepa_possible? + sepa_account_holder&.all_fields_present? || false + end + end +end diff --git a/plugins/invoices/app/models/concerns/group_order_extensions.rb b/plugins/invoices/app/models/concerns/group_order_extensions.rb new file mode 100644 index 000000000..1e5158c74 --- /dev/null +++ b/plugins/invoices/app/models/concerns/group_order_extensions.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module GroupOrderExtensions + extend ActiveSupport::Concern + + included do + has_one :group_order_invoice + # belongs_to :ordergroup_invoice, optional: true + belongs_to :multi_group_order, optional: true + belongs_to :multi_order, optional: true + end +end diff --git a/plugins/invoices/app/models/concerns/invoice_common.rb b/plugins/invoices/app/models/concerns/invoice_common.rb new file mode 100644 index 000000000..a85b0f307 --- /dev/null +++ b/plugins/invoices/app/models/concerns/invoice_common.rb @@ -0,0 +1,34 @@ +# app/models/concerns/invoice_common.rb +module InvoiceCommon + extend ActiveSupport::Concern + + included do + include InvoiceHelper + + validates_presence_of :invoice_number + validates_uniqueness_of :invoice_number + validate :tax_number_set + + after_initialize :init, unless: :persisted? + end + + def mark_sepa_downloaded + self.sepa_downloaded = true + save + end + + def unmark_sepa_downloaded + self.sepa_downloaded = false + save + end + + def name + I18n.t("activerecord.attributes.#{self.class.name.underscore}.name") + "_#{invoice_number}" + end + + def tax_number_set + return if FoodsoftConfig[:contact][:tax_number].present? + + errors.add(:base, I18n.t('activerecord.attributes.group_order_invoice.tax_number_not_set')) + end +end diff --git a/plugins/invoices/app/models/concerns/order_extensions.rb b/plugins/invoices/app/models/concerns/order_extensions.rb new file mode 100644 index 000000000..0dcfd59ba --- /dev/null +++ b/plugins/invoices/app/models/concerns/order_extensions.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module OrderExtensions + extend ActiveSupport::Concern + + included do + belongs_to :multi_order, optional: true, inverse_of: :orders + scope :non_multi_order, -> { where(multi_order_id: nil) } + end +end diff --git a/plugins/invoices/app/models/group_order_invoice.rb b/plugins/invoices/app/models/group_order_invoice.rb new file mode 100644 index 000000000..16671199d --- /dev/null +++ b/plugins/invoices/app/models/group_order_invoice.rb @@ -0,0 +1,41 @@ +class GroupOrderInvoice < ApplicationRecord + include InvoiceCommon + + belongs_to :group_order + validates_presence_of :group_order + validates_uniqueness_of :group_order_id + + def init + self.invoice_date = Time.now unless invoice_date + self.invoice_number = generate_invoice_number(self, 1) unless invoice_number + transaction_type = group_order&.financial_transaction&.financial_transaction_type + self.payment_method = transaction_type&.name || FoodsoftConfig[:group_order_invoices]&.[](:payment_method) || I18n.t('activerecord.attributes.group_order_invoice.payment_method') unless payment_method + end + + def load_data_for_invoice + invoice_data = {} + order = group_order.order + invoice_data[:pickup] = order.pickup + invoice_data[:supplier] = order.supplier&.name + invoice_data[:ordergroup] = group_order.ordergroup + invoice_data[:group_order_ids] = [group_order.id] + invoice_data[:invoice_number] = invoice_number + invoice_data[:invoice_date] = invoice_date + invoice_data[:tax_number] = FoodsoftConfig[:contact][:tax_number] + invoice_data[:payment_method] = payment_method + invoice_data[:order_articles] = {} + group_order.order_articles.each do |order_article| + # Get the result of last time ordering, if possible + goa = group_order.group_order_articles.detect { |tmp_goa| tmp_goa.order_article_id == order_article.id } + + # Build hash with relevant data + invoice_data[:order_articles][order_article.id] = { + price: order_article.article_version.fc_price, + quantity: (goa ? goa.quantity : 0), + total_price: (goa ? goa.total_price : 0), + tax: order_article.article_version.tax + } + end + invoice_data + end +end diff --git a/plugins/invoices/app/models/multi_group_order.rb b/plugins/invoices/app/models/multi_group_order.rb new file mode 100644 index 000000000..c90518598 --- /dev/null +++ b/plugins/invoices/app/models/multi_group_order.rb @@ -0,0 +1,21 @@ +class MultiGroupOrder < ApplicationRecord + belongs_to :multi_order, optional: false + has_many :group_orders, dependent: :nullify + has_one :ordergroup_invoice, dependent: :destroy + + def ordergroup + group_orders.first&.ordergroup + end + + def price + group_orders.map(&:price).sum + end + + def group_order_invoice + ordergroup_invoice + end + + def order + multi_order + end +end diff --git a/plugins/invoices/app/models/multi_order.rb b/plugins/invoices/app/models/multi_order.rb new file mode 100644 index 000000000..fd141f05e --- /dev/null +++ b/plugins/invoices/app/models/multi_order.rb @@ -0,0 +1,73 @@ +class MultiOrder < ApplicationRecord + has_many :orders, dependent: :nullify + has_many :order_articles, through: :orders + has_many :multi_group_orders, dependent: :destroy + + # TODO: diese association lösen + has_many :group_orders, through: :multi_group_orders + # has_many :ordergroups, through: :group_orders + has_many :ordergroup_invoices, through: :multi_group_orders + + validate :check_orders + after_create :create_multi_group_orders + + def name + orders.map(&:name).join(', ') + end + + def closed? + orders.all?(&:closed?) + end + + def stockit? + orders.all?(&:stockit?) + end + + def updated_by + orders.map(&:updated_by).compact.first + end + + def updated_at + orders.map(&:updated_at).compact.first + end + + def foodcoop_result + orders.map(&:foodcoop_result).compact_blank.sum + end + + def supplier + # TODO: who is this? + orders.map(&:supplier).compact.first + end + + private + + def check_orders + if orders.blank? + errors.add(:base, 'No orders selected') + return + end + orders.each do |order| + errors.add(:base, "Order #{order.name} has no group orders") if order.group_orders.blank? + errors.add(:base, "Order #{order.name} is not closed") unless order.closed? + errors.add(:base, "Order #{order.name} has group order invoices") if order.group_orders.any? { |go| go.group_order_invoice.present? } + end + end + + def create_multi_group_orders + return if orders.empty? + + all_group_orders = orders.flat_map(&:group_orders) + grouped_by_ordergroup = all_group_orders.group_by(&:ordergroup_id) + + grouped_by_ordergroup.each_value do |group_orders| + multi_group_order = MultiGroupOrder.create!( + multi_order: self, group_orders: group_orders + ) + # Now, associate each group_order with the new multi_group_order + group_orders.each do |group_order| + group_order.update!(multi_group_order: multi_group_order) + end + end + end +end diff --git a/plugins/invoices/app/models/ordergroup_invoice.rb b/plugins/invoices/app/models/ordergroup_invoice.rb new file mode 100644 index 000000000..c3cc1393d --- /dev/null +++ b/plugins/invoices/app/models/ordergroup_invoice.rb @@ -0,0 +1,54 @@ +class OrdergroupInvoice < ApplicationRecord + include InvoiceCommon + + belongs_to :multi_group_order + + after_initialize :init, unless: :persisted? + + def init + self.invoice_date = Time.now unless invoice_date + self.invoice_number = generate_invoice_number(self, 1) unless invoice_number + transaction_type = multi_group_order&.financial_transaction&.financial_transaction_type + self.payment_method = transaction_type&.name || FoodsoftConfig[:ordergroup_invoices]&.[](:payment_method) || I18n.t('activerecord.attributes.ordergroup_invoice.payment_method') unless payment_method + end + + def ordergroup + return if group_orders.empty? + + group_orders.first.ordergroup + end + + def send_invoice + NotifyOrdergroupInvoiceJob.perform_later(self) + end + + def load_data_for_invoice + invoice_data = {} + group_orders = multi_group_order.group_orders + order = group_orders.map(&:order).first + # how to define one order? + + invoice_data[:pickup] = order.pickup + invoice_data[:supplier] = FoodsoftConfig[:name] + invoice_data[:ordergroup] = group_orders.first.ordergroup + invoice_data[:group_order_ids] = group_orders.pluck(:id) + invoice_data[:invoice_number] = invoice_number + invoice_data[:invoice_date] = invoice_date + invoice_data[:tax_number] = FoodsoftConfig[:contact][:tax_number] + invoice_data[:payment_method] = payment_method + invoice_data[:order_articles] = {} + group_orders.map(&:order_articles).flatten.each do |order_article| + # Get the result of last time ordering, if possible + # goa = group_orders.group_order_articles.detect { |tmp_goa| tmp_goa.order_article_id == order_article.id } + goa = group_orders.map(&:group_order_articles).flatten.detect { |tmp_goa| tmp_goa.order_article_id == order_article.id } + # Build hash with relevant data + invoice_data[:order_articles][order_article.id] = { + price: order_article.article_version.fc_price, + quantity: (goa ? goa.quantity : 0), + total_price: (goa ? goa.total_price : 0), + tax: order_article.article_version.tax + } + end + invoice_data + end +end diff --git a/plugins/invoices/app/models/sepa_account_holder.rb b/plugins/invoices/app/models/sepa_account_holder.rb new file mode 100644 index 000000000..cc7c216c4 --- /dev/null +++ b/plugins/invoices/app/models/sepa_account_holder.rb @@ -0,0 +1,22 @@ +class SepaAccountHolder < ApplicationRecord + require 'sepa_king' + + belongs_to :group + belongs_to :user + + validates_with SEPA::IBANValidator, field_name: :iban, if: -> { iban.present? } + validates_with SEPA::BICValidator, field_name: :bic, if: -> { bic.present? } + + before_validation :strip_whitespace_from_bic_and_iban + + def all_fields_present? + iban.present? && bic.present? && mandate_id.present? && user_id.present? && mandate_date_of_signature.present? && group_id.present? + end + + private + + def strip_whitespace_from_bic_and_iban + self.iban = iban&.gsub(/\s+/, '') + self.bic = bic&.gsub(/\s+/, '') + end +end diff --git a/plugins/invoices/app/overrides/admin/configs/_tab_payment/add_group_order_invoices.html.haml.deface b/plugins/invoices/app/overrides/admin/configs/_tab_payment/add_group_order_invoices.html.haml.deface new file mode 100644 index 000000000..4a570954a --- /dev/null +++ b/plugins/invoices/app/overrides/admin/configs/_tab_payment/add_group_order_invoices.html.haml.deface @@ -0,0 +1,13 @@ +/ insert_before 'h4' +%h4= t '.group_order_invoices' += form.fields_for :group_order_invoices do |field| + = config_input field, :ignore_minimum_balance, as: :boolean + = config_input field, :use_automatic_invoices, as: :boolean + = config_input field, :separate_deposits, as: :boolean + = config_input field, :vat_exempt, as: :boolean + = config_input field, :payment_method, collection: FinancialTransactionType.all.map { |t| [t.name, t.id] }, as: :select, include_blank: true + %p + %i Für SEPA-Lastschrift-Export: + = config_input field, :iban + = config_input field, :bic + = config_input field, :creditor_identifier diff --git a/plugins/invoices/app/overrides/admin/ordergroups/_form/add_sepa_account_holder.html.haml.deface b/plugins/invoices/app/overrides/admin/ordergroups/_form/add_sepa_account_holder.html.haml.deface new file mode 100644 index 000000000..c675eee9f --- /dev/null +++ b/plugins/invoices/app/overrides/admin/ordergroups/_form/add_sepa_account_holder.html.haml.deface @@ -0,0 +1,3 @@ +/ insert_before 'erb:contains("custom_form_fields")' +- unless @ordergroup.new_record? + = render 'sepa_account_holder', f: f diff --git a/plugins/invoices/app/overrides/finance/balancing/_order_row/add_combine_column.html.haml.deface b/plugins/invoices/app/overrides/finance/balancing/_order_row/add_combine_column.html.haml.deface new file mode 100644 index 000000000..ba6dde673 --- /dev/null +++ b/plugins/invoices/app/overrides/finance/balancing/_order_row/add_combine_column.html.haml.deface @@ -0,0 +1,4 @@ +/ insert_before 'tr td:first-child' +- if FoodsoftInvoices.enabled? + %td + = check_box_tag "order_ids_for_multi_order[]", order.id, false, class: "order-checkbox", id: "order_#{order.id}_combine", data: { order_id: order.id } diff --git a/plugins/invoices/app/overrides/finance/balancing/_order_row/add_expanded_row.html.haml.deface b/plugins/invoices/app/overrides/finance/balancing/_order_row/add_expanded_row.html.haml.deface new file mode 100644 index 000000000..466a34441 --- /dev/null +++ b/plugins/invoices/app/overrides/finance/balancing/_order_row/add_expanded_row.html.haml.deface @@ -0,0 +1,6 @@ +/ insert_after 'tr' +- if FoodsoftInvoices.enabled? + - row_id = "expanded-row-#{order.id}" + %tr{:class => 'expanded-row hidden', :id => row_id} + %td{:colspan => '7'} + = render partial: 'group_order_invoices/modal', locals: { order: order } diff --git a/plugins/invoices/app/overrides/finance/balancing/_order_row/add_invoice_column.html.haml.deface b/plugins/invoices/app/overrides/finance/balancing/_order_row/add_invoice_column.html.haml.deface new file mode 100644 index 000000000..f56402bbc --- /dev/null +++ b/plugins/invoices/app/overrides/finance/balancing/_order_row/add_invoice_column.html.haml.deface @@ -0,0 +1,11 @@ +/ insert_before 'tr:not(.expanded-row) td:last-child' +- if FoodsoftInvoices.enabled? + - col_id = "group-order-invoices-for-order-#{order.id}" + %td{id: col_id} + - if order.closed? + -if FoodsoftConfig[:contact][:tax_number] && order.ordergroups.present? + = link_to I18n.t('activerecord.attributes.group_order_invoice.open_details_modal'), "#", class: 'btn btn-small expand-trigger' + -else + = I18n.t('activerecord.attributes.group_order_invoice.tax_number_not_set') + - else + = t('orders.index.not_closed') diff --git a/plugins/invoices/app/overrides/finance/balancing/_orders/add_combine_button.html.haml.deface b/plugins/invoices/app/overrides/finance/balancing/_orders/add_combine_button.html.haml.deface new file mode 100644 index 000000000..ce4e2149e --- /dev/null +++ b/plugins/invoices/app/overrides/finance/balancing/_orders/add_combine_button.html.haml.deface @@ -0,0 +1,3 @@ +/ insert_after 'table' +- if FoodsoftInvoices.enabled? + = button_tag I18n.t('activerecord.attributes.ordergroup_invoice.merge'), class: 'btn btn-primary merge-orders-btn', type: 'button', data: { url: multi_orders_path } diff --git a/plugins/invoices/app/overrides/finance/balancing/_orders/add_combine_column_heading.html.haml.deface b/plugins/invoices/app/overrides/finance/balancing/_orders/add_combine_column_heading.html.haml.deface new file mode 100644 index 000000000..dcea8c657 --- /dev/null +++ b/plugins/invoices/app/overrides/finance/balancing/_orders/add_combine_column_heading.html.haml.deface @@ -0,0 +1,3 @@ +/ insert_before 'thead tr th:first-child' +- if FoodsoftInvoices.enabled? + %th=I18n.t('activerecord.attributes.group_order_invoice.links.combine') diff --git a/plugins/invoices/app/overrides/finance/balancing/_orders/add_invoice_column_heading.html.haml.deface b/plugins/invoices/app/overrides/finance/balancing/_orders/add_invoice_column_heading.html.haml.deface new file mode 100644 index 000000000..c46fd6712 --- /dev/null +++ b/plugins/invoices/app/overrides/finance/balancing/_orders/add_invoice_column_heading.html.haml.deface @@ -0,0 +1,3 @@ +/ insert_before 'thead tr th:last-child' +- if FoodsoftInvoices.enabled? + %th= heading_helper GroupOrderInvoice, :name diff --git a/plugins/invoices/app/overrides/finance/balancing/_orders/replace_table_body.html.haml.deface b/plugins/invoices/app/overrides/finance/balancing/_orders/replace_table_body.html.haml.deface new file mode 100644 index 000000000..815e20550 --- /dev/null +++ b/plugins/invoices/app/overrides/finance/balancing/_orders/replace_table_body.html.haml.deface @@ -0,0 +1,9 @@ +/ replace_contents 'tbody' +- @orders.each do |order| + - unless FoodsoftInvoices.enabled? + = render partial: 'order_row', locals: { order: order } + - else + - if order.class == Order + = render partial: 'order_row', locals: { order: order } + - else + = render partial: 'multi_order_row', locals: { multi_order: order } diff --git a/plugins/invoices/app/views/admin/ordergroups/_sepa_account_holder.html.haml b/plugins/invoices/app/views/admin/ordergroups/_sepa_account_holder.html.haml new file mode 100644 index 000000000..c89348e3a --- /dev/null +++ b/plugins/invoices/app/views/admin/ordergroups/_sepa_account_holder.html.haml @@ -0,0 +1,20 @@ += f.simple_fields_for :sepa_account_holder, @ordergroup.sepa_account_holder || @ordergroup.build_sepa_account_holder do |sepa_f| + = sepa_f.input :user_id, collection: f.object.users.map { |user| [user.name, user.id, { 'data-iban' => user.iban, 'data-bic' => user.bic }] }, selected: sepa_f.object.user_id, as: :select, label: I18n.t('activerecord.attributes.sepa_account_holder.holder' ) , include_blank: true, input_html: { id: 'user_id_select' }, hint: I18n.t('activerecord.attributes.sepa_account_holder.hint') + = sepa_f.hidden_field :group_id, value: @ordergroup.id + = sepa_f.input :iban + = sepa_f.input :bic + = sepa_f.input :mandate_id + = sepa_f.input :mandate_date_of_signature, as: :date_picker + +- content_for :javascript do + :javascript + $(document).ready(function() { + $('#user_id_select').on('change', function() { + var selectedOption = $(this).find('option:selected'); + var iban = selectedOption.data('iban'); + var bic = selectedOption.data('bic'); + + $('#ordergroup_sepa_account_holder_attributes_iban').val(iban || ''); // Update the IBAN input field + $('#ordergroup_sepa_account_holder_attributes_bic').val(bic || ''); // Update the BIC input field + }); + }); \ No newline at end of file diff --git a/plugins/invoices/app/views/finance/balancing/_multi_order_row.html.haml b/plugins/invoices/app/views/finance/balancing/_multi_order_row.html.haml new file mode 100644 index 000000000..5fa1474b2 --- /dev/null +++ b/plugins/invoices/app/views/finance/balancing/_multi_order_row.html.haml @@ -0,0 +1,26 @@ +%tr{:class => cycle("even","odd", :name => "multi_order"), 'data-multi_order_id' => multi_order.id} + %td + %td + = "*Multi*" + -multi_order.orders.each do |order| + = link_to truncate(order.name), new_finance_order_path(order_id: order.id) + =", " if order != multi_order.orders.last + %td=h format_time(multi_order.ends) unless multi_order.ends.nil? + %td= multi_order.closed? ? t('finance.balancing.orders.cleared', amount: number_to_currency(multi_order.foodcoop_result)) : t('finance.balancing.orders.ended') + %td= show_user(multi_order.updated_by) + %td{id: "group-multi_order-invoices-for-multi-order-#{multi_order.id}"} + - if multi_order.closed? + -if FoodsoftConfig[:contact][:tax_number] && multi_order.multi_group_orders.present? + = link_to I18n.t('activerecord.attributes.group_order_invoice.open_details_modal'), "#", remote: true, class: 'btn btn-small expand-trigger' + -else + = I18n.t('activerecord.attributes.group_order_invoice.tax_number_not_set') + - else + = t('orders.index.not_closed') + %td + = link_to multi_order, method: :delete, data: { confirm: I18n.t('ui.confirm_delete', name: "Multi Bestellung #{multi_order.name}" ) } do + Multi Bestellung auflösen + %i.icon-remove + +%tr{:class => 'expanded-row hidden', :id => "expanded-multi-row-#{multi_order.id}"} + %td{:colspan => '7'} + = render partial: 'ordergroup_invoices/modal', locals: { multi_order: multi_order } \ No newline at end of file diff --git a/plugins/invoices/app/views/group_order_invoices/_collective_direct_debit.html.haml b/plugins/invoices/app/views/group_order_invoices/_collective_direct_debit.html.haml new file mode 100644 index 000000000..04da89981 --- /dev/null +++ b/plugins/invoices/app/views/group_order_invoices/_collective_direct_debit.html.haml @@ -0,0 +1,6 @@ +- if foodsoft_sepa_ready? + = link_to I18n.t('activerecord.attributes.group_order_invoice.links.direct_debit_for_all'), collective_direct_debit_order_path(id: order.id, mode: 'all'), class: 'btn btn-block', data: { turbolinks: false, order_id: order.id, supplier: order.supplier&.name }, id: "collective-direct-debit-link-all-#{order.id}" + = link_to I18n.t('activerecord.attributes.group_order_invoice.links.direct_debit_for_selected'), collective_direct_debit_order_path(id: order.id, mode: 'selected'), class: 'btn btn-block', data: { turbolinks: false, order_id: order.id, supplier: order.supplier&.name}, id: "collective-direct-debit-link-selected-#{order.id}" +- else + %i + = t('activerecord.attributes.group_order_invoice.links.sepa_not_ready') diff --git a/plugins/invoices/app/views/group_order_invoices/_links.html.haml b/plugins/invoices/app/views/group_order_invoices/_links.html.haml new file mode 100644 index 000000000..ef72a234d --- /dev/null +++ b/plugins/invoices/app/views/group_order_invoices/_links.html.haml @@ -0,0 +1,70 @@ +- show_generate_with_date = true +- order.group_orders.each do |go| + - if go.group_order_invoice.present? + - show_generate_with_date = false +- if show_generate_with_date + = form_for :group_order_invoice, url: finance_group_order_invoice_path(foodcoop: FoodsoftConfig[:default_scope], protocol: :https), remote: true do |f| + = f.label :invoice_date, I18n.t('activerecord.attributes.group_order_invoice.links.invoice_date') + = f.date_field :invoice_date, {value: Date.today, max: Date.today, required: true} + = f.hidden_field :order_id, value: order.id + = f.submit I18n.t('activerecord.attributes.group_order_invoice.links.generate_with_date'), class: 'btn btn' +%table.table.group-order-invoices-table + %thead + %tr + %th=I18n.t('activerecord.attributes.group_order_invoice.links.ordergroup') + %th=I18n.t('activerecord.attributes.group_order_invoice.links.paid') + %th=I18n.t('activerecord.attributes.group_order_invoice.links.sepa_downloaded') + %th=I18n.t('activerecord.attributes.group_order_invoice.links.sepa_sequence_type') + %th=I18n.t('activerecord.attributes.group_order_invoice.links.sepa_select') + %th= I18n.t('activerecord.attributes.group_order_invoice.links.invoice_number') + %th + %tbody + - order.group_orders.includes([:group_order_invoice, :ordergroup]).each do |go| + -if go.ordergroup.present? + - if go.group_order_invoice + %tr.order-row{id: "group_order_#{go.id}"} + %td= link_to go.ordergroup&.name, edit_admin_ordergroup_path(go.ordergroup) + %td + .div{id: "paid_#{go.group_order_invoice.id}"} + = render :partial => "group_order_invoices/toggle_paid", locals: { group_order_invoice: go.group_order_invoice } + %td + .div{id: "sepa_downloaded_#{go.group_order_invoice.id}"} + = render :partial => "group_order_invoices/toggle_sepa_downloaded", locals: { group_order_invoice: go.group_order_invoice } + %td + .div{id: "select_sepa_sequence_type_#{go.group_order_invoice.id}"} + =render :partial => 'group_order_invoices/select_sepa_sequence_type', locals:{ group_order_invoice: go.group_order_invoice, group_order: go } + %td + = check_box_tag "group_order_ids_for_order_#{order.id}", go.id, false, class: "group-order-checkbox", id: "group_order_#{go.id}_included_in_sepa", data: { order_id: go.id } + %td + %b= go.group_order_invoice.invoice_number + %td + = link_to I18n.t('activerecord.attributes.group_order_invoice.links.download'), group_order_invoice_path(go.group_order_invoice, :format => 'pdf'), class: 'btn btn-block' + = link_to I18n.t('activerecord.attributes.group_order_invoice.links.delete'), go.group_order_invoice, method: :delete, class: 'btn btn-block btn-danger', remote: true, data: { confirm: I18n.t('ui.confirm_delete', name: I18n.t('activerecord.attributes.group_order_invoice.links.invoice_for_group', name: go.ordergroup.name)) } + - else + %tr + %td + = go.ordergroup&.name + = button_to I18n.t('activerecord.attributes.group_order_invoice.links.generate'), group_order_invoices_path(:method => :post, group_order: go) ,class: 'btn btn-small', params: {id: order.id}, remote: true + %td + %td + %td + %td + %td + %td + + - if order.group_orders.map(&:group_order_invoice).compact.present? + %tr.order-row + %td= I18n.t('activerecord.attributes.group_order_invoice.links.actions_for_all') + %td= render :partial => 'group_order_invoices/toggle_all_paid', locals: { order: order } + %td + .div{id: "toggle_all_sepa_downloaded_#{order.id}"} + = render :partial => 'group_order_invoices/toggle_all_sepa_downloaded', locals: { order: order } + %td + .div{id: "select_all_sepa_sequence_type_#{order.id}"} + = render :partial => 'group_order_invoices/select_all_sepa_sequence_type', locals: { order: order } + %td + .div{id: "select_all_sepa_#{order.id}"} + = render :partial => 'group_order_invoices/collective_direct_debit', locals: { order: order } + %td + %td + = link_to I18n.t('activerecord.attributes.group_order_invoice.links.download_all_zip'), download_all_group_order_invoices_path(order_id: order.id), class: 'btn btn-block' \ No newline at end of file diff --git a/plugins/invoices/app/views/group_order_invoices/_modal.html.haml b/plugins/invoices/app/views/group_order_invoices/_modal.html.haml new file mode 100644 index 000000000..66948156e --- /dev/null +++ b/plugins/invoices/app/views/group_order_invoices/_modal.html.haml @@ -0,0 +1,2 @@ +.div{id: "order_#{order.id}_modal", class: 'order-modal', data: { order_id: order.id, supplier: order.supplier&.name } } + = render :partial => 'group_order_invoices/links', locals: { order: order } \ No newline at end of file diff --git a/plugins/invoices/app/views/group_order_invoices/_select_all_sepa_sequence_type.html.haml b/plugins/invoices/app/views/group_order_invoices/_select_all_sepa_sequence_type.html.haml new file mode 100644 index 000000000..a7b3decad --- /dev/null +++ b/plugins/invoices/app/views/group_order_invoices/_select_all_sepa_sequence_type.html.haml @@ -0,0 +1,2 @@ += link_to select_all_sepa_sequence_type_group_order_invoices_path(order_id: order.id), remote: true, method: :patch, class: "ajax-update-all-link-#{order.id}" , data: { turbolinks: false } do + = select_tag 'sepa_sequence_type', options_for_select(InvoiceHelper::SEPA_SEQUENCE_TYPES.keys.map { |st| [I18n.t("activerecord.attributes.group_order_invoice.sequence_type.#{st}"), st] }, selected: @sequence_type || order.group_orders.map(&:group_order_invoice)&.compact&.first.sepa_sequence_type), class: 'form-control', id: "all_sepa_sequence_type_#{order.id}" \ No newline at end of file diff --git a/plugins/invoices/app/views/group_order_invoices/_select_sepa_sequence_type.html.haml b/plugins/invoices/app/views/group_order_invoices/_select_sepa_sequence_type.html.haml new file mode 100644 index 000000000..6915d3af4 --- /dev/null +++ b/plugins/invoices/app/views/group_order_invoices/_select_sepa_sequence_type.html.haml @@ -0,0 +1 @@ += select_tag :sepa_sequence_type, options_for_select(InvoiceHelper::SEPA_SEQUENCE_TYPES.keys.map { |st| [I18n.t("activerecord.attributes.group_order_invoice.sequence_type.#{st}"), st] }, group_order_invoice.sepa_sequence_type), class: 'form-control ajax-update-sepa-select', id: "sepa_sequence_type_multi_#{group_order.id}", data: { url: select_sepa_sequence_type_group_order_invoice_path(group_order_invoice) } \ No newline at end of file diff --git a/plugins/invoices/app/views/group_order_invoices/_toggle_all_paid.html.haml b/plugins/invoices/app/views/group_order_invoices/_toggle_all_paid.html.haml new file mode 100644 index 000000000..9e50e2fa8 --- /dev/null +++ b/plugins/invoices/app/views/group_order_invoices/_toggle_all_paid.html.haml @@ -0,0 +1,2 @@ += link_to toggle_all_paid_group_order_invoices_path(order_id: order.id, paid: order.group_orders.map(&:group_order_invoice).compact.map(&:paid)&.all? ), remote: true, method: :patch, data: { confirm: I18n.t('ui.confirm_mark_all', name: order.group_orders.map(&:group_order_invoice).compact.map(&:paid)&.all? ? I18n.t('activerecord.attributes.group_order_invoice.links.not_paid'): I18n.t('activerecord.attributes.group_order_invoice.links.paid') ) } do + = check_box_tag :paid, '1', order.group_orders.map(&:group_order_invoice).compact.map(&:paid)&.all? , class: 'form-check-input', id: "paid_all_#{order.id}" \ No newline at end of file diff --git a/plugins/invoices/app/views/group_order_invoices/_toggle_all_sepa_downloaded.html.haml b/plugins/invoices/app/views/group_order_invoices/_toggle_all_sepa_downloaded.html.haml new file mode 100644 index 000000000..47cd11612 --- /dev/null +++ b/plugins/invoices/app/views/group_order_invoices/_toggle_all_sepa_downloaded.html.haml @@ -0,0 +1,2 @@ += link_to toggle_all_sepa_downloaded_group_order_invoices_path(order_id: order.id, sepa_downloaded: order.group_orders.map(&:group_order_invoice).compact.map(&:sepa_downloaded)&.all? ), remote: true, method: :patch, data: { confirm: I18n.t('ui.confirm_mark_all', name: order.group_orders.map(&:group_order_invoice).compact.map(&:sepa_downloaded)&.all? ? I18n.t('activerecord.attributes.group_order_invoice.links.sepa_not_downloaded') : I18n.t('activerecord.attributes.group_order_invoice.links.sepa_downloaded')) } do + = check_box_tag :sepa_downloaded, '1', order.group_orders.map(&:group_order_invoice).compact.map(&:sepa_downloaded)&.all? , class: 'form-check-input', id: "sepa_downloaded_all_#{order.id}" \ No newline at end of file diff --git a/plugins/invoices/app/views/group_order_invoices/_toggle_paid.html.haml b/plugins/invoices/app/views/group_order_invoices/_toggle_paid.html.haml new file mode 100644 index 000000000..ac14b5991 --- /dev/null +++ b/plugins/invoices/app/views/group_order_invoices/_toggle_paid.html.haml @@ -0,0 +1,2 @@ += link_to toggle_paid_group_order_invoice_path(group_order_invoice), remote: true, method: :patch, data: { turbolinks: false } do + = check_box_tag 'paid', '1', group_order_invoice.paid , class: 'form-check-input', id: "paid_#{group_order_invoice.id}" \ No newline at end of file diff --git a/plugins/invoices/app/views/group_order_invoices/_toggle_sepa_downloaded.html.haml b/plugins/invoices/app/views/group_order_invoices/_toggle_sepa_downloaded.html.haml new file mode 100644 index 000000000..a78181b24 --- /dev/null +++ b/plugins/invoices/app/views/group_order_invoices/_toggle_sepa_downloaded.html.haml @@ -0,0 +1,2 @@ += link_to toggle_sepa_downloaded_group_order_invoice_path(group_order_invoice), remote: true, method: :patch do + = check_box_tag 'sepa_downloaded', '1', group_order_invoice.sepa_downloaded , class: 'form-check-input', id: "sepa_downloaded_#{group_order_invoice.id}" \ No newline at end of file diff --git a/plugins/invoices/app/views/group_order_invoices/create.js.erb b/plugins/invoices/app/views/group_order_invoices/create.js.erb new file mode 100644 index 000000000..fde95941d --- /dev/null +++ b/plugins/invoices/app/views/group_order_invoices/create.js.erb @@ -0,0 +1 @@ +$('#order_<%= @order.id %>_modal').html('<%= j render partial: "group_order_invoices/links", locals: { order: @order } %>'); \ No newline at end of file diff --git a/plugins/invoices/app/views/group_order_invoices/create_multiple.js.erb b/plugins/invoices/app/views/group_order_invoices/create_multiple.js.erb new file mode 100644 index 000000000..fde95941d --- /dev/null +++ b/plugins/invoices/app/views/group_order_invoices/create_multiple.js.erb @@ -0,0 +1 @@ +$('#order_<%= @order.id %>_modal').html('<%= j render partial: "group_order_invoices/links", locals: { order: @order } %>'); \ No newline at end of file diff --git a/plugins/invoices/app/views/group_order_invoices/destroy.js.erb b/plugins/invoices/app/views/group_order_invoices/destroy.js.erb new file mode 100644 index 000000000..fde95941d --- /dev/null +++ b/plugins/invoices/app/views/group_order_invoices/destroy.js.erb @@ -0,0 +1 @@ +$('#order_<%= @order.id %>_modal').html('<%= j render partial: "group_order_invoices/links", locals: { order: @order } %>'); \ No newline at end of file diff --git a/plugins/invoices/app/views/group_order_invoices/select_all_sepa_sequence_type.js.erb b/plugins/invoices/app/views/group_order_invoices/select_all_sepa_sequence_type.js.erb new file mode 100644 index 000000000..4e537e9f5 --- /dev/null +++ b/plugins/invoices/app/views/group_order_invoices/select_all_sepa_sequence_type.js.erb @@ -0,0 +1 @@ +$("#order_<%= @order.id %>_modal").html("<%= escape_javascript(render partial: 'modal', locals: { order: @order, sequence_type: @sequence_type }) %>"); diff --git a/plugins/invoices/app/views/group_order_invoices/select_sepa_sequence_type.js.erb b/plugins/invoices/app/views/group_order_invoices/select_sepa_sequence_type.js.erb new file mode 100644 index 000000000..d92ef8ed5 --- /dev/null +++ b/plugins/invoices/app/views/group_order_invoices/select_sepa_sequence_type.js.erb @@ -0,0 +1 @@ +$("#select_sepa_sequence_type_<%= @invoice.id %>").html("<%= j(render partial: 'select_sepa_sequence_type', locals: {group_order_invoice: @invoice, group_order: @group_order}) %>"); diff --git a/plugins/invoices/app/views/group_order_invoices/toggle_all_paid.js.erb b/plugins/invoices/app/views/group_order_invoices/toggle_all_paid.js.erb new file mode 100644 index 000000000..fde95941d --- /dev/null +++ b/plugins/invoices/app/views/group_order_invoices/toggle_all_paid.js.erb @@ -0,0 +1 @@ +$('#order_<%= @order.id %>_modal').html('<%= j render partial: "group_order_invoices/links", locals: { order: @order } %>'); \ No newline at end of file diff --git a/plugins/invoices/app/views/group_order_invoices/toggle_all_sepa_downloaded.js.erb b/plugins/invoices/app/views/group_order_invoices/toggle_all_sepa_downloaded.js.erb new file mode 100644 index 000000000..bc4e5851a --- /dev/null +++ b/plugins/invoices/app/views/group_order_invoices/toggle_all_sepa_downloaded.js.erb @@ -0,0 +1,4 @@ +<% @group_order_invoices.each do |group_order_invoice| %> + $("#sepa_downloaded_<%= group_order_invoice.id %>").html("<%= escape_javascript(render partial: 'toggle_sepa_downloaded', locals: { group_order_invoice: group_order_invoice }) %>"); +<% end %> + $("#toggle_all_sepa_downloaded_<%= @order.id %>").html("<%= escape_javascript(render partial: 'toggle_all_sepa_downloaded', locals: { order: @order }) %>"); diff --git a/plugins/invoices/app/views/group_order_invoices/toggle_paid.js.erb b/plugins/invoices/app/views/group_order_invoices/toggle_paid.js.erb new file mode 100644 index 000000000..7b8487468 --- /dev/null +++ b/plugins/invoices/app/views/group_order_invoices/toggle_paid.js.erb @@ -0,0 +1 @@ +$('#paid_<%= @invoice.id %>').html('<%= j render partial: "group_order_invoices/toggle_paid", locals: { group_order_invoice: @invoice } %>'); \ No newline at end of file diff --git a/plugins/invoices/app/views/group_order_invoices/toggle_sepa_downloaded.js.erb b/plugins/invoices/app/views/group_order_invoices/toggle_sepa_downloaded.js.erb new file mode 100644 index 000000000..50ca293ab --- /dev/null +++ b/plugins/invoices/app/views/group_order_invoices/toggle_sepa_downloaded.js.erb @@ -0,0 +1 @@ +$("#sepa_downloaded_<%= @invoice.id %>").html("<%= escape_javascript(render partial: 'toggle_sepa_downloaded', locals: {group_order_invoice: @invoice}) %>"); diff --git a/plugins/invoices/app/views/invoice_mailer/group_order_invoice.text.haml b/plugins/invoices/app/views/invoice_mailer/group_order_invoice.text.haml new file mode 100644 index 000000000..75948fbe2 --- /dev/null +++ b/plugins/invoices/app/views/invoice_mailer/group_order_invoice.text.haml @@ -0,0 +1 @@ += raw t '.text', group: @group.name, supplier: @supplier , foodcoop: FoodsoftConfig[:name] diff --git a/plugins/invoices/app/views/invoice_mailer/ordergroup_invoice.text.haml b/plugins/invoices/app/views/invoice_mailer/ordergroup_invoice.text.haml new file mode 100644 index 000000000..75948fbe2 --- /dev/null +++ b/plugins/invoices/app/views/invoice_mailer/ordergroup_invoice.text.haml @@ -0,0 +1 @@ += raw t '.text', group: @group.name, supplier: @supplier , foodcoop: FoodsoftConfig[:name] diff --git a/plugins/invoices/app/views/ordergroup_invoices/_collective_direct_debit.html.haml b/plugins/invoices/app/views/ordergroup_invoices/_collective_direct_debit.html.haml new file mode 100644 index 000000000..c496a7cca --- /dev/null +++ b/plugins/invoices/app/views/ordergroup_invoices/_collective_direct_debit.html.haml @@ -0,0 +1,8 @@ +- if foodsoft_sepa_ready? + = link_to I18n.t('activerecord.attributes.group_order_invoice.links.direct_debit_for_all'), collective_direct_debit_multi_order_path(id: multi_order.id, mode: 'all'), class: 'btn btn-block', data: { turbolinks: false, multi_order_id: multi_order.id, supplier: FoodsoftConfig[:name] }, id: "collective-direct-debit-link-all-#{multi_order.id}" + = link_to I18n.t('activerecord.attributes.group_order_invoice.links.direct_debit_for_selected'), collective_direct_debit_multi_order_path(id: multi_order.id, mode: 'selected'), class: 'btn btn-block', data: { turbolinks: false, multi_order_id: multi_order.id, supplier: FoodsoftConfig[:name] }, id: "collective-direct-debit-link-selected-#{multi_order.id}" +- else + %i + = t('activerecord.attributes.group_order_invoice.links.sepa_not_ready') + +-# solve hotfix: multi_order.orders.first.supplier&.name \ No newline at end of file diff --git a/plugins/invoices/app/views/ordergroup_invoices/_links.html.haml b/plugins/invoices/app/views/ordergroup_invoices/_links.html.haml new file mode 100644 index 000000000..28bd89270 --- /dev/null +++ b/plugins/invoices/app/views/ordergroup_invoices/_links.html.haml @@ -0,0 +1,80 @@ +- show_generate_with_date = true +- multi_order.multi_group_orders.each do |mgo| + - if mgo.ordergroup_invoice.present? + - show_generate_with_date = false +- if show_generate_with_date + = form_for :ordergroup_invoice, url: finance_ordergroup_invoice_path(foodcoop: FoodsoftConfig[:default_scope], protocol: :https), remote: true do |f| + = f.label :invoice_date, I18n.t('activerecord.attributes.group_order_invoice.links.invoice_date') + = f.date_field :invoice_date, {value: Date.today, max: Date.today, required: true} + = f.hidden_field :multi_order_id, value: multi_order.id + + = f.submit I18n.t('activerecord.attributes.group_order_invoice.links.generate_with_date'), class: 'btn btn' + +%table.table.group-order-invoices-table + %thead + %tr + %th=I18n.t('activerecord.attributes.group_order_invoice.links.ordergroup') + %th=I18n.t('activerecord.attributes.group_order_invoice.links.paid') + %th=I18n.t('activerecord.attributes.group_order_invoice.links.sepa_downloaded') + %th=I18n.t('activerecord.attributes.group_order_invoice.links.sepa_sequence_type') + %th=I18n.t('activerecord.attributes.group_order_invoice.links.sepa_select') + %th=I18n.t('activerecord.attributes.group_order_invoice.links.invoice_number') + %th + %tbody + - multi_order.multi_group_orders.each do |mgo| + -if mgo.ordergroup_invoice.present? + %tr.order-row{id: "multi_group_order_#{mgo.id}"} + %td + = link_to mgo.ordergroup&.name, edit_admin_ordergroup_path(mgo.ordergroup) + %td + .div{id: "paid_multi_#{mgo.ordergroup_invoice.id}"} + = render :partial => "ordergroup_invoices/toggle_paid", locals: { ordergroup_invoice: mgo.ordergroup_invoice } + %td + .div{id: "sepa_downloaded_multi_#{mgo.ordergroup_invoice.id}"} + = render :partial => "ordergroup_invoices/toggle_sepa_downloaded", locals: { ordergroup_invoice: mgo.ordergroup_invoice } + %td + .div{id: "select_sepa_sequence_type_multi_#{mgo.ordergroup_invoice.id}"} + =render :partial => 'ordergroup_invoices/select_sepa_sequence_type', locals:{ ordergroup_invoice: mgo.ordergroup_invoice, multi_group_order: mgo } + %td + = check_box_tag "group_order_ids_for_multi_order_#{multi_order.id}", mgo.id, false, class: "group-order-checkbox", id: "group_order_#{mgo.id}_included_in_sepa", data: { multi_group_order_id: mgo.id } + %td + %b= mgo.ordergroup_invoice.invoice_number + - if mgo.ordergroup_invoice.email_sent_at.present? + %br/ + = I18n.t('activerecord.attributes.ordergroup_invoice.email_sent') + = I18n.l(mgo.ordergroup_invoice.email_sent_at, format: :short) + %td + = link_to I18n.t('activerecord.attributes.group_order_invoice.links.delete'), mgo.ordergroup_invoice, method: :delete, class: 'btn btn-block btn-danger', remote: true, data: { confirm: I18n.t('ui.confirm_delete', name: I18n.t('activerecord.attributes.group_order_invoice.links.invoice_for_group', name: mgo.ordergroup.name)) } + = link_to I18n.t('activerecord.attributes.group_order_invoice.links.download'), ordergroup_invoice_path(mgo.ordergroup_invoice, :format => 'pdf'), class: 'btn btn-block' + - else + %tr + %td + = mgo.group_orders.first.ordergroup&.name + = button_to I18n.t('activerecord.attributes.group_order_invoice.links.generate'), ordergroup_invoices_path(:method => :post, multi_group_order_id: mgo) ,class: 'btn btn-small', remote: true + %td + %td + %td + %td + %td + %td + - if multi_order.multi_group_orders.map(&:ordergroup_invoice).compact.present? + %tr.order-row + %td= I18n.t('activerecord.attributes.group_order_invoice.links.actions_for_all') + %td + .div{id: "toggle_all_paid_multi_#{multi_order.id}"} + = render :partial => 'ordergroup_invoices/toggle_all_paid', locals: { multi_order: multi_order } + %td + .div{id: "toggle_all_sepa_downloaded_multi_#{multi_order.id}"} + = render :partial => 'ordergroup_invoices/toggle_all_sepa_downloaded', locals: { multi_order: multi_order } + %td + .div{id: "select_all_sepa_sequence_type_multi_#{multi_order.id}"} + = render :partial => 'ordergroup_invoices/select_all_sepa_sequence_type', locals: { multi_order: multi_order } + %td + .div{id: "select_all_sepa_#{multi_order.id}"} + = render :partial => 'ordergroup_invoices/collective_direct_debit', locals: { multi_order: multi_order } + %td + %td + - if multi_order.multi_group_orders.count == multi_order.multi_group_orders.map(&:ordergroup_invoice).compact&.count + = link_to I18n.t('activerecord.attributes.group_order_invoice.links.download_all_zip'), download_all_ordergroup_invoices_path(multi_order), class: 'btn btn-block' + -# sends all ordergroup invoices to the ordergoups mail address via notifyjob + = link_to I18n.t('activerecord.attributes.group_order_invoice.links.send_all_by_email'), send_all_ordergroup_invoices_path(multi_order), class: 'btn btn-block', method: :post, data: { confirm: I18n.t('activerecord.attributes.group_order_invoice.links.confirm_send_all', ordergroups: "#{multi_order.multi_group_orders.map(&:ordergroup).map(&:name).join(', ')}" ) } diff --git a/plugins/invoices/app/views/ordergroup_invoices/_modal.html.haml b/plugins/invoices/app/views/ordergroup_invoices/_modal.html.haml new file mode 100644 index 000000000..9443613c8 --- /dev/null +++ b/plugins/invoices/app/views/ordergroup_invoices/_modal.html.haml @@ -0,0 +1,2 @@ +.div{id: "multi_order_#{multi_order.id}_modal", class: 'multi-order-modal', data: { multi_order_id: multi_order.id, supplier: multi_order&.name } } + = render :partial => 'ordergroup_invoices/links', locals: { multi_order: multi_order } diff --git a/plugins/invoices/app/views/ordergroup_invoices/_select_all_sepa_sequence_type.html.haml b/plugins/invoices/app/views/ordergroup_invoices/_select_all_sepa_sequence_type.html.haml new file mode 100644 index 000000000..01c269231 --- /dev/null +++ b/plugins/invoices/app/views/ordergroup_invoices/_select_all_sepa_sequence_type.html.haml @@ -0,0 +1,2 @@ += link_to select_all_sepa_sequence_type_ordergroup_invoices_path(multi_order_id: multi_order.id), remote: true, method: :patch, class: "ajax-update-all-link-#{multi_order.id}" , data: { turbolinks: false } do + = select_tag 'sepa_sequence_type', options_for_select(InvoiceHelper::SEPA_SEQUENCE_TYPES.keys.map { |st| [I18n.t("activerecord.attributes.group_order_invoice.sequence_type.#{st}"), st] }, selected: @sequence_type || multi_order.multi_group_orders.map(&:ordergroup_invoice)&.compact&.first&.sepa_sequence_type), class: 'form-control', id: "all_sepa_sequence_type_multi_#{multi_order.id}" \ No newline at end of file diff --git a/plugins/invoices/app/views/ordergroup_invoices/_select_sepa_sequence_type.html.haml b/plugins/invoices/app/views/ordergroup_invoices/_select_sepa_sequence_type.html.haml new file mode 100644 index 000000000..1c1178d94 --- /dev/null +++ b/plugins/invoices/app/views/ordergroup_invoices/_select_sepa_sequence_type.html.haml @@ -0,0 +1 @@ += select_tag :sepa_sequence_type, options_for_select(InvoiceHelper::SEPA_SEQUENCE_TYPES.keys.map { |st| [I18n.t("activerecord.attributes.group_order_invoice.sequence_type.#{st}"), st] }, ordergroup_invoice.sepa_sequence_type), class: 'form-control ajax-update-sepa-select', id: "sepa_sequence_type_multi_#{multi_group_order.id}", data: { url: select_sepa_sequence_type_ordergroup_invoice_path(ordergroup_invoice) } \ No newline at end of file diff --git a/plugins/invoices/app/views/ordergroup_invoices/_toggle_all_paid.html.haml b/plugins/invoices/app/views/ordergroup_invoices/_toggle_all_paid.html.haml new file mode 100644 index 000000000..9cb1817f6 --- /dev/null +++ b/plugins/invoices/app/views/ordergroup_invoices/_toggle_all_paid.html.haml @@ -0,0 +1,2 @@ += link_to toggle_all_paid_ordergroup_invoices_path(multi_order_id: multi_order.id, paid: multi_order.multi_group_orders.map(&:ordergroup_invoice).compact.map(&:paid)&.all? ), remote: true, method: :patch, data: { confirm: I18n.t('ui.confirm_mark_all', name: multi_order.multi_group_orders.map(&:ordergroup_invoice).compact.map(&:paid)&.all? ? I18n.t('activerecord.attributes.group_order_invoice.links.not_paid'): I18n.t('activerecord.attributes.group_order_invoice.links.paid') ) } do + = check_box_tag :paid, '1', multi_order.multi_group_orders.map(&:ordergroup_invoice).compact.map(&:paid)&.all? , class: 'form-check-input', id: "paid_all_multi_#{multi_order.id}" \ No newline at end of file diff --git a/plugins/invoices/app/views/ordergroup_invoices/_toggle_all_sepa_downloaded.html.haml b/plugins/invoices/app/views/ordergroup_invoices/_toggle_all_sepa_downloaded.html.haml new file mode 100644 index 000000000..ff212377e --- /dev/null +++ b/plugins/invoices/app/views/ordergroup_invoices/_toggle_all_sepa_downloaded.html.haml @@ -0,0 +1,2 @@ += link_to toggle_all_sepa_downloaded_ordergroup_invoices_path(multi_order_id: multi_order.id, sepa_downloaded: multi_order.multi_group_orders.map(&:ordergroup_invoice).compact.map(&:sepa_downloaded)&.all? ), remote: true, method: :patch, data: { confirm: I18n.t('ui.confirm_mark_all', name: multi_order.multi_group_orders.map(&:ordergroup_invoice).compact.map(&:sepa_downloaded)&.all? ? I18n.t('activerecord.attributes.group_order_invoice.links.sepa_not_downloaded') : I18n.t('activerecord.attributes.group_order_invoice.links.sepa_downloaded')) } do + = check_box_tag :sepa_downloaded, '1', multi_order.multi_group_orders.map(&:ordergroup_invoice).compact.map(&:sepa_downloaded)&.all? , class: 'form-check-input', id: "sepa_downloaded_all_multi_#{multi_order.id}" \ No newline at end of file diff --git a/plugins/invoices/app/views/ordergroup_invoices/_toggle_paid.html.haml b/plugins/invoices/app/views/ordergroup_invoices/_toggle_paid.html.haml new file mode 100644 index 000000000..4d2715d67 --- /dev/null +++ b/plugins/invoices/app/views/ordergroup_invoices/_toggle_paid.html.haml @@ -0,0 +1,2 @@ += link_to toggle_paid_ordergroup_invoice_path(ordergroup_invoice), remote: true, method: :patch, data: { turbolinks: false } do + = check_box_tag 'paid', '1', ordergroup_invoice.paid , class: 'form-check-input', id: "paid_multi_#{ordergroup_invoice.id}" \ No newline at end of file diff --git a/plugins/invoices/app/views/ordergroup_invoices/_toggle_sepa_downloaded.html.haml b/plugins/invoices/app/views/ordergroup_invoices/_toggle_sepa_downloaded.html.haml new file mode 100644 index 000000000..064d18387 --- /dev/null +++ b/plugins/invoices/app/views/ordergroup_invoices/_toggle_sepa_downloaded.html.haml @@ -0,0 +1,2 @@ += link_to toggle_sepa_downloaded_ordergroup_invoice_path(ordergroup_invoice), remote: true, method: :patch do + = check_box_tag 'sepa_downloaded', '1', ordergroup_invoice.sepa_downloaded , class: 'form-check-input', id: "sepa_downloaded_multi_#{ordergroup_invoice.id}" \ No newline at end of file diff --git a/plugins/invoices/app/views/ordergroup_invoices/create.js.erb b/plugins/invoices/app/views/ordergroup_invoices/create.js.erb new file mode 100644 index 000000000..bb012a280 --- /dev/null +++ b/plugins/invoices/app/views/ordergroup_invoices/create.js.erb @@ -0,0 +1 @@ +$("#multi_order_<%= @multi_order.id %>_modal").html("<%= escape_javascript(render partial: 'links', locals: {multi_order: @multi_order}) %>"); diff --git a/plugins/invoices/app/views/ordergroup_invoices/create_multiple.js.erb b/plugins/invoices/app/views/ordergroup_invoices/create_multiple.js.erb new file mode 100644 index 000000000..bb012a280 --- /dev/null +++ b/plugins/invoices/app/views/ordergroup_invoices/create_multiple.js.erb @@ -0,0 +1 @@ +$("#multi_order_<%= @multi_order.id %>_modal").html("<%= escape_javascript(render partial: 'links', locals: {multi_order: @multi_order}) %>"); diff --git a/plugins/invoices/app/views/ordergroup_invoices/destroy.js.erb b/plugins/invoices/app/views/ordergroup_invoices/destroy.js.erb new file mode 100644 index 000000000..ac4ebdddc --- /dev/null +++ b/plugins/invoices/app/views/ordergroup_invoices/destroy.js.erb @@ -0,0 +1 @@ +$("#multi_order_<%= @multi_order.id %>_modal").html("<%= escape_javascript(render partial: 'links', locals: {multi_order: @multi_order}) %>"); \ No newline at end of file diff --git a/plugins/invoices/app/views/ordergroup_invoices/select_all_sepa_sequence_type.js.erb b/plugins/invoices/app/views/ordergroup_invoices/select_all_sepa_sequence_type.js.erb new file mode 100644 index 000000000..60519b8bf --- /dev/null +++ b/plugins/invoices/app/views/ordergroup_invoices/select_all_sepa_sequence_type.js.erb @@ -0,0 +1 @@ +$("#multi_order_<%= @multi_order.id %>_modal").html("<%= escape_javascript(render partial: 'modal', locals: { multi_order: @multi_order, sequence_type: @sequence_type }) %>"); diff --git a/plugins/invoices/app/views/ordergroup_invoices/select_sepa_sequence_type.js.erb b/plugins/invoices/app/views/ordergroup_invoices/select_sepa_sequence_type.js.erb new file mode 100644 index 000000000..b8fe48134 --- /dev/null +++ b/plugins/invoices/app/views/ordergroup_invoices/select_sepa_sequence_type.js.erb @@ -0,0 +1 @@ +$("#select_sepa_sequence_type_multi_<%= @invoice.id %>").html("<%= j(render partial: 'select_sepa_sequence_type', locals: {ordergroup_invoice: @invoice, multi_group_order: @group_order}) %>"); diff --git a/plugins/invoices/app/views/ordergroup_invoices/toggle_all_paid.js.erb b/plugins/invoices/app/views/ordergroup_invoices/toggle_all_paid.js.erb new file mode 100644 index 000000000..40a788cc2 --- /dev/null +++ b/plugins/invoices/app/views/ordergroup_invoices/toggle_all_paid.js.erb @@ -0,0 +1,4 @@ +<% @ordergroup_invoices.each do |ordergroup_invoice| %> + $("#paid_multi_<%= ordergroup_invoice.id %>").html("<%= escape_javascript(render partial: 'toggle_paid', locals: { ordergroup_invoice: ordergroup_invoice }) %>"); +<% end %> + $("#toggle_all_paid_multi_<%= @multi_order.id %>").html("<%= escape_javascript(render partial: 'toggle_all_paid', locals: { multi_order: @multi_order }) %>"); diff --git a/plugins/invoices/app/views/ordergroup_invoices/toggle_all_sepa_downloaded.js.erb b/plugins/invoices/app/views/ordergroup_invoices/toggle_all_sepa_downloaded.js.erb new file mode 100644 index 000000000..0c68246ba --- /dev/null +++ b/plugins/invoices/app/views/ordergroup_invoices/toggle_all_sepa_downloaded.js.erb @@ -0,0 +1,4 @@ +<% @ordergroup_invoices.each do |ordergroup_invoice| %> + $("#sepa_downloaded_multi_<%= ordergroup_invoice.id %>").html("<%= escape_javascript(render partial: 'toggle_sepa_downloaded', locals: { ordergroup_invoice: ordergroup_invoice }) %>"); +<% end %> + $("#toggle_all_sepa_downloaded_multi_<%= @multi_order.id %>").html("<%= escape_javascript(render partial: 'toggle_all_sepa_downloaded', locals: { multi_order: @multi_order }) %>"); diff --git a/plugins/invoices/app/views/ordergroup_invoices/toggle_paid.js.erb b/plugins/invoices/app/views/ordergroup_invoices/toggle_paid.js.erb new file mode 100644 index 000000000..0832d8bff --- /dev/null +++ b/plugins/invoices/app/views/ordergroup_invoices/toggle_paid.js.erb @@ -0,0 +1 @@ +$("#paid_multi_<%= @invoice.id %>").html("<%= escape_javascript(render partial: 'toggle_paid', locals: {ordergroup_invoice: @invoice}) %>"); diff --git a/plugins/invoices/app/views/ordergroup_invoices/toggle_sepa_downloaded.js.erb b/plugins/invoices/app/views/ordergroup_invoices/toggle_sepa_downloaded.js.erb new file mode 100644 index 000000000..ffe34b609 --- /dev/null +++ b/plugins/invoices/app/views/ordergroup_invoices/toggle_sepa_downloaded.js.erb @@ -0,0 +1 @@ +$("#sepa_downloaded_multi_<%= @invoice.id %>").html("<%= escape_javascript(render partial: 'toggle_sepa_downloaded', locals: {ordergroup_invoice: @invoice}) %>"); diff --git a/plugins/invoices/config/locales/de.yml b/plugins/invoices/config/locales/de.yml new file mode 100644 index 000000000..4104dcc67 --- /dev/null +++ b/plugins/invoices/config/locales/de.yml @@ -0,0 +1,169 @@ +de: + activerecord: + attributes: + group_order_invoice: + name: Bestellgruppenrechnung + sequence_type: + FRST: "Erst-Lastschrift" + RCUR: "Folge-Lastschrift" + OOFF: "Einmalige Lastschrift" + FNAL: "Letztmalige Lastschrift" + links: + actions_for_all: Aktion für alle ausführen + delete: Rechnung löschen + direct_debit_for_all: Sammellastschrift für alle (.xml) + direct_debit_for_selected: Sammellastschrift für ausgewählte (.xml) + create_cumulative_invoice: Kumulative Rechnung erstellen + confirm_send_all: Möchtest Du wirklich diese Bestellgruppenrechnungen an %{ordergroups} versenden? + combine: Rechnungen zusammenfassen + download: Rechnung herunterladen + download_all_zip: Alle Rechnungen herunterladen (zip) + generate: Rechnung erzeugen + generate_with_date: setzen & erzeugen + invoice_date: Datum der Bestellgruppenrechnung + invoice_for_group: "Bestellgruppenrechnung für %{name}" + invoice_number: Rechnungsnummer + ordergroup: Bestellgruppe + paid: Bezahlt + not_paid: Nicht Bezahlt + send_all_by_email: Alle Rechnungen versenden + send_all_success: Rechnungen erfolgreich versendet + sepa_downloaded: SEPA exportiert + sepa_not_downloaded: SEPA nicht exportiert + sepa_not_ready: + SEPA Export nicht verfügbar. Wichtige Einstellungen für SEPA Export in Administration -> Einstellungen-> Finanzen fehlen + sepa_select: SEPA Export + sepa_sequence_type: SEPA Typ + open_details_modal: Details ein/ausklappen + payment_method: Guthaben + tax_number_not_set: Steuernummer in den Einstellungen nicht gesetzt oder keine Bestellgruppe vorhanden + ordergroup_invoice: + merge: Zusammenführen + name: Bestellgruppenrechnung + email_sent: versendet am + sepa_account_holder: + bic: BIC + holder: SEPA Kontoinhaber*in + iban: IBAN + mandate_id: Mandatsreferenz-ID + mandate_date_of_signature: Datum der Mandatsunterzeichnung + hint: "Achtung: Die IBAN und BIC werden automatisch eingefüllt, sobald SEPA Kontoinhaber*in ausgewählt wird. Vorige Eintragungen werden überschrieben." + + admin: + configs: + tab_payment: + group_order_invoices: Bestellgruppenrechnungen + + config: + hints: + group_order_invoices: + ignore_minimum_balance: Mitglieder können auch bestellen, wenn ihr Kontostand unter dem Minimum liegt. (Wichtig, wenn SEPA Lastschrift als Zahlart verwendet wird) + use_automatic_go_invoices: Es werden auf die Bestellgruppen zugeschnittene Rechnungen für die jeweilige Bestellung beim Klicken auf "abrechnen" an alle Bestellgruppenmitglieder per Mail versendet. + payment_method: Zahlungsart wird auf der Bestellgruppenrechnung deklariert + vat_exempt: Eine Auflistung der Rechnungsartikel erfolgt ohne explizite Ausweisung der MwSt. und die Rechnung erhält den notwendigen Zusatz bzgl. der Kleinunternehmerregelung §19 (FoodCoop Marge ebenfalls nicht in Rechnung enthalten) + iban: IBAN ohne Leerzeichen angeben + bic: BIC ohne Leerzeichen angeben + creditor_identifier: Gläubiger-ID ohne Leerzeichen angeben + keys: + group_order_invoices: + ignore_minimum_balance: Mindestkontostand ignorieren + use_automatic_invoices: Automatisch bei Abrechnung per Mail versenden + separate_deposits: Pfand getrennt abrechnen + payment_method: Zahlungsart + vat_exempt: Diese Foodcoop ist MwSt. befreit + iban: IBAN + bic: BIC + creditor_identifier: Gläubiger-ID + + documents: + group_order_invoice_pdf: + deposit: Pfand + deposit_excluded: zzgl. Pfand + filename: Rechnung%{number} + invoicer: Rechnungsteller*in + invoicee: Rechnungsempfänger*in + invoice_date: 'Rechnungsdatum: %{invoice_date}' + invoice_number: 'Rechnungsnummer: %{invoice_number}' + markup_included: zzgl. Foodcoop Marge auf brutto Preis %{marge}% + ordergroup: + contact_phone: 'Telefonnummer: %{contact_phone}' + contact_address: 'Adresse : %{contact_address}' + customer_number: 'Kundennummer: %{customer_number}' + name: Bestellgruppe %{ordergroup} + payment_method: 'Zahlungsart: %{payment_method}' + pickup_date: 'Lieferdatum: %{invoice_date}' + products: Produkte + sum_to_pay: Zu zahlen gesamt + sum_to_pay_net: Zu zahlen gesamt (netto) + sum_to_pay_gross: Gesamt + small_business_regulation: Als Kleinunternehmer*in im Sinne von §19 Abs. 1 Umsatzsteuergesetz (UStG) wird keine Umsatzsteuer berechnet. + table_headline: 'Für die Bestellung fallen folgende Posten an:' + tax_excluded: exkl. MwSt. + tax_included: zzgl. Gesamtsumme MwSt. %{tax}% + tax_line: "%{label} mit %{tax_key}%" + tax_number: 'Steuernummer: %{number}' + title: Rechnung für die Bestellung bei %{supplier} + vat_exempt_rows: + - Name + - Anzahl + - Einzelpreis + - Artikel Gesamtpreis + no_price_markup_rows: + - Name + - Anzahl + - Einzelpreis (netto) + - Artikel Gesamtpreis (netto) + - MwSt. + - Artikel Gesamtpreis (brutto) + price_markup_rows: + - Name + - Anzahl + - Einzelpreis (netto) + - Artikel Gesamtpreis (netto) + - MwSt. + - Artikel Gesamtpreis (brutto) inkl. Foodcoopmarge %{marge}% + vat_sum_table: + gross: Brutto + margin: FC-Marge + net: Netto + tax: MwSt + + + mailer: + group_order_invoice: + subject: Bestellgruppenrechnung für %{group} bei %{supplier} + ordergroup_invoice: + subject: Bestellgruppenrechnung für %{group} bei %{supplier} + + invoice_mailer: + group_order_invoice: + text: | + Liebe Bestellgruppe %{group}, + + Die Sammelbestellung bei %{supplier} wurde soeben abgerechnet und für die jeweiligen Bestellgruppen Rechnungen angelegt. + Im Anhang befindet sich daher eure Rechnung. + + Viele Grüße von %{foodcoop} + ordergroup_invoice: + text: | + Liebe Bestellgruppe %{group}, + + Die Sammelbestellung bei %{supplier} wurde soeben abgerechnet und für die jeweiligen Bestellgruppen Rechnungen angelegt. + Im Anhang befindet sich daher eure Rechnung. + + Viele Grüße von %{foodcoop} + + multi_orders: + destroy: + invoices_left: Lösche erst die Rechnungen + create: + no_multi_multi: Du kannst keine Multi-Bestellungen in eine Multi-Multi-Bestellung umwandeln. + invalid_orders: Die Bestellung ist bereits Teil einer Multi-Bestellung oder ist noch nicht abgeschlossen. + merge_not_possible_invoices_present: Zusammenführen nicht möglich. Es gibt bereits Rechnungen für einige der Bestellgruppen. + success: "Multi Bestellung für %{suppliers} erstellt" + collective_direct_debit: + filename_suffix: Sammellastschrift + + orders: + collective_direct_debit: + alert: Wichtige Daten für eine SEPA Lastschrift bei %{ordergroup_names} fehlen. diff --git a/plugins/invoices/config/locales/en.yml b/plugins/invoices/config/locales/en.yml new file mode 100644 index 000000000..62d1aa1c6 --- /dev/null +++ b/plugins/invoices/config/locales/en.yml @@ -0,0 +1,166 @@ +en: + activerecord: + attributes: + group_order_invoice: + name: Group Order Invoice + sequence_type: + FRST: "First Direct Debit" + RCUR: "Recurring Direct Debit" + OOFF: "One-time Direct Debit" + FNAL: "Final Direct Debit" + links: + actions_for_all: Actions for all group orders + create_cumulative_invoice: create cumulative invoice + combine: combine invoices + confirm_send_all: Are you sure you want to send these ordergroup invoices to %{ordergroups}? + delete: delete invoice + direct_debit_for_all: Collective direct debit for all (.xml) + direct_debit_for_selected: Collective direct debit for selected (.xml) + download: download invoice + download_all_zip: download all invoices as zip + generate: generate invoice + generate_with_date: set & generate + invoice_date: date of group order invoice + invoice_number: invoice number + ordergroup: Ordergroup + paid: paid + not_paid: unpaid + send_all_by_email: send all invoices + send_all_success: Invoices were sent to all ordergroups + sepa_downloaded: SEPA exported + sepa_not_downloaded: SEPA not exported + sepa_not_ready: Configurations for SEPA are missing in Admin->Settings->Finances + sepa_select: SEPA export + sepa_sequence_type: SEPA type + open_details_modal: Toggle details + payment_method: Credit + tax_number_not_set: Tax number not set in configs or no ordergroup present + ordergroup_invoice: + merge: Merge + name: Ordergroup Invoice + email_sent: Email sent at + sepa_account_holder: + bic: BIC + holder: SEPA Account holder + iban: IBAN + mandate_id: Mandate reference ID + mandate_date_of_signature: Date of mandate signature + hint: "Attention: The IBAN and BIC will be filled in automatically once the SEPA account holder is selected. Previous entries will be overwritten." + + admin: + configs: + tab_payment: + group_order_invoices: Group order invoices + + config: + hints: + group_order_invoices: + ignore_minimum_balance: Members can order even if their account balance is below the minimum. (Important when using SEPA direct debit as payment method) + use_automatic_go_invoices: Customized invoices for the respective order will be sent by email to all ordergroup members when clicking on "settle". + payment_method: Payment method is declared on the ordergroup invoice + vat_exempt: A list of invoice items is made without explicit indication of VAT and the invoice receives the necessary addition regarding the small business regulation §19 (FoodCoop margin also not included in invoice) + iban: Enter IBAN without spaces + bic: Enter BIC without spaces + creditor_identifier: Enter creditor identifier without spaces + keys: + group_order_invoices: + ignore_minimum_balance: Ignore minimum balance + use_automatic_invoices: Send automatically via mail after oder settlement + payment_method: Payment method + separate_deposits: Separate deposits on invoice + vat_exempt: This foodcoop is VAT exempt + iban: IBAN + bic: BIC + creditor_identifier: Creditor identifier + + documents: + group_order_invoice_pdf: + ordergroup: + contact_phone: 'Phone: %{contact_phone}' + contact_address: 'Address: %{contact_address}' + customer_number: 'Customer number: %{customer_number}' + name: 'Ordergroup: %{ordergroup}' + deposit: Deposit + deposit_excluded: plus deposit + filename: Invoice%{number} + invoicee: Invoicee + invoicer: Invoicer + invoice_date: 'Invoice date: %{invoice_date}' + invoice_number: 'Invoice number: %{invoice_number}' + markup_included: incl. Foodcoop margin on gross price %{marge}% + payment_method: 'Payment method: %{payment_method}' + pickup_date: 'Delivery date: %{invoice_date}' + products: Products + small_business_regulation: As a small entrepreneur in the sense of §19 para. 1 of the Umsatzsteuergesetz (UStG), no value added tax is charged. + sum_to_pay: Total sum + sum_to_pay_net: Total sum (net) + sum_to_pay_gross: Total sum (gross) + table_headline: 'The following items will be charged for the order:' + tax_excluded: excl. VAT + tax_included: incl. VAT %{tax}% + tax_line: "%{label} with %{tax_key}%" + tax_number: 'Tax number: %{number}' + title: Invoice for order at %{supplier} + vat_sum_table: + gross: Gross + margin: FC margin + net: Net + tax: VAT + vat_exempt_rows: + - Name + - Quantity + - Unit price + - Total price + no_price_markup_rows: + - Name + - Quantity + - Unit price (net) + - Total price (net) + - VAT + - Total price (gross) + price_markup_rows: + - Name + - Quantity + - Unit price (net) + - Total price (net) + - VAT + - Total price (gross) incl. foodcoop margin + + mailer: + group_order_invoice: + subject: Group order invoice for %{group} at %{supplier} + ordergroup_invoice: + subject: Group order invoice for %{group} at %{supplier} + + invoice_mailer: + group_order_invoice: + text: | + Dear order group %{group}, + + The collective order at %{supplier} has just been settled and invoices have been created for the respective order groups. + Attached you will find your invoice. + + Best regards from %{foodcoop} + ordergroup_invoice: + text: | + Dear order group %{group}, + + The collective order at %{supplier} has just been settled and invoices have been created for the respective order groups. + Attached you will find your invoice. + + Best regards from %{foodcoop} + + multi_orders: + destroy: + invoices_left: Delete the invoices first + create: + no_multi_multi: You cannot convert multi-orders into a multi-multi order. + invalid_orders: The order is already part of a multi-order or is not yet closed. + merge_not_possible_invoices_present: Merge not possible. There are already invoices for some of the order groups. + success: "Multi order for %{suppliers} created" + collective_direct_debit: + filename_suffix: CollectiveDirectDebit + + orders: + collective_direct_debit: + alert: Important data for a SEPA direct debit is missing for %{ordergroup_names}. diff --git a/plugins/invoices/config/locales/nl.yml b/plugins/invoices/config/locales/nl.yml new file mode 100644 index 000000000..5f917e77f --- /dev/null +++ b/plugins/invoices/config/locales/nl.yml @@ -0,0 +1,166 @@ +nl: + activerecord: + attributes: + group_order_invoice: + name: Huishoudensfactuur + sequence_type: + FRST: "Eerste automatische incasso" + RCUR: "Terugkerende automatische incasso" + OOFF: "Eenmalige automatische incasso" + FNAL: "Laatste automatische incasso" + links: + actions_for_all: Acties voor alle bestellingen + delete: Factuur verwijderen + direct_debit_for_all: Verzamel-incasso voor alle (.xml) + direct_debit_for_selected: Verzamel-incasso voor geselecteerde (.xml) + create_cumulative_invoice: Cumulatieve factuur maken + confirm_send_all: Weet je zeker dat je deze huishoudensfacturen wilt versturen naar %{ordergroups}? + combine: Facturen combineren + download: Factuur downloaden + download_all_zip: Alle facturen downloaden (zip) + generate: Factuur genereren + generate_with_date: Instellen & genereren + invoice_date: Datum van huishoudensfactuur + invoice_number: Factuurnummer + ordergroup: Huishouden + paid: Betaald + not_paid: Niet betaald + send_all_by_email: Alle facturen versturen + send_all_success: Facturen zijn naar alle huishoudens verstuurd + sepa_downloaded: SEPA geëxporteerd + sepa_not_downloaded: SEPA niet geëxporteerd + sepa_not_ready: Configuraties voor SEPA ontbreken in Beheer->Instellingen->Financiën + sepa_select: SEPA-export + sepa_sequence_type: SEPA-type + open_details_modal: Details tonen/verbergen + payment_method: Tegoed + tax_number_not_set: BTW-nummer niet ingesteld in configuratie of geen huishouden aanwezig + ordergroup_invoice: + merge: Samenvoegen + name: Huishoudensfactuur + email_sent: E-mail verzonden op + sepa_account_holder: + bic: BIC + holder: SEPA-rekeninghouder + iban: IBAN + mandate_id: Mandaatreferentie-ID + mandate_date_of_signature: Datum van ondertekening mandaat + hint: "Let op: De IBAN en BIC worden automatisch ingevuld zodra de SEPA-rekeninghouder is geselecteerd. Eerdere invoer wordt overschreven." + + admin: + configs: + tab_payment: + group_order_invoices: Huishoudensfacturen + + config: + hints: + group_order_invoices: + ignore_minimum_balance: Leden kunnen ook bestellen als hun rekeningsaldo onder het minimum ligt. (Belangrijk bij gebruik van SEPA-incasso als betaalmethode) + use_automatic_go_invoices: Aangepaste facturen voor de betreffende bestelling worden per e-mail verzonden naar alle huishoudensleden bij het klikken op "afrekenen". + payment_method: Betaalmethode wordt vermeld op de huishoudensfactuur + vat_exempt: Een lijst van factuuritems wordt gemaakt zonder expliciete vermelding van BTW en de factuur krijgt de nodige toevoeging met betrekking tot de kleine ondernemersregeling §19 (Foodcoop-marge ook niet inbegrepen in factuur) + iban: Voer IBAN in zonder spaties + bic: Voer BIC in zonder spaties + creditor_identifier: Voer incassant-ID in zonder spaties + keys: + group_order_invoices: + ignore_minimum_balance: Minimaal saldo negeren + use_automatic_invoices: Automatisch per e-mail verzenden na afrekening bestelling + payment_method: Betaalmethode + separate_deposits: Statiegeld apart factureren + vat_exempt: Deze foodcoop is BTW-vrijgesteld + iban: IBAN + bic: BIC + creditor_identifier: Incassant-ID + + documents: + group_order_invoice_pdf: + deposit: Statiegeld + deposit_excluded: plus statiegeld + filename: Factuur%{number} + invoicer: Factureerder + invoicee: Geadresseerde + invoice_date: 'Factuurdatum: %{invoice_date}' + invoice_number: 'Factuurnummer: %{invoice_number}' + markup_included: incl. Foodcoop marge op brutoprijs %{marge}% + ordergroup: + contact_phone: 'Telefoonnummer: %{contact_phone}' + contact_address: 'Adres: %{contact_address}' + customer_number: 'Klantnummer: %{customer_number}' + name: Huishouden %{ordergroup} + payment_method: 'Betaalmethode: %{payment_method}' + pickup_date: 'Leverdatum: %{invoice_date}' + products: Producten + sum_to_pay: Totaal te betalen + sum_to_pay_net: Totaal te betalen (netto) + sum_to_pay_gross: Totaal + small_business_regulation: Als kleine ondernemer in de zin van §19 lid 1 van de Umsatzsteuergesetz (UStG) wordt geen BTW in rekening gebracht. + table_headline: 'Voor de bestelling worden de volgende items in rekening gebracht:' + tax_excluded: excl. BTW + tax_included: incl. BTW %{tax}% + tax_line: "%{label} met %{tax_key}%" + tax_number: 'BTW-nummer: %{number}' + title: Factuur voor bestelling bij %{supplier} + vat_sum_table: + gross: Bruto + margin: FC-marge + net: Netto + tax: BTW + vat_exempt_rows: + - Naam + - Aantal + - Eenheidsprijs + - Totaalprijs artikel + no_price_markup_rows: + - Naam + - Aantal + - Eenheidsprijs (netto) + - Totaalprijs artikel (netto) + - BTW + - Totaalprijs artikel (bruto) + price_markup_rows: + - Naam + - Aantal + - Eenheidsprijs (netto) + - Totaalprijs artikel (netto) + - BTW + - Totaalprijs artikel (bruto) incl. Foodcoop marge %{marge}% + + mailer: + group_order_invoice: + subject: Huishoudensfactuur voor %{group} bij %{supplier} + ordergroup_invoice: + subject: Huishoudensfactuur voor %{group} bij %{supplier} + + invoice_mailer: + group_order_invoice: + text: | + Beste bestelgroep %{group}, + + De gezamenlijke bestelling bij %{supplier} is zojuist afgerekend en er zijn facturen aangemaakt voor de betreffende bestelgroepen. + In de bijlage vind je jullie factuur. + + Vriendelijke groeten van %{foodcoop} + ordergroup_invoice: + text: | + Beste bestelgroep %{group}, + + De gezamenlijke bestelling bij %{supplier} is zojuist afgerekend en er zijn facturen aangemaakt voor de betreffende bestelgroepen. + In de bijlage vind je jullie factuur. + + Vriendelijke groeten van %{foodcoop} + + multi_orders: + destroy: + invoices_left: Verwijder eerst de facturen + create: + no_multi_multi: Je kunt geen multi-bestellingen omzetten naar een multi-multi-bestelling. + invalid_orders: De bestelling is al onderdeel van een multi-bestelling of is nog niet afgesloten. + merge_not_possible_invoices_present: Samenvoegen niet mogelijk. Er bestaan al facturen voor sommige huishoudens. + success: "Multi-bestelling voor %{suppliers} aangemaakt" + collective_direct_debit: + filename_suffix: Verzamelincasso + + orders: + collective_direct_debit: + alert: Belangrijke gegevens voor een SEPA-incasso ontbreken voor %{ordergroup_names}. diff --git a/plugins/invoices/config/routes.rb b/plugins/invoices/config/routes.rb new file mode 100644 index 000000000..e20307c1f --- /dev/null +++ b/plugins/invoices/config/routes.rb @@ -0,0 +1,51 @@ +Rails.application.routes.draw do + scope '/:foodcoop' do + post 'finance/group_order_invoice', to: 'group_order_invoices#create_multiple' + post 'finance/ordergroup_invoice', to: 'ordergroup_invoices#create_multiple' + get 'multi_orders/:multi_order_id/ordergroup_invoices/download_all', to: 'ordergroup_invoices#download_all', as: 'download_all_ordergroup_invoices' + post 'multi_orders/:multi_order_id/ordergroup_invoices/send_all', to: 'ordergroup_invoices#send_all', as: 'send_all_ordergroup_invoices' + + resources :orders do + member do + get :collective_direct_debit + end + end + + resources :group_order_invoices do + member do + patch :select_sepa_sequence_type + patch :toggle_paid + patch :toggle_sepa_downloaded + end + collection do + get :download_within_date + patch :select_all_sepa_sequence_type + patch :toggle_all_paid + patch :toggle_all_sepa_downloaded + get :download_all + end + end + + resources :ordergroup_invoices do + member do + get :download_collective + patch :select_sepa_sequence_type + patch :toggle_paid + patch :toggle_sepa_downloaded + end + collection do + get :download_within_date + patch :select_all_sepa_sequence_type + patch :toggle_all_sepa_downloaded + patch :toggle_all_paid + end + end + + resources :multi_orders, only: %i[create show destroy] do + member do + get :generate_ordergroup_invoices + get :collective_direct_debit + end + end + end +end diff --git a/plugins/invoices/db/migrate/20240101000000_create_group_order_invoices.rb b/plugins/invoices/db/migrate/20240101000000_create_group_order_invoices.rb new file mode 100644 index 000000000..a4554378e --- /dev/null +++ b/plugins/invoices/db/migrate/20240101000000_create_group_order_invoices.rb @@ -0,0 +1,16 @@ +class CreateGroupOrderInvoices < ActiveRecord::Migration[7.0] + def change + create_table :group_order_invoices do |t| + t.integer :group_order_id + t.bigint :invoice_number, unique: true, limit: 8 + t.date :invoice_date + t.string :payment_method + t.boolean :paid, default: false, null: false + t.boolean :sepa_downloaded, default: false, null: false + t.string :sepa_sequence_type, default: 'RCUR' + + t.timestamps + end + add_index :group_order_invoices, :group_order_id, unique: true + end +end diff --git a/plugins/invoices/db/migrate/20240101000001_create_multi_orders.rb b/plugins/invoices/db/migrate/20240101000001_create_multi_orders.rb new file mode 100644 index 000000000..8e16ebfa9 --- /dev/null +++ b/plugins/invoices/db/migrate/20240101000001_create_multi_orders.rb @@ -0,0 +1,9 @@ +class CreateMultiOrders < ActiveRecord::Migration[7.0] + def change + create_table :multi_orders do |t| + t.datetime :ends + + t.timestamps + end + end +end diff --git a/plugins/invoices/db/migrate/20240101000002_create_multi_group_orders.rb b/plugins/invoices/db/migrate/20240101000002_create_multi_group_orders.rb new file mode 100644 index 000000000..6a20befc3 --- /dev/null +++ b/plugins/invoices/db/migrate/20240101000002_create_multi_group_orders.rb @@ -0,0 +1,8 @@ +class CreateMultiGroupOrders < ActiveRecord::Migration[7.0] + def change + create_table :multi_group_orders do |t| + t.references :multi_order, null: false, foreign_key: true + t.timestamps + end + end +end diff --git a/plugins/invoices/db/migrate/20240101000003_create_ordergroup_invoices.rb b/plugins/invoices/db/migrate/20240101000003_create_ordergroup_invoices.rb new file mode 100644 index 000000000..f1758200e --- /dev/null +++ b/plugins/invoices/db/migrate/20240101000003_create_ordergroup_invoices.rb @@ -0,0 +1,15 @@ +class CreateOrdergroupInvoices < ActiveRecord::Migration[7.0] + def change + create_table :ordergroup_invoices do |t| + t.date :invoice_date + t.string :invoice_number + t.string :payment_method + t.boolean :paid, default: false, null: false + t.boolean :sepa_downloaded, default: false, null: false + t.string :sepa_sequence_type, default: 'RCUR' + t.references :multi_group_order, foreign_key: true + t.datetime :email_sent_at + t.timestamps + end + end +end diff --git a/plugins/invoices/db/migrate/20240101000004_create_sepa_account_holders.rb b/plugins/invoices/db/migrate/20240101000004_create_sepa_account_holders.rb new file mode 100644 index 000000000..b27c0359a --- /dev/null +++ b/plugins/invoices/db/migrate/20240101000004_create_sepa_account_holders.rb @@ -0,0 +1,14 @@ +class CreateSepaAccountHolders < ActiveRecord::Migration[7.0] + def change + create_table :sepa_account_holders do |t| + t.references :user, null: false + t.references :group, null: false + + t.string :iban + t.string :bic + t.string :mandate_id + t.date :mandate_date_of_signature + t.timestamps + end + end +end diff --git a/plugins/invoices/db/migrate/20240101000005_add_multi_order_to_orders.rb b/plugins/invoices/db/migrate/20240101000005_add_multi_order_to_orders.rb new file mode 100644 index 000000000..7447670e8 --- /dev/null +++ b/plugins/invoices/db/migrate/20240101000005_add_multi_order_to_orders.rb @@ -0,0 +1,5 @@ +class AddMultiOrderToOrders < ActiveRecord::Migration[7.0] + def change + add_reference :orders, :multi_order, foreign_key: true + end +end diff --git a/plugins/invoices/db/migrate/20240101000006_add_multi_group_order_to_group_orders.rb b/plugins/invoices/db/migrate/20240101000006_add_multi_group_order_to_group_orders.rb new file mode 100644 index 000000000..a6aaa81a7 --- /dev/null +++ b/plugins/invoices/db/migrate/20240101000006_add_multi_group_order_to_group_orders.rb @@ -0,0 +1,5 @@ +class AddMultiGroupOrderToGroupOrders < ActiveRecord::Migration[7.0] + def change + add_reference :group_orders, :multi_group_order, foreign_key: true + end +end diff --git a/plugins/invoices/foodsoft_invoices.gemspec b/plugins/invoices/foodsoft_invoices.gemspec new file mode 100644 index 000000000..48797aa61 --- /dev/null +++ b/plugins/invoices/foodsoft_invoices.gemspec @@ -0,0 +1,26 @@ +$:.push File.expand_path('lib', __dir__) + +# Maintain your gem's version: +require 'foodsoft_invoices/version' + +# Describe your gem and declare its dependencies: +Gem::Specification.new do |s| + s.name = 'foodsoft_invoices' + s.version = FoodsoftInvoices::VERSION + s.authors = %w[Viehlieb Robert] + s.email = ['rw@roko.li'] + s.homepage = 'https://github.com/foodcoops/foodsoft' + s.summary = 'Invoice plugin for foodsoft.' + s.description = 'Adds comprehensive invoice functionality to foodsoft.' + s.required_ruby_version = '>= 3.4' + + s.files = Dir['{app,config,db,lib}/**/*'] + ['Rakefile', 'README.md'] + + s.add_dependency 'rails' + s.add_dependency 'deface', '~> 1.0' + s.add_dependency 'prawn' + s.add_dependency 'prawn-table' + s.add_dependency 'sepa_king' + + s.metadata['rubygems_mfa_required'] = 'true' +end diff --git a/plugins/invoices/lib/foodsoft_invoices.rb b/plugins/invoices/lib/foodsoft_invoices.rb new file mode 100644 index 000000000..dfd5ad2cf --- /dev/null +++ b/plugins/invoices/lib/foodsoft_invoices.rb @@ -0,0 +1,10 @@ +require 'foodsoft_invoices/engine' +require 'deface' + +module FoodsoftInvoices + # Return whether invoices are used or not. + # Disabled by default in {FoodsoftConfig}. + def self.enabled? + FoodsoftConfig[:use_invoices] + end +end diff --git a/plugins/invoices/lib/foodsoft_invoices/engine.rb b/plugins/invoices/lib/foodsoft_invoices/engine.rb new file mode 100644 index 000000000..cd73c6cde --- /dev/null +++ b/plugins/invoices/lib/foodsoft_invoices/engine.rb @@ -0,0 +1,39 @@ +module FoodsoftInvoices + class Engine < ::Rails::Engine + config.to_prepare do + FoodsoftInvoices.enable_extensions! if FoodsoftInvoices.enabled? + end + + initializer 'foodsoft_invoices.test_assets_precompile' do |app| + app.config.assets.precompile += %w[foodsoft_invoices.js foodsoft_invoices.css] if Rails.env.test? + end + + def default_foodsoft_config(cfg) + cfg[:use_invoices] = false + end + end + + def self.enable_extensions! + # Register assets + Foodsoft::AssetRegistry.register_stylesheet('foodsoft_invoices') + Foodsoft::AssetRegistry.register_javascript('foodsoft_invoices') + # Register controller extensions + Finance::BalancingController.include BalancingControllerExtensions + OrdersController.include OrdersControllerExtensions + # Register model extensions + Group.include GroupExtensions + GroupOrder.include GroupOrderExtensions + Order.include OrderExtensions + + # Send group order invoices when order is closed + ActiveSupport::Notifications.subscribe('foodsoft.order.closed') do |*args| + order = ActiveSupport::Notifications::Event.new(*args).payload[:order] + if FoodsoftConfig[:group_order_invoices]&.[](:use_automatic_invoices) && order.closed? + order.group_orders.each do |go| + goi = GroupOrderInvoice.find_or_create_by!(group_order_id: go.id) + NotifyGroupOrderInvoiceJob.perform_later(goi) if goi.save! + end + end + end + end +end diff --git a/plugins/invoices/lib/foodsoft_invoices/version.rb b/plugins/invoices/lib/foodsoft_invoices/version.rb new file mode 100644 index 000000000..e71418d39 --- /dev/null +++ b/plugins/invoices/lib/foodsoft_invoices/version.rb @@ -0,0 +1,3 @@ +module FoodsoftInvoices + VERSION = '0.1.0' +end diff --git a/plugins/invoices/spec/controllers/finance/balancing_controller_spec.rb b/plugins/invoices/spec/controllers/finance/balancing_controller_spec.rb new file mode 100644 index 000000000..5bef0f507 --- /dev/null +++ b/plugins/invoices/spec/controllers/finance/balancing_controller_spec.rb @@ -0,0 +1,44 @@ +require 'spec_helper' + +RSpec.describe Finance::BalancingController do + let(:admin) { create(:user, groups: [create(:workgroup, role_finance: true)]) } + let(:user) { create(:user, groups: [create(:ordergroup)]) } + let(:supplier) { create(:supplier) } + let(:article) { create(:article, supplier: supplier) } + + before do + FoodsoftInvoices.enable_extensions! + login(admin) + end + + describe 'GET #index' do + it 'combines MultiOrders and finished non-multi orders' do + # One finished non-multi order + solo_order = create(:order, supplier: supplier, article_ids: [article.id]) + create(:group_order, order: solo_order, ordergroup: user.ordergroup) + solo_order.update!(state: 'closed') + + # Two closed orders grouped into a multi order + o1 = create(:order, supplier: supplier, article_ids: [article.id]) + o2 = create(:order, supplier: supplier, article_ids: [article.id]) + create(:group_order, order: o1, ordergroup: user.ordergroup) + create(:group_order, order: o2, ordergroup: user.ordergroup) + o1.update!(state: 'closed') + o2.update!(state: 'closed') + multi = create(:multi_order, orders: [o1, o2]) + + captured = nil + + allow(Kaminari).to receive(:paginate_array).and_wrap_original do |m, arr| + captured = arr + m.call(arr) + end + + get_with_defaults :index + + expect(Kaminari).to have_received(:paginate_array) + expect(captured).to include(multi) + expect(captured).to include(solo_order) + end + end +end diff --git a/plugins/invoices/spec/controllers/multi_orders_controller_spec.rb b/plugins/invoices/spec/controllers/multi_orders_controller_spec.rb new file mode 100644 index 000000000..94caee1cc --- /dev/null +++ b/plugins/invoices/spec/controllers/multi_orders_controller_spec.rb @@ -0,0 +1,167 @@ +require 'spec_helper' + +RSpec.describe MultiOrdersController do + let(:sepa_og) { create(:ordergroup_with_sepa) } + let(:admin) { create(:user, groups: [create(:workgroup, role_finance: true)]) } + let(:user) { create(:user, groups: [sepa_og]) } + let(:supplier) { create(:supplier) } + let(:article) { create(:article, supplier: supplier) } + + before do + FoodsoftInvoices.enable_extensions! + login(admin) + FoodsoftConfig[:contact] ||= {} + FoodsoftConfig[:contact][:tax_number] = '123456789' + end + + describe 'POST #create' do + it 'creates a MultiOrder for closed orders (JS)' do + o1 = create(:order, supplier: supplier, article_ids: [article.id]) + o2 = create(:order, supplier: supplier, article_ids: [article.id]) + create(:group_order, order: o1, ordergroup: user.ordergroup) + create(:group_order, order: o2, ordergroup: user.ordergroup) + o1.update!(state: 'closed') + o2.update!(state: 'closed') + + expect do + post_with_defaults :create, params: { order_ids_for_multi_order: [o1.id, o2.id] }, xhr: true, format: :js + end.to change(MultiOrder, :count).by(1) + + expect(response).to be_successful + expect(flash[:notice]).to be_present + end + + it 'rejects multi-multi creation with alert (JS)' do + post_with_defaults :create, params: { multi_order_ids_for_multi_multi_order: [1, 2] }, xhr: true, format: :js + + expect(response).to be_successful + expect(flash[:alert]).to eq(I18n.t('multi_orders.create.no_multi_multi')) + end + + it 'rejects when one order is not closed (JS)' do + o1 = create(:order, supplier: supplier, article_ids: [article.id]) + o2 = create(:order, supplier: supplier, article_ids: [article.id]) + create(:group_order, order: o1, ordergroup: user.ordergroup) + create(:group_order, order: o2, ordergroup: user.ordergroup) + o1.update!(state: 'closed') + o2.update!(state: 'open') + + post_with_defaults :create, params: { order_ids_for_multi_order: [o1.id, o2.id] }, xhr: true, format: :js + + expect(response).to be_successful + expect(flash[:alert]).to eq(I18n.t('multi_orders.create.invalid_orders')) + end + + it 'rejects when orders already have invoices (JS)' do + o1 = create(:order, supplier: supplier, article_ids: [article.id]) + o2 = create(:order, supplier: supplier, article_ids: [article.id]) + go1 = create(:group_order, order: o1, ordergroup: user.ordergroup) + go2 = create(:group_order, order: o2, ordergroup: user.ordergroup) + o1.update!(state: 'closed') + o2.update!(state: 'closed') + create(:group_order_invoice, group_order: go1) + create(:group_order_invoice, group_order: go2) + + post_with_defaults :create, params: { order_ids_for_multi_order: [o1.id, o2.id] }, xhr: true, format: :js + + expect(response).to be_successful + expect(flash[:alert]).to eq(I18n.t('multi_orders.create.merge_not_possible_invoices_present')) + end + end + + describe 'DELETE #destroy' do + it 'blocks deletion when invoices exist' do + o1 = create(:order, supplier: supplier, article_ids: [article.id]) + o2 = create(:order, supplier: supplier, article_ids: [article.id]) + create(:group_order, order: o1, ordergroup: user.ordergroup) + create(:group_order, order: o2, ordergroup: user.ordergroup) + o1.update!(state: 'closed') + o2.update!(state: 'closed') + multi = create(:multi_order, orders: [o1, o2]) + mgo = multi.multi_group_orders.first + OrdergroupInvoice.create!(multi_group_order: mgo) + + delete_with_defaults :destroy, params: { id: multi.id } + + expect(flash[:alert]).to eq(I18n.t('multi_orders.destroy.invoices_left')) + expect(MultiOrder.exists?(multi.id)).to be(true) + end + end + + describe 'GET #generate_ordergroup_invoices' do + it 'invokes creation and redirects with notice' do + o1 = create(:order, supplier: supplier, article_ids: [article.id]) + o2 = create(:order, supplier: supplier, article_ids: [article.id]) + create(:group_order, order: o1, ordergroup: user.ordergroup) + create(:group_order, order: o2, ordergroup: user.ordergroup) + o1.update!(state: 'closed') + o2.update!(state: 'closed') + multi = create(:multi_order, orders: [o1, o2]) + + # Workaround: Stub creation to avoid dependency on view/overrides + allow(OrdergroupInvoice).to receive(:create!).and_return(instance_double(OrdergroupInvoice)) + + get_with_defaults :generate_ordergroup_invoices, params: { id: multi.id } + + expect(response).to redirect_to(finance_order_index_path) + expect(flash[:notice]).to eq(I18n.t('finance.balancing.close.notice')) + end + end + + describe 'GET #collective_direct_debit' do + before do + FoodsoftConfig[:name] = 'Spec Coop' + FoodsoftConfig[:group_order_invoices] = { + iban: 'DE02120300000000202051', + bic: 'BYLADEM1001', + creditor_identifier: 'DE98ZZZ09999999999', + payment_method: 'SEPA' + } + end + + it 'returns xml and marks invoices when mode all' do + o1 = create(:order, supplier: supplier, article_ids: [article.id]) + create(:group_order, order: o1, ordergroup: user.ordergroup) + o1.update!(state: 'closed') + multi = create(:multi_order, orders: [o1]) + mgo = multi.multi_group_orders.first + OrdergroupInvoice.create!(multi_group_order: mgo) + + xml_double = instance_double(OrderCollectiveDirectDebitXml, xml_string: 'ok') + allow(OrderCollectiveDirectDebitXml).to receive(:new).and_return(xml_double) + + get_with_defaults :collective_direct_debit, params: { id: multi.id, mode: 'all' }, format: :xml + + expect(response.media_type).to eq('text/xml') + expect(response.body).to eq('ok') + expect(mgo.reload.ordergroup_invoice.sepa_downloaded).to be(true) + end + + it 'redirects with alert when SEPA not ready' do + FoodsoftConfig[:group_order_invoices] = {} + get_with_defaults :collective_direct_debit, params: { id: 1 }, format: :html + expect(response).to redirect_to(finance_order_index_path) + expect(flash[:alert]).to eq(I18n.t('activerecord.attributes.group_order_invoice.links.sepa_not_ready')) + end + + it 'returns error json and unmarks on StandardError' do + o1 = create(:order, supplier: supplier, article_ids: [article.id]) + create(:group_order, order: o1, ordergroup: user.ordergroup) + o1.update!(state: 'closed') + multi = create(:multi_order, orders: [o1]) + mgo = multi.multi_group_orders.first + inv = OrdergroupInvoice.create!(multi_group_order: mgo) + inv.update!(sepa_downloaded: true) + + allow(OrderCollectiveDirectDebitXml).to receive(:new).and_raise(StandardError, 'boom') + + get_with_defaults :collective_direct_debit, params: { id: multi.id, mode: 'all' }, format: :xml + + # Controller renders json on XML format errors; response may be considered xml by Rails stack + # Accept both application/json and application/xml when JSON body is sent + expect(response.media_type).to(satisfy { |mt| %w[application/json application/xml].include?(mt) }) + expect(JSON.parse(response.body)['error']).to be_present + expect(mgo.reload.ordergroup_invoice.sepa_downloaded).to be(false) + end + end +end diff --git a/plugins/invoices/spec/controllers/orders_controller_spec.rb b/plugins/invoices/spec/controllers/orders_controller_spec.rb new file mode 100644 index 000000000..3fba3ad91 --- /dev/null +++ b/plugins/invoices/spec/controllers/orders_controller_spec.rb @@ -0,0 +1,72 @@ +require 'spec_helper' + +RSpec.describe OrdersController do + let(:admin) { create(:user, groups: [create(:workgroup, role_orders: true)]) } + let(:ordergroup) { create(:ordergroup) } + let(:supplier) { create(:supplier) } + let(:article) { create(:article, supplier: supplier) } + let(:order) { create(:order, supplier: supplier, article_ids: [article.id]) } + + before do + FoodsoftInvoices.enable_extensions! + login(admin) + # Minimal SEPA ready configuration + FoodsoftConfig[:name] = 'Spec Coop' + FoodsoftConfig[:group_order_invoices] = { + iban: 'DE02120300000000202051', + bic: 'BYLADEM1001', + creditor_identifier: 'DE98ZZZ09999999999', + payment_method: 'SEPA' + } + FoodsoftConfig[:contact] ||= {} + FoodsoftConfig[:contact][:tax_number] = '123456789' + end + + describe 'GET #collective_direct_debit' do + context 'when SEPA is not ready' do + it 'redirects with alert' do + FoodsoftConfig[:group_order_invoices] = {} + + get_with_defaults :collective_direct_debit, params: { id: order.id }, format: :html + + expect(response).to redirect_to(finance_order_index_path) + expect(flash[:alert]).to eq(I18n.t('activerecord.attributes.group_order_invoice.links.sepa_not_ready')) + end + end + + context 'with invalid mode' do + it 'redirects with alert' do + get_with_defaults :collective_direct_debit, params: { id: order.id, mode: 'invalid' }, format: :html + + expect(response).to have_http_status(:redirect) + expect(flash[:alert]).to eq(I18n.t('orders.collective_direct_debit.alert', ordergroup_names: '')) + end + end + + context 'with mode all and eligible group orders' do + let!(:group_order) { create(:group_order, order: order, ordergroup: ordergroup) } + + before do + # Prepare SEPA eligibility on group (stub to avoid association loading issues) + # rubocop:disable RSpec/AnyInstance + allow_any_instance_of(Ordergroup).to receive(:sepa_possible?).and_return(true) + # rubocop:enable RSpec/AnyInstance + order.update!(state: 'closed') + create(:group_order_invoice, group_order: group_order) + end + + it 'sends xml data and marks invoices as downloaded' do + # Stub XML generator to have deterministic output + xml_double = instance_double(OrderCollectiveDirectDebitXml, xml_string: 'ok') + allow(OrderCollectiveDirectDebitXml).to receive(:new).and_return(xml_double) + + get_with_defaults :collective_direct_debit, params: { id: order.id, mode: 'all' }, format: :xml + + expect(response.media_type).to eq('text/xml') + expect(response.body).to eq('ok') + expect(OrderCollectiveDirectDebitXml).to have_received(:new) + expect(group_order.reload.group_order_invoice.sepa_downloaded).to be(true) + end + end + end +end diff --git a/plugins/invoices/spec/documents/group_order_invoice_pdf_spec.rb b/plugins/invoices/spec/documents/group_order_invoice_pdf_spec.rb new file mode 100644 index 000000000..ce3214008 --- /dev/null +++ b/plugins/invoices/spec/documents/group_order_invoice_pdf_spec.rb @@ -0,0 +1,95 @@ +require 'spec_helper' + +describe GroupOrderInvoicePdf do + let(:user) { create(:user, groups: [create(:ordergroup)]) } + let(:supplier) { create(:supplier, name: 'Test Supplier') } + let(:article) { create(:article, supplier: supplier) } + let(:order) { create(:order, supplier: supplier, article_ids: [article.id]) } + let(:group_order) { create(:group_order, order: order, ordergroup: user.ordergroup) } + let(:invoice_number) { 2_025_072_900_001 } + let(:invoice_date) { Date.new(2025, 7, 29) } + + before do + FoodsoftConfig[:contact] = { + street: 'Test Street 123', + zip_code: '12345', + city: 'Test City', + email: 'test@example.com', + phone: '123-456-7890', + tax_number: '123456789' + } + end + + describe '#filename' do + it 'returns the correct filename' do + pdf = described_class.new( + ordergroup: user.ordergroup, + invoice_number: invoice_number + ) + + expected_filename = "#{user.ordergroup.name}_" + + I18n.t('documents.group_order_invoice_pdf.filename', number: invoice_number) + + '.pdf' + + expect(pdf.filename).to eq(expected_filename) + end + end + + describe '#title' do + it 'returns the correct title' do + pdf = described_class.new( + supplier: supplier.name + ) + + expected_title = I18n.t('documents.group_order_invoice_pdf.title', supplier: supplier.name) + + expect(pdf.title).to eq(expected_title) + end + end + + describe '#body' do + context 'with VAT exempt configuration' do + before do + FoodsoftConfig[:group_order_invoices] = { vat_exempt: true } + end + + it 'calls body_for_vat_exempt method' do + pdf = described_class.new( + ordergroup: user.ordergroup, + supplier: supplier.name, + invoice_number: invoice_number, + invoice_date: invoice_date, + group_order_ids: [group_order.id], + tax_number: FoodsoftConfig[:contact][:tax_number], + payment_method: 'Cash' + ) + + allow(pdf).to receive(:body_for_vat_exempt) + pdf.body + expect(pdf).to have_received(:body_for_vat_exempt) + end + end + + context 'with VAT included configuration' do + before do + FoodsoftConfig[:group_order_invoices] = { vat_exempt: false } + end + + it 'calls body_with_vat method' do + pdf = described_class.new( + ordergroup: user.ordergroup, + supplier: supplier.name, + invoice_number: invoice_number, + invoice_date: invoice_date, + group_order_ids: [group_order.id], + tax_number: FoodsoftConfig[:contact][:tax_number], + payment_method: 'Cash' + ) + + allow(pdf).to receive(:body_with_vat) + pdf.body + expect(pdf).to have_received(:body_with_vat) + end + end + end +end diff --git a/plugins/invoices/spec/factories/group_order_invoice.rb b/plugins/invoices/spec/factories/group_order_invoice.rb new file mode 100644 index 000000000..576e72efe --- /dev/null +++ b/plugins/invoices/spec/factories/group_order_invoice.rb @@ -0,0 +1,7 @@ +require 'factory_bot' + +FactoryBot.define do + factory :group_order_invoice do + group_order + end +end diff --git a/plugins/invoices/spec/factories/multi_order.rb b/plugins/invoices/spec/factories/multi_order.rb new file mode 100644 index 000000000..3a0905f05 --- /dev/null +++ b/plugins/invoices/spec/factories/multi_order.rb @@ -0,0 +1,17 @@ +require 'factory_bot' + +FactoryBot.define do + factory :multi_order do + after(:build) do |multi_order, evaluator| + # Assign orders before validation so custom validations can see them + multi_order.orders = evaluator.orders + end + + after(:create) do |multi_order, _evaluator| + # Persist the relationship by updating the orders (if needed) + multi_order.orders.each do |order| + order.update!(multi_order: multi_order) + end + end + end +end diff --git a/plugins/invoices/spec/factories/ordergroup.rb b/plugins/invoices/spec/factories/ordergroup.rb new file mode 100644 index 000000000..2cb0b16f4 --- /dev/null +++ b/plugins/invoices/spec/factories/ordergroup.rb @@ -0,0 +1,7 @@ +FactoryBot.define do + factory :ordergroup_with_sepa, parent: :ordergroup do + after(:create) do |group| + create(:sepa_account_holder, group: group, user: create(:user)) + end + end +end diff --git a/plugins/invoices/spec/factories/sepa_account_holder.rb b/plugins/invoices/spec/factories/sepa_account_holder.rb new file mode 100644 index 000000000..2de426eed --- /dev/null +++ b/plugins/invoices/spec/factories/sepa_account_holder.rb @@ -0,0 +1,12 @@ +require 'factory_bot' + +FactoryBot.define do + factory :sepa_account_holder do + group + user + iban { 'DE02120300000000202051' } + bic { 'BYLADEM1001' } + mandate_id { "MDT-#{SecureRandom.hex(4)}" } + mandate_date_of_signature { Time.zone.today } + end +end diff --git a/plugins/invoices/spec/features/finance_balancing_index_spec.rb b/plugins/invoices/spec/features/finance_balancing_index_spec.rb new file mode 100644 index 000000000..3a1114d19 --- /dev/null +++ b/plugins/invoices/spec/features/finance_balancing_index_spec.rb @@ -0,0 +1,38 @@ +require 'spec_helper' + +describe 'Finance Balancing index shows MultiOrders and finished orders', :js do + let(:admin) { create(:user, groups: [create(:workgroup, role_finance: true)]) } + let(:user) { create(:user, groups: [create(:ordergroup)]) } + let(:supplier) { create(:supplier) } + let(:article) { create(:article, supplier: supplier) } + + before do + FoodsoftInvoices.enable_extensions! + login admin + end + + it 'lists a finished single order and a MultiOrder row' do + # finished single order + solo = create(:order, supplier: supplier, article_ids: [article.id]) + create(:group_order, order: solo, ordergroup: user.ordergroup) + solo.update!(state: 'closed') + + # two closed orders combined into a MultiOrder + o1 = create(:order, supplier: supplier, article_ids: [article.id]) + o2 = create(:order, supplier: supplier, article_ids: [article.id]) + create(:group_order, order: o1, ordergroup: user.ordergroup) + create(:group_order, order: o2, ordergroup: user.ordergroup) + o1.update!(state: 'closed') + o2.update!(state: 'closed') + create(:multi_order, orders: [o1, o2]) + + visit finance_order_index_path + + # Expect solo order row present + expect(page).to have_css("tr[data-order_id='#{solo.id}']") + + # Expect both order links to be present (MultiOrder entry rendered) + expect(page).to have_link(o1.name) + expect(page).to have_link(o2.name) + end +end diff --git a/plugins/invoices/spec/models/group_order_invoice_spec.rb b/plugins/invoices/spec/models/group_order_invoice_spec.rb new file mode 100644 index 000000000..8f2411837 --- /dev/null +++ b/plugins/invoices/spec/models/group_order_invoice_spec.rb @@ -0,0 +1,60 @@ +require 'spec_helper' + +describe GroupOrderInvoice do + let(:user) { create(:user, groups: [create(:ordergroup)]) } + let(:supplier) { create(:supplier) } + let(:article) { create(:article, supplier: supplier) } + let(:order) { create(:order, supplier: supplier, article_ids: [article.id]) } + let(:group_order) { create(:group_order, order: order, ordergroup: user.ordergroup) } + + describe 'erroneous group order invoice' do + let(:invoice) { create(:group_order_invoice, group_order_id: group_order.id) } + + it 'does not create group order invoice if tax_number not set' do + expect { invoice }.to raise_error(ActiveRecord::RecordInvalid, /.*/) + end + end + + describe 'valid group order invoice' do + before do + FoodsoftConfig[:contact][:tax_number] = 123_457_8 + end + + invoice_number1 = Time.now.strftime('%Y%m%d') + '0001' + invoice_number2 = Time.now.strftime('%Y%m%d') + '0002' + + let(:user2) { create(:user, groups: [create(:ordergroup)]) } + + let(:invoice) { create(:group_order_invoice, group_order_id: group_order.id) } + let(:invoice_duplicate) { create(:group_order_invoice, group_order_id: group_order.id) } + + let(:another_order_group_order) { create(:group_order, order: order, ordergroup: user2.ordergroup) } + + let(:another_invoice) { create(:group_order_invoice, group_order_id: another_order_group_order.id) } + let(:invoice_with_duplicate_number) { create(:group_order_invoice, group_order_id: another_order_group_order.id, invoice_number: invoice_number1) } + + it 'creates group order invoice if tax_number is set' do + expect(invoice).to be_valid + end + + it 'sets invoice_number according to date' do + number = Time.now.strftime('%Y%m%d') + '0001' + expect(invoice.invoice_number).to eq(number.to_i) + end + + it 'fails to create if group_order_id is used multiple times for creation' do + expect(invoice.group_order.id).to eq(group_order.id) + expect { invoice_duplicate }.to raise_error(ActiveRecord::RecordInvalid) + end + + it 'creates two different group order invoice with different invoice_numbers' do + expect(invoice.invoice_number).to eq(invoice_number1.to_i) + expect(another_invoice.invoice_number).to eq(invoice_number2.to_i) + end + + it 'fails to create two different group order invoice with same invoice_numbers' do + invoice + expect { invoice_with_duplicate_number }.to raise_error(ActiveRecord::RecordInvalid) + end + end +end diff --git a/plugins/invoices/spec/models/multi_group_order_spec.rb b/plugins/invoices/spec/models/multi_group_order_spec.rb new file mode 100644 index 000000000..cea4deebb --- /dev/null +++ b/plugins/invoices/spec/models/multi_group_order_spec.rb @@ -0,0 +1,34 @@ +require 'spec_helper' + +describe MultiGroupOrder do + let(:admin) { create(:user, groups: [create(:workgroup, role_finance: true), create(:ordergroup, name: 'AdminOrders')]) } + let(:user) { create(:user, groups: [create(:ordergroup)]) } + + before do + FoodsoftInvoices.enable_extensions! + end + + context 'when orders are not closed' do + it 'is not generated without valid multi_order' do + order1 = create(:order) + order2 = create(:order) + create(:group_order, ordergroup: user.ordergroup, order: order1) + create(:group_order, ordergroup: user.ordergroup, order: order2) + expect { create(:multi_order, orders: [order1, order2]) }.to raise_error(ActiveRecord::RecordInvalid) + expect(described_class.count).to eq(0) + end + end + + context 'when orders are closed' do + it 'is created by MultiOrder' do + order1 = create(:order) + order2 = create(:order) + create(:group_order, ordergroup: user.ordergroup, order: order1) + create(:group_order, ordergroup: user.ordergroup, order: order2) + order1.update!(state: 'closed') + order2.update!(state: 'closed') + create(:multi_order, orders: [order1, order2]) + expect(described_class.count).to eq(1) + end + end +end diff --git a/plugins/invoices/spec/models/multi_order_spec.rb b/plugins/invoices/spec/models/multi_order_spec.rb new file mode 100644 index 000000000..d17975c1e --- /dev/null +++ b/plugins/invoices/spec/models/multi_order_spec.rb @@ -0,0 +1,56 @@ +require 'spec_helper' + +describe MultiOrder do + let(:admin) { create(:user, groups: [create(:workgroup, role_finance: true), create(:ordergroup, name: 'AdminOrders')]) } + let(:user) { create(:user, groups: [create(:ordergroup)]) } + let(:order) { create(:order) } + let(:another_order) { create(:order) } + + before do + FoodsoftInvoices.enable_extensions! + end + + context 'when orders are open' do + let!(:order_group_order) { create(:group_order, ordergroup: user.ordergroup, order: order) } + let!(:another_order_group_order) { create(:group_order, ordergroup: user.ordergroup, order: another_order) } + + before do + order.update!(state: 'open') + another_order.update!(state: 'open') + FoodsoftConfig[:contact][:tax_number] = 123_457_8 + end + + it 'cannot be created' do + expect { create(:multi_order, orders: [order]) }.to raise_error(ActiveRecord::RecordInvalid) + end + + it 'cannot be created with existing invoices' do + order.update!(state: 'closed') + another_order.update!(state: 'closed') + order_group_order.update!(group_order_invoice: create(:group_order_invoice)) + another_order_group_order.update!(group_order_invoice: create(:group_order_invoice)) + expect { create(:multi_order, orders: [order, another_order]) }.to raise_error(ActiveRecord::RecordInvalid) + end + end + + context 'when orders are closed' do + let(:yet_another_order) { create(:order) } + + before do + create(:group_order, ordergroup: user.ordergroup, order: order) + create(:group_order, ordergroup: user.ordergroup, order: another_order) + + order.update!(state: 'closed') + another_order.update!(state: 'closed') + yet_another_order.update!(state: 'closed') + end + + it 'is valid for one order' do + expect(create(:multi_order, orders: [order])).to be_valid + end + + it 'is valid for two orders' do + expect(create(:multi_order, orders: [order, another_order])).to be_valid + end + end +end diff --git a/plugins/invoices/spec/models/ordergroup_invoice_spec.rb b/plugins/invoices/spec/models/ordergroup_invoice_spec.rb new file mode 100644 index 000000000..6f0788e98 --- /dev/null +++ b/plugins/invoices/spec/models/ordergroup_invoice_spec.rb @@ -0,0 +1,89 @@ +require 'spec_helper' + +RSpec.describe OrdergroupInvoice do + let(:user) { create(:user, groups: [create(:ordergroup)]) } + let(:ordergroup) { user.ordergroup } + let(:supplier) { create(:supplier, name: 'Spec Supplier') } + let(:article) { create(:article, supplier: supplier) } + let(:first_order) { create(:order, supplier: supplier, article_ids: [article.id]) } + let(:second_order) { create(:order, supplier: supplier, article_ids: [article.id]) } + + before do + FoodsoftInvoices.enable_extensions! + FoodsoftConfig[:contact] ||= {} + FoodsoftConfig[:contact][:tax_number] = 12_345_678 + FoodsoftConfig[:name] = 'Spec Foodcoop' + end + + def build_multi_group_order + create(:group_order, ordergroup: ordergroup, order: first_order) + create(:group_order, ordergroup: ordergroup, order: second_order) + + first_order.update!(state: 'closed') + second_order.update!(state: 'closed') + + multi_order = create(:multi_order, orders: [first_order, second_order]) + multi_order.multi_group_orders.first + end + + context 'with initialization and defaults' do + it 'sets invoice_date, invoice_number and payment_method from config' do + FoodsoftConfig[:ordergroup_invoices] = { payment_method: 'Bank transfer' } + mgo = build_multi_group_order + + invoice = described_class.create!(multi_group_order: mgo) + + expect(invoice.invoice_date).to be_present + expect(invoice.invoice_number).to be_present + expect(invoice.payment_method).to eq('Bank transfer') + end + + it 'fails validation when tax number is missing' do + FoodsoftConfig[:contact][:tax_number] = nil + mgo = build_multi_group_order + + expect { described_class.create!(multi_group_order: mgo) }.to raise_error(ActiveRecord::RecordInvalid) + end + end + + describe '#load_data_for_invoice' do + it 'returns expected invoice data' do + FoodsoftConfig[:ordergroup_invoices] = { payment_method: 'Direct debit' } + mgo = build_multi_group_order + invoice = described_class.create!(multi_group_order: mgo) + + data = invoice.load_data_for_invoice + + expect(data[:pickup]).to eq(first_order.pickup) + expect(data[:supplier]).to eq('Spec Foodcoop') + expect(data[:ordergroup]).to eq(ordergroup) + expect(data[:group_order_ids]).to match_array(mgo.group_orders.pluck(:id)) + expect(data[:invoice_number]).to eq(invoice.invoice_number) + expect(data[:invoice_date]).to eq(invoice.invoice_date) + expect(data[:tax_number]).to eq(12_345_678) + expect(data[:payment_method]).to eq('Direct debit') + expect(data[:order_articles]).to be_a(Hash) + end + end + + describe 'InvoiceCommon behaviour' do + it 'returns a translated name including the invoice number' do + mgo = build_multi_group_order + invoice = described_class.create!(multi_group_order: mgo) + expected_prefix = I18n.t('activerecord.attributes.ordergroup_invoice.name') + + expect(invoice.name).to eq("#{expected_prefix}_#{invoice.invoice_number}") + end + + it 'marks and unmarks SEPA downloaded' do + mgo = build_multi_group_order + invoice = described_class.create!(multi_group_order: mgo) + + invoice.mark_sepa_downloaded + expect(invoice.reload.sepa_downloaded).to be(true) + + invoice.unmark_sepa_downloaded + expect(invoice.reload.sepa_downloaded).to be(false) + end + end +end diff --git a/plugins/invoices/spec/models/sepa_account_holder_spec.rb b/plugins/invoices/spec/models/sepa_account_holder_spec.rb new file mode 100644 index 000000000..8785ec8b3 --- /dev/null +++ b/plugins/invoices/spec/models/sepa_account_holder_spec.rb @@ -0,0 +1,70 @@ +require 'spec_helper' + +RSpec.describe SepaAccountHolder do + let(:group) { create(:group) } + let(:user) { create(:user) } + + before do + FoodsoftInvoices.enable_extensions! + end + + describe '#all_fields_present?' do + it 'returns true when all required fields are set' do + sah = described_class.new( + group: group, + user: user, + iban: 'DE89 3704 0044 0532 0130 00', + bic: 'COBADEFFXXX', + mandate_id: 'M-123', + mandate_date_of_signature: Date.new(2024, 1, 1) + ) + + expect(sah.all_fields_present?).to be(true) + end + + it 'returns false when some required fields are missing' do + sah = described_class.new(group: group, user: user) + expect(sah.all_fields_present?).to be(false) + end + end + + describe 'IBAN/BIC handling' do + it 'strips whitespace from IBAN and BIC before validation' do + sah = described_class.new( + group: group, + user: user, + iban: ' DE89 3704 0044 0532 0130 00 ', + bic: " COBA DE FF XXX \t" + ) + + sah.valid? # triggers before_validation + + expect(sah.iban).to eq('DE89370400440532013000') + expect(sah.bic).to eq('COBADEFFXXX') + end + + it 'is invalid with an invalid IBAN when present' do + sah = described_class.new( + group: group, + user: user, + iban: 'INVALIDIBAN', + bic: 'COBADEFFXXX' + ) + + expect(sah).not_to be_valid + expect(sah.errors[:iban]).not_to be_empty + end + + it 'is invalid with an invalid BIC when present' do + sah = described_class.new( + group: group, + user: user, + iban: 'DE89370400440532013000', + bic: 'INVALIDBIC' + ) + + expect(sah).not_to be_valid + expect(sah.errors[:bic]).not_to be_empty + end + end +end diff --git a/spec/factories/group_order.rb b/spec/factories/group_order.rb index d62172ea8..561edc11a 100644 --- a/spec/factories/group_order.rb +++ b/spec/factories/group_order.rb @@ -5,5 +5,6 @@ factory :group_order do ordergroup { create(:user, groups: [FactoryBot.create(:ordergroup)]).ordergroup } updated_by { create(:user) } + order end end