einvoicing 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 548238110199faaae0ccfe8c7b278169728edb8b57dfb84ae10ba5d871c5e883
4
- data.tar.gz: 8a517b11240b33c5f6a80d3fc05db58f7c4531a1375fb72ceb4bc790b284f68c
3
+ metadata.gz: f41193fc67e566fb4f03b91fcbb9666e81540c72411d03535ac58fe92d3573a2
4
+ data.tar.gz: 3d36fab9deb25fb3f25cadde7e435c5227a20f37e9011d3c22977659e1058bb2
5
5
  SHA512:
6
- metadata.gz: fc6e9095689ef5485eba2d52cc576dc6e144f11eaff51ea00128a4608342778d97366cf4d0689632233dc9019bcd3b076bf886340800df0bd8d18855dbbc3b43
7
- data.tar.gz: b959a718712cddeb2efd777570b7d780038bc881ee9232be0e99a39bf62a648db5cc1a23730077abea5624f80dcc62fbfb9ba64df4fde97bf64e21f00b8afad5
6
+ metadata.gz: 98d84a972f5dc4eaa604ca1360d741a916ceac43f54ec824f6dfd25143706c793cb27c5376d2e959cf919e61805fef9eb64066d839a3da3c875a638db9927261
7
+ data.tar.gz: fa99bcf35ebdbf90bb4ede4a031406201899fc4c288093a634df8126b0b738830340ba1894027b932c84397c232c3f4b8dab7f0cbd208a7a3148fcff1c499cc5
data/CHANGELOG.md CHANGED
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.0] - 2026-08-05
11
+
12
+ Everything here comes from a batch of 15 deliberately hostile Factur-X invoices
13
+ (multi-rate, reverse charge, 500 lines, DOM rates, foreign currency) run through
14
+ the XSD, the PDF/A-3 container checks and a hand-recomputed EN 16931 rule set.
15
+ Four of the five findings were ours.
16
+
17
+ ### Added
18
+ - The full EN 16931 VAT category set (BT-118) on `LineItem`, `AllowanceCharge` and `Tax`: `:standard` (S), `:zero_rated` (Z), `:exempt` (E), `:reverse_charge` (AE), `:intra_community` (K), `:export` (G), `:not_subject` (O). An unknown category now raises instead of silently emitting `S`
19
+ - VAT exemption reason (BT-120) and reason code (BT-121), emitted as `ram:ExemptionReason` / `ram:ExemptionReasonCode` in CII and `cbc:TaxExemptionReason` / `cbc:TaxExemptionReasonCode` in UBL. AE, K, G and O default to their VATEX code (`VATEX-EU-AE`, `VATEX-EU-IC`, `VATEX-EU-G`, `VATEX-EU-O`); E takes the caller's, with `Einvoicing::Tax::VATEX_FR_FRANCHISE` provided for the art. 293 B franchise. S and Z never carry one (BR-Z-10)
20
+ - `tax_exchange_rate` on `Invoice`, plus `#tax_accounting_currency?` and `#tax_total_in_tax_currency`. When BT-6 differs from BT-5, CII now emits `ram:TaxCurrencyCode` and a second `ram:TaxTotalAmount` in the accounting currency (BT-111), and UBL a second `cac:TaxTotal` — BR-53
21
+ - `Einvoicing::Validators::FR.valid_vat_rate?`, and FR validator checks for a missing exemption reason (BR-E-10, BR-AE-10, BR-IC-10, BR-G-10, BR-O-10) and for a declared accounting currency with no exchange rate (BR-53)
22
+
23
+ ### Fixed
24
+ - Each VAT category's tax amount (BT-117) is now derived from its rounded taxable base (BT-116 × BT-119) instead of summing the rounded per-line VAT. The two agree on small invoices and drift apart on large ones — a 500-line invoice was off by 3 and 15 cents on two categories, which a strict BR-S-09 check rejects outright
25
+ - The FR validator no longer rejects a counterparty's own VAT number. It now validates against the party's country (BT-40 / BT-55), with patterns for the 27 member states plus XI; every intra-Community and reverse-charge invoice used to fail on the buyer's VAT number
26
+ - The French VAT rate list now includes the rates the "20 / 10 / 5.5 / 0" shortlist leaves out: 2.1 % (press, reimbursable medicine), the DOM rates 8.5 %, 1.75 % and 1.05 %, and the Corsican rates 13 % and 0.9 %. Comparison moved to `BigDecimal` at 4 decimals so 1.05 % and 1.75 % stop colliding
27
+ - Exempt, reverse-charge and out-of-scope categories now bill 0 % whatever rate was passed in, so `RateApplicablePercent` and `CalculatedAmount` can no longer disagree
28
+ - `Invoice#with` no longer returns an invoice with a nil `tax_breakdown` on Ruby 3.2. It relied on `Data#with` calling `#initialize`, which only holds from Ruby 3.3 on; on 3.2 the members are copied straight across, so the deliberate `tax_breakdown: nil` was written as-is and every total derived from it raised `NoMethodError`. Broken since 0.8.0 for `#with(lines:)`, `#with(allowances:)` and `#with(charges:)` — the gem declares `>= 3.2`
29
+
10
30
  ## [0.8.1] - 2026-07-25
11
31
 
12
32
  ### Fixed
data/README.md CHANGED
@@ -18,7 +18,8 @@ This gem gives you a clean Ruby API to build compliant invoices, validate them a
18
18
  - Generate **Factur-X** invoices (PDF/A-3b with embedded CII D16B XML)
19
19
  - Generate **UBL 2.1** XML (Peppol BIS Billing 3.0)
20
20
  - Generate **CII D16B** XML (EN 16931 / ZUGFeRD)
21
- - Validate French B2B requirements: SIREN, SIRET (Luhn), TVA format, standard VAT rates
21
+ - Full EN 16931 **VAT category set** (S, Z, E, AE, K, G, O) with exemption reasons (BT-120 / BT-121)
22
+ - Validate French B2B requirements: SIREN, SIRET (Luhn), EU VAT number formats, French VAT rates including the DOM and Corsican ones
22
23
  - Structured error reporting: `{ field:, error:, message: }` with i18n support (EN + FR)
23
24
  - Payment means: IBAN, BIC/SWIFT, UNCL4461 type codes
24
25
  - **Rails concern** (`Einvoicing::Invoiceable`) for ActiveRecord models
@@ -287,6 +288,71 @@ invoice.gross_total # BT-112 => 1140.00
287
288
 
