diff --git a/lib/secretariat/invoice.rb b/lib/secretariat/invoice.rb index 585c964..a37608e 100644 --- a/lib/secretariat/invoice.rb +++ b/lib/secretariat/invoice.rb @@ -105,7 +105,7 @@ def valid? return false end line_item_sum = line_items.inject(BigDecimal(0)) do |m, item| - m + BigDecimal(item.charge_amount) + m + BigDecimal(item.quantity.negative? ? -item.charge_amount : item.charge_amount) end if line_item_sum != basis @errors << "Line items do not add up to basis amount #{line_item_sum} / #{basis}" diff --git a/lib/secretariat/line_item.rb b/lib/secretariat/line_item.rb index 17a8919..d0d3472 100644 --- a/lib/secretariat/line_item.rb +++ b/lib/secretariat/line_item.rb @@ -49,7 +49,7 @@ def valid? gross_price = BigDecimal(gross_amount) charge_price = BigDecimal(charge_amount) tax = BigDecimal(tax_amount) - unit_price = net_price * BigDecimal(quantity) + unit_price = net_price * BigDecimal(quantity.abs) if charge_price != unit_price @errors << "charge price and gross price times quantity deviate: #{charge_price} / #{unit_price}" @@ -66,6 +66,7 @@ def valid? calculated_tax = charge_price * BigDecimal(tax_percent) / BigDecimal(100) calculated_tax = calculated_tax.round(2) + calculated_tax = -calculated_tax if quantity.negative? if calculated_tax != tax @errors << "Tax and calculated tax deviate: #{tax} / #{calculated_tax}" return false @@ -85,6 +86,17 @@ def tax_category_code(version: 2) end def to_xml(xml, line_item_index, version: 2, validate: true) + if net_amount&.zero? + self.tax_percent = 0 + end + if net_amount&.negative? + # Zugferd doesn't allow negative amounts at the item level. + # Instead, a negative quantity is used. + self.quantity = -quantity + self.gross_amount = gross_amount&.abs + self.net_amount = net_amount&.abs + self.charge_amount = charge_amount&.abs + end if validate && !valid? pp errors raise ValidationError.new("LineItem #{line_item_index} is invalid", errors) @@ -160,7 +172,7 @@ def to_xml(xml, line_item_index, version: 2, validate: true) end monetary_summation = by_version(version, 'SpecifiedTradeSettlementMonetarySummation', 'SpecifiedTradeSettlementLineMonetarySummation') xml['ram'].send(monetary_summation) do - Helpers.currency_element(xml, 'ram', 'LineTotalAmount', charge_amount, currency_code, add_currency: version == 1) + Helpers.currency_element(xml, 'ram', 'LineTotalAmount', (quantity.negative? ? -charge_amount : charge_amount), currency_code, add_currency: version == 1) end end diff --git a/test/invoice_test.rb b/test/invoice_test.rb index aefadc2..d47ded1 100644 --- a/test/invoice_test.rb +++ b/test/invoice_test.rb @@ -24,13 +24,13 @@ def make_eu_invoice line_item = LineItem.new( name: 'Depfu Starter Plan', quantity: 1, - gross_amount: '29', - net_amount: '29', + gross_amount: BigDecimal('29'), + net_amount: BigDecimal('29'), unit: :PIECE, - charge_amount: '29', + charge_amount: BigDecimal('29'), tax_category: :REVERSECHARGE, tax_percent: 0, - tax_amount: "0", + tax_amount: 0, origin_country_code: 'DE', currency_code: 'EUR' ) @@ -46,9 +46,9 @@ def make_eu_invoice payment_type: :CREDITCARD, payment_text: 'Kreditkarte', tax_category: :REVERSECHARGE, - tax_amount: '0', - basis_amount: '29', - grand_total_amount: 29, + tax_amount: 0, + basis_amount: BigDecimal('29'), + grand_total_amount: BigDecimal('29'), due_amount: 0, paid_amount: 29, payment_due_date: Date.today + 14 @@ -76,14 +76,14 @@ def make_de_invoice name: 'Depfu Starter Plan', quantity: 1, unit: :PIECE, - gross_amount: '29', - net_amount: '20', - charge_amount: '20', - discount_amount: '9', + gross_amount: BigDecimal('29'), + net_amount: BigDecimal('20'), + charge_amount: BigDecimal('20'), + discount_amount: BigDecimal('9'), discount_reason: 'Rabatt', tax_category: :STANDARDRATE, tax_percent: '19', - tax_amount: "3.80", + tax_amount: BigDecimal("3.80"), origin_country_code: 'DE', currency_code: 'EUR' ) @@ -103,11 +103,11 @@ def make_de_invoice payment_iban: 'DE02120300000000202051', payment_terms_text: "Zahlbar innerhalb von 14 Tagen ohne Abzug", tax_category: :STANDARDRATE, - tax_amount: '3.80', - basis_amount: '20', - grand_total_amount: '23.80', + tax_amount: BigDecimal('3.80'), + basis_amount: BigDecimal('20'), + grand_total_amount: BigDecimal('23.80'), due_amount: 0, - paid_amount: '23.80', + paid_amount: BigDecimal('23.80'), payment_due_date: Date.today + 14 ) end @@ -133,12 +133,12 @@ def make_de_invoice_with_multiple_tax_rates name: 'Depfu Starter Plan', quantity: 2, unit: :PIECE, - gross_amount: '23.80', - net_amount: '20', - charge_amount: '40', + gross_amount: BigDecimal('23.80'), + net_amount: BigDecimal('20'), + charge_amount: BigDecimal('40'), tax_category: :STANDARDRATE, tax_percent: '19', - tax_amount: "7.60", + tax_amount: BigDecimal("7.60"), origin_country_code: 'DE', currency_code: 'EUR' ) @@ -146,12 +146,12 @@ def make_de_invoice_with_multiple_tax_rates name: 'Cup of Coffee', quantity: 1, unit: :PIECE, - gross_amount: '2.68', - net_amount: '2.50', - charge_amount: '2.50', + gross_amount: BigDecimal('2.68'), + net_amount: BigDecimal('2.50'), + charge_amount: BigDecimal('2.50'), tax_category: :STANDARDRATE, tax_percent: '7', - tax_amount: "0.18", + tax_amount: BigDecimal("0.18"), origin_country_code: 'DE', currency_code: 'EUR' ) @@ -159,12 +159,12 @@ def make_de_invoice_with_multiple_tax_rates name: 'Returnable Deposit', quantity: 1, unit: :PIECE, - gross_amount: '5', - net_amount: '5', - charge_amount: '5', + gross_amount: BigDecimal('5'), + net_amount: BigDecimal('5'), + charge_amount: BigDecimal('5'), tax_category: :ZEROTAXPRODUCTS, tax_percent: '0', - tax_amount: "0", + tax_amount: BigDecimal("0"), origin_country_code: 'DE', currency_code: 'EUR' ) @@ -183,11 +183,66 @@ def make_de_invoice_with_multiple_tax_rates payment_iban: 'DE02120300000000202051', payment_terms_text: "Zahlbar innerhalb von 14 Tagen ohne Abzug", tax_category: :STANDARDRATE, - tax_amount: '7.78', - basis_amount: '47.50', - grand_total_amount: '55.28', + tax_amount: BigDecimal('7.78'), + basis_amount: BigDecimal('47.50'), + grand_total_amount: BigDecimal('55.28'), due_amount: 0, - paid_amount: '55.28', + paid_amount: BigDecimal('55.28'), + payment_due_date: Date.today + 14 + ) + end + + def make_negative_de_invoice + 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: 2, + unit: :PIECE, + gross_amount: BigDecimal('-100'), + net_amount: BigDecimal('-100'), + charge_amount: BigDecimal('-200'), + tax_category: :STANDARDRATE, + tax_percent: '19', + tax_amount: BigDecimal('-38'), + 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], + currency_code: 'USD', + payment_type: :CREDITCARD, + payment_text: 'Kreditkarte', + payment_reference: 'INV 123123123', + payment_iban: 'DE02120300000000202051', + payment_terms_text: "Wir zahlen die Gutschrift unmittelbar aus", + tax_category: :STANDARDRATE, + tax_amount: BigDecimal('-38'), + basis_amount: BigDecimal('-200'), + grand_total_amount: BigDecimal('-238'), + due_amount: BigDecimal('-238'), + paid_amount: 0, payment_due_date: Date.today + 14 ) end @@ -334,5 +389,29 @@ def test_de_multiple_taxes_invoice_against_schematron_2 end assert_equal [], errors end + def test_negative_de_invoice_against_schematron_1 + xml = make_negative_de_invoice.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 + def test_negative_de_invoice_against_schematron_2 + xml = make_negative_de_invoice.to_xml(version: 2) + v = Validator.new(xml, version: 2) + 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