einvoicing 0.8.1 → 0.9.1

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: a112029dd945f7dfbdc67dcf4ee76420d1708284a7bb18d6c04a2c32de2a7015
4
+ data.tar.gz: 6f4a1302247e1b572b9713b51d4605d4e354c9000159d5755e36c1331ba70090
5
5
  SHA512:
6
- metadata.gz: fc6e9095689ef5485eba2d52cc576dc6e144f11eaff51ea00128a4608342778d97366cf4d0689632233dc9019bcd3b076bf886340800df0bd8d18855dbbc3b43
7
- data.tar.gz: b959a718712cddeb2efd777570b7d780038bc881ee9232be0e99a39bf62a648db5cc1a23730077abea5624f80dcc62fbfb9ba64df4fde97bf64e21f00b8afad5
6
+ metadata.gz: ecf8f50fc15ce50e55506a7c74ab492d0a2b23987d29087a6555c229d62b9c03848d459b22da07e1428fd79ab0445da5726670f324823af36f611d61bdd0fdad
7
+ data.tar.gz: e7867f59abf64d56f82fe5903d5e76994986ee3e64445a49897fd25929e19b5afc245905955b749f06ecff83e893f9bfbfa676079a5ce7622f85441106ebf4db
data/CHANGELOG.md CHANGED
@@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.1] - 2026-08-06
11
+
12
+ ### Added
13
+ - Link to the online Factur-X validator in the README — runs entirely client-side with no upload required
14
+
15
+ ### Fixed
16
+ - CII no longer emits an empty `ram:BuyerReference` when no payment reference is set. It falls back to the invoice number, as UBL already did — the two formats produced different documents for the same invoice, and an empty BT-10 is rejected by Chorus Pro, which requires it
17
+ - French VAT numbers are now checked against their control key, not just their shape. `FR83552032534` has the right form and the right SIREN but the wrong key (it is 27), and the validator accepted it — a number that VIES and any tax administration rejects. The key is derived as `(12 + 3 × (SIREN mod 97)) mod 97` and the embedded SIREN must pass Luhn
18
+ - The official French structure is `FR` + 2 characters + 9 digits, and those two characters may be alphanumeric. Only a numeric key follows the published formula, so an alphanumeric one is still accepted on its shape and its SIREN — refusing it would reject valid invoices
19
+ - Every French identifier used in the test fixtures, the sample scripts and the documented examples was regenerated: all but one carried an invalid check key or a SIREN failing Luhn, which is precisely why the gap went unnoticed
20
+
21
+ ## [0.9.0] - 2026-08-05
22
+
23
+ Everything here comes from a batch of 15 deliberately hostile Factur-X invoices
24
+ (multi-rate, reverse charge, 500 lines, DOM rates, foreign currency) run through
25
+ the XSD, the PDF/A-3 container checks and a hand-recomputed EN 16931 rule set.
26
+ Four of the five findings were ours.
27
+
28
+ ### Added
29
+ - 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`
30
+ - 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)
31
+ - `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
32
+ - `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)
33
+
34
+ ### Fixed
35
+ - 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
36
+ - 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
37
+ - 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
38
+ - Exempt, reverse-charge and out-of-scope categories now bill 0 % whatever rate was passed in, so `RateApplicablePercent` and `CalculatedAmount` can no longer disagree
39
+ - `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`
40
+
10
41
  ## [0.8.1] - 2026-07-25
11
42
 
12
43
  ### 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 with the French check key verified, 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
@@ -60,7 +61,7 @@ buyer = Einvoicing::Party.new(
60
61
  postal_code: "75001",
61
62
  country_code: "FR",
62
63
  siren: "552032534",
63
- vat_number: "FR83552032534"
64
+ vat_number: "FR27552032534"
64
65
  )
65
66
 
66
67
  lines = [
@@ -144,6 +145,16 @@ errors = Einvoicing::Validators::FR.validate(invoice)
144
145
  # => [{ field: :seller_siren, error: :siren_invalid, message: "Le numéro SIREN est invalide" }]
145
146
  ```
146
147
 
