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 b836540db..1c934fe7b 100644 --- a/Gemfile +++ b/Gemfile @@ -79,6 +79,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' @@ -131,4 +132,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 94664b163..bd3a5f5ae 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -35,6 +35,15 @@ PATH rails ruby-filemagic +PATH + remote: plugins/invoices + specs: + foodsoft_invoices (0.0.1) + deface (~> 1.0) + prawn + prawn-table + rails + PATH remote: plugins/links specs: @@ -659,6 +668,7 @@ DEPENDENCIES foodsoft_b85! foodsoft_discourse! foodsoft_documents! + foodsoft_invoices! foodsoft_links! foodsoft_messages! foodsoft_polls! diff --git a/app/lib/foodsoft/asset_registry.rb b/app/lib/foodsoft/asset_registry.rb new file mode 100644 index 000000000..fb5e162c3 --- /dev/null +++ b/app/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/app/views/finance/balancing/_orders.html.haml b/app/views/finance/balancing/_orders.html.haml index 3f20d850e..54bf2f90a 100644 --- a/app/views/finance/balancing/_orders.html.haml +++ b/app/views/finance/balancing/_orders.html.haml @@ -12,7 +12,8 @@ %th %tbody - @orders.each do |order| - %tr{:class => cycle("even","odd", :name => "order")} + - 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('.cleared', amount: number_to_currency(order.foodcoop_result)) : t('.ended') 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..98d0e5bee 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,12 @@ 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: + 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/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..88cddd3e9 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -1566,6 +1566,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 +1977,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 +2012,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..87ebe714f 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1571,6 +1571,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 +1982,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 +2017,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/plugins/invoices/README.md b/plugins/invoices/README.md new file mode 100644 index 000000000..b9fda231a --- /dev/null +++ b/plugins/invoices/README.md @@ -0,0 +1,71 @@ +# Foodsoft Invoices Plugin + +This plugin adds comprehensive invoice functionality to the [Foodsoft](https://github.com/foodcoops/foodsoft) system. + +## Features + +The plugin will provide the following features: + +- Group Order Invoices - Invoices for individual group orders +- Ordergroup Invoices - Invoices that can span multiple group orders for the same ordergroup +- PDF generation for invoices +- Email notifications for invoices +- SEPA integration for payment processing +- Administrative features for invoice management + +## Installation + +1. Add the plugin to your Gemfile: + ```ruby + gem 'foodsoft_invoices', path: 'plugins/invoices' + ``` + +2. Run bundle install: + ``` + bundle install + ``` + +3. Enable the plugin in your foodsoft configuration: + ```yml + use_invoices: true + ``` + +## Development + +This plugin is currently under development. The initial version provides the basic infrastructure for the invoice functionality, with additional features to be added in subsequent releases. + +## Migrations + +To install the required database migrations, run the following rake task: + +``` +rake foodsoft_invoices_engine:install:migrations +``` + +Then run the migrations with: + +``` +rake db:migrate +``` + +## Configuration + +The plugin provides several configuration options that can be set in your `app_config.yml`: + +- `use_invoices` - Enable or disable the plugin (default: false) +- `contact.tax_number` - The tax number to be displayed on invoices +- `group_order_invoices.vat_exempt` - Set to `true` if your organization is VAT exempt (default: false) + +Example configuration: + +```yaml +use_invoices: true +contact: + tax_number: "123456789" +group_order_invoices: + vat_exempt: true +``` + +## Contact + +Most of the code was originally written by @viehlieb. The code was ported to this plugin by Robert (rw@roko.li). It is part of the Foodsoft project. Original sources may be available in [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..dd826661f --- /dev/null +++ b/plugins/invoices/app/assets/javascripts/foodsoft_invoices.js @@ -0,0 +1,24 @@ +$(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 + }); +}); 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/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..f4fd56270 --- /dev/null +++ b/plugins/invoices/app/controllers/group_order_invoices_controller.rb @@ -0,0 +1,109 @@ +class GroupOrderInvoicesController < OrderInvoicesControllerBase + include InvoicesHelper + 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 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 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/order_invoices_controller_base.rb b/plugins/invoices/app/controllers/order_invoices_controller_base.rb new file mode 100644 index 000000000..58e6ef856 --- /dev/null +++ b/plugins/invoices/app/controllers/order_invoices_controller_base.rb @@ -0,0 +1,27 @@ +class OrderInvoicesControllerBase < ApplicationController + before_action :authenticate_finance + + def toggle_paid + @invoice = invoice_class.find(params[:id]) + @invoice.paid = !@invoice.paid + 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/documents/group_order_invoice_pdf.rb b/plugins/invoices/app/documents/group_order_invoice_pdf.rb new file mode 100644 index 000000000..d57725ba8 --- /dev/null +++ b/plugins/invoices/app/documents/group_order_invoice_pdf.rb @@ -0,0 +1,414 @@ +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 << ['zzgl. Pfand', + 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 << ['zzgl. Pfand', + 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, 'Netto', 'MwSt', 'FC-Marge', 'Brutto']] + else + [[nil, nil, nil, 'Netto', 'MwSt', 'Brutto']] + 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, 'Produkte', marge) + + # Add deposit row if needed + sum = add_tax_sum_row(sum, key, tax_hashes[:deposit], 'Pfand', 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, "#{label} mit #{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/invoices_helper.rb b/plugins/invoices/app/helpers/invoices_helper.rb new file mode 100644 index 000000000..9c4c0d8d4 --- /dev/null +++ b/plugins/invoices/app/helpers/invoices_helper.rb @@ -0,0 +1,10 @@ +module InvoicesHelper + 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) + 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/models/concerns/group_order_extensions.rb b/plugins/invoices/app/models/concerns/group_order_extensions.rb new file mode 100644 index 000000000..569ae7f59 --- /dev/null +++ b/plugins/invoices/app/models/concerns/group_order_extensions.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module GroupOrderExtensions + extend ActiveSupport::Concern + + included do + has_one :group_order_invoice + 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..532852fd3 --- /dev/null +++ b/plugins/invoices/app/models/concerns/invoice_common.rb @@ -0,0 +1,24 @@ +# app/models/concerns/invoice_common.rb +module InvoiceCommon + extend ActiveSupport::Concern + + included do + include InvoicesHelper + + validates_presence_of :invoice_number + validates_uniqueness_of :invoice_number + validate :tax_number_set + + after_initialize :init, unless: :persisted? + 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/group_order_invoice.rb b/plugins/invoices/app/models/group_order_invoice.rb new file mode 100644 index 000000000..059056630 --- /dev/null +++ b/plugins/invoices/app/models/group_order_invoice.rb @@ -0,0 +1,40 @@ +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 + self.payment_method = group_order&.financial_transaction&.financial_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/overrides/finance/balancing/_orders/add_expanded_row.html.haml.deface b/plugins/invoices/app/overrides/finance/balancing/_orders/add_expanded_row.html.haml.deface new file mode 100644 index 000000000..0ad072615 --- /dev/null +++ b/plugins/invoices/app/overrides/finance/balancing/_orders/add_expanded_row.html.haml.deface @@ -0,0 +1,6 @@ +/ insert_after 'tbody 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/_orders/add_invoice_column.html.haml.deface b/plugins/invoices/app/overrides/finance/balancing/_orders/add_invoice_column.html.haml.deface new file mode 100644 index 000000000..fa22a4b35 --- /dev/null +++ b/plugins/invoices/app/overrides/finance/balancing/_orders/add_invoice_column.html.haml.deface @@ -0,0 +1,11 @@ +/ insert_before 'tbody tr 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_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/views/group_order_invoices/_links.html.haml b/plugins/invoices/app/views/group_order_invoices/_links.html.haml new file mode 100644 index 000000000..8fad13bbe --- /dev/null +++ b/plugins/invoices/app/views/group_order_invoices/_links.html.haml @@ -0,0 +1,46 @@ +- 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.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 + %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: "Bestellgruppenrechnung für #{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 + - 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 + %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/_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_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/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/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_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/config/locales/de.yml b/plugins/invoices/config/locales/de.yml new file mode 100644 index 000000000..12404410d --- /dev/null +++ b/plugins/invoices/config/locales/de.yml @@ -0,0 +1,80 @@ +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 + 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_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: Für SEPA Export markieren + 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 + + documents: + group_order_invoice_pdf: + 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}' + 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_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}% diff --git a/plugins/invoices/config/locales/en.yml b/plugins/invoices/config/locales/en.yml new file mode 100644 index 000000000..aca24096a --- /dev/null +++ b/plugins/invoices/config/locales/en.yml @@ -0,0 +1,78 @@ +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 + 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: mark for 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 + + 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}' + 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}' + 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_number: 'Tax number: %{number}' + title: Invoice for order at %{supplier} + 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 diff --git a/plugins/invoices/config/locales/nl.yml b/plugins/invoices/config/locales/nl.yml new file mode 100644 index 000000000..e3c7d18ab --- /dev/null +++ b/plugins/invoices/config/locales/nl.yml @@ -0,0 +1,79 @@ +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 + 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: Markeren voor 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 + + documents: + group_order_invoice_pdf: + 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}' + 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_number: 'BTW-nummer: %{number}' + title: Factuur voor bestelling bij %{supplier} + 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}% \ No newline at end of file diff --git a/plugins/invoices/config/routes.rb b/plugins/invoices/config/routes.rb new file mode 100644 index 000000000..6e4f5f92b --- /dev/null +++ b/plugins/invoices/config/routes.rb @@ -0,0 +1,16 @@ +Rails.application.routes.draw do + scope '/:foodcoop' do + post 'finance/group_order_invoice', to: 'group_order_invoices#create_multiple' + + resources :group_order_invoices do + member do + patch :toggle_paid + end + collection do + get :download_within_date + patch :toggle_all_paid + get :download_all + 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/foodsoft_invoices.gemspec b/plugins/invoices/foodsoft_invoices.gemspec new file mode 100644 index 000000000..2182b0e38 --- /dev/null +++ b/plugins/invoices/foodsoft_invoices.gemspec @@ -0,0 +1,25 @@ +$:.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 = '>= 2.7' + + 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.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..489ff14ce --- /dev/null +++ b/plugins/invoices/lib/foodsoft_invoices/engine.rb @@ -0,0 +1,15 @@ +module FoodsoftInvoices + class Engine < ::Rails::Engine + config.to_prepare do + if FoodsoftInvoices.enabled? + Foodsoft::AssetRegistry.register_stylesheet('foodsoft_invoices') + Foodsoft::AssetRegistry.register_javascript('foodsoft_invoices') + GroupOrder.include GroupOrderExtensions + end + end + + def default_foodsoft_config(cfg) + cfg[:use_invoices] = false + 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..5137727de --- /dev/null +++ b/plugins/invoices/lib/foodsoft_invoices/version.rb @@ -0,0 +1,3 @@ +module FoodsoftInvoices + VERSION = '0.0.1' +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/models/group_order_invoice_spec.rb b/plugins/invoices/spec/models/group_order_invoice_spec.rb new file mode 100644 index 000000000..793566223 --- /dev/null +++ b/plugins/invoices/spec/models/group_order_invoice_spec.rb @@ -0,0 +1,56 @@ +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 + it 'does not create group order invoice if tax_number not set' do + expect { described_class.create!(group_order_id: group_order.id) }.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(:group_order2) { create(:group_order, order: order, ordergroup: user2.ordergroup) } + + it 'creates group order invoice if tax_number is set' do + goi = described_class.new(group_order_id: group_order.id) + expect(goi).to be_valid + end + + it 'sets invoice_number according to date' do + goi = described_class.create!(group_order_id: group_order.id) + number = Time.now.strftime('%Y%m%d') + '0001' + expect(goi.invoice_number).to eq(number.to_i) + end + + it 'fails to create if group_order_id is used multiple times for creation' do + goi1 = described_class.create!(group_order_id: group_order.id) + expect(goi1.group_order.id).to eq(group_order.id) + expect { described_class.create!(group_order_id: group_order.id) }.to raise_error(ActiveRecord::RecordInvalid) + end + + it 'creates two different group order invoice with different invoice_numbers' do + goi1 = described_class.create!(group_order_id: group_order.id) + goi3 = described_class.create!(group_order_id: group_order2.id) + expect(goi1.invoice_number).to eq(invoice_number1.to_i) + expect(goi3.invoice_number).to eq(invoice_number2.to_i) + end + + it 'fails to create two different group order invoice with same invoice_numbers' do + described_class.create!(group_order_id: group_order.id) + expect { described_class.create!(group_order_id: group_order2.id, invoice_number: invoice_number1.to_i) }.to raise_error(ActiveRecord::RecordInvalid) + end + end +end