einvoicing 0.2.0 → 0.3.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 +55 -0
- data/README.md +277 -0
- data/config/locales/einvoicing.en.yml +27 -0
- data/config/locales/einvoicing.fr.yml +27 -0
- data/lib/einvoicing/data/srgb.icc +0 -0
- data/lib/einvoicing/formats/cii.rb +218 -0
- data/lib/einvoicing/formats/facturx.rb +195 -0
- data/lib/einvoicing/formats/ubl.rb +226 -0
- data/lib/einvoicing/i18n.rb +22 -0
- data/lib/einvoicing/invoice.rb +99 -0
- data/lib/einvoicing/invoiceable.rb +120 -0
- data/lib/einvoicing/line_item.rb +54 -0
- data/lib/einvoicing/party.rb +29 -0
- data/lib/einvoicing/ppf/client.rb +117 -0
- data/lib/einvoicing/ppf/errors.rb +12 -0
- data/lib/einvoicing/ppf/invoice_adapter.rb +61 -0
- data/lib/einvoicing/ppf/submitter.rb +32 -0
- data/lib/einvoicing/ppf.rb +6 -0
- data/lib/einvoicing/rails/concern.rb +4 -0
- data/lib/einvoicing/rails/engine.rb +21 -0
- data/lib/einvoicing/tax.rb +38 -0
- data/lib/einvoicing/validators/base.rb +52 -0
- data/lib/einvoicing/validators/fr.rb +191 -0
- data/lib/einvoicing/version.rb +1 -1
- data/lib/einvoicing/xml_builder.rb +67 -0
- data/lib/einvoicing.rb +45 -5
- metadata +135 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 01ba6a9d7f94ac75ba98a3c64141ae25293b27323e8f8c8b55c147bd8ae5ff4c
|
|
4
|
+
data.tar.gz: 9e929b17b7f00a01b23186b9e8ebded133bb5e3a7fc60da9f6cbbb271cab2074
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4c70ee9bfa6505caa0d17993a166bdf9220bd435649893ca349cb0e3efc2d8948db40fb691293f833a3ad0eb0318a9924eadd53415872c47b552a99e30d2153d
|
|
7
|
+
data.tar.gz: 4adc977fe39f03b2aa1395ca0ade81ff3d942425f1e580994b48b212c5dba458e305d1fb78776f5d36138c73d30239aa3e8e82b268db1f2fac4193633838f15f
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.3.0] - 2026-03-13
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Credit notes (`document_type: :credit_note`, TypeCode 381) in CII and UBL
|
|
12
|
+
- BillingReference in UBL credit notes referencing original invoice
|
|
13
|
+
- IBAN and BIC format validation in FR validator
|
|
14
|
+
- TaxCurrencyCode support in UBL for non-EUR invoices
|
|
15
|
+
- XSD schema validation in test suite
|
|
16
|
+
- BuyerReference always emitted in UBL (EN 16931 BT-10 compliance, fallback to invoice_number)
|
|
17
|
+
|
|
18
|
+
## [0.2.0] - 2026-03-13
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
- i18n error messages with English and French translations
|
|
22
|
+
- Payment means support (IBAN, BIC, payment type code) in CII and UBL
|
|
23
|
+
- Ruby symbol error codes in validators (`{ field:, error:, message: }`)
|
|
24
|
+
- Configurable validator in Invoiceable concern (`einvoicing_validator=`)
|
|
25
|
+
- ELI5 documentation in docs/eli5-e-invoicing.md
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
- PDF/A-3 conformance: bundled sRGB ICC profile for OutputIntent (Mustang PDF:valid)
|
|
29
|
+
- BigDecimal arithmetic throughout (was Float — rounding errors on financial totals)
|
|
30
|
+
- CII element ordering: URIUniversalCommunication before SpecifiedTaxRegistration
|
|
31
|
+
- Reverse charge: category: :reverse_charge instead of sentinel -1; emits RateApplicablePercent 0
|
|
32
|
+
- BuyerReference emitted in ApplicableHeaderTradeAgreement
|
|
33
|
+
- Empty XML elements suppressed by XMLBuilder
|
|
34
|
+
- SIREN/SIRET examples in sample script use known-valid Luhn values
|
|
35
|
+
- Gemfile.lock excluded from gem package
|
|
36
|
+
|
|
37
|
+
### Changed
|
|
38
|
+
- Validator errors now return `Array<Hash>` with `:field`, `:error`, `:message` keys
|
|
39
|
+
- All monetary amounts use BigDecimal (breaking change for Float inputs: wrap in BigDecimal())
|
|
40
|
+
|
|
41
|
+
## [0.1.0] - 2026-03-13
|
|
42
|
+
|
|
43
|
+
### Added
|
|
44
|
+
- Core invoice data model (`Invoice`, `Party`, `LineItem`, `Tax`) using Ruby 3.2 `Data.define`
|
|
45
|
+
- CII D16B XML generator (`Einvoicing::Formats::CII`) — EN 16931 / Factur-X EN16931 profile
|
|
46
|
+
- UBL 2.1 XML generator (`Einvoicing::Formats::UBL`) — EN 16931 / Peppol BIS Billing 3.0
|
|
47
|
+
- Factur-X embedding (`Einvoicing::Formats::FacturX`) — embeds CII XML into PDF/A-3 via hexapdf
|
|
48
|
+
- French validators (`Einvoicing::Validators::FR`) — SIREN, SIRET (Luhn), TVA format, invoice number
|
|
49
|
+
- Rails concern (`Einvoicing::Invoiceable`) — `to_cii_xml`, `to_ubl_xml`, `to_facturx`, `einvoicing_valid?`
|
|
50
|
+
- Rails engine (`Einvoicing::Rails::Engine`)
|
|
51
|
+
- Zero runtime dependencies beyond hexapdf (stdlib-only XML generation via internal builder)
|
|
52
|
+
- RSpec test suite
|
|
53
|
+
|
|
54
|
+
[Unreleased]: https://github.com/sxnlabs/einvoicing/compare/v0.1.0...HEAD
|
|
55
|
+
[0.1.0]: https://github.com/sxnlabs/einvoicing/releases/tag/v0.1.0
|
data/README.md
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
# einvoicing
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/einvoicing)
|
|
4
|
+
[](https://github.com/sxnlabs/einvoicing/actions)
|
|
5
|
+
|
|
6
|
+
**EN 16931 electronic invoicing for Ruby.** Generate Factur-X (PDF/A-3 + CII XML), UBL 2.1, and CII D16B invoices. Validate French B2B compliance (SIREN, SIRET, TVA). Rails-ready.
|
|
7
|
+
|
|
8
|
+
## Why
|
|
9
|
+
|
|
10
|
+
France mandates structured e-invoicing for all B2B transactions starting **September 2026** (Ordonnance n° 2021-1190). Every invoice between French VAT-registered companies must be issued in a structured format (Factur-X, UBL, or CII) and transmitted via the PPF or a certified PDP.
|
|
11
|
+
|
|
12
|
+
This gem gives you a clean Ruby API to build compliant invoices, validate them against French rules, and produce all required output formats — without pulling in a heavy XML library.
|
|
13
|
+
|
|
14
|
+
## Features
|
|
15
|
+
|
|
16
|
+
- Generate **Factur-X** invoices (PDF/A-3b with embedded CII D16B XML)
|
|
17
|
+
- Generate **UBL 2.1** XML (Peppol BIS Billing 3.0)
|
|
18
|
+
- Generate **CII D16B** XML (EN 16931 / ZUGFeRD)
|
|
19
|
+
- Validate French B2B requirements: SIREN, SIRET (Luhn), TVA format, standard VAT rates
|
|
20
|
+
- Structured error reporting: `{ field:, error:, message: }` with i18n support (EN + FR)
|
|
21
|
+
- Payment means: IBAN, BIC/SWIFT, UNCL4461 type codes
|
|
22
|
+
- **Rails concern** (`Einvoicing::Invoiceable`) for ActiveRecord models
|
|
23
|
+
- Only one runtime dependency: `hexapdf` (for PDF/A-3 embedding). XML generation uses Ruby stdlib.
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
# Gemfile
|
|
29
|
+
gem "einvoicing"
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
bundle install
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Quick Start
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
require "einvoicing"
|
|
40
|
+
require "date"
|
|
41
|
+
|
|
42
|
+
seller = Einvoicing::Party.new(
|
|
43
|
+
name: "SXN Labs",
|
|
44
|
+
street: "5 Lot Coat an Lem",
|
|
45
|
+
city: "Plouezoc'h",
|
|
46
|
+
postal_code: "29252",
|
|
47
|
+
country_code: "FR",
|
|
48
|
+
siren: "898208145",
|
|
49
|
+
siret: "89820814500018",
|
|
50
|
+
vat_number: "FR46898208145",
|
|
51
|
+
email: "contact@sxnlabs.com"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
buyer = Einvoicing::Party.new(
|
|
55
|
+
name: "Gecobat",
|
|
56
|
+
street: "12 rue du Bâtiment",
|
|
57
|
+
city: "Paris",
|
|
58
|
+
postal_code: "75001",
|
|
59
|
+
country_code: "FR",
|
|
60
|
+
siren: "552032534",
|
|
61
|
+
vat_number: "FR83552032534"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
lines = [
|
|
65
|
+
Einvoicing::LineItem.new(
|
|
66
|
+
description: "Développement backend — API REST (forfait)",
|
|
67
|
+
quantity: 1,
|
|
68
|
+
unit_price: BigDecimal("2500.00"),
|
|
69
|
+
vat_rate: 0.20
|
|
70
|
+
),
|
|
71
|
+
Einvoicing::LineItem.new(
|
|
72
|
+
description: "Intégration Factur-X",
|
|
73
|
+
quantity: 5,
|
|
74
|
+
unit_price: BigDecimal("350.00"),
|
|
75
|
+
vat_rate: 0.20
|
|
76
|
+
)
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
invoice = Einvoicing::Invoice.new(
|
|
80
|
+
invoice_number: "FAC-2024-0042",
|
|
81
|
+
issue_date: Date.new(2024, 3, 15),
|
|
82
|
+
due_date: Date.new(2024, 4, 15),
|
|
83
|
+
seller: seller,
|
|
84
|
+
buyer: buyer,
|
|
85
|
+
lines: lines,
|
|
86
|
+
payment_reference: "FAC-2024-0042",
|
|
87
|
+
note: "30 jours net",
|
|
88
|
+
payment_means_code: 30,
|
|
89
|
+
iban: "FR7630006000011234567890189",
|
|
90
|
+
bic: "BNPAFRPP"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Totals are computed automatically (BigDecimal, no rounding errors)
|
|
94
|
+
invoice.net_total # => 0.4000e4 (4000.00)
|
|
95
|
+
invoice.tax_total # => 0.800e3 (800.00)
|
|
96
|
+
invoice.gross_total # => 0.4800e4 (4800.00)
|
|
97
|
+
|
|
98
|
+
# Validate for French compliance
|
|
99
|
+
errors = Einvoicing::Validators::FR.validate(invoice)
|
|
100
|
+
errors.empty? # => true
|
|
101
|
+
|
|
102
|
+
# Generate CII D16B XML (Factur-X / ZUGFeRD)
|
|
103
|
+
xml = Einvoicing::Formats::CII.generate(invoice)
|
|
104
|
+
File.write("invoice.xml", xml)
|
|
105
|
+
|
|
106
|
+
# Generate UBL 2.1 XML (Peppol)
|
|
107
|
+
ubl = Einvoicing::Formats::UBL.generate(invoice)
|
|
108
|
+
File.write("invoice_ubl.xml", ubl)
|
|
109
|
+
|
|
110
|
+
# Embed CII XML into an existing PDF → Factur-X PDF/A-3
|
|
111
|
+
pdf_data = File.binread("invoice.pdf")
|
|
112
|
+
facturx = Einvoicing::Formats::FacturX.embed(pdf_data, xml)
|
|
113
|
+
File.binwrite("invoice_facturx.pdf", facturx)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Validation Errors
|
|
117
|
+
|
|
118
|
+
Errors are returned as an array of hashes — no exceptions, no monkey-patching:
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
errors = Einvoicing::Validators::FR.validate(invoice)
|
|
122
|
+
# => [
|
|
123
|
+
# { field: :seller_siren, error: :siren_invalid, message: "SIREN is invalid" },
|
|
124
|
+
# { field: :invoice_number, error: :number_invalid, message: "Invoice number format is invalid" }
|
|
125
|
+
# ]
|
|
126
|
+
|
|
127
|
+
# Raise instead of returning
|
|
128
|
+
Einvoicing::Validators::FR.validate!(invoice)
|
|
129
|
+
# => raises Einvoicing::Validators::ValidationError on failure
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### i18n (French messages)
|
|
133
|
+
|
|
134
|
+
The gem integrates with Rails i18n automatically. For standalone Ruby, set the locale before validating:
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
require "i18n"
|
|
138
|
+
I18n.load_path += Dir[File.join(__dir__, "config/locales/*.yml")]
|
|
139
|
+
I18n.locale = :fr
|
|
140
|
+
|
|
141
|
+
errors = Einvoicing::Validators::FR.validate(invoice)
|
|
142
|
+
# => [{ field: :seller_siren, error: :siren_invalid, message: "Le numéro SIREN est invalide" }]
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Formats
|
|
146
|
+
|
|
147
|
+
### Factur-X (PDF/A-3 + CII)
|
|
148
|
+
|
|
149
|
+
The standard French hybrid format: a valid PDF that also carries machine-readable XML inside.
|
|
150
|
+
|
|
151
|
+
```ruby
|
|
152
|
+
xml = Einvoicing::Formats::CII.generate(invoice)
|
|
153
|
+
pdf_data = File.binread("invoice.pdf")
|
|
154
|
+
facturx = Einvoicing::Formats::FacturX.embed(pdf_data, xml)
|
|
155
|
+
File.binwrite("invoice_facturx.pdf", facturx)
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
The result is PDF/A-3b conformant with an embedded `factur-x.xml` file tagged as `AFRelationship: Data`.
|
|
159
|
+
|
|
160
|
+
### CII D16B (XML only)
|
|
161
|
+
|
|
162
|
+
```ruby
|
|
163
|
+
xml = Einvoicing::Formats::CII.generate(invoice)
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Produces a `rsm:CrossIndustryInvoice` document with guideline ID `urn:cen.eu:en16931:2017`.
|
|
167
|
+
|
|
168
|
+
### UBL 2.1 (Peppol)
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
ubl = Einvoicing::Formats::UBL.generate(invoice)
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Produces a UBL 2.1 `Invoice` document with Peppol BIS Billing 3.0 customization ID.
|
|
175
|
+
|
|
176
|
+
## Rails Integration
|
|
177
|
+
|
|
178
|
+
Include `Einvoicing::Invoiceable` in your ActiveRecord model and implement three methods:
|
|
179
|
+
|
|
180
|
+
```ruby
|
|
181
|
+
class Invoice < ApplicationRecord
|
|
182
|
+
include Einvoicing::Invoiceable
|
|
183
|
+
|
|
184
|
+
def einvoicing_seller
|
|
185
|
+
Einvoicing::Party.new(
|
|
186
|
+
name: company.name,
|
|
187
|
+
siren: company.siren,
|
|
188
|
+
vat_number: company.vat_number,
|
|
189
|
+
street: company.address_street,
|
|
190
|
+
city: company.address_city,
|
|
191
|
+
postal_code: company.address_postal_code
|
|
192
|
+
)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def einvoicing_buyer
|
|
196
|
+
Einvoicing::Party.new(name: client.name, siren: client.siren)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def einvoicing_lines
|
|
200
|
+
line_items.map do |li|
|
|
201
|
+
Einvoicing::LineItem.new(
|
|
202
|
+
description: li.description,
|
|
203
|
+
quantity: li.quantity,
|
|
204
|
+
unit_price: li.unit_price_excl_tax,
|
|
205
|
+
vat_rate: li.vat_rate
|
|
206
|
+
)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Then in a controller or service:
|
|
213
|
+
|
|
214
|
+
```ruby
|
|
215
|
+
invoice = Invoice.find(42)
|
|
216
|
+
|
|
217
|
+
if invoice.einvoicing_valid?
|
|
218
|
+
cii_xml = invoice.to_cii_xml
|
|
219
|
+
ubl_xml = invoice.to_ubl_xml
|
|
220
|
+
|
|
221
|
+
pdf_data = invoice.pdf_attachment.download
|
|
222
|
+
facturx_pdf = invoice.to_facturx(pdf_data)
|
|
223
|
+
else
|
|
224
|
+
puts invoice.einvoicing_errors.map { |e| e[:message] }
|
|
225
|
+
end
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### Custom validator
|
|
229
|
+
|
|
230
|
+
Use a different validator (e.g. for a non-French context):
|
|
231
|
+
|
|
232
|
+
```ruby
|
|
233
|
+
class Invoice < ApplicationRecord
|
|
234
|
+
include Einvoicing::Invoiceable
|
|
235
|
+
self.einvoicing_validator = Einvoicing::Validators::FR # default; swap for your own
|
|
236
|
+
end
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
A custom validator is any module that responds to `.validate(invoice)` and returns `Array<Hash>`.
|
|
240
|
+
|
|
241
|
+
## Payment Means
|
|
242
|
+
|
|
243
|
+
Add IBAN, BIC, and UNCL4461 payment type code to the invoice. Both CII and UBL generators emit the appropriate elements automatically.
|
|
244
|
+
|
|
245
|
+
```ruby
|
|
246
|
+
invoice = Einvoicing::Invoice.new(
|
|
247
|
+
# ... other fields ...
|
|
248
|
+
payment_means_code: 30, # UNCL4461: 30 = credit transfer
|
|
249
|
+
iban: "FR7630006000011234567890189",
|
|
250
|
+
bic: "BNPAFRPP"
|
|
251
|
+
)
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Common `payment_means_code` values (UNCL4461):
|
|
255
|
+
|
|
256
|
+
| Code | Meaning |
|
|
257
|
+
|------|---------|
|
|
258
|
+
| 30 | Credit transfer |
|
|
259
|
+
| 42 | Payment to bank account |
|
|
260
|
+
| 58 | SEPA credit transfer |
|
|
261
|
+
|
|
262
|
+
## Requirements
|
|
263
|
+
|
|
264
|
+
- **Ruby >= 3.2** (uses `Data.define`)
|
|
265
|
+
- **hexapdf ~> 1.0** (runtime, for Factur-X PDF/A-3 embedding)
|
|
266
|
+
- **Java** (optional) — for local validation with the Mustang CLI validator
|
|
267
|
+
|
|
268
|
+
## Contributing
|
|
269
|
+
|
|
270
|
+
1. Fork the repository
|
|
271
|
+
2. Create a feature branch (`git checkout -b feature/my-feature`)
|
|
272
|
+
3. Write tests first (`bundle exec rspec`)
|
|
273
|
+
4. Submit a pull request
|
|
274
|
+
|
|
275
|
+
## License
|
|
276
|
+
|
|
277
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
en:
|
|
2
|
+
einvoicing:
|
|
3
|
+
errors:
|
|
4
|
+
invoice:
|
|
5
|
+
number_missing: "Invoice number is required"
|
|
6
|
+
number_invalid: "Invoice number format is invalid (1-35 alphanumeric/dash/slash chars)"
|
|
7
|
+
issue_date_missing: "Issue date is required"
|
|
8
|
+
currency_missing: "Currency is required"
|
|
9
|
+
lines_empty: "Invoice must have at least one line item"
|
|
10
|
+
original_invoice_number_missing: "Original invoice number is required for credit notes"
|
|
11
|
+
iban_invalid: "IBAN format or checksum is invalid"
|
|
12
|
+
bic_invalid: "BIC format is invalid (8 or 11 characters)"
|
|
13
|
+
seller:
|
|
14
|
+
name_missing: "Seller name is required"
|
|
15
|
+
siren_invalid: "Seller SIREN must be 9 digits (Luhn check failed)"
|
|
16
|
+
siret_invalid: "Seller SIRET must be 14 digits (Luhn check failed)"
|
|
17
|
+
vat_number_invalid: "Seller VAT number must be in format FR + 2 chars + 9 digits"
|
|
18
|
+
buyer:
|
|
19
|
+
name_missing: "Buyer name is required"
|
|
20
|
+
siren_invalid: "Buyer SIREN must be 9 digits (Luhn check failed)"
|
|
21
|
+
siret_invalid: "Buyer SIRET must be 14 digits (Luhn check failed)"
|
|
22
|
+
vat_number_invalid: "Buyer VAT number must be in format FR + 2 chars + 9 digits"
|
|
23
|
+
line:
|
|
24
|
+
description_missing: "Line %{index}: description is required"
|
|
25
|
+
quantity_invalid: "Line %{index}: quantity must be positive"
|
|
26
|
+
unit_price_invalid: "Line %{index}: unit price must be non-negative"
|
|
27
|
+
vat_rate_invalid: "Line %{index}: VAT rate must be a known French rate (0%%, 5.5%%, 10%%, 20%%)"
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
fr:
|
|
2
|
+
einvoicing:
|
|
3
|
+
errors:
|
|
4
|
+
invoice:
|
|
5
|
+
number_missing: "Le numéro de facture est requis"
|
|
6
|
+
number_invalid: "Le format du numéro de facture est invalide (1 à 35 caractères alphanumériques, tirets ou barres obliques)"
|
|
7
|
+
issue_date_missing: "La date d'émission est requise"
|
|
8
|
+
currency_missing: "La devise est requise"
|
|
9
|
+
lines_empty: "La facture doit comporter au moins une ligne"
|
|
10
|
+
original_invoice_number_missing: "Le numéro de facture d'origine est requis pour les avoirs"
|
|
11
|
+
iban_invalid: "Le format ou la somme de contrôle de l'IBAN est invalide"
|
|
12
|
+
bic_invalid: "Le format du BIC est invalide (8 ou 11 caractères)"
|
|
13
|
+
seller:
|
|
14
|
+
name_missing: "Le nom du vendeur est requis"
|
|
15
|
+
siren_invalid: "Le SIREN du vendeur doit comporter 9 chiffres (vérification Luhn échouée)"
|
|
16
|
+
siret_invalid: "Le SIRET du vendeur doit comporter 14 chiffres (vérification Luhn échouée)"
|
|
17
|
+
vat_number_invalid: "Le numéro de TVA du vendeur doit être au format FR + 2 caractères + 9 chiffres"
|
|
18
|
+
buyer:
|
|
19
|
+
name_missing: "Le nom de l'acheteur est requis"
|
|
20
|
+
siren_invalid: "Le SIREN de l'acheteur doit comporter 9 chiffres (vérification Luhn échouée)"
|
|
21
|
+
siret_invalid: "Le SIRET de l'acheteur doit comporter 14 chiffres (vérification Luhn échouée)"
|
|
22
|
+
vat_number_invalid: "Le numéro de TVA de l'acheteur doit être au format FR + 2 caractères + 9 chiffres"
|
|
23
|
+
line:
|
|
24
|
+
description_missing: "Ligne %{index} : la description est requise"
|
|
25
|
+
quantity_invalid: "Ligne %{index} : la quantité doit être positive"
|
|
26
|
+
unit_price_invalid: "Ligne %{index} : le prix unitaire doit être non négatif"
|
|
27
|
+
vat_rate_invalid: "Ligne %{index} : le taux de TVA doit être un taux français standard (0%%, 5,5%%, 10%%, 20%%)"
|
|
Binary file
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Einvoicing
|
|
4
|
+
module Formats
|
|
5
|
+
# Generates CII D16B (Cross Industry Invoice) XML compliant with EN 16931
|
|
6
|
+
# and the Factur-X EN16931 profile.
|
|
7
|
+
#
|
|
8
|
+
# @example
|
|
9
|
+
# xml = Einvoicing::Formats::CII.generate(invoice)
|
|
10
|
+
# File.write("invoice.xml", xml)
|
|
11
|
+
module CII
|
|
12
|
+
GUIDELINE_ID = "urn:cen.eu:en16931:2017"
|
|
13
|
+
|
|
14
|
+
RSM_NS = "urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100"
|
|
15
|
+
RAM_NS = "urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100"
|
|
16
|
+
UDT_NS = "urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100"
|
|
17
|
+
QDT_NS = "urn:un:unece:uncefact:data:standard:QualifiedDataType:100"
|
|
18
|
+
|
|
19
|
+
def self.generate(invoice)
|
|
20
|
+
b = XMLBuilder.new
|
|
21
|
+
b.tag(
|
|
22
|
+
"rsm:CrossIndustryInvoice",
|
|
23
|
+
"xmlns:rsm" => RSM_NS,
|
|
24
|
+
"xmlns:ram" => RAM_NS,
|
|
25
|
+
"xmlns:udt" => UDT_NS,
|
|
26
|
+
"xmlns:qdt" => QDT_NS,
|
|
27
|
+
"xmlns:xsi" => "http://www.w3.org/2001/XMLSchema-instance"
|
|
28
|
+
) do
|
|
29
|
+
exchanged_document_context(b)
|
|
30
|
+
exchanged_document(b, invoice)
|
|
31
|
+
supply_chain_trade_transaction(b, invoice)
|
|
32
|
+
end
|
|
33
|
+
b.to_xml
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# -- Private helpers ---------------------------------------------------
|
|
37
|
+
|
|
38
|
+
def self.exchanged_document_context(b)
|
|
39
|
+
b.tag("rsm:ExchangedDocumentContext") do
|
|
40
|
+
b.tag("ram:GuidelineSpecifiedDocumentContextParameter") do
|
|
41
|
+
b.text("ram:ID", GUIDELINE_ID)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
private_class_method :exchanged_document_context
|
|
46
|
+
|
|
47
|
+
def self.exchanged_document(b, invoice)
|
|
48
|
+
b.tag("rsm:ExchangedDocument") do
|
|
49
|
+
b.text("ram:ID", invoice.invoice_number)
|
|
50
|
+
b.text("ram:TypeCode", invoice.document_type == :credit_note ? "381" : "380")
|
|
51
|
+
b.tag("ram:IssueDateTime") do
|
|
52
|
+
b.text("udt:DateTimeString", format_date(invoice.issue_date), "format" => "102")
|
|
53
|
+
end
|
|
54
|
+
if invoice.document_type == :credit_note && invoice.original_invoice_number
|
|
55
|
+
b.tag("ram:IncludedNote") do
|
|
56
|
+
note = "Avoir sur facture #{invoice.original_invoice_number}"
|
|
57
|
+
note += " du #{invoice.original_invoice_date.strftime('%d/%m/%Y')}" if invoice.original_invoice_date
|
|
58
|
+
b.text("ram:Content", note)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
if invoice.note
|
|
62
|
+
b.tag("ram:IncludedNote") do
|
|
63
|
+
b.text("ram:Content", invoice.note)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
private_class_method :exchanged_document
|
|
69
|
+
|
|
70
|
+
def self.supply_chain_trade_transaction(b, invoice)
|
|
71
|
+
b.tag("rsm:SupplyChainTradeTransaction") do
|
|
72
|
+
invoice.lines.each_with_index do |line, idx|
|
|
73
|
+
trade_line_item(b, line, idx + 1, invoice.currency)
|
|
74
|
+
end
|
|
75
|
+
header_trade_agreement(b, invoice)
|
|
76
|
+
b.tag("ram:ApplicableHeaderTradeDelivery")
|
|
77
|
+
header_trade_settlement(b, invoice)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
private_class_method :supply_chain_trade_transaction
|
|
81
|
+
|
|
82
|
+
def self.trade_line_item(b, line, index, currency)
|
|
83
|
+
b.tag("ram:IncludedSupplyChainTradeLineItem") do
|
|
84
|
+
b.tag("ram:AssociatedDocumentLineDocument") do
|
|
85
|
+
b.text("ram:LineID", index.to_s)
|
|
86
|
+
end
|
|
87
|
+
b.tag("ram:SpecifiedTradeProduct") do
|
|
88
|
+
b.text("ram:Name", line.description)
|
|
89
|
+
end
|
|
90
|
+
b.tag("ram:SpecifiedLineTradeAgreement") do
|
|
91
|
+
b.tag("ram:NetPriceProductTradePrice") do
|
|
92
|
+
b.text("ram:ChargeAmount", format_amount(line.unit_price))
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
b.tag("ram:SpecifiedLineTradeDelivery") do
|
|
96
|
+
b.text("ram:BilledQuantity", format_quantity(line.quantity), "unitCode" => line.unit)
|
|
97
|
+
end
|
|
98
|
+
b.tag("ram:SpecifiedLineTradeSettlement") do
|
|
99
|
+
b.tag("ram:ApplicableTradeTax") do
|
|
100
|
+
b.text("ram:TypeCode", "VAT")
|
|
101
|
+
b.text("ram:CategoryCode", line.tax_category_code)
|
|
102
|
+
b.text("ram:RateApplicablePercent", format_amount(line.vat_rate_percent))
|
|
103
|
+
end
|
|
104
|
+
b.tag("ram:SpecifiedTradeSettlementLineMonetarySummation") do
|
|
105
|
+
b.text("ram:LineTotalAmount", format_amount(line.net_amount))
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
private_class_method :trade_line_item
|
|
111
|
+
|
|
112
|
+
def self.header_trade_agreement(b, invoice)
|
|
113
|
+
b.tag("ram:ApplicableHeaderTradeAgreement") do
|
|
114
|
+
# BuyerReference must be first in the sequence (EN 16931 BR-10 / XSD order).
|
|
115
|
+
b.text("ram:BuyerReference", invoice.payment_reference || "")
|
|
116
|
+
b.tag("ram:SellerTradeParty") { party_xml(b, invoice.seller) }
|
|
117
|
+
b.tag("ram:BuyerTradeParty") { party_xml(b, invoice.buyer) }
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
private_class_method :header_trade_agreement
|
|
121
|
+
|
|
122
|
+
def self.party_xml(b, party)
|
|
123
|
+
b.text("ram:Name", party.name)
|
|
124
|
+
if party.siren_number
|
|
125
|
+
b.tag("ram:SpecifiedLegalOrganization") do
|
|
126
|
+
b.text("ram:ID", party.siren_number, "schemeID" => "0002")
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
b.tag("ram:PostalTradeAddress") do
|
|
130
|
+
b.text("ram:PostcodeCode", party.postal_code)
|
|
131
|
+
b.text("ram:LineOne", party.street)
|
|
132
|
+
b.text("ram:CityName", party.city)
|
|
133
|
+
b.text("ram:CountryID", party.country_code || "FR")
|
|
134
|
+
end
|
|
135
|
+
if party.email
|
|
136
|
+
b.tag("ram:URIUniversalCommunication") do
|
|
137
|
+
b.text("ram:URIID", party.email, "schemeID" => "EM")
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
if party.vat_number
|
|
141
|
+
b.tag("ram:SpecifiedTaxRegistration") do
|
|
142
|
+
b.text("ram:ID", party.vat_number, "schemeID" => "VA")
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
private_class_method :party_xml
|
|
147
|
+
|
|
148
|
+
def self.header_trade_settlement(b, invoice)
|
|
149
|
+
b.tag("ram:ApplicableHeaderTradeSettlement") do
|
|
150
|
+
b.text("ram:PaymentReference", invoice.payment_reference || invoice.invoice_number)
|
|
151
|
+
b.text("ram:InvoiceCurrencyCode", invoice.currency)
|
|
152
|
+
|
|
153
|
+
if invoice.payment_means_code
|
|
154
|
+
b.tag("ram:SpecifiedTradeSettlementPaymentMeans") do
|
|
155
|
+
b.text("ram:TypeCode", invoice.payment_means_code.to_s)
|
|
156
|
+
if invoice.iban
|
|
157
|
+
b.tag("ram:PayeePartyCreditorFinancialAccount") do
|
|
158
|
+
b.text("ram:IBANID", invoice.iban)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
if invoice.bic
|
|
162
|
+
b.tag("ram:PayeeSpecifiedCreditorFinancialInstitution") do
|
|
163
|
+
b.text("ram:BICID", invoice.bic)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
invoice.tax_breakdown.each do |tax|
|
|
170
|
+
b.tag("ram:ApplicableTradeTax") do
|
|
171
|
+
b.text("ram:CalculatedAmount", format_amount(tax.tax_amount))
|
|
172
|
+
b.text("ram:TypeCode", "VAT")
|
|
173
|
+
b.text("ram:BasisAmount", format_amount(tax.taxable_amount))
|
|
174
|
+
b.text("ram:CategoryCode", tax.category_code)
|
|
175
|
+
b.text("ram:RateApplicablePercent", format_amount(tax.rate_percent))
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
if invoice.due_date
|
|
180
|
+
b.tag("ram:SpecifiedTradePaymentTerms") do
|
|
181
|
+
b.tag("ram:DueDateDateTime") do
|
|
182
|
+
b.text("udt:DateTimeString", format_date(invoice.due_date), "format" => "102")
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
b.tag("ram:SpecifiedTradeSettlementHeaderMonetarySummation") do
|
|
188
|
+
b.text("ram:LineTotalAmount", format_amount(invoice.net_total))
|
|
189
|
+
b.text("ram:TaxBasisTotalAmount", format_amount(invoice.net_total))
|
|
190
|
+
b.text("ram:TaxTotalAmount", format_amount(invoice.tax_total),
|
|
191
|
+
"currencyID" => invoice.currency)
|
|
192
|
+
b.text("ram:GrandTotalAmount", format_amount(invoice.gross_total))
|
|
193
|
+
b.text("ram:DuePayableAmount", format_amount(invoice.due_amount))
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
private_class_method :header_trade_settlement
|
|
198
|
+
|
|
199
|
+
# Format a Date or string as YYYYMMDD (CII date format 102).
|
|
200
|
+
def self.format_date(date)
|
|
201
|
+
d = date.is_a?(Date) ? date : Date.parse(date.to_s)
|
|
202
|
+
d.strftime("%Y%m%d")
|
|
203
|
+
end
|
|
204
|
+
private_class_method :format_date
|
|
205
|
+
|
|
206
|
+
def self.format_amount(value)
|
|
207
|
+
format("%.2f", value)
|
|
208
|
+
end
|
|
209
|
+
private_class_method :format_amount
|
|
210
|
+
|
|
211
|
+
def self.format_quantity(value)
|
|
212
|
+
v = value.to_f
|
|
213
|
+
v % 1 == 0 ? v.to_i.to_s : format("%.4f", v)
|
|
214
|
+
end
|
|
215
|
+
private_class_method :format_quantity
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|