148
+ ### Check a generated file online
149
+
150
+ Drop a Factur-X PDF into our free validator for a report on the PDF/A-3
151
+ container, the embedded XML, the official XSD and the EN 16931 business rules.
152
+ It runs entirely in the browser — the invoice never leaves your machine, and
153
+ there is no account to create.
154
+
155
+ - English — https://www.sxnlabs.com/en/factur-x-validator/
156
+ - Français — https://www.sxnlabs.com/verifier-facture-electronique/
157
+
147
158
  ## Formats
148
159
 
149
160
  ### Factur-X (PDF/A-3 + CII)
@@ -287,6 +298,71 @@ invoice.gross_total # BT-112 => 1140.00
287
298
 
288
299
  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
300
 
301
+ ## VAT Categories and Exemption Reasons
302
+
303
+ Every EN 16931 VAT category (BT-118) is available on a line, on a document-level
304
+ allowance or charge, and on a hand-built breakdown entry:
305
+
306
+ | Symbol | Code | Meaning |
307
+ |---|---|---|
308
+ | `:standard` | S | Standard rate |
309
+ | `:zero_rated` | Z | Zero rated |
310
+ | `:exempt` | E | Exempt from VAT |
311
+ | `:reverse_charge` | AE | Reverse charge |
312
+ | `:intra_community` | K | Intra-Community supply |
313
+ | `:export` | G | Export outside the EU |
314
+ | `:not_subject` | O | Outside the scope of VAT |
315
+
316
+ `E`, `AE`, `K`, `G` and `O` must state why no VAT is charged (BR-E-10, BR-AE-10,
317
+ BR-IC-10, BR-G-10, BR-O-10). AE, K, G and O fall back to their VATEX code; `E`
318
+ depends on the exemption invoked, so you supply it:
319
+
320
+ ```ruby
321
+ Einvoicing::LineItem.new(
322
+ description: "Prestation de conseil",
323
+ quantity: 1,
324
+ unit_price: BigDecimal("2500.00"),
325
+ vat_rate: 0,
326
+ category: :exempt,
327
+ exemption_reason: "TVA non applicable, art. 293 B du CGI",
328
+ exemption_reason_code: Einvoicing::Tax::VATEX_FR_FRANCHISE
329
+ )
330
+ ```
331
+
332
+ A reverse-charge line needs nothing extra — the breakdown carries
333
+ `VATEX-EU-AE` / "Reverse charge" on its own:
334
+
335
+ ```ruby
336
+ Einvoicing::LineItem.new(description: "Développement", quantity: 1,
337
+ unit_price: BigDecimal("10920.00"),
338
+ vat_rate: 0, category: :reverse_charge)
339
+ ```
340
+
341
+ Category VAT (BT-117) is computed from the category's taxable base (BT-116 ×
342
+ BT-119), not by summing the per-line VAT — the two diverge by a cent every few
343
+ dozen lines and BR-S-09 is checked to the cent.
344
+
345
+ ## VAT Accounting Currency
346
+
347
+ When you invoice in one currency and account for VAT in another (BT-6 ≠ BT-5),
348
+ BR-53 requires the VAT total restated in the accounting currency (BT-111). Give
349
+ the gem the rate and it emits both:
350
+
351
+ ```ruby
352
+ invoice = Einvoicing::Invoice.new(
353
+ # ... other fields ...
354
+ currency: "USD",
355
+ tax_currency: "EUR",
356
+ tax_exchange_rate: BigDecimal("0.92")
357
+ )
358
+
359
+ invoice.tax_total # => 1840.00 (USD)
360
+ invoice.tax_total_in_tax_currency # => 1692.80 (EUR)
361
+ ```
362
+
363
+ The FR validator flags a declared `tax_currency` with no `tax_exchange_rate`,
364
+ since the document could not satisfy BR-53.
365
+
290
366
  ## Payment Means
291
367
 
292
368
  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 is invalid for its country (format, or check key for France)"
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 is invalid for its country (format, or check key for France)"
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 numéro de TVA du vendeur est invalide pour son pays (format, ou clé de contrôle pour la France)"
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 numéro de TVA de l'acheteur est invalide pour son pays (format, ou clé de contrôle pour la France)"
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.
@@ -116,7 +116,9 @@ module Einvoicing
116
116
  def self.header_trade_agreement(b, invoice, profile)