288
289
  Each allowance and charge adjusts the taxable base and the VAT of its own category in the breakdown (EN 16931 BR-CO-10 to BR-CO-13). EN 16931 requires a reason or a reason code on each one (BR-33 / BR-38), and the FR validator enforces it.
289
290
 
291
+ ## VAT Categories and Exemption Reasons
292
+
293
+ Every EN 16931 VAT category (BT-118) is available on a line, on a document-level
294
+ allowance or charge, and on a hand-built breakdown entry:
295
+
296
+ | Symbol | Code | Meaning |
297
+ |---|---|---|
298
+ | `:standard` | S | Standard rate |
299
+ | `:zero_rated` | Z | Zero rated |
300
+ | `:exempt` | E | Exempt from VAT |
301
+ | `:reverse_charge` | AE | Reverse charge |
302
+ | `:intra_community` | K | Intra-Community supply |
303
+ | `:export` | G | Export outside the EU |
304
+ | `:not_subject` | O | Outside the scope of VAT |
305
+
306
+ `E`, `AE`, `K`, `G` and `O` must state why no VAT is charged (BR-E-10, BR-AE-10,
307
+ BR-IC-10, BR-G-10, BR-O-10). AE, K, G and O fall back to their VATEX code; `E`
308
+ depends on the exemption invoked, so you supply it:
309
+
310
+ ```ruby
311
+ Einvoicing::LineItem.new(
312
+ description: "Prestation de conseil",
313
+ quantity: 1,
314
+ unit_price: BigDecimal("2500.00"),
315
+ vat_rate: 0,
316
+ category: :exempt,
317
+ exemption_reason: "TVA non applicable, art. 293 B du CGI",
318
+ exemption_reason_code: Einvoicing::Tax::VATEX_FR_FRANCHISE
319
+ )
320
+ ```
321
+
322
+ A reverse-charge line needs nothing extra — the breakdown carries
323
+ `VATEX-EU-AE` / "Reverse charge" on its own:
324
+
325
+ ```ruby
326
+ Einvoicing::LineItem.new(description: "Développement", quantity: 1,
327
+ unit_price: BigDecimal("10920.00"),
328
+ vat_rate: 0, category: :reverse_charge)
329
+ ```
330
+
331
+ Category VAT (BT-117) is computed from the category's taxable base (BT-116 ×
332
+ BT-119), not by summing the per-line VAT — the two diverge by a cent every few
333
+ dozen lines and BR-S-09 is checked to the cent.
334
+
335
+ ## VAT Accounting Currency
336
+
337
+ When you invoice in one currency and account for VAT in another (BT-6 ≠ BT-5),
338
+ BR-53 requires the VAT total restated in the accounting currency (BT-111). Give
339
+ the gem the rate and it emits both:
340
+
341
+ ```ruby
342
+ invoice = Einvoicing::Invoice.new(
343
+ # ... other fields ...
344
+ currency: "USD",
345
+ tax_currency: "EUR",
346
+ tax_exchange_rate: BigDecimal("0.92")
347
+ )
348
+
349
+ invoice.tax_total # => 1840.00 (USD)
350
+ invoice.tax_total_in_tax_currency # => 1692.80 (EUR)
351
+ ```
352
+
353
+ The FR validator flags a declared `tax_currency` with no `tax_exchange_rate`,
354
+ since the document could not satisfy BR-53.
355
+
290
356
  ## Payment Means
291
357
 
292
358
  Add IBAN, BIC, and UNCL4461 payment type code to the invoice. Both CII and UBL generators emit the appropriate elements automatically.
@@ -12,32 +12,36 @@ en:
12
12
  bic_invalid: "BIC format is invalid (8 or 11 characters)"
13
13
  prepaid_amount_negative: "Prepaid amount cannot be negative"
14
14
  prepaid_amount_exceeds_total: "Prepaid amount cannot exceed the invoice total"
15
+ tax_exchange_rate_missing: "A VAT accounting currency (BT-6) requires tax_exchange_rate so the VAT total can be restated in it (BT-111)"
15
16
  seller:
16
17
  name_missing: "Seller name is required"
17
18
  siren_invalid: "Seller SIREN must be 9 digits (Luhn check failed)"
18
19
  siret_invalid: "Seller SIRET must be 14 digits (Luhn check failed)"
19
- vat_number_invalid: "Seller VAT number must be in format FR + 2 chars + 9 digits"
20
+ vat_number_invalid: "Seller VAT number format is invalid for its country prefix"
20
21
  buyer:
21
22
  name_missing: "Buyer name is required"
22
23
  siren_invalid: "Buyer SIREN must be 9 digits (Luhn check failed)"
23
24
  siret_invalid: "Buyer SIRET must be 14 digits (Luhn check failed)"
24
- vat_number_invalid: "Buyer VAT number must be in format FR + 2 chars + 9 digits"
25
+ vat_number_invalid: "Buyer VAT number format is invalid for its country prefix"
25
26
  line:
26
27
  description_missing: "Line %{index}: description is required"
27
28
  quantity_invalid: "Line %{index}: quantity must be positive"
28
29
  unit_price_invalid: "Line %{index}: unit price must be non-negative"
29
- vat_rate_invalid: "Line %{index}: VAT rate must be a known French rate (0%%, 5.5%%, 10%%, 20%%)"
30
+ vat_rate_invalid: "Line %{index}: VAT rate must be a known French rate (0%%, 0.9%%, 1.05%%, 1.75%%, 2.1%%, 5.5%%, 8.5%%, 10%%, 13%%, 20%%)"
30
31
  allowance:
31
32
  amount_invalid: "Allowance %{index}: amount must be non-negative"
32
33
  reason_missing: "Allowance %{index}: a reason or a reason code is required"
33
- vat_rate_invalid: "Allowance %{index}: VAT rate must be a known French rate (0%%, 5.5%%, 10%%, 20%%)"
34
+ vat_rate_invalid: "Allowance %{index}: VAT rate must be a known French rate (0%%, 0.9%%, 1.05%%, 1.75%%, 2.1%%, 5.5%%, 8.5%%, 10%%, 13%%, 20%%)"
34
35
  charge:
35
36
  amount_invalid: "Charge %{index}: amount must be non-negative"
36
37
  reason_missing: "Charge %{index}: a reason or a reason code is required"
