From 92d79bc9c70e0be0157fe905e098799ab465373c Mon Sep 17 00:00:00 2001 From: Sylvain Date: Wed, 14 Aug 2019 10:54:19 +0200 Subject: [PATCH 1/4] [bug] (#140) VAT rate is erronous in invoices --- app/pdfs/pdf/invoice.rb | 10 +- app/services/accounting_export_service.rb | 253 ++++++++++++++++++ app/services/coupon_service.rb | 9 +- app/services/vat_history_service.rb | 2 +- .../api/plans/shallow_index.json.jbuilder | 2 +- .../exports/users_subscriptions.xlsx.axlsx | 2 +- .../subscriptions/create_as_admin_test.rb | 2 +- .../subscriptions/create_as_user_test.rb | 4 +- test/test_helper.rb | 2 +- 9 files changed, 269 insertions(+), 17 deletions(-) diff --git a/app/pdfs/pdf/invoice.rb b/app/pdfs/pdf/invoice.rb index 607ff0f21..f15c152ae 100644 --- a/app/pdfs/pdf/invoice.rb +++ b/app/pdfs/pdf/invoice.rb @@ -191,14 +191,14 @@ class PDF::Invoice < Prawn::Document cp = invoice.coupon discount = 0 if cp.type == 'percent_off' - discount = total_calc * cp.percent_off / 100.0 + discount = total_calc * cp.percent_off / 100.00 elsif cp.type == 'amount_off' # refunds of invoices with cash coupons: we need to ventilate coupons on paid items if invoice.is_a?(Avoir) paid_items = invoice.invoice.invoice_items.select{ |ii| ii.amount.positive? }.length refund_items = invoice.invoice_items.select{ |ii| ii.amount.positive? }.length - discount = ((invoice.coupon.amount_off / paid_items) * refund_items) / 100.0 + discount = ((invoice.coupon.amount_off / paid_items) * refund_items) / 100.00 else discount = cp.amount_off / 100.00 end @@ -222,7 +222,7 @@ class PDF::Invoice < Prawn::Document end # total verification - total = invoice.total / 100.0 + total = invoice.total / 100.00 puts "ERROR: totals are NOT equals => expected: #{total}, computed: #{total_calc}" if total_calc != total # TVA @@ -231,7 +231,7 @@ class PDF::Invoice < Prawn::Document vat_service = VatHistoryService.new vat_rate = vat_service.invoice_vat(invoice) - vat = total / (vat_rate / 100 + 1) + vat = total / (vat_rate / 100.00 + 1) data += [[I18n.t('invoices.including_VAT_RATE', RATE: vat_rate), number_to_currency(total - vat)]] data += [[I18n.t('invoices.including_total_excluding_taxes'), number_to_currency(vat)]] data += [[I18n.t('invoices.including_amount_payed_on_ordering'), number_to_currency(total)]] @@ -299,7 +299,7 @@ class PDF::Invoice < Prawn::Document else # subtract the wallet amount for this invoice from the total if invoice.wallet_amount - wallet_amount = invoice.wallet_amount / 100.0 + wallet_amount = invoice.wallet_amount / 100.00 total -= wallet_amount else wallet_amount = nil diff --git a/app/services/accounting_export_service.rb b/app/services/accounting_export_service.rb index e69de29bb..19efe6dd8 100644 --- a/app/services/accounting_export_service.rb +++ b/app/services/accounting_export_service.rb @@ -0,0 +1,253 @@ +# frozen_string_literal: false + +# Provides the routine to export the accounting data to an external accounting software +class AccountingExportService + attr_reader :encoding, :format, :separator, :journal_code, :date_format, :columns, :vat_service + + def initialize(columns, encoding = 'UTF-8', format = 'CSV', separator = ';', date_format = '%d/%m/%Y') + @encoding = encoding + @format = format + @separator = separator + @journal_code = Setting.find_by(name: 'accounting_journal_code')&.value || '' + @date_format = date_format + @columns = columns + @vat_service = VatHistoryService.new + end + + def export(start_date, end_date, file) + # build CVS content + content = header_row + invoices = Invoice.where('created_at >= ? AND created_at <= ?', start_date, end_date).order('created_at ASC') + invoices.each do |i| + content << generate_rows(i) + end + + # write content to file + File.open(file, "w:#{encoding}") { |f| f.puts content.encode(encoding, invalid: :replace, undef: :replace) } + end + + private + + def header_row + row = '' + columns.each do |column| + row << I18n.t("accounting_export.#{column}") << separator + end + "#{row}\n" + end + + def generate_rows(invoice) + "#{client_row(invoice)}\n" \ + "#{items_rows(invoice)}" \ + "#{vat_row(invoice)}\n" + end + + # Generate the "subscription" and "reservation" rows associated with the provided invoice + def items_rows(invoice) + rows = invoice.subscription_invoice? ? "#{subscription_row(invoice)}\n" : '' + if invoice.invoiced_type == 'Reservation' + invoice.invoice_items.each do |item| + rows << "#{reservation_row(invoice, item)}\n" + end + end + rows + end + + # Generate the "client" row, which contains the debit to the client account, all taxes included + def client_row(invoice) + total = invoice.total / 100.00 + row = '' + columns.each do |column| + case column + when 'journal_code' + row << journal_code + when 'date' + row << invoice.created_at&.strftime(date_format) + when 'account_code' + row << account(invoice, :client) + when 'account_label' + row << account(invoice, :client, :label) + when 'piece' + row << invoice.reference + when 'line_label' + row << label(invoice.invoicing_profile.full_name) + when 'debit_origin' + row << debit_client(invoice, total) + when 'credit_origin' + row << credit_client(invoice, total) + when 'debit_euro' + row << debit_client(invoice, total) + when 'credit_euro' + row << credit_client(invoice, total) + when 'lettering' + row << '' + else + puts "Unsupported column: #{column}" + end + row << separator + end + row + end + + # Generate the "reservation" row, which contains the credit to the reservation account, all taxes excluded + def reservation_row(invoice, item) + wo_taxes = (item.amount / (vat_service.invoice_vat(invoice) / 100.00 + 1)) / 100.00 + row = '' + columns.each do |column| + case column + when 'journal_code' + row << journal_code + when 'date' + row << invoice.created_at&.strftime(date_format) + when 'account_code' + row << account(invoice, :reservation) + when 'account_label' + row << account(invoice, :reservation, :label) + when 'piece' + row << invoice.reference + when 'line_label' + row << label(item.description) + when 'debit_origin' + row << debit(invoice, wo_taxes) + when 'credit_origin' + row << credit(invoice, wo_taxes) + when 'debit_euro' + row << debit(invoice, wo_taxes) + when 'credit_euro' + row << credit(invoice, wo_taxes) + when 'lettering' + row << '' + else + puts "Unsupported column: #{column}" + end + row << separator + end + row + end + + # Generate the "subscription" row, which contains the credit to the subscription account, all taxes excluded + def subscription_row(invoice) + subscription_item = invoice.invoice_items.select(&:subscription).first + wo_taxes = (subscription_item.amount / (vat_service.invoice_vat(invoice) / 100.00 + 1)) / 100.00 + row = '' + columns.each do |column| + case column + when 'journal_code' + row << journal_code + when 'date' + row << invoice.created_at&.strftime(date_format) + when 'account_code' + row << account(invoice, :subscription) + when 'account_label' + row << account(invoice, :subscription, :label) + when 'piece' + row << invoice.reference + when 'line_label' + row << label(subscription_item.description) + when 'debit_origin' + row << debit(invoice, wo_taxes) + when 'credit_origin' + row << credit(invoice, wo_taxes) + when 'debit_euro' + row << debit(invoice, wo_taxes) + when 'credit_euro' + row << credit(invoice, wo_taxes) + when 'lettering' + row << '' + else + puts "Unsupported column: #{column}" + end + row << separator + end + row + end + + # Generate the "VAT" row, which contains the credit to the VAT account, with VAT amount only + def vat_row(invoice) + vat = (invoice.total - (invoice.total / (vat_service.invoice_vat(invoice) / 100.00 + 1))) / 100.00 + row = '' + columns.each do |column| + case column + when 'journal_code' + row << journal_code + when 'date' + row << invoice.created_at&.strftime(date_format) + when 'account_code' + row << account(invoice, :vat) + when 'account_label' + row << account(invoice, :vat, :label) + when 'piece' + row << invoice.reference + when 'line_label' + row << I18n.t('accounting_export.VAT') + when 'debit_origin' + row << debit(invoice, vat) + when 'credit_origin' + row << credit(invoice, vat) + when 'debit_euro' + row << debit(invoice, vat) + when 'credit_euro' + row << credit(invoice, vat) + when 'lettering' + row << '' + else + puts "Unsupported column: #{column}" + end + row << separator + end + row + end + + # Get the account code (or label) for the given invoice and the specified line type (client, vat, subscription or reservation) + def account(invoice, account, type = :code) + res = case account + when :client + Setting.find_by(name: "accounting_client_#{type}")&.value + when :vat + Setting.find_by(name: "accounting_VAT_#{type}")&.value + when :subscription + if invoice.subscription_invoice? + Setting.find_by(name: "accounting_subscription_#{type}")&.value + else + puts "WARN: Invoice #{invoice.id} has no subscription" + end + when :reservation + if invoice.invoiced_type == 'Reservation' + Setting.find_by(name: "accounting_#{invoice.invoiced.reservable_type}_#{type}")&.value + else + puts "WARN: Invoice #{invoice.id} has no reservation" + end + else + puts "Unsupported account #{account}" + end + res || '' + end + + # Fill the value of the "debit" column: if the invoice is a refund, returns the given amount, returns 0 otherwise + def debit(invoice, amount) + avoir = invoice.is_a? Avoir + avoir ? amount.to_s : '0' + end + + # Fill the value of the "credit" column: if the invoice is a refund, returns 0, otherwise, returns the given amount + def credit(invoice, amount) + avoir = invoice.is_a? Avoir + avoir ? '0' : amount.to_s + end + + # Fill the value of the "debit" column for the client row: if the invoice is a refund, returns 0, otherwise, returns the given amount + def debit_client(invoice, amount) + credit(invoice, amount) + end + + # Fill the value of the "credit" column, for the client row: if the invoice is a refund, returns the given amount, returns 0 otherwise + def credit_client(invoice, amount) + debit(invoice, amount) + end + + # Format the given text to match the accounting software rules for the labels + def label(text) + res = text.tr separator, '' + res.truncate(50) + end +end diff --git a/app/services/coupon_service.rb b/app/services/coupon_service.rb index 7600de148..d87d07e88 100644 --- a/app/services/coupon_service.rb +++ b/app/services/coupon_service.rb @@ -22,7 +22,7 @@ class CouponService unless _coupon.nil? if _coupon.status(user_id, total) == 'active' if _coupon.type == 'percent_off' - price = price - (price * _coupon.percent_off / 100.0) + price -= price * _coupon.percent_off / 100.00 elsif _coupon.type == 'amount_off' # do not apply cash coupon unless it has a lower amount that the total price if _coupon.amount_off <= price @@ -35,7 +35,6 @@ class CouponService price end - ## # Ventilate the discount of the provided coupon over the given amount proportionately to the invoice's total # @param total {Number} total amount of the invoice expressed in monetary units @@ -44,11 +43,11 @@ class CouponService ## def ventilate(total, amount, coupon) price = amount - if !coupon.nil? and total != 0 + if !coupon.nil? && total != 0 if coupon.type == 'percent_off' - price = amount - ( amount * coupon.percent_off / 100.0 ) + price = amount - (amount * coupon.percent_off / 100.00) elsif coupon.type == 'amount_off' - ratio = (coupon.amount_off / 100.0) / total + ratio = (coupon.amount_off / 100.00) / total discount = amount * ratio.abs price = amount - discount else diff --git a/app/services/vat_history_service.rb b/app/services/vat_history_service.rb index 827712008..c5b302621 100644 --- a/app/services/vat_history_service.rb +++ b/app/services/vat_history_service.rb @@ -28,7 +28,7 @@ class VatHistoryService def vat_history key_dates = [] Setting.find_by(name: 'invoice_VAT-rate').history_values.each do |rate| - key_dates.push(date: rate.created_at, rate: (rate.value.to_i / 100.0)) + key_dates.push(date: rate.created_at, rate: rate.value.to_i) end Setting.find_by(name: 'invoice_VAT-active').history_values.each do |v| key_dates.push(date: v.created_at, rate: 0) if v.value == 'false' diff --git a/app/views/api/plans/shallow_index.json.jbuilder b/app/views/api/plans/shallow_index.json.jbuilder index c299af718..d9417d330 100644 --- a/app/views/api/plans/shallow_index.json.jbuilder +++ b/app/views/api/plans/shallow_index.json.jbuilder @@ -3,7 +3,7 @@ json.array!(@plans) do |plan| json.ui_weight plan.ui_weight json.group_id plan.group_id json.base_name plan.base_name - json.amount plan.amount / 100.0 + json.amount plan.amount / 100.00 json.interval plan.interval json.interval_count plan.interval_count json.type plan.type diff --git a/app/views/exports/users_subscriptions.xlsx.axlsx b/app/views/exports/users_subscriptions.xlsx.axlsx index 5b1a08d38..4b7257c9f 100644 --- a/app/views/exports/users_subscriptions.xlsx.axlsx +++ b/app/views/exports/users_subscriptions.xlsx.axlsx @@ -22,7 +22,7 @@ wb.add_worksheet(name: t('export_subscriptions.subscriptions')) do |sheet| t("duration.#{sub.plan.interval}", count: sub.plan.interval_count), sub.created_at.to_date, sub.expired_at.to_date, - number_to_currency(sub.plan.amount / 100), + number_to_currency(sub.plan.amount / 100.00), (sub.stp_subscription_id.nil?)? t('export_subscriptions.local_payment') : t('export_subscriptions.online_payment') ] styles = [nil, nil, nil, nil, nil, date, date, nil, nil] diff --git a/test/integration/subscriptions/create_as_admin_test.rb b/test/integration/subscriptions/create_as_admin_test.rb index 15be278b4..fa7295e29 100644 --- a/test/integration/subscriptions/create_as_admin_test.rb +++ b/test/integration/subscriptions/create_as_admin_test.rb @@ -46,7 +46,7 @@ module Subscriptions # Check that the user benefit from prices of his plan printer = Machine.find_by(slug: 'imprimante-3d') - assert_equal 15, (printer.prices.find_by(group_id: user.group_id, plan_id: user.subscription.plan_id).amount / 100), 'machine hourly price does not match' + assert_equal 15, (printer.prices.find_by(group_id: user.group_id, plan_id: user.subscription.plan_id).amount / 100.00), 'machine hourly price does not match' # Check notification was sent to the user notification = Notification.find_by(notification_type_id: NotificationType.find_by_name('notify_member_subscribed_plan'), attached_object_type: 'Subscription', attached_object_id: subscription[:id]) diff --git a/test/integration/subscriptions/create_as_user_test.rb b/test/integration/subscriptions/create_as_user_test.rb index fa8729649..46db07689 100644 --- a/test/integration/subscriptions/create_as_user_test.rb +++ b/test/integration/subscriptions/create_as_user_test.rb @@ -43,7 +43,7 @@ class Subscriptions::CreateAsUserTest < ActionDispatch::IntegrationTest # Check that the user benefit from prices of his plan printer = Machine.find_by(slug: 'imprimante-3d') assert_equal 15, - (printer.prices.find_by(group_id: @user.group_id, plan_id: @user.subscription.plan_id).amount / 100), + (printer.prices.find_by(group_id: @user.group_id, plan_id: @user.subscription.plan_id).amount / 100.00), 'machine hourly price does not match' # Check notifications were sent for every admins @@ -133,7 +133,7 @@ class Subscriptions::CreateAsUserTest < ActionDispatch::IntegrationTest assert_equal 10, (printer.prices.find_by( group_id: @vlonchamp.group_id, - plan_id: @vlonchamp.subscription.plan_id).amount / 100 + plan_id: @vlonchamp.subscription.plan_id).amount / 100.00 ), 'machine hourly price does not match' diff --git a/test/test_helper.rb b/test/test_helper.rb index 25c8d9d66..90a362a5c 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -94,7 +94,7 @@ class ActiveSupport::TestCase if Setting.find_by(name: 'invoice_VAT-active').value == 'true' vat_service = VatHistoryService.new vat_rate = vat_service.invoice_vat(invoice) - computed_ht = sprintf('%.2f', (invoice.total / (vat_rate / 100 + 1)) / 100.0).to_f + computed_ht = sprintf('%.2f', (invoice.total / (vat_rate / 100.00 + 1)) / 100.00).to_f assert_equal computed_ht, ht_amount, 'Total excluding taxes rendered in the PDF file is not computed correctly' else From 0c4475acea6f0fdb35c780c954a5ccdc511c6cb8 Mon Sep 17 00:00:00 2001 From: Nicolas Florentin Date: Wed, 14 Aug 2019 14:18:04 +0200 Subject: [PATCH 2/4] removes only_invoice scope from regenerate_invoices rake task --- lib/tasks/fablab/maintenance.rake | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/tasks/fablab/maintenance.rake b/lib/tasks/fablab/maintenance.rake index bd3303063..3333c5efb 100644 --- a/lib/tasks/fablab/maintenance.rake +++ b/lib/tasks/fablab/maintenance.rake @@ -11,8 +11,7 @@ namespace :fablab do end_date = start_date.next_month puts "-> Start regenerate the invoices PDF between #{I18n.l start_date, format: :long} and " \ "#{I18n.l end_date - 1.minute, format: :long}" - invoices = Invoice.only_invoice - .where('created_at >= :start_date AND created_at < :end_date', start_date: start_date, end_date: end_date) + invoices = Invoice.where('created_at >= :start_date AND created_at < :end_date', start_date: start_date, end_date: end_date) .order(created_at: :asc) invoices.each(&:regenerate_invoice_pdf) puts '-> Done' From 65035ea315de696a408b76f9ea0bc5eead6d300f Mon Sep 17 00:00:00 2001 From: Nicolas Florentin Date: Wed, 14 Aug 2019 15:23:04 +0200 Subject: [PATCH 3/4] fix rake task fablab:maintenance:regenerate_invoices was not taking into account the timezone --- lib/tasks/fablab/maintenance.rake | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/tasks/fablab/maintenance.rake b/lib/tasks/fablab/maintenance.rake index 3333c5efb..1d83138d3 100644 --- a/lib/tasks/fablab/maintenance.rake +++ b/lib/tasks/fablab/maintenance.rake @@ -5,9 +5,9 @@ namespace :fablab do namespace :maintenance do desc 'Regenerate the invoices PDF' task :regenerate_invoices, %i[year month] => :environment do |_task, args| - year = args.year || Time.now.year - month = args.month || Time.now.month - start_date = Time.new(year.to_i, month.to_i, 1) + year = args.year || Time.current.year + month = args.month || Time.current.month + start_date = Time.zone.local(year.to_i, month.to_i, 1) end_date = start_date.next_month puts "-> Start regenerate the invoices PDF between #{I18n.l start_date, format: :long} and " \ "#{I18n.l end_date - 1.minute, format: :long}" From cd81de79cfc72225a4c01c9fa6bd8f443dc6af0f Mon Sep 17 00:00:00 2001 From: Cyril Date: Wed, 14 Aug 2019 15:34:12 +0200 Subject: [PATCH 4/4] Version 4.0.4 --- CHANGELOG.md | 5 +++++ package.json | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6694d3d1a..df46f4b6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog Fab Manager +## v4.0.4 2019 August 14 +- Fix a bug: #140 VAT rate is erronous in invoices. + Note: this bug was introduced in v4.0.3 and requires (if you are on v4.0.3) to regenerate the invoices since August 1st (if +- [TODO DEPLOY] `rake fablab:maintenance:regenerate_invoices[2019,8]` + ## v4.0.3 2019 August 01 - Fix a bug: no user can be created after the last member was deleted diff --git a/package.json b/package.json index 40683088e..cfd44b35f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fab-manager", - "version": "4.0.3", + "version": "4.0.4", "description": "FabManager is the FabLab management solution. It is web-based, open-source and totally free.", "keywords": [ "fablab",