117
117
  b.tag("ram:ApplicableHeaderTradeAgreement") do
118
118
  # BuyerReference must be first in the sequence (EN 16931 BR-10 / XSD order).
119
- b.text("ram:BuyerReference", invoice.payment_reference || "")
119
+ # Falls back to the invoice number like UBL does: an empty element is
120
+ # worse than either value, and BT-10 is mandatory for Chorus Pro.
121
+ b.text("ram:BuyerReference", invoice.payment_reference || invoice.invoice_number)
120
122
  b.tag("ram:SellerTradeParty") { party_xml(b, invoice.seller, profile) }
121
123
  b.tag("ram:BuyerTradeParty") { party_xml(b, invoice.buyer, profile) }
122
124
  end
@@ -154,6 +156,8 @@ module Einvoicing
154
156
  def self.header_trade_settlement(b, invoice)
155
157
  b.tag("ram:ApplicableHeaderTradeSettlement") do
156
158
  b.text("ram:PaymentReference", invoice.payment_reference || invoice.invoice_number)
159
+ # BT-6 precedes BT-5 in the CII sequence.
160
+ b.text("ram:TaxCurrencyCode", invoice.tax_currency) if invoice.tax_accounting_currency?
157
161
  b.text("ram:InvoiceCurrencyCode", invoice.currency)
158
162
 
159
163
  if invoice.payment_means_code
@@ -176,8 +180,12 @@ module Einvoicing
176
180
  b.tag("ram:ApplicableTradeTax") do
177
181
  b.text("ram:CalculatedAmount", format_amount(tax.tax_amount))
178
182
  b.text("ram:TypeCode", "VAT")
183
+ # BT-120 / BT-121 sit either side of BasisAmount/CategoryCode in
184
+ # the CII sequence — order is not ours to choose.
185
+ b.text("ram:ExemptionReason", tax.exemption_reason) if emit_exemption?(tax, :reason)
179
186
  b.text("ram:BasisAmount", format_amount(tax.taxable_amount))
180
187
  b.text("ram:CategoryCode", tax.category_code)
188
+ b.text("ram:ExemptionReasonCode", tax.exemption_reason_code) if emit_exemption?(tax, :code)
181
189
  b.text("ram:RateApplicablePercent", format_amount(tax.rate_percent))
182
190
  end
183
191
  end
@@ -200,6 +208,12 @@ module Einvoicing
200
208
  b.text("ram:TaxBasisTotalAmount", format_amount(invoice.net_total))
201
209
  b.text("ram:TaxTotalAmount", format_amount(invoice.tax_total),
202
210
  "currencyID" => invoice.currency)
211
+ # BT-111 — BR-53 requires the VAT total in the accounting currency
212
+ # as soon as BT-6 is present.
213
+ if (converted = invoice.tax_total_in_tax_currency)
214
+ b.text("ram:TaxTotalAmount", format_amount(converted),
215
+ "currencyID" => invoice.tax_currency)
216
+ end
203
217
  b.text("ram:GrandTotalAmount", format_amount(invoice.gross_total))
204
218
  b.text("ram:TotalPrepaidAmount", format_amount(invoice.prepaid_amount)) if invoice.prepaid_amount.positive?
205
219
  b.text("ram:DuePayableAmount", format_amount(invoice.due_amount))
@@ -232,6 +246,15 @@ module Einvoicing
232
246
  end
233
247
  private_class_method :trade_allowance_charge
234
248
 
249
+ # BR-Z-10 and the standard category forbid BT-120/BT-121; the exempt-like
250
+ # categories require one of the two.
251
+ def self.emit_exemption?(tax, part)
252
+ return false unless tax.exemption_required?
253
+
254
+ part == :reason ? !tax.exemption_reason.nil? : !tax.exemption_reason_code.nil?
255
+ end
256
+ private_class_method :emit_exemption?
257
+
235
258
  def self.preceding_invoice_reference(b, invoice)
236
259
  b.tag("ram:InvoiceReferencedDocument") do
237
260
  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
 