37
- vat_rate_invalid: "Charge %{index}: VAT rate must be a known French rate (0%%, 5.5%%, 10%%, 20%%)"
38
+ vat_rate_invalid: "Charge %{index}: VAT rate must be a known French rate (0%%, 0.9%%, 1.05%%, 1.75%%, 2.1%%, 5.5%%, 8.5%%, 10%%, 13%%, 20%%)"
39
+ tax:
40
+ exemption_reason_missing: "VAT category %{category} requires an exemption reason (BT-120) or an exemption reason code (BT-121)"
38
41
  formats:
39
42
  unknown_format: "Unknown format: %{fmt}. Use :cii or :ubl"
40
43
  unknown_market: "Unknown market: %{market}. Use :fr"
41
44
  invalid_pdf: "pdf_data does not appear to be a valid PDF (missing %PDF- magic bytes)"
42
45
  tax:
43
46
  invalid_rate: "rate must be >= 0, got %{rate}"
47
+ unknown_category: "unknown VAT category %{category}"
@@ -12,32 +12,36 @@ fr:
12
12
  bic_invalid: "Le format du BIC est invalide (8 ou 11 caractères)"
13
13
  prepaid_amount_negative: "Le montant déjà payé ne peut pas être négatif"
14
14
  prepaid_amount_exceeds_total: "Le montant déjà payé ne peut pas dépasser le total de la facture"
15
+ tax_exchange_rate_missing: "Une devise de comptabilisation de la TVA (BT-6) impose tax_exchange_rate pour exprimer le total de TVA dans cette devise (BT-111)"
15
16
  seller:
16
17
  name_missing: "Le nom du vendeur est requis"
17
18
  siren_invalid: "Le SIREN du vendeur doit comporter 9 chiffres (vérification Luhn échouée)"
18
19
  siret_invalid: "Le SIRET du vendeur doit comporter 14 chiffres (vérification Luhn échouée)"
19
- vat_number_invalid: "Le numéro de TVA du vendeur doit être au format FR + 2 caractères + 9 chiffres"
20
+ vat_number_invalid: "Le format du numéro de TVA du vendeur est invalide pour son préfixe pays"
20
21
  buyer:
21
22
  name_missing: "Le nom de l'acheteur est requis"
22
23
  siren_invalid: "Le SIREN de l'acheteur doit comporter 9 chiffres (vérification Luhn échouée)"
23
24
  siret_invalid: "Le SIRET de l'acheteur doit comporter 14 chiffres (vérification Luhn échouée)"
24
- vat_number_invalid: "Le numéro de TVA de l'acheteur doit être au format FR + 2 caractères + 9 chiffres"
25
+ vat_number_invalid: "Le format du numéro de TVA de l'acheteur est invalide pour son préfixe pays"
25
26
  line:
26
27
  description_missing: "Ligne %{index} : la description est requise"
27
28
  quantity_invalid: "Ligne %{index} : la quantité doit être positive"
28
29
  unit_price_invalid: "Ligne %{index} : le prix unitaire doit être non négatif"
29
- vat_rate_invalid: "Ligne %{index} : le taux de TVA doit être un taux français standard (0%%, 5,5%%, 10%%, 20%%)"
30
+ vat_rate_invalid: "Ligne %{index} : le taux de TVA doit être un taux français standard (0%%, 0,9%%, 1,05%%, 1,75%%, 2,1%%, 5,5%%, 8,5%%, 10%%, 13%%, 20%%)"
30
31
  allowance:
31
32
  amount_invalid: "Remise %{index} : le montant doit être non négatif"
32
33
  reason_missing: "Remise %{index} : un motif ou un code motif est requis"
33
- vat_rate_invalid: "Remise %{index} : le taux de TVA doit être un taux français standard (0%%, 5,5%%, 10%%, 20%%)"
34
+ vat_rate_invalid: "Remise %{index} : le taux de TVA doit être un taux français standard (0%%, 0,9%%, 1,05%%, 1,75%%, 2,1%%, 5,5%%, 8,5%%, 10%%, 13%%, 20%%)"
34
35
  charge:
35
36
  amount_invalid: "Frais %{index} : le montant doit être non négatif"
36
37
  reason_missing: "Frais %{index} : un motif ou un code motif est requis"
37
- vat_rate_invalid: "Frais %{index} : le taux de TVA doit être un taux français standard (0%%, 5,5%%, 10%%, 20%%)"
38
+ vat_rate_invalid: "Frais %{index} : le taux de TVA doit être un taux français standard (0%%, 0,9%%, 1,05%%, 1,75%%, 2,1%%, 5,5%%, 8,5%%, 10%%, 13%%, 20%%)"
39
+ tax:
40
+ exemption_reason_missing: "La catégorie de TVA %{category} impose un motif d'exonération (BT-120) ou un code motif (BT-121)"
38
41
  formats:
39
42
  unknown_format: "Format inconnu : %{fmt}. Utilisez :cii ou :ubl"
40
43
  unknown_market: "Marché inconnu : %{market}. Utilisez :fr"
41
44
  invalid_pdf: "pdf_data ne semble pas être un PDF valide (octets magiques %PDF- manquants)"
42
45
  tax:
43
46
  invalid_rate: "le taux doit être >= 0, valeur reçue : %{rate}"
47
+ unknown_category: "catégorie de TVA inconnue : %{category}"
@@ -20,16 +20,21 @@ module Einvoicing
20
20
  # percentage: 10
21
21
  # )
22
22
  AllowanceCharge = Data.define(:amount, :vat_rate, :category, :reason,
23
- :reason_code, :base_amount, :percentage) do
23
+ :reason_code, :base_amount, :percentage,
24
+ :exemption_reason, :exemption_reason_code) do
24
25
  # @param amount [Numeric] BT-92 / BT-99 — always positive
25
26
  # @param vat_rate [Numeric] BT-96 / BT-103 — the rate the amount applies to
26
- # @param category [Symbol, nil] nil for standard/zero, :reverse_charge for AE
27
+ # @param category [Symbol, nil] a key of Einvoicing::Tax::CATEGORY_CODES;
28
+ # nil infers standard or zero-rated from the rate
27
29
  # @param reason [String, nil] BT-97 / BT-104
28
30
  # @param reason_code [String, nil] BT-98 / BT-105 (UNCL5189 / UNCL7161)
29
31
  # @param base_amount [Numeric, nil] BT-93 / BT-100
30
32
  # @param percentage [Numeric, nil] BT-94 / BT-101
