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 +4 -4
- data/CHANGELOG.md +31 -0
- data/README.md +78 -2
- 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 +24 -1
- data/lib/einvoicing/formats/ubl.rb +15 -1
- data/lib/einvoicing/invoice.rb +57 -17
- data/lib/einvoicing/line_item.rb +15 -8
- data/lib/einvoicing/party.rb +2 -2
- data/lib/einvoicing/tax.rb +77 -18
- data/lib/einvoicing/validators/fr.rb +115 -8
- data/lib/einvoicing/version.rb +1 -1
- data/lib/einvoicing.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: a112029dd945f7dfbdc67dcf4ee76420d1708284a7bb18d6c04a2c32de2a7015
|
|
4
|
+
data.tar.gz: 6f4a1302247e1b572b9713b51d4605d4e354c9000159d5755e36c1331ba70090
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
-
|
|
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: "
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
@@ -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
|
-
|
|
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.
|
|
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
|
@@ -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: "
|
|
11
|
-
# buyer = Einvoicing::Party.new(name: "Client SA", siren: "
|
|
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,
|
|
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/party.rb
CHANGED
|
@@ -10,8 +10,8 @@ module Einvoicing
|
|
|
10
10
|
# city: "Paris",
|
|
11
11
|
# postal_code: "75001",
|
|
12
12
|
# country_code: "FR",
|
|
13
|
-
# siren: "
|
|
14
|
-
# vat_number: "
|
|
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,
|
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,49 @@ 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. "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.
|
|
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 &&
|
|
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
|
|
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
|
|
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
|
data/lib/einvoicing/version.rb
CHANGED
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: "
|
|
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.
|
|
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.
|
|
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: []
|