@@ -7,8 +7,8 @@ module Einvoicing
7
7
  # Core invoice model. All monetary values are in the invoice currency.
8
8
  #
9
9
  # @example
10
- # seller = Einvoicing::Party.new(name: "Acme SAS", siren: "123456789", vat_number: "FR12123456789")
11
- # buyer = Einvoicing::Party.new(name: "Client SA", siren: "987654321")
10
+ # seller = Einvoicing::Party.new(name: "Acme SAS", siren: "123456782", vat_number: "FR11123456782")
11
+ # buyer = Einvoicing::Party.new(name: "Client SA", siren: "987654316")
12
12
  # line = Einvoicing::LineItem.new(description: "Consulting", quantity: 1, unit_price: 1000.00)
13
13
  #
14
14
  # invoice = Einvoicing::Invoice.new(
@@ -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.
@@ -10,8 +10,8 @@ module Einvoicing
10
10
  # city: "Paris",
11
11
  # postal_code: "75001",
12
12
  # country_code: "FR",
13
- # siren: "123456789",
14
- # vat_number: "FR12123456789"
13
+ # siren: "123456782",
14
+ # vat_number: "FR11123456782"
15
15
  # )
16
16
  Party = Data.define(:name, :street, :city, :postal_code, :country_code,
17
17
  :siren, :siret, :vat_number, :email,
@@ -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,49 @@ 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. "FR11123456782", "DE811907980"
107
+ # @param country_code [String, nil] the party's country (BT-40 / BT-55)
66
108
  # @return [Boolean]
67
- def self.valid_vat_number?(vat)
68
- vat.to_s.match?(VAT_RE)
109
+ def self.valid_vat_number?(vat, country_code: nil)
110
+ str = vat.to_s.gsub(/\s/, "").upcase
111
+ country = country_code.to_s.upcase
112
+ country = str[0, 2].to_s unless EU_VAT_PATTERNS.key?(country)
113
+
114
+ pattern = EU_VAT_PATTERNS[country]
115
+ return str.match?(NON_EU_VAT_RE) unless pattern
116
+ return false unless str.match?(pattern)
117
+
118
+ country == "FR" ? valid_fr_vat_number?(str) : true
119
+ end
120
+
121
+ # A French VAT number carries a check key over its SIREN, so a typo that
122
+ # keeps the shape still has to be caught: FR83552032534 looks right and
123
+ # is not (the key for that SIREN is 27).
124
+ #
125
+ # The official structure is "FR" + 2 characters + 9 digits, and those two
126
+ # characters may be alphanumeric. Only a numeric key comes from the
127
+ # published formula, so an alphanumeric one is checked on its shape and
128
+ # on its SIREN alone — rejecting it would refuse valid invoices.
129
+ # @param vat [String] already stripped and upcased, matching VAT_RE
130
+ # @return [Boolean]
131
+ def self.valid_fr_vat_number?(vat)
132
+ key = vat[2, 2]
133
+ siren = vat[4, 9]
134
+ return false unless valid_siren?(siren)
135
+ return true unless key.match?(/\A\d{2}\z/)
136
+
137
+ key.to_i == (12 + (3 * (siren.to_i % 97))) % 97
138
+ end
139
+
140
+ # A known French VAT rate, including the DOM and Corsican rates that the
141
+ # "20 / 10 / 5.5 / 0" shortlist leaves out.
142
+ # @param rate [Numeric] e.g. 0.021
143
+ # @return [Boolean]
144
+ def self.valid_vat_rate?(rate)
145
+ VAT_RATES.include?(BigDecimal(rate.to_s).round(4))
69
146
  end
70
147
 
71
148
  # Validate an invoice number format (alphanumeric, dashes, slashes, 1-35 chars).
@@ -157,7 +234,8 @@ module Einvoicing
157
234
  message: Einvoicing::I18n.t("errors.#{role}.siret_invalid") }
158
235
  end
159
236
 
160
- if party.vat_number && !valid_vat_number?(party.vat_number)
237
+ if party.vat_number &&
238
+ !valid_vat_number?(party.vat_number, country_code: party.country_code)
161
239
  errors << { field: :"#{role}_vat_number", error: :vat_number_invalid,
162
240
  message: Einvoicing::I18n.t("errors.#{role}.vat_number_invalid") }