33
+ # @param exemption_reason [String, nil] BT-120, carried into the VAT breakdown
34
+ # @param exemption_reason_code [String, nil] BT-121, carried into the VAT breakdown
31
35
  def initialize(amount:, vat_rate: 0.20, category: nil, reason: nil,
32
- reason_code: nil, base_amount: nil, percentage: nil)
36
+ reason_code: nil, base_amount: nil, percentage: nil,
37
+ exemption_reason: nil, exemption_reason_code: nil)
33
38
  super(
34
39
  amount: BigDecimal(amount.to_s),
35
40
  vat_rate: vat_rate,
@@ -37,19 +42,19 @@ module Einvoicing
37
42
  reason: reason,
38
43
  reason_code: reason_code,
39
44
  base_amount: base_amount.nil? ? nil : BigDecimal(base_amount.to_s),
40
- percentage: percentage
45
+ percentage: percentage,
46
+ exemption_reason: exemption_reason,
47
+ exemption_reason_code: exemption_reason_code
41
48
  )
42
49
  end
43
50
 
44
51
  # VAT carried by this allowance/charge at its own rate.
45
52
  def vat_amount
46
- (amount * BigDecimal(vat_rate.to_s)).round(2, :half_up)
53
+ (amount * Tax.effective_rate(rate: vat_rate, category: category)).round(2, :half_up)
47
54
  end
48
55
 
49
56
  def vat_rate_percent
50
- return BigDecimal("0") if category == :reverse_charge
51
-
52
- (BigDecimal(vat_rate.to_s) * 100).round(2)
57
+ (Tax.effective_rate(rate: vat_rate, category: category) * 100).round(2)
53
58
  end
54
59
 
55
60
  # CII/UBL tax category code — delegates to shared Tax logic.
@@ -154,6 +154,8 @@ module Einvoicing
154
154
  def self.header_trade_settlement(b, invoice)
155
155
  b.tag("ram:ApplicableHeaderTradeSettlement") do
156
156
  b.text("ram:PaymentReference", invoice.payment_reference || invoice.invoice_number)
157
+ # BT-6 precedes BT-5 in the CII sequence.
158
+ b.text("ram:TaxCurrencyCode", invoice.tax_currency) if invoice.tax_accounting_currency?
157
159
  b.text("ram:InvoiceCurrencyCode", invoice.currency)
158
160
 
159
161
  if invoice.payment_means_code
@@ -176,8 +178,12 @@ module Einvoicing
176
178
  b.tag("ram:ApplicableTradeTax") do
177
179
  b.text("ram:CalculatedAmount", format_amount(tax.tax_amount))
178
180
  b.text("ram:TypeCode", "VAT")
181
+ # BT-120 / BT-121 sit either side of BasisAmount/CategoryCode in
182
+ # the CII sequence — order is not ours to choose.
183
+ b.text("ram:ExemptionReason", tax.exemption_reason) if emit_exemption?(tax, :reason)
179
184
  b.text("ram:BasisAmount", format_amount(tax.taxable_amount))
180
185
  b.text("ram:CategoryCode", tax.category_code)
186
+ b.text("ram:ExemptionReasonCode", tax.exemption_reason_code) if emit_exemption?(tax, :code)
181
187
  b.text("ram:RateApplicablePercent", format_amount(tax.rate_percent))
182
188
  end
183
189
  end
@@ -200,6 +206,12 @@ module Einvoicing
200
206
  b.text("ram:TaxBasisTotalAmount", format_amount(invoice.net_total))
201
207
  b.text("ram:TaxTotalAmount", format_amount(invoice.tax_total),
202
208
  "currencyID" => invoice.currency)
209
+ # BT-111 — BR-53 requires the VAT total in the accounting currency
210
+ # as soon as BT-6 is present.
211
+ if (converted = invoice.tax_total_in_tax_currency)
212
+ b.text("ram:TaxTotalAmount", format_amount(converted),
213
+ "currencyID" => invoice.tax_currency)
214
+ end
203
215
  b.text("ram:GrandTotalAmount", format_amount(invoice.gross_total))
204
216
  b.text("ram:TotalPrepaidAmount", format_amount(invoice.prepaid_amount)) if invoice.prepaid_amount.positive?
205
217
  b.text("ram:DuePayableAmount", format_amount(invoice.due_amount))
@@ -232,6 +244,15 @@ module Einvoicing
232
244
  end
233
245
  private_class_method :trade_allowance_charge
234
246
 
247
+ # BR-Z-10 and the standard category forbid BT-120/BT-121; the exempt-like
248
+ # categories require one of the two.
249
+ def self.emit_exemption?(tax, part)
250
+ return false unless tax.exemption_required?
251
+
252
+ part == :reason ? !tax.exemption_reason.nil? : !tax.exemption_reason_code.nil?
253
+ end
254
+ private_class_method :emit_exemption?
255
+
235
256
  def self.preceding_invoice_reference(b, invoice)
236
257
  b.tag("ram:InvoiceReferencedDocument") do
237
258
  b.text("ram:IssuerAssignedID", invoice.original_invoice_number)
@@ -54,7 +54,7 @@ module Einvoicing
54
54
  b.text("cbc:InvoiceTypeCode", invoice.document_type == :credit_note ? "381" : "380")
55
55
  b.text("cbc:Note", invoice.note) if invoice.note
56
56
  b.text("cbc:DocumentCurrencyCode", invoice.currency)
57
- b.text("cbc:TaxCurrencyCode", invoice.tax_currency) if invoice.tax_currency
57
+ b.text("cbc:TaxCurrencyCode", invoice.tax_currency) if invoice.tax_accounting_currency?
58
58
  b.text("cbc:BuyerReference", invoice.payment_reference || invoice.invoice_number)
59
59
  end
60
60
  private_class_method :header
@@ -145,11 +145,25 @@ module Einvoicing
145
145
  b.tag("cac:TaxCategory") do
146
146
  b.text("cbc:ID", tax.category_code)
147
147
  b.text("cbc:Percent", format_amount(tax.rate_percent))
148
+ if tax.exemption_required?
149
+ if tax.exemption_reason_code
150
+ b.text("cbc:TaxExemptionReasonCode", tax.exemption_reason_code)
151
+ end
152
+ b.text("cbc:TaxExemptionReason", tax.exemption_reason) if tax.exemption_reason
153
+ end
148
154
  b.tag("cac:TaxScheme") { b.text("cbc:ID", "VAT") }
149
155
  end
150
156
  end
151
157
  end
152
158
  end
159
+
160
+ # BT-111 — a second TaxTotal carrying only the VAT total converted into
161
+ # the VAT accounting currency (BR-53).
162
+ return unless (converted = invoice.tax_total_in_tax_currency)
163
+
164
+ b.tag("cac:TaxTotal") do
165
+ b.text("cbc:TaxAmount", format_amount(converted), "currencyID" => invoice.tax_currency)
166
+ end
153
167
  end
