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 +4 -4
- data/CHANGELOG.md +20 -0
- data/README.md +67 -1
- data/config/locales/einvoicing.en.yml +9 -5
- data/config/locales/einvoicing.fr.yml +9 -5
- data/lib/einvoicing/allowance_charge.rb +13 -8
- data/lib/einvoicing/formats/cii.rb +21 -0
- data/lib/einvoicing/formats/ubl.rb +15 -1
- data/lib/einvoicing/invoice.rb +55 -15
- data/lib/einvoicing/line_item.rb +15 -8
- data/lib/einvoicing/tax.rb +77 -18
- data/lib/einvoicing/validators/fr.rb +92 -8
- data/lib/einvoicing/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f41193fc67e566fb4f03b91fcbb9666e81540c72411d03535ac58fe92d3573a2
|
|
4
|
+
data.tar.gz: 3d36fab9deb25fb3f25cadde7e435c5227a20f37e9011d3c22977659e1058bb2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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]
|
|
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 *
|
|
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
|
-
|
|
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.
|
|
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
|
|
data/lib/einvoicing/invoice.rb
CHANGED
|
@@ -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,
|
|
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
|
-
#
|
|
78
|
-
#
|
|
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
|
|
84
|
-
return super if (kwargs.keys & RECOMPUTES_TAX_BREAKDOWN).empty?
|
|
89
|
+
return self if kwargs.empty?
|
|
85
90
|
|
|
86
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
tax_amt =
|
|
150
|
-
|
|
151
|
-
|
|
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
|
|
154
|
-
|
|
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
|
data/lib/einvoicing/line_item.rb
CHANGED
|
@@ -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
|
|
17
|
-
|
|
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 *
|
|
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
|
-
|
|
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.
|
data/lib/einvoicing/tax.rb
CHANGED
|
@@ -4,35 +4,94 @@ require "bigdecimal"
|
|
|
4
4
|
require "bigdecimal/util"
|
|
5
5
|
|
|
6
6
|
module Einvoicing
|
|
7
|
-
# VAT breakdown entry for a single
|
|
8
|
-
Tax
|
|
9
|
-
|
|
10
|
-
#
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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
|
|
42
|
+
# Shared lookup used by Tax, LineItem and AllowanceCharge.
|
|
20
43
|
def self.category_code_for(rate:, category: nil)
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
"
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
|
65
|
-
#
|
|
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.
|
|
68
|
-
|
|
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 &&
|
|
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
|
|
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
|
|
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
|
data/lib/einvoicing/version.rb
CHANGED
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.
|
|
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.
|
|
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: []
|