163
241
  end
@@ -187,7 +265,7 @@ module Einvoicing
187
265
  { field: :"line_#{n}_unit_price", error: :unit_price_invalid,
188
266
  message: Einvoicing::I18n.t("errors.line.unit_price_invalid", index: n) }
189
267
  end),
190
- (unless [ 0.0, 0.055, 0.10, 0.20 ].include?(line.vat_rate.to_f.round(3))
268
+ (unless valid_vat_rate?(line.vat_rate)
191
269
  { field: :"line_#{n}_vat_rate", error: :vat_rate_invalid,
192
270
  message: Einvoicing::I18n.t("errors.line.vat_rate_invalid", index: n) }
193
271
  end)
@@ -212,7 +290,7 @@ module Einvoicing
212
290
  { field: :"#{kind}_#{n}_reason", error: :reason_missing,
213
291
  message: Einvoicing::I18n.t("errors.#{kind}.reason_missing", index: n) }
214
292
  end),
215
- (unless [ 0.0, 0.055, 0.10, 0.20 ].include?(item.vat_rate.to_f.round(3))
293
+ (unless valid_vat_rate?(item.vat_rate)
216
294
  { field: :"#{kind}_#{n}_vat_rate", error: :vat_rate_invalid,
217
295
  message: Einvoicing::I18n.t("errors.#{kind}.vat_rate_invalid", index: n) }
218
296
  end)
@@ -220,6 +298,35 @@ module Einvoicing
220
298
  end.compact
221
299
  end
222
300
  private_class_method :validate_allowances_charges
301
+
302
+ # BR-E-10 / BR-AE-10 / BR-IC-10 / BR-G-10 / BR-O-10: an exempt, reverse
303
+ # charge, intra-Community, export or out-of-scope breakdown line must
304
+ # state why no VAT is charged (BT-120 or BT-121).
305
+ def self.validate_tax_breakdown(breakdown)
306
+ return [] if breakdown.nil? || breakdown.empty?
307
+
308
+ breakdown.each_with_index.filter_map do |tax, idx|
309
+ next unless tax.exemption_required?
310
+ next unless tax.exemption_reason.to_s.strip.empty? &&
311
+ tax.exemption_reason_code.to_s.strip.empty?
312
+
313
+ { field: :"tax_#{idx + 1}_exemption_reason", error: :exemption_reason_missing,
314
+ message: Einvoicing::I18n.t("errors.tax.exemption_reason_missing",
315
+ category: tax.category_code) }
316
+ end
317
+ end
318
+ private_class_method :validate_tax_breakdown
319
+
320
+ # BR-53: once a VAT accounting currency (BT-6) is declared, the VAT total
321
+ # has to be restated in it (BT-111).
322
+ def self.validate_tax_currency(invoice)
323
+ return [] unless invoice.tax_accounting_currency?
324
+ return [] unless invoice.tax_total_in_tax_currency.nil?
325
+
326
+ [ { field: :tax_exchange_rate, error: :tax_exchange_rate_missing,
327
+ message: Einvoicing::I18n.t("errors.invoice.tax_exchange_rate_missing") } ]
328
+ end
329
+ private_class_method :validate_tax_currency
223
330
  end
224
331
  end
225
332
  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.1"
5
5
  end
data/lib/einvoicing.rb CHANGED
@@ -37,7 +37,7 @@ end
37
37
  # SIRET, TVA). Provides a Rails concern for ActiveRecord models.
38
38
  #
39
39
  # @example Quick start
40
- # seller = Einvoicing::Party.new(name: "Acme SAS", siren: "356000000", vat_number: "FR83356000000")
40
+ # seller = Einvoicing::Party.new(name: "Acme SAS", siren: "356000000", vat_number: "FR39356000000")
41
41
  # buyer = Einvoicing::Party.new(name: "Client SA", siren: "552032534")
42
42
  # line = Einvoicing::LineItem.new(description: "Consulting", quantity: 1, unit_price: 1000.00)
43
43
  #
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.1
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: []