Skip to content

Commit ba2f5cc

Browse files
committed
adding untaxed invoices and a nop tax calc mode
- Allows for untaxed invoices - Allows for single-tax invoices with slight rounding errors
1 parent 6224e57 commit ba2f5cc

8 files changed

Lines changed: 103 additions & 32 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## 3.4.0
4+
5+
- [FEATURE] Allow for "untaxable" invoices. This is, for example the case when you deliver services to US states that don't tax these, or if you're falling below the registration threshold.
6+
- [FEATURE] To support valid "untaxable" invoices, `:tax_id`, `:global_id` and `:global_id_scheme_id` are introduced on `TradeParty` as attributes. ZUGFeRD does not allow VAT ids on invoices that are untaxable, so you need a global id (and other validators want you to add the local tax id as well, but that's mandatory, I think). This could be a SWIFT id, for example, which would be an IBAN for EU folks. (IANAL!)
7+
- [FEATURE] Introduce tax_calculation_method = :NONE, which skips collecting taxes from line items and just uses the global values. It also skips some of the validations. This is a hack-ish solution to a problem I ran into where the invoicing service calculated the VAT and net amounts backwards from a round gross amount, resulting in rounding errors when trying to calculate them the correct way round. This is easier to use when you only have one tax rate and all the values are already known.
8+
39
## 3.3.0
410

511
- [FEATURE] Allow for vertical tax calculation. PR by @RST-J

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ See tests for examples.
77
1. This is an opinionated library optimised for my very specific usecase
88
2. While I did start to add some validations to make sure you can't input absolute garbage into this, I cannot guarantee factual (as in taxation law) correctness of the resulting XML.
99
1. The library, for ZUGFeRD 2.x, currently only supports the EN16931 variant. This is probably what you want as well. PRs welcome.
10-
3. This does not contain any code to attach the XML to a PDF file, mainly because I have yet to find a ruby library to do that. For software that does this, take a look at [this python library](https://github.com/akretion/factur-x).
10+
3. This does not contain any code to attach the XML to a PDF file, mainly because I have yet to find a ruby library to do that. For software that does this, take a look at [this python library](https://github.com/akretion/factur-x) or [this Java library which also does extended validation](https://mustangproject.org)
1111

1212
## Contributors
1313

lib/secretariat/constants.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ module Secretariat
6666

6767
# For the background of vertical and horizontal tax calculation see https://hilfe.pacemo.de/de-form/articles/3489851-rundungsfehler-bei-rechnungen
6868
# The idea of introducing an unknown value is that this could be inferred from the given invoice total and line items by probing both variants and selecting the matching one - or reporting a taxation error if neither matches.
69-
TAX_CALCULATION_METHODS = %i[HORIZONTAL VERTICAL UNKNOWN].freeze
69+
TAX_CALCULATION_METHODS = %i[HORIZONTAL VERTICAL NONE UNKNOWN].freeze
7070

7171
UNIT_CODES = {
7272
:PIECE => "C62",

lib/secretariat/invoice.rb

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,26 @@ def tax_category_code(tax, version: 2)
6565

6666
def taxes
6767
taxes = {}
68+
# Shortcut for cases where invoices only have one tax and the calculation is off by a cent because of rounding errors
69+
# (This can happen if the VAT and the net amount is calculated backwards from a round gross amount)
70+
if tax_calculation_method == :NONE
71+
tax = Tax.new(tax_percent: BigDecimal(tax_percent || BigDecimal(0)), tax_category: tax_category)
72+
tax.base_amount = BigDecimal(basis_amount)
73+
tax.tax_amount = BigDecimal(tax_amount || 0)
74+
return [tax]
75+
end
76+
6877
line_items.each do |line_item|
69-
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?
70-
taxes[line_item.tax_percent].tax_amount += BigDecimal(line_item.tax_amount)
71-
taxes[line_item.tax_percent].base_amount += BigDecimal(line_item.net_amount) * line_item.quantity
78+
if line_item.tax_percent.nil?
79+
taxes['0'] = Tax.new(tax_percent: BigDecimal(0), tax_category: line_item.tax_category, tax_amount: BigDecimal(0)) if taxes['0'].nil?
80+
taxes['0'].base_amount += BigDecimal(line_item.net_amount) * line_item.quantity
81+
else
82+
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?
83+
taxes[line_item.tax_percent].tax_amount += BigDecimal(line_item.tax_amount)
84+
taxes[line_item.tax_percent].base_amount += BigDecimal(line_item.net_amount) * line_item.quantity
85+
end
7286
end
87+
7388
if tax_calculation_method == :VERTICAL
7489
taxes.values.map do |tax|
7590
tax.tax_amount = (tax.base_amount * tax.tax_percent / 100).round(2)
@@ -98,12 +113,14 @@ def valid?
98113
@errors << "Base amount and summed tax base amount deviate: #{basis} / #{summed_tax_base_amount}"
99114
return false
100115
end
101-
taxes.each do |tax|
102-
calc_tax = tax.base_amount * BigDecimal(tax.tax_percent) / BigDecimal(100)
103-
calc_tax = calc_tax.round(2)
104-
if tax.tax_amount != calc_tax
105-
@errors << "Tax amount and calculated tax amount deviate for rate #{tax.tax_percent}: #{tax.tax_amount} / #{calc_tax}"
106-
return false
116+
if tax_calculation_method != :NONE
117+
taxes.each do |tax|
118+
calc_tax = tax.base_amount * BigDecimal(tax.tax_percent) / BigDecimal(100)
119+
calc_tax = calc_tax.round(2)
120+
if tax.tax_amount != calc_tax
121+
@errors << "Tax amount and calculated tax amount deviate for rate #{tax.tax_percent}: #{tax.tax_amount} / #{calc_tax}"
122+
return false
123+
end
107124
end
108125
end
109126
grand_total = BigDecimal(grand_total_amount)
@@ -250,9 +267,10 @@ def to_xml(version: 1, validate: true)
250267
end
251268
Helpers.currency_element(xml, 'ram', 'BasisAmount', tax.base_amount, currency_code, add_currency: version == 1)
252269
xml['ram'].CategoryCode tax_category_code(tax, version: version)
253-
254-
percent = by_version(version, 'ApplicablePercent', 'RateApplicablePercent')
255-
xml['ram'].send(percent, Helpers.format(tax.tax_percent))
270+
# unless tax.untaxable?
271+
percent = by_version(version, 'ApplicablePercent', 'RateApplicablePercent')
272+
xml['ram'].send(percent, Helpers.format(tax.tax_percent))
273+
# end
256274
end
257275
end
258276
if version == 2 && service_period_start && service_period_end

lib/secretariat/line_item.rb

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,6 @@
1414
limitations under the License.
1515
=end
1616

17-
18-
1917
require 'bigdecimal'
2018
module Secretariat
2119

@@ -63,13 +61,15 @@ def valid?
6361
return false
6462
end
6563
end
66-
67-
calculated_tax = charge_price * BigDecimal(tax_percent) / BigDecimal(100)
68-
calculated_tax = calculated_tax.round(2)
69-
calculated_tax = -calculated_tax if quantity.negative?
70-
if calculated_tax != tax
71-
@errors << "Tax and calculated tax deviate: #{tax} / #{calculated_tax}"
72-
return false
64+
if tax_category != :UNTAXEDSERVICE
65+
self.tax_percent ||= BigDecimal(0)
66+
calculated_tax = charge_price * BigDecimal(tax_percent) / BigDecimal(100)
67+
calculated_tax = calculated_tax.round(2)
68+
calculated_tax = -calculated_tax if quantity.negative?
69+
if calculated_tax != tax
70+
@errors << "Tax and calculated tax deviate: #{tax} / #{calculated_tax}"
71+
return false
72+
end
7373
end
7474
return true
7575
end
@@ -85,11 +85,17 @@ def tax_category_code(version: 2)
8585
TAX_CATEGORY_CODES[tax_category] || 'S'
8686
end
8787

88+
def untaxable?
89+
tax_category == :UNTAXEDSERVICE
90+
end
91+
8892
def to_xml(xml, line_item_index, version: 2, validate: true)
8993
net_price = net_amount && BigDecimal(net_amount)
9094
gross_price = gross_amount && BigDecimal(gross_amount)
9195
charge_price = charge_amount && BigDecimal(charge_amount)
9296

97+
self.tax_percent ||= BigDecimal(0)
98+
9399
if net_price&.zero?
94100
self.tax_percent = 0
95101
end
@@ -170,10 +176,10 @@ def to_xml(xml, line_item_index, version: 2, validate: true)
170176
xml['ram'].ApplicableTradeTax do
171177
xml['ram'].TypeCode 'VAT'
172178
xml['ram'].CategoryCode tax_category_code(version: version)
173-
174-
percent = by_version(version, 'ApplicablePercent', 'RateApplicablePercent')
175-
xml['ram'].send(percent,Helpers.format(tax_percent))
176-
179+
unless untaxable?
180+
percent = by_version(version, 'ApplicablePercent', 'RateApplicablePercent')
181+
xml['ram'].send(percent,Helpers.format(tax_percent))
182+
end
177183
end
178184
monetary_summation = by_version(version, 'SpecifiedTradeSettlementMonetarySummation', 'SpecifiedTradeSettlementLineMonetarySummation')
179185
xml['ram'].send(monetary_summation) do

lib/secretariat/tax.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,5 +30,10 @@ def initialize(*)
3030
self.tax_amount = 0
3131
self.base_amount = 0
3232
end
33+
34+
def untaxable?
35+
tax_category == :UNTAXEDSERVICE
36+
end
37+
3338
end
3439
end

lib/secretariat/trade_party.rb

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,15 @@
1616

1717
module Secretariat
1818
TradeParty = Struct.new('TradeParty',
19-
:name, :street1, :street2, :city, :postal_code, :country_id, :vat_id,
19+
:name, :street1, :street2, :city, :postal_code, :country_id, :vat_id, :global_id, :global_id_scheme_id, :tax_id,
2020
keyword_init: true,
2121
) do
2222
def to_xml(xml, exclude_tax: false, version: 2)
23+
if global_id && global_id != '' && global_id_scheme_id && global_id_scheme_id != ''
24+
xml['ram'].GlobalID(schemeID: global_id_scheme_id) do
25+
xml.text(global_id)
26+
end
27+
end
2328
xml['ram'].Name name
2429
xml['ram'].PostalTradeAddress do
2530
xml['ram'].PostcodeCode postal_code
@@ -36,9 +41,13 @@ def to_xml(xml, exclude_tax: false, version: 2)
3641
xml.text(vat_id)
3742
end
3843
end
44+
elsif tax_id && tax_id != ''
45+
xml['ram'].SpecifiedTaxRegistration do
46+
xml['ram'].ID(schemeID: 'FC') do
47+
xml.text(tax_id)
48+
end
49+
end
3950
end
4051
end
4152
end
42-
43-
4453
end

test/invoice_test.rb

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,6 @@ def make_foreign_invoice(tax_category: :TAXEXEMPT)
6363
city: 'Hamburg',
6464
postal_code: '20253',
6565
country_id: 'DE',
66-
vat_id: 'DE304755032'
6766
)
6867
buyer = TradeParty.new(
6968
name: 'Another Corp Inc.',
@@ -362,6 +361,10 @@ def test_simple_eu_invoice_v2
362361
pp e.errors
363362
end
364363

364+
assert_match(/<ram:CategoryCode>AE<\/ram:CategoryCode>/, xml)
365+
assert_match(/<ram:ExemptionReason>Reverse Charge<\/ram:ExemptionReason>/, xml)
366+
assert_match(/<ram:RateApplicablePercent>/, xml)
367+
365368
v = Validator.new(xml, version: 2)
366369
errors = v.validate_against_schema
367370
if !errors.empty?
@@ -375,7 +378,7 @@ def test_simple_eu_invoice_v2
375378
puts e.errors
376379
end
377380

378-
def test_simple_foreign_invoice_v2
381+
def test_simple_foreign_invoice_v2_taxexpempt
379382
begin
380383
xml = make_foreign_invoice(tax_category: :TAXEXEMPT).to_xml(version: 2)
381384
rescue ValidationError => e
@@ -384,6 +387,30 @@ def test_simple_foreign_invoice_v2
384387

385388
assert_match(/<ram:CategoryCode>E<\/ram:CategoryCode>/, xml)
386389
assert_match(/<ram:ExemptionReason>VAT exempt<\/ram:ExemptionReason>/, xml)
390+
assert_match(/<ram:RateApplicablePercent>/, xml)
391+
392+
v = Validator.new(xml, version: 2)
393+
errors = v.validate_against_schema
394+
if !errors.empty?
395+
puts xml
396+
errors.each do |error|
397+
puts error
398+
end
399+
end
400+
assert_equal [], errors
401+
rescue ValidationError => e
402+
puts e.errors
403+
end
404+
405+
def test_simple_foreign_invoice_v2_untaxed
406+
begin
407+
xml = make_foreign_invoice(tax_category: :UNTAXEDSERVICE).to_xml(version: 2)
408+
rescue ValidationError => e
409+
pp e.errors
410+
end
411+
412+
assert_match(/<ram:CategoryCode>O<\/ram:CategoryCode>/, xml)
413+
assert_match(/<ram:ExemptionReason>Not subject to VAT<\/ram:ExemptionReason>/, xml)
387414

388415
v = Validator.new(xml, version: 2)
389416
errors = v.validate_against_schema

0 commit comments

Comments
 (0)