154
168
  private_class_method :tax_total
155
169
 
@@ -24,6 +24,7 @@ module Einvoicing
24
24
  :due_date,
25
25
  :currency,
26
26
  :tax_currency,
27
+ :tax_exchange_rate,
27
28
  :seller,
28
29
  :buyer,
29
30
  :lines,
@@ -42,7 +43,8 @@ module Einvoicing
42
43
  ) do
43
44
  def initialize(invoice_number:, issue_date:, seller:, buyer:, lines:,
44
45
  allowances: [], charges: [],
45
- due_date: nil, currency: "EUR", tax_currency: nil, tax_breakdown: nil,
46
+ due_date: nil, currency: "EUR", tax_currency: nil,
47
+ tax_exchange_rate: nil, tax_breakdown: nil,
46
48
  payment_reference: nil, note: nil,
47
49
  payment_means_code: nil, iban: nil, bic: nil,
48
50
  document_type: :invoice, original_invoice_number: nil, original_invoice_date: nil,
@@ -56,6 +58,7 @@ module Einvoicing
56
58
  due_date: due_date,
57
59
  currency: currency,
58
60
  tax_currency: tax_currency,
61
+ tax_exchange_rate: tax_exchange_rate.nil? ? nil : BigDecimal(tax_exchange_rate.to_s),
59
62
  seller: seller,
60
63
  buyer: buyer,
61
64
  lines: lines,
@@ -74,16 +77,23 @@ module Einvoicing
74
77
  )
75
78
  end
76
79
 
77
- # Data#with feeds every current member back through the constructor, which
78
- # would carry the already-computed tax_breakdown over to the copy and leave
79
- # it stale. Drop it so the copy recomputes — unless the caller supplies one.
80
+ # Changing any of these invalidates the memoised breakdown, so the copy has
81
+ # to recompute it unless the caller supplies one of their own.
80
82
  RECOMPUTES_TAX_BREAKDOWN = %i[lines allowances charges].freeze
81
83
 
84
+ # Rebuilt through the constructor rather than by delegating to Data#with:
85
+ # only Ruby >= 3.3 routes #with through #initialize. On 3.2 it copies the
86
+ # members straight across, so `tax_breakdown: nil` would be written as-is
87
+ # and every total computed from it would blow up on nil.
82
88
  def with(**kwargs)
83
- return super if kwargs.key?(:tax_breakdown)
84
- return super if (kwargs.keys & RECOMPUTES_TAX_BREAKDOWN).empty?
89
+ return self if kwargs.empty?
85
90
 
86
- super(**kwargs, tax_breakdown: nil)
91
+ attributes = to_h.merge(kwargs)
92
+ if !kwargs.key?(:tax_breakdown) && kwargs.keys.intersect?(RECOMPUTES_TAX_BREAKDOWN)
93
+ attributes[:tax_breakdown] = nil
94
+ end
95
+
96
+ self.class.new(**attributes)
87
97
  end
88
98
 
89
99
  # Sum of all line net amounts, before document-level adjustments (BT-106).
@@ -126,11 +136,31 @@ module Einvoicing
126
136
  gross_total - prepaid_amount
127
137
  end
128
138
 
139
+ # BT-6 is set and differs from the invoice currency (BT-5), so BR-53 requires
140
+ # the total VAT to also be stated in the VAT accounting currency (BT-111).
141
+ def tax_accounting_currency?
142
+ !tax_currency.nil? && tax_currency != currency
143
+ end
144
+
145
+ # Total VAT in the VAT accounting currency (BT-111). Derived from
146
+ # `tax_exchange_rate` when given; nil when there is nothing to convert.
147
+ def tax_total_in_tax_currency
148
+ return nil unless tax_accounting_currency?
149
+ return nil if tax_exchange_rate.nil?
150
+
151
+ (tax_total * tax_exchange_rate).round(2, :half_up)
152
+ end
153
+
129
154
  private
130
155
 
131
156
  # Per-category taxable base (BT-116) and VAT (BT-117), adjusted by the
132
157
  # document-level allowances and charges that carry the same rate/category
133
158
  # (EN 16931 BR-CO-10 through BR-CO-12).
159
+ #
160
+ # BT-117 is derived from the rounded category base, not from the sum of the
161
+ # per-line VAT amounts: BR-S-09 (and its BR-Z/E/AE/K/G/O siblings) require
162
+ # BT-117 = BT-116 × BT-119. Summing rounded line VAT drifts by a cent per
163
+ # few dozen lines and a strict validator rejects the invoice.
134
164
  def compute_tax_breakdown(lines, allowances, charges)
135
165
  key = ->(item) { [ item.vat_rate, item.category ] }
136
166
  grouped_lines = lines.group_by(&key)
@@ -143,18 +173,28 @@ module Einvoicing
143
173
  rate_allowances = grouped_allowances.fetch(k, [])
144
174
  rate_charges = grouped_charges.fetch(k, [])
145
175
 
146
- taxable = sum_of(rate_lines, :net_amount) -
147
- sum_of(rate_allowances, :amount) +
148
- sum_of(rate_charges, :amount)
149
- tax_amt = sum_of(rate_lines, :vat_amount) -
150
- sum_of(rate_allowances, :vat_amount) +
151
- sum_of(rate_charges, :vat_amount)
176
+ taxable = (sum_of(rate_lines, :net_amount) -
177
+ sum_of(rate_allowances, :amount) +
178
+ sum_of(rate_charges, :amount)).round(2, :half_up)
179
+ tax_amt = (taxable * Tax.effective_rate(rate: rate, category: category)).round(2, :half_up)
180
+
181
+ reason, reason_code = exemption_for(rate_lines + rate_allowances + rate_charges)
152
182
 
153
- Tax.new(rate: rate, taxable_amount: taxable.round(2, :half_up),
154
- tax_amount: tax_amt.round(2, :half_up), category: category)
183
+ Tax.new(rate: rate, taxable_amount: taxable, tax_amount: tax_amt, category: category,
184
+ exemption_reason: reason, exemption_reason_code: reason_code)
155
185
  end
156
186
  end
157
187
 
188
+ # BT-120 / BT-121 travel with the items; the breakdown adopts the first
189
+ # reason found in the group. Tax falls back to the VATEX default for the
190
+ # category when none is supplied.
191
+ def exemption_for(items)
192
+ [
193
+ items.find { |i| !i.exemption_reason.nil? }&.exemption_reason,
194
+ items.find { |i| !i.exemption_reason_code.nil? }&.exemption_reason_code
195
+ ]
196
+ end
197
+
158
198
  def sum_of(items, method)
