From 7fd7fb65ea4ce06e4fd9d216ec749d00adcc4f61 Mon Sep 17 00:00:00 2001 From: Subandi Khairun Date: Wed, 4 Dec 2024 15:48:16 +0100 Subject: [PATCH] support multiple tax rates per invoice (#9) * support multiple tax rates per invoice * take into account line_item.quantity --- lib/secretariat.rb | 1 + lib/secretariat/invoice.rb | 51 +++++++++++++----- lib/secretariat/tax.rb | 34 ++++++++++++ test/invoice_test.rb | 108 ++++++++++++++++++++++++++++++++++++- 4 files changed, 178 insertions(+), 16 deletions(-) create mode 100644 lib/secretariat/tax.rb diff --git a/lib/secretariat.rb b/lib/secretariat.rb index 0cfb52c..933e0ce 100644 --- a/lib/secretariat.rb +++ b/lib/secretariat.rb @@ -23,3 +23,4 @@ require_relative 'secretariat/trade_party' require_relative 'secretariat/line_item' require_relative 'secretariat/validator' +require_relative 'secretariat/tax' diff --git a/lib/secretariat/invoice.rb b/lib/secretariat/invoice.rb index bef75cb..c1ea973 100644 --- a/lib/secretariat/invoice.rb +++ b/lib/secretariat/invoice.rb @@ -61,6 +61,16 @@ def tax_category_code(version: 2) TAX_CATEGORY_CODES[tax_category] || 'S' end + def taxes + taxes = {} + line_items.each do |line_item| + taxes[line_item.tax_percent] = Tax.new(tax_percent: BigDecimal(line_item.tax_percent)) if taxes[line_item.tax_percent].nil? + taxes[line_item.tax_percent].tax_amount += BigDecimal(line_item.tax_amount) + taxes[line_item.tax_percent].base_amount += BigDecimal(line_item.net_amount) * line_item.quantity + end + taxes.values + end + def payment_code PAYMENT_CODES[payment_type] || '1' end @@ -69,12 +79,24 @@ def valid? @errors = [] tax = BigDecimal(tax_amount) basis = BigDecimal(basis_amount) - calc_tax = basis * BigDecimal(tax_percent) / BigDecimal(100) - calc_tax = calc_tax.round(2, :down) - if tax != calc_tax - @errors << "Tax amount and calculated tax amount deviate: #{tax} / #{calc_tax}" + summed_tax_amount = taxes.sum(&:tax_amount) + if tax != summed_tax_amount + @errors << "Tax amount and summed tax amounts deviate: #{tax_amount} / #{summed_tax_amount}" + return false + end + summed_tax_base_amount = taxes.sum(&:base_amount) + if basis != summed_tax_base_amount + @errors << "Base amount and summed tax base amount deviate: #{basis} / #{summed_tax_base_amount}" return false end + taxes.each do |tax| + calc_tax = tax.base_amount * BigDecimal(tax.tax_percent) / BigDecimal(100) + calc_tax = calc_tax.round(2, :down) + if tax.tax_amount != calc_tax + @errors << "Tax amount and calculated tax amount deviate for rate #{tax.tax_percent}: #{tax.tax_amount} / #{calc_tax}" + return false + end + end grand_total = BigDecimal(grand_total_amount) calc_grand_total = basis + tax if grand_total != calc_grand_total @@ -200,18 +222,19 @@ def to_xml(version: 1, validate: true) end end end - xml['ram'].ApplicableTradeTax do + taxes.each do |tax| + xml['ram'].ApplicableTradeTax do + Helpers.currency_element(xml, 'ram', 'CalculatedAmount', tax.tax_amount, currency_code, add_currency: version == 1) + xml['ram'].TypeCode 'VAT' + if tax_reason_text && tax_reason_text != '' + xml['ram'].ExemptionReason tax_reason_text + end + Helpers.currency_element(xml, 'ram', 'BasisAmount', tax.base_amount, currency_code, add_currency: version == 1) + xml['ram'].CategoryCode tax_category_code(version: version) - Helpers.currency_element(xml, 'ram', 'CalculatedAmount', tax_amount, currency_code, add_currency: version == 1) - xml['ram'].TypeCode 'VAT' - if tax_reason_text && tax_reason_text != '' - xml['ram'].ExemptionReason tax_reason_text + percent = by_version(version, 'ApplicablePercent', 'RateApplicablePercent') + xml['ram'].send(percent, Helpers.format(tax.tax_percent)) end - Helpers.currency_element(xml, 'ram', 'BasisAmount', basis_amount, currency_code, add_currency: version == 1) - xml['ram'].CategoryCode tax_category_code(version: version) - - percent = by_version(version, 'ApplicablePercent', 'RateApplicablePercent') - xml['ram'].send(percent, Helpers.format(tax_percent)) end if version == 2 && service_period_start && service_period_end xml['ram'].BillingSpecifiedPeriod do diff --git a/lib/secretariat/tax.rb b/lib/secretariat/tax.rb new file mode 100644 index 0000000..4fc69a3 --- /dev/null +++ b/lib/secretariat/tax.rb @@ -0,0 +1,34 @@ +=begin +Copyright Jan Krutisch + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +=end + +require 'bigdecimal' + +module Secretariat + Tax = Struct.new('Tax', + :tax_percent, + :tax_amount, + :base_amount, + keyword_init: true + ) do + + def initialize(*) + super + self.tax_amount = 0 + self.base_amount = 0 + end + end +end diff --git a/test/invoice_test.rb b/test/invoice_test.rb index 248d296..f4fe781 100644 --- a/test/invoice_test.rb +++ b/test/invoice_test.rb @@ -46,7 +46,6 @@ def make_eu_invoice payment_type: :CREDITCARD, payment_text: 'Kreditkarte', tax_category: :REVERSECHARGE, - tax_percent: 0, tax_amount: '0', basis_amount: '29', grand_total_amount: 29, @@ -103,7 +102,6 @@ def make_de_invoice payment_iban: 'DE02120300000000202051', payment_terms_text: "Zahlbar innerhalb von 14 Tagen ohne Abzug", tax_category: :STANDARDRATE, - tax_percent: '19', tax_amount: '3.80', basis_amount: '20', grand_total_amount: '23.80', @@ -113,6 +111,73 @@ def make_de_invoice ) end + def make_de_invoice_with_multiple_tax_rates + seller = TradeParty.new( + name: 'Depfu inc', + street1: 'Quickbornstr. 46', + city: 'Hamburg', + postal_code: '20253', + country_id: 'DE', + vat_id: 'DE304755032' + ) + buyer = TradeParty.new( + name: 'Depfu inc', + street1: 'Quickbornstr. 46', + city: 'Hamburg', + postal_code: '20253', + country_id: 'DE', + vat_id: 'DE304755032' + ) + line_item = LineItem.new( + name: 'Depfu Starter Plan', + quantity: 1, + unit: :PIECE, + gross_amount: '23.80', + net_amount: '20', + charge_amount: '20', + tax_category: :STANDARDRATE, + tax_percent: '19', + tax_amount: "3.80", + origin_country_code: 'DE', + currency_code: 'EUR' + ) + line_item2 = LineItem.new( + name: 'Cup of Coffee', + quantity: 2, + unit: :PIECE, + gross_amount: '2.14', + net_amount: '2', + charge_amount: '4', + tax_category: :STANDARDRATE, + tax_percent: '7', + tax_amount: "0.28", + origin_country_code: 'DE', + currency_code: 'EUR' + ) + Invoice.new( + id: '12345', + issue_date: Date.today, + service_period_start: Date.today, + service_period_end: Date.today + 30, + seller: seller, + buyer: buyer, + buyer_reference: "112233", + line_items: [line_item, line_item2], + currency_code: 'USD', + payment_type: :CREDITCARD, + payment_text: 'Kreditkarte', + payment_iban: 'DE02120300000000202051', + payment_terms_text: "Zahlbar innerhalb von 14 Tagen ohne Abzug", + tax_category: :STANDARDRATE, + tax_amount: '4.08', + basis_amount: '24', + grand_total_amount: '28.08', + due_amount: 0, + paid_amount: '28.08', + payment_due_date: Date.today + 14 + ) + end + def test_simple_eu_invoice_v2 begin xml = make_eu_invoice.to_xml(version: 2) @@ -204,5 +269,44 @@ def test_simple_de_invoice_against_schematron end assert_equal [], errors end + + def test_de_multiple_taxes_invoice_v1 + xml = make_de_invoice_with_multiple_tax_rates.to_xml(version: 1) + v = Validator.new(xml, version: 1) + errors = v.validate_against_schema + if !errors.empty? + puts xml + errors.each do |error| + puts error + end + end + assert_equal [], errors + end + + def test_de_multiple_taxes_invoice_v2 + xml = make_de_invoice_with_multiple_tax_rates.to_xml(version: 2) + v = Validator.new(xml, version: 2) + errors = v.validate_against_schema + if !errors.empty? + puts xml + errors.each do |error| + puts error + end + end + assert_equal [], errors + end + + def test_de_multiple_taxes_invoice_against_schematron + xml = make_de_invoice_with_multiple_tax_rates.to_xml(version: 1) + v = Validator.new(xml, version: 1) + errors = v.validate_against_schematron + if !errors.empty? + puts xml + errors.each do |error| + puts "#{error[:line]}: #{error[:message]}" + end + end + assert_equal [], errors + end end end