diff --git a/lib/secretariat/invoice.rb b/lib/secretariat/invoice.rb index 140627b..0fac5d3 100644 --- a/lib/secretariat/invoice.rb +++ b/lib/secretariat/invoice.rb @@ -95,11 +95,11 @@ def taxes line_items.each do |line_item| if line_item.tax_percent.nil? taxes['0'] = Tax.new(tax_percent: BigDecimal(0), tax_category: line_item.tax_category, tax_amount: BigDecimal(0)) if taxes['0'].nil? - taxes['0'].base_amount += BigDecimal(line_item.net_amount) * line_item.billed_quantity + taxes['0'].base_amount += line_item.signed_charge_amount else taxes[line_item.tax_percent] = Tax.new(tax_percent: BigDecimal(line_item.tax_percent), tax_category: line_item.tax_category) 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.billed_quantity + taxes[line_item.tax_percent].base_amount += line_item.signed_charge_amount end end @@ -132,9 +132,10 @@ def valid? return false end if tax_calculation_method == :ITEM_BASED - line_items_tax_amount = line_items.sum(&:tax_amount) - if tax_amount != line_items_tax_amount - @errors << "Tax amount #{tax_amount} and summed up item tax amounts #{line_items_tax_amount} deviate" + line_items_tax_amount = line_items.sum(BigDecimal(0)) { |item| BigDecimal(item.tax_amount) } + if tax != line_items_tax_amount + @errors << "Tax amount #{tax.to_s('F')} and summed up item tax amounts #{line_items_tax_amount.to_s('F')} deviate" + return false end elsif tax_calculation_method != :NONE taxes.each do |tax| diff --git a/lib/secretariat/line_item.rb b/lib/secretariat/line_item.rb index 8f7a683..27f5c28 100644 --- a/lib/secretariat/line_item.rb +++ b/lib/secretariat/line_item.rb @@ -18,6 +18,12 @@ module Secretariat + # A line's VAT may differ from net x rate by rounding: when the gross price is + # fixed and net and VAT are derived from it, the VAT can be a cent off the + # recalculated value. EN 16931 has no line VAT amount, so this only bounds the + # amounts the line contributes to the VAT breakdown. + LINE_TAX_ROUNDING_TOLERANCE = BigDecimal('0.01') + LineItem = Struct.new('LineItem', :name, :billed_quantity, @@ -93,7 +99,7 @@ def valid? calculated_tax = charge_price * BigDecimal(tax_percent) / BigDecimal(100) calculated_tax = calculated_tax.round(2) calculated_tax = -calculated_tax if billed_quantity.negative? - if calculated_tax != tax + if (calculated_tax - tax).abs > LINE_TAX_ROUNDING_TOLERANCE @errors << "Tax and calculated tax deviate: #{tax} / #{calculated_tax}" return false end @@ -101,6 +107,13 @@ def valid? return true end + # The line total (BT-131) as it adds up to the VAT base: negative for a + # negative quantity, since the amounts themselves are kept positive. + def signed_charge_amount + charge = BigDecimal(charge_amount) + billed_quantity.negative? ? -charge : charge + end + def unit_code UNIT_CODES[unit] || 'C62' end diff --git a/test/invoice_test.rb b/test/invoice_test.rb index 8af2c76..eb6be47 100644 --- a/test/invoice_test.rb +++ b/test/invoice_test.rb @@ -861,6 +861,106 @@ def test_fr_invoice assert_match(%r{\s*304755032\s*}, xml) end + # A gross-leading row: the gross price is fixed, net and VAT are derived from + # it, so the row's VAT (27.21) is one cent below net x rate (27.2153 -> 27.22). + def make_gross_leading_invoice(line_tax:, invoice_tax:) + line_item = LineItem.new( + name: '8 x Overnight stay', + billed_quantity: BigDecimal('8'), + unit: :PIECE, + gross_amount: BigDecimal('52'), + net_amount: BigDecimal('48.59875'), + charge_amount: BigDecimal('388.79'), + tax_category: :STANDARDRATE, + tax_percent: '7', + tax_amount: line_tax, + origin_country_code: 'DE', + currency_code: 'EUR' + ) + invoice = make_de_invoice + invoice.currency_code = 'EUR' + invoice.tax_calculation_method = :ITEM_BASED + invoice.line_items = [line_item] + invoice.basis_amount = '388.79' + invoice.tax_amount = invoice_tax + invoice.grand_total_amount = (BigDecimal('388.79') + BigDecimal(invoice_tax)).to_s('F') + invoice.due_amount = invoice.grand_total_amount + invoice.paid_amount = 0 + invoice + end + + def test_item_based_invoice_keeps_line_vat_rounded_from_the_gross_price + invoice = make_gross_leading_invoice(line_tax: BigDecimal('27.21'), invoice_tax: '27.21') + + assert invoice.valid?, invoice.errors.inspect + xml = invoice.to_xml(version: 2) + assert_match(%r{27.21}, xml) + assert_match(%r{416.00}, xml) + end + + def test_line_vat_more_than_a_cent_off_is_still_rejected + line_item = make_gross_leading_invoice(line_tax: BigDecimal('27.20'), invoice_tax: '27.20').line_items.first + + refute line_item.valid? + assert_equal ["Tax and calculated tax deviate: 0.272e2 / 0.2722e2"], line_item.errors + end + + def test_item_based_totals_passed_as_strings_leave_no_errors + invoice = make_gross_leading_invoice(line_tax: BigDecimal('27.21'), invoice_tax: '27.21') + + assert invoice.valid? + assert_equal [], invoice.errors + end + + def test_item_based_invoice_rejects_a_tax_total_that_differs_from_its_lines + invoice = make_de_invoice + invoice.tax_calculation_method = :ITEM_BASED + untaxed = invoice.line_items.first + untaxed.tax_category = :UNTAXEDSERVICE + untaxed.tax_percent = nil + untaxed.tax_amount = BigDecimal('3.80') + invoice.tax_amount = BigDecimal('0') + invoice.grand_total_amount = BigDecimal('20') + + refute invoice.valid? + assert_equal ["Tax amount 0.0 and summed up item tax amounts 3.8 deviate"], invoice.errors + end + + # Two days, 120 participants, 5,000.00 net per day: the unit price is stored + # with four decimals (41.6667), so 41.6667 x 120 = 5000.004 while each line + # total is 5000.00. Summing unit price x quantity drifts to 10000.008 and + # rounds to 10000.01, a cent off the lines it is made of. + def test_vat_base_is_the_sum_of_the_line_totals + line_items = %w[Day1 Day2].map do |day| + LineItem.new( + name: "Conference package #{day}", + billed_quantity: BigDecimal('120'), + unit: :PIECE, + gross_amount: BigDecimal('41.6667'), + net_amount: BigDecimal('41.6667'), + charge_amount: BigDecimal('5000.00'), + tax_category: :STANDARDRATE, + tax_percent: '19', + tax_amount: BigDecimal('950.00'), + origin_country_code: 'DE', + currency_code: 'EUR' + ) + end + invoice = make_de_invoice + invoice.currency_code = 'EUR' + invoice.tax_calculation_method = :ITEM_BASED + invoice.line_items = line_items + invoice.basis_amount = '10000.00' + invoice.tax_amount = '1900.00' + invoice.grand_total_amount = '11900.00' + invoice.due_amount = '11900.00' + invoice.paid_amount = 0 + + assert invoice.valid?, invoice.errors.inspect + xml = invoice.to_xml(version: 2) + assert_match(%r{10000.00}, xml) + end + def test_invoice_with_quantity_causing_sub_cent_amounts errors = []