Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

support multiple tax rates per invoice #9

Merged
merged 2 commits into from
Dec 4, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/secretariat.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,4 @@
require_relative 'secretariat/trade_party'
require_relative 'secretariat/line_item'
require_relative 'secretariat/validator'
require_relative 'secretariat/tax'
51 changes: 37 additions & 14 deletions lib/secretariat/invoice.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
end
taxes.values
end

def payment_code
PAYMENT_CODES[payment_type] || '1'
end
Expand All @@ -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 base amounts 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
Expand Down Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions lib/secretariat/tax.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
=begin
Copyright Jan Krutisch
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure how this works, or if I should have put my name in here? I personally don't care, but neither "signing in your name" nor "putting in my name here while I don't care about copyright at all" felt truly right.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll come up with something along the lines of "Copyright Secretariat Team and Contributors, see CONTRIBUTORS.md" or something.


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
110 changes: 108 additions & 2 deletions test/invoice_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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',
Expand All @@ -113,6 +111,75 @@ 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: '29',
net_amount: '20',
charge_amount: '20',
discount_amount: '9',
discount_reason: 'Rabatt',
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: 1,
unit: :PIECE,
gross_amount: '2',
net_amount: '2',
charge_amount: '2',
tax_category: :STANDARDRATE,
tax_percent: '7',
tax_amount: "0.14",
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: '3.94',
basis_amount: '22',
grand_total_amount: '25.94',
due_amount: 0,
paid_amount: '25.94',
payment_due_date: Date.today + 14
)
end

def test_simple_eu_invoice_v2
begin
xml = make_eu_invoice.to_xml(version: 2)
Expand Down Expand Up @@ -204,5 +271,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