159
199
  items.sum(BigDecimal("0"), &method)
160
200
  end
@@ -13,15 +13,24 @@ module Einvoicing
13
13
  # unit_price: 150.00,
14
14
  # vat_rate: 0.20
15
15
  # )
16
- LineItem = Data.define(:description, :quantity, :unit_price, :vat_rate, :unit, :category) do
17
- def initialize(description:, quantity:, unit_price:, vat_rate: 0.20, unit: "C62", category: nil)
16
+ LineItem = Data.define(:description, :quantity, :unit_price, :vat_rate, :unit, :category,
17
+ :exemption_reason, :exemption_reason_code) do
18
+ # @param category [Symbol, nil] a key of Einvoicing::Tax::CATEGORY_CODES
19
+ # (:standard, :zero_rated, :exempt, :reverse_charge, :intra_community,
20
+ # :export, :not_subject); nil infers standard or zero-rated from the rate
21
+ # @param exemption_reason [String, nil] BT-120, carried into the VAT breakdown
22
+ # @param exemption_reason_code [String, nil] BT-121, carried into the VAT breakdown
23
+ def initialize(description:, quantity:, unit_price:, vat_rate: 0.20, unit: "C62",
24
+ category: nil, exemption_reason: nil, exemption_reason_code: nil)
18
25
  super(
19
26
  description: description,
20
27
  quantity: BigDecimal(quantity.to_s),
21
28
  unit_price: BigDecimal(unit_price.to_s),
22
29
  vat_rate: vat_rate,
23
30
  unit: unit,
24
- category: category
31
+ category: category,
32
+ exemption_reason: exemption_reason,
33
+ exemption_reason_code: exemption_reason_code
25
34
  )
26
35
  end
27
36
 
@@ -30,9 +39,9 @@ module Einvoicing
30
39
  (quantity * unit_price).round(2, :half_up)
31
40
  end
32
41
 
33
- # VAT amount for this line.
42
+ # VAT amount for this line. Exempt / reverse-charge categories bill 0 %.
34
43
  def vat_amount
35
- (net_amount * BigDecimal(vat_rate.to_s)).round(2, :half_up)
44
+ (net_amount * Tax.effective_rate(rate: vat_rate, category: category)).round(2, :half_up)
36
45
  end
37
46
 
38
47
  # Gross line total (including VAT).
@@ -41,9 +50,7 @@ module Einvoicing
41
50
  end
42
51
 
43
52
  def vat_rate_percent
44
- return BigDecimal("0") if category == :reverse_charge
45
-
46
- (BigDecimal(vat_rate.to_s) * 100).round(2)
53
+ (Tax.effective_rate(rate: vat_rate, category: category) * 100).round(2)
47
54
  end
48
55
 
49
56
  # CII/UBL tax category code — delegates to shared Tax logic.
@@ -4,35 +4,94 @@ require "bigdecimal"
4
4
  require "bigdecimal/util"
5
5
 
6
6
  module Einvoicing
7
- # VAT breakdown entry for a single tax rate.
8
- Tax = Data.define(:rate, :taxable_amount, :tax_amount, :category) do
9
- # @param rate [Numeric] e.g. 0.20 for 20% VAT; 0 for zero-rated or reverse charge
10
- # @param taxable_amount [Numeric] net amount subject to this rate
11
- # @param tax_amount [Numeric] VAT amount for this rate
12
- # @param category [Symbol, nil] nil for standard/zero, :reverse_charge for AE
13
- def initialize(rate:, taxable_amount:, tax_amount:, category: nil)
14
- raise ArgumentError, Einvoicing::I18n.t("tax.invalid_rate", rate: rate) if rate.to_f.negative?
7
+ # VAT breakdown entry (BG-23) for a single rate/category pair.
8
+ class Tax < Data.define(:rate, :taxable_amount, :tax_amount, :category,
9
+ :exemption_reason, :exemption_reason_code)
10
+ # EN 16931 BT-118 the UNCL5305 subset the standard allows.
11
+ CATEGORY_CODES = {
12
+ standard: "S", # Standard rate
13
+ zero_rated: "Z", # Zero rated goods
14
+ exempt: "E", # Exempt from VAT
15
+ reverse_charge: "AE", # VAT reverse charge
16
+ intra_community: "K", # Intra-Community supply
17
+ export: "G", # Export outside the EU
18
+ not_subject: "O" # Services outside scope of tax
19
+ }.freeze
15
20
 
16
- super
17
- end
21
+ # BR-E-10, BR-AE-10, BR-IC-10, BR-G-10, BR-O-10: these categories must carry
22
+ # a VAT exemption reason (BT-120) or reason code (BT-121). BR-Z-10 and the
23
+ # standard category must NOT carry one.
24
+ EXEMPTION_REQUIRED_CODES = %w[E AE K G O].freeze
25
+
26
+ # Categories that net out to 0 % VAT whatever rate the caller passed in.
27
+ ZERO_VAT_CODES = %w[Z E AE K G O].freeze
28
+
29
+ # Defaults from the EN 16931 VATEX code list. "E" is deliberately absent:
30
+ # the reason depends on which exemption is invoked, so the caller supplies
31
+ # it (e.g. VATEX_FR_FRANCHISE for the French art. 293 B franchise).
32
+ DEFAULT_EXEMPTIONS = {
33
+ "AE" => [ "VATEX-EU-AE", "Reverse charge" ],
34
+ "K" => [ "VATEX-EU-IC", "Intra-Community supply" ],
35
+ "G" => [ "VATEX-EU-G", "Export outside the EU" ],
36
+ "O" => [ "VATEX-EU-O", "Not subject to VAT" ]
37
+ }.freeze
38
+
39
+ # France, art. 293 B du CGI — franchise en base de TVA.
40
+ VATEX_FR_FRANCHISE = "VATEX-FR-FRANCHISE"
18
41
 
19
- # Shared lookup used by both Tax and LineItem.
42
+ # Shared lookup used by Tax, LineItem and AllowanceCharge.
20
43
  def self.category_code_for(rate:, category: nil)
21
- if rate.to_f == 0.0
22
- category == :reverse_charge ? "AE" : "Z"
23
- else
24
- "S"
44
+ return rate.to_f.zero? ? "Z" : "S" if category.nil?
45
+
46
+ CATEGORY_CODES.fetch(category) do
47
+ raise ArgumentError, Einvoicing::I18n.t("tax.unknown_category", category: category.inspect)
25
48
  end
26
49
  end
27
50
 
51
+ # The rate actually billed once the category is taken into account: an
52
+ # exempt / reverse-charge / out-of-scope category charges 0 % whatever rate
53
+ # was passed in.
54
+ def self.effective_rate(rate:, category: nil)
55
+ return BigDecimal("0") if ZERO_VAT_CODES.include?(category_code_for(rate: rate, category: category))
56
+
57
+ BigDecimal(rate.to_s)
58
+ end
59
+
60
+ # @param rate [Numeric] e.g. 0.20 for 20% VAT; 0 for zero-rated or exempt
61
+ # @param taxable_amount [Numeric] net amount subject to this rate (BT-116)
62
+ # @param tax_amount [Numeric] VAT amount for this rate (BT-117)
63
+ # @param category [Symbol, nil] a key of CATEGORY_CODES; nil infers standard
64
+ # or zero-rated from the rate
65
+ # @param exemption_reason [String, nil] BT-120
66
+ # @param exemption_reason_code [String, nil] BT-121
67
+ def initialize(rate:, taxable_amount:, tax_amount:, category: nil,
68
+ exemption_reason: nil, exemption_reason_code: nil)
69
+ raise ArgumentError, Einvoicing::I18n.t("tax.invalid_rate", rate: rate) if rate.to_f.negative?
70
+
71
+ code = Tax.category_code_for(rate: rate, category: category)
72
+ default_code, default_reason = DEFAULT_EXEMPTIONS.fetch(code, [ nil, nil ])
73
+
74
+ super(
75
+ rate: rate,
76
+ taxable_amount: taxable_amount,
77
+ tax_amount: tax_amount,
78
+ category: category,
79
+ exemption_reason: exemption_reason || default_reason,
80
+ exemption_reason_code: exemption_reason_code || default_code
81
+ )
82
+ end
83
+
28
84
  def category_code
29
85
  Tax.category_code_for(rate: rate, category: category)
30
86
  end
31
87
 
32
- def rate_percent
33
- return BigDecimal("0") if category == :reverse_charge
88
+ # True when EN 16931 requires BT-120 or BT-121 on this breakdown line.
89
+ def exemption_required?
90
+ EXEMPTION_REQUIRED_CODES.include?(category_code)
91
+ end
34
92
 
35
- (BigDecimal(rate.to_s) * 100).round(2)
93
+ def rate_percent
94
+ (Tax.effective_rate(rate: rate, category: category) * 100).round(2)
36
95
  end
37
96
  end
38
97
  end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "bigdecimal"
4
+ require "bigdecimal/util"
5
+
3
6
  module Einvoicing
4
7
  module Validators
5
8
  # French invoice validator.
@@ -17,6 +20,40 @@ module Einvoicing
17
20
  # FR VAT: "FR" + 2 alphanumeric chars + 9-digit SIREN
18
21
  VAT_RE = /\AFR[A-Z0-9]{2}\d{9}\z/
19
22
  INV_NUM_RE = /\A[\w\-\/]{1,35}\z/
23
+
24
+ # An invoice to a company in another member state carries that country's
25
+ # VAT number (BT-48), not a French one. Rejecting it broke every
26
+ # intra-Community and reverse-charge invoice.
27
+ EU_VAT_PATTERNS = {
28
+ "AT" => /\AATU\d{8}\z/, "BE" => /\ABE[01]\d{9}\z/,
29
+ "BG" => /\ABG\d{9,10}\z/, "CY" => /\ACY\d{8}[A-Z]\z/,
30
+ "CZ" => /\ACZ\d{8,10}\z/, "DE" => /\ADE\d{9}\z/,
31
+ "DK" => /\ADK\d{8}\z/, "EE" => /\AEE\d{9}\z/,
32
+ "EL" => /\AEL\d{9}\z/, "ES" => /\AES[A-Z0-9]\d{7}[A-Z0-9]\z/,
33
+ "FI" => /\AFI\d{8}\z/, "FR" => VAT_RE,
34
+ "HR" => /\AHR\d{11}\z/, "HU" => /\AHU\d{8}\z/,
35
+ "IE" => /\AIE(\d{7}[A-Z]{1,2}|\d[A-Z0-9+*]\d{5}[A-Z])\z/,
36
+ "IT" => /\AIT\d{11}\z/, "LT" => /\ALT(\d{9}|\d{12})\z/,
37
+ "LU" => /\ALU\d{8}\z/, "LV" => /\ALV\d{11}\z/,
38
+ "MT" => /\AMT\d{8}\z/, "NL" => /\ANL\d{9}B\d{2}\z/,
39
+ "PL" => /\APL\d{10}\z/, "PT" => /\APT\d{9}\z/,
40
+ "RO" => /\ARO\d{2,10}\z/, "SE" => /\ASE\d{12}\z/,
41
+ "SI" => /\ASI\d{8}\z/, "SK" => /\ASK\d{10}\z/,
42
+ # Northern Ireland keeps an EU VAT identifier under the Windsor Framework.
43
+ "XI" => /\AXI(\d{9}|\d{12}|(GD|HA)\d{3})\z/
44
+ }.freeze
45
+
46
+ # Fallback for non-EU counterparties: 2-letter country prefix + 2 to 15
47
+ # alphanumeric characters. Enough to catch a typo, not a customs ruling.
48
+ NON_EU_VAT_RE = /\A[A-Z]{2}[A-Z0-9]{2,15}\z/
49
+
50
+ # France (metropolitan): 20 %, 10 %, 5.5 %, 2.1 %.
51
+ # DOM (Guadeloupe, Martinique, Réunion): 8.5 %, 2.1 %, 1.75 %, 1.05 %.
52
+ # Corsica: 20 %, 13 %, 10 %, 2.1 %, 0.9 %.
53
+ VAT_RATES = [
54
+ "0", "0.009", "0.0105", "0.0175", "0.021",
55
+ "0.055", "0.085", "0.10", "0.13", "0.20"
56
+ ].map { |r| BigDecimal(r) }.freeze
20
57
  # IBAN: country code (2 alpha) + 2 check digits + BBAN (11-30 alphanumeric) = 15-34 total
21
58
  IBAN_RE = /\A[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}\z/
22
59
  # BIC: 4 alpha (institution) + 2 alpha (country) + 2 alphanumeric (location) + optional 3 alphanumeric (branch)
@@ -31,7 +68,9 @@ module Einvoicing
31
68
  *validate_party(invoice.buyer, :buyer),
32
69
  *validate_lines(invoice.lines),
33
70
  *validate_allowances_charges(invoice.allowances, :allowance),
34
- *validate_allowances_charges(invoice.charges, :charge)
71
+ *validate_allowances_charges(invoice.charges, :charge),
72
+ *validate_tax_breakdown(invoice.tax_breakdown),
73
+ *validate_tax_currency(invoice)
35
74
  ]
36
75
  end
37
76
 
@@ -61,11 +100,26 @@ module Einvoicing
61
100
  Base.luhn_valid?(siret.to_s)
62
101
  end
63
102
 
64
- # Validate a French VAT number format.
65
- # @param vat [String] e.g. "FR12123456789"
103
+ # Validate a VAT number against the format of the country it belongs to.
104
+ # The party's own country wins when known, so a French party still has to
105
+ # carry an FR number; otherwise the number's own prefix decides.
106
+ # @param vat [String] e.g. "FR12123456789", "DE811907980"
107
+ # @param country_code [String, nil] the party's country (BT-40 / BT-55)
108
+ # @return [Boolean]
109
+ def self.valid_vat_number?(vat, country_code: nil)
110
+ str = vat.to_s.gsub(/\s/, "").upcase
111
+ pattern = EU_VAT_PATTERNS[country_code.to_s.upcase] || EU_VAT_PATTERNS[str[0, 2]]
112
+ return str.match?(pattern) if pattern
113
+
114
+ str.match?(NON_EU_VAT_RE)
115
+ end
116
+
117
+ # A known French VAT rate, including the DOM and Corsican rates that the
118
+ # "20 / 10 / 5.5 / 0" shortlist leaves out.
119
+ # @param rate [Numeric] e.g. 0.021
66
120
  # @return [Boolean]
67
- def self.valid_vat_number?(vat)
68
- vat.to_s.match?(VAT_RE)
121
+ def self.valid_vat_rate?(rate)
122
+ VAT_RATES.include?(BigDecimal(rate.to_s).round(4))
69
123
  end
70
124
 
71
125
  # Validate an invoice number format (alphanumeric, dashes, slashes, 1-35 chars).
@@ -157,7 +211,8 @@ module Einvoicing
157
211
  message: Einvoicing::I18n.t("errors.#{role}.siret_invalid") }
158
212
  end
159
213
 
160
- if party.vat_number && !valid_vat_number?(party.vat_number)
214
+ if party.vat_number &&
215
+ !valid_vat_number?(party.vat_number, country_code: party.country_code)
161
216
  errors << { field: :"#{role}_vat_number", error: :vat_number_invalid,
162
217
  message: Einvoicing::I18n.t("errors.#{role}.vat_number_invalid") }
163
218
  end
@@ -187,7 +242,7 @@ module Einvoicing
187
242
  { field: :"line_#{n}_unit_price", error: :unit_price_invalid,
188
243
  message: Einvoicing::I18n.t("errors.line.unit_price_invalid", index: n) }
189
244
  end),
190
- (unless [ 0.0, 0.055, 0.10, 0.20 ].include?(line.vat_rate.to_f.round(3))
245
+ (unless valid_vat_rate?(line.vat_rate)
191
246
  { field: :"line_#{n}_vat_rate", error: :vat_rate_invalid,
192
247
  message: Einvoicing::I18n.t("errors.line.vat_rate_invalid", index: n) }
193
248
  end)
@@ -212,7 +267,7 @@ module Einvoicing
212
267
  { field: :"#{kind}_#{n}_reason", error: :reason_missing,
213
268
  message: Einvoicing::I18n.t("errors.#{kind}.reason_missing", index: n) }
214
269
  end),
215
- (unless [ 0.0, 0.055, 0.10, 0.20 ].include?(item.vat_rate.to_f.round(3))
270
+ (unless valid_vat_rate?(item.vat_rate)
216
271
  { field: :"#{kind}_#{n}_vat_rate", error: :vat_rate_invalid,
217
272
  message: Einvoicing::I18n.t("errors.#{kind}.vat_rate_invalid", index: n) }
218
273
  end)
@@ -220,6 +275,35 @@ module Einvoicing
220
275
  end.compact
221
276
  end
222
277
  private_class_method :validate_allowances_charges
278
+
279
+ # BR-E-10 / BR-AE-10 / BR-IC-10 / BR-G-10 / BR-O-10: an exempt, reverse
280
+ # charge, intra-Community, export or out-of-scope breakdown line must
281
+ # state why no VAT is charged (BT-120 or BT-121).
282
+ def self.validate_tax_breakdown(breakdown)
283
+ return [] if breakdown.nil? || breakdown.empty?
284
+
285
+ breakdown.each_with_index.filter_map do |tax, idx|
286
+ next unless tax.exemption_required?
287
+ next unless tax.exemption_reason.to_s.strip.empty? &&
288
+ tax.exemption_reason_code.to_s.strip.empty?
289
+
290
+ { field: :"tax_#{idx + 1}_exemption_reason", error: :exemption_reason_missing,
291
+ message: Einvoicing::I18n.t("errors.tax.exemption_reason_missing",
292
+ category: tax.category_code) }
293
+ end
294
+ end
295
+ private_class_method :validate_tax_breakdown
296
+
297
+ # BR-53: once a VAT accounting currency (BT-6) is declared, the VAT total
298
+ # has to be restated in it (BT-111).
299
+ def self.validate_tax_currency(invoice)
300
+ return [] unless invoice.tax_accounting_currency?
301
+ return [] unless invoice.tax_total_in_tax_currency.nil?
302
+
303
+ [ { field: :tax_exchange_rate, error: :tax_exchange_rate_missing,
304
+ message: Einvoicing::I18n.t("errors.invoice.tax_exchange_rate_missing") } ]
305
+ end
306
+ private_class_method :validate_tax_currency
223
307
  end
224
308
  end
225
309
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Einvoicing
4
- VERSION = "0.8.1"
4
+ VERSION = "0.9.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: einvoicing
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.1
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nathan Le Ray
@@ -215,7 +215,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
215
215
  - !ruby/object:Gem::Version
216
216
  version: '0'
217
217
  requirements: []
218
- rubygems_version: 4.0.16
218
+ rubygems_version: 4.0.17
219
219
  specification_version: 4
220
220
  summary: EU electronic invoicing for Ruby — EN 16931, Factur-X, UBL 2.1
221
221
  test_files: []