facturx 1.0.0 → 1.1.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 +23 -1
- data/DOCUMENTATION.md +271 -0
- data/README.md +67 -89
- data/lib/facturx/attach.rb +4 -20
- data/lib/facturx/error.rb +5 -2
- data/lib/facturx/generate.rb +4 -4
- data/lib/facturx/pdf/composer.rb +32 -0
- data/lib/facturx/profile.rb +1 -1
- data/lib/facturx/profiles.rb +1 -8
- data/lib/facturx/source_reader.rb +1 -1
- data/lib/facturx/validation.rb +61 -0
- data/lib/facturx/version.rb +1 -1
- data/lib/facturx/{conformance.rb → writer/tracker.rb} +53 -85
- data/lib/facturx/writer.rb +20 -12
- data/lib/facturx/xml/parser.rb +1 -1
- data/lib/facturx/xml/report_builder.rb +44 -0
- data/lib/facturx/xml/schema_registry.rb +22 -0
- data/lib/facturx/xml/schema_validator.rb +7 -2
- data/lib/facturx/xml/validator.rb +32 -0
- data/lib/facturx/xml/verifier.rb +16 -11
- data/lib/facturx.rb +15 -6
- data/rbi/facturx.rbi +701 -36
- metadata +8 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 61ff49d55f56bb55d9b6624eb58fd59e16ed016672464f72507ea21b556c3050
|
|
4
|
+
data.tar.gz: 3f6be26b5cb8a65ea5d024cc68c709835154cd67fd48147d4a184182f7938c19
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 071dce1b5bb8ca3925152e2c0b5bcebaff0b2d73399d7e681f9407eeb59bb0ba981dbcbc194cb9b8faace203cdddc7ba5b332c22e92b377b3082d2641dacce8b
|
|
7
|
+
data.tar.gz: 1ffab4f27f2f0ba54e6ffcf3e5d949aafecde9dd92c758acb1f3a1c9f4a1792794b2c91372d564ec49c917114c715f061d90843a14393fabac44bb0058af03c7
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,27 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [1.1.0] - 2026-09-14
|
|
8
|
+
|
|
9
|
+
### Add
|
|
10
|
+
|
|
11
|
+
- Add packaged reference documentation for the public API and operational constraints.
|
|
12
|
+
- Add structured XML and document validation reports with a shared issue model.
|
|
13
|
+
- Add precise errors for invalid input sources, invalid documents, and unavailable schemas.
|
|
14
|
+
|
|
15
|
+
### Fix
|
|
16
|
+
|
|
17
|
+
- Validate generated XML only once before composing and verifying the PDF.
|
|
18
|
+
- Limit the supported public surface to facades, domain values, results, profiles, and emitted errors.
|
|
19
|
+
- Keep profile metadata independent from the bundled schema layout.
|
|
20
|
+
- Expose strict XML failures from `attach` through the shared validation report and issue model.
|
|
21
|
+
|
|
22
|
+
### Del
|
|
23
|
+
|
|
24
|
+
- Remove `verify_xml`, `Conformance` reports, `ConformanceError`, and direct access to implementation services.
|
|
25
|
+
- Remove `Profile#xsd_path` and the public schema layout constants from `Profiles`.
|
|
26
|
+
- Make the internal `Profiles` lookup constants private; use `Profiles.all`, `.fetch`, or `.for_guideline_urn`.
|
|
27
|
+
|
|
7
28
|
## [1.0.0] - 2026-09-13
|
|
8
29
|
|
|
9
30
|
### Add
|
|
@@ -37,7 +58,8 @@ All notable changes to this project will be documented in this file.
|
|
|
37
58
|
- Compose PDF/A-3b Factur-X documents with Ghostscript.
|
|
38
59
|
- Extract embedded Factur-X XML from PDF documents.
|
|
39
60
|
|
|
40
|
-
[Unreleased]: https://github.com/AdVitam/facturx/compare/v1.
|
|
61
|
+
[Unreleased]: https://github.com/AdVitam/facturx/compare/v1.1.0...HEAD
|
|
62
|
+
[1.1.0]: https://github.com/AdVitam/facturx/compare/v1.0.0...v1.1.0
|
|
41
63
|
[1.0.0]: https://github.com/AdVitam/facturx/compare/v0.2.0...v1.0.0
|
|
42
64
|
[0.2.0]: https://github.com/AdVitam/facturx/compare/v0.1.0...v0.2.0
|
|
43
65
|
[0.1.0]: https://github.com/AdVitam/facturx/releases/tag/v0.1.0
|
data/DOCUMENTATION.md
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# Facturx documentation
|
|
2
|
+
|
|
3
|
+
## Contents
|
|
4
|
+
|
|
5
|
+
- [Public API](#public-api)
|
|
6
|
+
- [Build an invoice](#build-an-invoice)
|
|
7
|
+
- [Use an existing XML invoice](#use-an-existing-xml-invoice)
|
|
8
|
+
- [Read a received invoice](#read-a-received-invoice)
|
|
9
|
+
- [Profiles](#profiles)
|
|
10
|
+
- [Validation](#validation)
|
|
11
|
+
- [PDF composition](#pdf-composition)
|
|
12
|
+
- [Round-trip guarantees](#round-trip-guarantees)
|
|
13
|
+
- [Security and resource usage](#security-and-resource-usage)
|
|
14
|
+
- [Development](#development)
|
|
15
|
+
|
|
16
|
+
## Public API
|
|
17
|
+
|
|
18
|
+
All PDF and XML inputs and outputs are byte strings. Facturx never interprets a string as a file path.
|
|
19
|
+
|
|
20
|
+
The three primary workflows are:
|
|
21
|
+
|
|
22
|
+
| Operation | API | Result |
|
|
23
|
+
|---|---|---|
|
|
24
|
+
| Build and embed XML | `Facturx.generate(pdf:, document:, profile:)` | PDF/A-3b bytes |
|
|
25
|
+
| Read XML or PDF | `Facturx.read(source)` | `Facturx::Reading` |
|
|
26
|
+
| Embed existing XML | `Facturx.attach(pdf:, xml:)` | PDF/A-3b bytes |
|
|
27
|
+
|
|
28
|
+
Focused operations are available when the complete workflow is not needed:
|
|
29
|
+
|
|
30
|
+
| Operation | API | Result |
|
|
31
|
+
|---|---|---|
|
|
32
|
+
| Validate XML | `Facturx.validate_xml(xml:)` | `Facturx::Validation::Report` |
|
|
33
|
+
| Validate typed data | `Facturx.validate_document(document:, profile:)` | `Facturx::Validation::Report` |
|
|
34
|
+
| Build XML | `Facturx.build_xml(document:, profile:)` | XML bytes |
|
|
35
|
+
| Extract embedded XML | `Facturx.extract_xml(pdf:)` | Exact embedded XML bytes |
|
|
36
|
+
|
|
37
|
+
## Build an invoice
|
|
38
|
+
|
|
39
|
+
`Facturx::Document.build` provides a nested DSL for the immutable Factur-X model. Singular associations accept either keyword attributes or a block; repeating associations use singular helpers such as `line`, `note`, and `tax_breakdown`.
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
document = Facturx::Document.build(
|
|
43
|
+
invoice_number: 'INV-2026-0042',
|
|
44
|
+
type_code: '380',
|
|
45
|
+
issue_date: Date.new(2026, 9, 13),
|
|
46
|
+
currency: 'EUR'
|
|
47
|
+
) do |invoice|
|
|
48
|
+
invoice.seller do |seller|
|
|
49
|
+
seller.name = 'Seller SAS'
|
|
50
|
+
seller.address(
|
|
51
|
+
line_one: '10 Example Street',
|
|
52
|
+
postcode: '75001',
|
|
53
|
+
city: 'Paris',
|
|
54
|
+
country_code: 'FR'
|
|
55
|
+
)
|
|
56
|
+
seller.vat_identifier(value: 'FR00123456789')
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
invoice.buyer do |buyer|
|
|
60
|
+
buyer.name = 'Buyer SAS'
|
|
61
|
+
buyer.address(country_code: 'FR')
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
invoice.line do |line|
|
|
65
|
+
line.id = '1'
|
|
66
|
+
line.product(name: 'Consulting services')
|
|
67
|
+
line.quantity(value: BigDecimal('1'), unit_code: 'C62')
|
|
68
|
+
line.net_price(amount: BigDecimal('100.00'))
|
|
69
|
+
line.tax(category_code: 'S', rate: BigDecimal('20.00'))
|
|
70
|
+
line.net_amount = BigDecimal('100.00')
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
invoice.tax_breakdown(
|
|
74
|
+
category_code: 'S',
|
|
75
|
+
rate: BigDecimal('20.00'),
|
|
76
|
+
basis_amount: BigDecimal('100.00'),
|
|
77
|
+
tax_amount: BigDecimal('20.00')
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
invoice.totals(
|
|
81
|
+
line_total: BigDecimal('100.00'),
|
|
82
|
+
tax_basis_total: BigDecimal('100.00'),
|
|
83
|
+
tax_total: BigDecimal('20.00'),
|
|
84
|
+
grand_total: BigDecimal('120.00'),
|
|
85
|
+
due_payable: BigDecimal('120.00')
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Choose one of `:minimum`, `:basic_wl`, `:basic`, `:en16931`, or `:extended` when validating or generating a document:
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
source_pdf = File.binread('invoice.pdf')
|
|
94
|
+
facturx_pdf = Facturx.generate(pdf: source_pdf, document:, profile: :en16931)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`generate` performs document and XSD validation before composing the PDF. Use `validate_document` to collect every semantic issue without raising, or `build_xml` when only the XML is needed:
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
report = Facturx.validate_document(document:, profile: :en16931)
|
|
101
|
+
|
|
102
|
+
report.issues.each { |issue| warn issue.message } if report.invalid?
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Alternatively, call `build_xml` directly and rescue `Facturx::InvalidDocumentError` when the report is only needed on failure.
|
|
106
|
+
|
|
107
|
+
`build_xml` and `generate` raise `Facturx::InvalidDocumentError` with the same report in `error.details[:report]` when the document is invalid.
|
|
108
|
+
|
|
109
|
+
The writer inserts the selected profile's canonical BT-24 guideline URN when it is absent. It reports a profile mismatch when the document contains a conflicting value. Values are serialized from their declared semantic type; monetary values with more than two decimal places are rejected rather than rounded implicitly.
|
|
110
|
+
|
|
111
|
+
## Use an existing XML invoice
|
|
112
|
+
|
|
113
|
+
Validate XML against the XSD selected by its BT-24 guideline URN:
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
xml = File.binread('invoice.xml')
|
|
117
|
+
report = Facturx.validate_xml(xml:)
|
|
118
|
+
report.valid?
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
`validate_xml` reports malformed XML, a missing or unknown BT-24 profile, and every XSD violation without raising. A non-`String` input raises `Facturx::InvalidSourceError`; internal failures such as an unavailable bundled schema also raise a typed `Facturx::Error`.
|
|
122
|
+
|
|
123
|
+
Document validation reports all semantic issues in one pass. XSD validation runs only after that semantic layer succeeds, avoiding structural noise from XML already known to be incomplete.
|
|
124
|
+
|
|
125
|
+
Attach valid XML to an existing visual invoice PDF:
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
pdf = File.binread('invoice.pdf')
|
|
129
|
+
facturx_pdf = Facturx.attach(pdf:, xml:)
|
|
130
|
+
|
|
131
|
+
File.binwrite('invoice-facturx.pdf', facturx_pdf)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
`attach` validates the XML, converts the input to PDF/A-3b, embeds the original bytes as `factur-x.xml`, and verifies the attachment and Factur-X metadata. It rejects signed or encrypted PDFs because rewriting them would invalidate their protection.
|
|
135
|
+
|
|
136
|
+
Extracting XML is deliberately independent from validation:
|
|
137
|
+
|
|
138
|
+
```ruby
|
|
139
|
+
embedded_xml = Facturx.extract_xml(pdf: facturx_pdf)
|
|
140
|
+
Facturx.validate_xml(xml: embedded_xml)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
This separation keeps malformed third-party invoices inspectable.
|
|
144
|
+
|
|
145
|
+
## Read a received invoice
|
|
146
|
+
|
|
147
|
+
`read` accepts either XML or PDF bytes and returns an immutable `Facturx::Reading`:
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
reading = Facturx.read(File.binread('received-invoice.pdf'))
|
|
151
|
+
|
|
152
|
+
reading.document.invoice_number
|
|
153
|
+
reading.document.totals.grand_total
|
|
154
|
+
reading.profile.id
|
|
155
|
+
reading.diagnostics
|
|
156
|
+
reading.source
|
|
157
|
+
reading.source_type
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Dates are `Date`, decimals are `BigDecimal`, identifiers retain their schemes, and repeating groups are frozen arrays in XML order. `source` contains the exact XML bytes and `source_type` is either `:xml` or `:pdf`.
|
|
161
|
+
|
|
162
|
+
Reading is tolerant and does not run implicit XSD validation. Missing, duplicate, empty, invalid, or unmapped values produce immutable diagnostics while usable fields remain accessible. Call `validate_xml` when structural validation is required.
|
|
163
|
+
|
|
164
|
+
Unknown or missing BT-24 values fall back to the EN 16931 intersection represented by the EXTENDED profile and add a diagnostic. Reject them instead with:
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
Facturx.read(xml, on_unknown_profile: :raise)
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## Profiles
|
|
171
|
+
|
|
172
|
+
The five canonical profiles are available through `Facturx::Profiles`:
|
|
173
|
+
|
|
174
|
+
```ruby
|
|
175
|
+
profile = Facturx::Profiles.fetch(:en16931)
|
|
176
|
+
|
|
177
|
+
profile.id
|
|
178
|
+
profile.guideline_urn
|
|
179
|
+
profile.conformance_level
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Public writer methods accept either a profile symbol or its canonical `Facturx::Profile`. A separate profile object with matching attributes is rejected to keep profile resolution tied to the bundled XSD and metadata definitions.
|
|
183
|
+
|
|
184
|
+
The semantic registry covers all 184 EN 16931 business terms and the MINIMUM, BASIC WL, and BASIC subsets. EXTENDED-only fields remain available in `Reading#source` and produce diagnostics until represented in the typed model.
|
|
185
|
+
|
|
186
|
+
## Validation
|
|
187
|
+
|
|
188
|
+
`validate_xml` and `validate_document` return the same immutable report. Each issue identifies its validation layer, severity, message, and available XML or business-term location. A report can therefore be inspected without rescuing expected validation failures:
|
|
189
|
+
|
|
190
|
+
```ruby
|
|
191
|
+
report.issues.each do |issue|
|
|
192
|
+
warn "#{issue.layer}: #{issue.term_id || issue.path} #{issue.message}"
|
|
193
|
+
end
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
All issues currently emitted use the `:error` severity. The `:warning` severity is reserved for planned Schematron diagnostics.
|
|
197
|
+
|
|
198
|
+
Generated documents pass through two layers:
|
|
199
|
+
|
|
200
|
+
1. semantic conformance checks against the selected profile's business-term cardinalities and supported model mappings;
|
|
201
|
+
2. structural validation against the bundled official profile XSD.
|
|
202
|
+
|
|
203
|
+
Incoming XML passed to `validate_xml` is parsed, resolved to a profile from BT-24, and checked against that profile's XSD.
|
|
204
|
+
|
|
205
|
+
Domain failures use a `Facturx::Error` subclass with structured context in `details`. Common errors include:
|
|
206
|
+
|
|
207
|
+
| Error | Meaning |
|
|
208
|
+
|---|---|
|
|
209
|
+
| `Facturx::ValidationError` | Base class for strict validation failures |
|
|
210
|
+
| `Facturx::InvalidXmlError` | XML cannot be parsed |
|
|
211
|
+
| `Facturx::UnknownProfileError` | BT-24 is absent or unsupported during strict XML validation |
|
|
212
|
+
| `Facturx::XsdValidationError` | XML does not satisfy the selected profile XSD |
|
|
213
|
+
| `Facturx::UnsupportedProfileError` | A writer profile is unsupported |
|
|
214
|
+
| `Facturx::InvalidDocumentError` | A typed document violates the selected profile |
|
|
215
|
+
| `Facturx::InvalidSourceError` | An XML or reader source is not a byte `String` |
|
|
216
|
+
| `Facturx::SchemaLoadError` | A bundled validation schema cannot be loaded |
|
|
217
|
+
| `Facturx::ProtectedPdfError` | The source PDF is signed or encrypted |
|
|
218
|
+
| `Facturx::ComposerUnavailableError` | Required Ghostscript resources are unavailable |
|
|
219
|
+
| `Facturx::CompositionError` | Ghostscript composition failed |
|
|
220
|
+
| `Facturx::ExtractionError` | The embedded Factur-X XML cannot be selected or decoded |
|
|
221
|
+
| `Facturx::VerificationError` | The composed PDF does not match the requested invoice |
|
|
222
|
+
|
|
223
|
+
XSD validation is not complete regulatory validation. Facturx does not yet run the official Schematron business rules, calculate totals, or evaluate accounting consistency.
|
|
224
|
+
|
|
225
|
+
## PDF composition
|
|
226
|
+
|
|
227
|
+
PDF composition through `attach` or `generate` requires:
|
|
228
|
+
|
|
229
|
+
- Ghostscript 9.54 or newer;
|
|
230
|
+
- Ghostscript's `zugferd.ps` script;
|
|
231
|
+
- an RGB ICC profile.
|
|
232
|
+
|
|
233
|
+
These resources are not distributed with the gem. Facturx searches common installation paths and accepts explicit overrides when automatic discovery is not suitable:
|
|
234
|
+
|
|
235
|
+
```bash
|
|
236
|
+
export GHOSTSCRIPT_BIN=/opt/ghostscript/bin/gs
|
|
237
|
+
export FACTURX_ZUGFERD_PS=/opt/ghostscript/share/ghostscript/lib/zugferd.ps
|
|
238
|
+
export FACTURX_ICC_PROFILE=/opt/ghostscript/share/ghostscript/iccprofiles/default_rgb.icc
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
The composer invokes Ghostscript as an external process with a timeout, bounded output capture, and file access restricted to its staged inputs and outputs.
|
|
242
|
+
|
|
243
|
+
Facturx does not create the visual invoice. The supplied PDF remains the visual source that is converted to PDF/A-3b and enriched with Factur-X XML and metadata.
|
|
244
|
+
|
|
245
|
+
## Round-trip guarantees
|
|
246
|
+
|
|
247
|
+
The reader preserves the exact incoming XML in `Reading#source`, but reading and rebuilding is not a fidelity round-trip. The writer cannot reproduce values outside the typed semantic model.
|
|
248
|
+
|
|
249
|
+
Retain and reuse `Reading#source` when reissuing an incoming invoice without intentional semantic changes.
|
|
250
|
+
|
|
251
|
+
## Security and resource usage
|
|
252
|
+
|
|
253
|
+
The byte-string API materializes PDF streams in memory. Process untrusted PDFs in a resource-limited worker: limiting only the input file size does not prevent amplification by a compressed embedded stream.
|
|
254
|
+
|
|
255
|
+
Facturx does not communicate with a PDP or implement e-invoicing transport. Network submission, authentication, retries, storage, invoice calculations, and business approval workflows remain application responsibilities.
|
|
256
|
+
|
|
257
|
+
## Development
|
|
258
|
+
|
|
259
|
+
```bash
|
|
260
|
+
mise install
|
|
261
|
+
bundle install
|
|
262
|
+
bundle exec rubocop
|
|
263
|
+
bundle exec rspec
|
|
264
|
+
bundle exec rake build
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Maintainers can compare the semantic registry, D22B mappings, diagnostics, official XML examples, and paired PDF attachments with an extracted upstream package:
|
|
268
|
+
|
|
269
|
+
```bash
|
|
270
|
+
FACTURX_REFERENCE_ROOT=/path/to/ZUGFeRD_2.5.2_EN bundle exec rake reference:verify
|
|
271
|
+
```
|
data/README.md
CHANGED
|
@@ -1,133 +1,111 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Facturx
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://rubygems.org/gems/facturx)
|
|
4
|
+
[](https://github.com/AdVitam/facturx/actions/workflows/test.yml)
|
|
5
|
+
[](https://www.ruby-lang.org)
|
|
6
|
+
[](LICENSE.txt)
|
|
4
7
|
|
|
5
|
-
|
|
8
|
+
Build, read, validate, and compose Factur-X / ZUGFeRD invoices in Ruby.
|
|
6
9
|
|
|
7
|
-
|
|
10
|
+
Use typed Ruby objects to generate XSD-valid XML, embed it into an existing PDF, or inspect invoices received from third parties.
|
|
8
11
|
|
|
9
|
-
|
|
12
|
+
## Features
|
|
10
13
|
|
|
11
|
-
|
|
12
|
-
gem 'facturx'
|
|
13
|
-
```
|
|
14
|
+
### Main workflows
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
| | What you need | API |
|
|
17
|
+
|:---:|---|---|
|
|
18
|
+
| ✅ | Generate a Factur-X invoice from typed data | `Facturx::Document.build` + `Facturx.generate` |
|
|
19
|
+
| ✅ | Read an XML or PDF invoice | `Facturx.read` |
|
|
20
|
+
| ✅ | Embed existing Factur-X XML into a PDF | `Facturx.attach` |
|
|
16
21
|
|
|
17
|
-
|
|
22
|
+
### Focused tools
|
|
18
23
|
|
|
19
|
-
|
|
24
|
+
| | Capability | API |
|
|
25
|
+
|:---:|---|---|
|
|
26
|
+
| ✅ | Generate Factur-X XML | `Facturx.build_xml` |
|
|
27
|
+
| ✅ | Validate typed documents | `Facturx.validate_document` |
|
|
28
|
+
| ✅ | Validate XML against official XSDs | `Facturx.validate_xml` |
|
|
29
|
+
| ✅ | Extract the original embedded XML | `Facturx.extract_xml` |
|
|
20
30
|
|
|
21
|
-
|
|
31
|
+
### Boundaries
|
|
22
32
|
|
|
23
|
-
|
|
24
|
-
|
|
33
|
+
| | Capability | Responsibility |
|
|
34
|
+
|:---:|---|---|
|
|
35
|
+
| ⏳ | Schematron business rules | Planned |
|
|
36
|
+
| ➖ | Invoice calculations | Application |
|
|
37
|
+
| ➖ | Visual PDF generation | Application |
|
|
38
|
+
| ➖ | PDP transport and e-invoicing | Application |
|
|
25
39
|
|
|
26
|
-
|
|
27
|
-
pdf = File.binread('invoice.pdf')
|
|
40
|
+
✅ Supported · ⏳ Planned · ➖ Intentionally handled outside the gem
|
|
28
41
|
|
|
29
|
-
|
|
42
|
+
## Supported profiles
|
|
30
43
|
|
|
31
|
-
|
|
32
|
-
reading.document.invoice_number
|
|
33
|
-
reading.document.totals.grand_total
|
|
34
|
-
reading.diagnostics
|
|
44
|
+
Facturx supports Factur-X 1.09.2 / ZUGFeRD 2.5.2.
|
|
35
45
|
|
|
36
|
-
|
|
37
|
-
|
|
46
|
+
| Profile | Read | Build | XSD validation |
|
|
47
|
+
|---|:---:|:---:|:---:|
|
|
48
|
+
| MINIMUM | ✅ | ✅ | ✅ |
|
|
49
|
+
| BASIC WL | ✅ | ✅ | ✅ |
|
|
50
|
+
| BASIC | ✅ | ✅ | ✅ |
|
|
51
|
+
| EN 16931 | ✅ | ✅ | ✅ |
|
|
52
|
+
| EXTENDED | ✅* | ✅* | ✅ |
|
|
38
53
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
54
|
+
\* EXTENDED-only fields that are not represented by the typed model remain available in the original XML and are reported through diagnostics.
|
|
55
|
+
|
|
56
|
+
## Requirements
|
|
57
|
+
|
|
58
|
+
- Ruby 3.2 or newer
|
|
59
|
+
- Ghostscript 9.54 or newer, `zugferd.ps`, and an RGB ICC profile for PDF composition only
|
|
60
|
+
|
|
61
|
+
XML building, reading, extraction, and validation require no external executable.
|
|
62
|
+
|
|
63
|
+
## Quick start
|
|
42
64
|
|
|
43
|
-
|
|
65
|
+
Turn an existing invoice PDF and typed business data into a PDF/A-3b Factur-X invoice with XSD-validated XML:
|
|
44
66
|
|
|
45
67
|
```ruby
|
|
68
|
+
require 'facturx'
|
|
69
|
+
|
|
46
70
|
document = Facturx::Document.build(
|
|
47
71
|
invoice_number: 'INV-2026-0042',
|
|
48
72
|
type_code: '380',
|
|
49
|
-
issue_date: Date.new(2026, 9,
|
|
73
|
+
issue_date: Date.new(2026, 9, 13),
|
|
50
74
|
currency: 'EUR'
|
|
51
75
|
) do |invoice|
|
|
52
76
|
invoice.seller do |seller|
|
|
53
77
|
seller.name = 'Seller SAS'
|
|
54
78
|
seller.address(country_code: 'FR')
|
|
55
79
|
end
|
|
80
|
+
|
|
56
81
|
invoice.buyer(name: 'Buyer SAS')
|
|
82
|
+
|
|
57
83
|
invoice.totals(
|
|
58
84
|
tax_basis_total: BigDecimal('100.00'),
|
|
85
|
+
tax_total: BigDecimal('20.00'),
|
|
59
86
|
grand_total: BigDecimal('120.00'),
|
|
60
87
|
due_payable: BigDecimal('120.00')
|
|
61
88
|
)
|
|
62
89
|
end
|
|
63
90
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
xml = Facturx.build_xml(document:, profile: :minimum)
|
|
68
|
-
facturx_pdf = Facturx.generate(pdf:, document:, profile: :minimum)
|
|
69
|
-
```
|
|
70
|
-
|
|
71
|
-
`profile:` accepts one of `:minimum`, `:basic_wl`, `:basic`, `:en16931`, or `:extended`, as well as the corresponding canonical `Facturx::Profile`. When BT-24 is absent, the writer inserts the selected profile's guideline URN. A conflicting BT-24 is reported as a profile mismatch.
|
|
72
|
-
|
|
73
|
-
`validate_document` returns an immutable report containing every conformance issue found. `build_xml` and `generate` raise `Facturx::ConformanceError` with that report when the document is invalid. Values are serialized from their declared semantic type; monetary values with more than two decimal places are rejected instead of being rounded implicitly. Generated XML is always checked against the selected profile's XSD.
|
|
74
|
-
|
|
75
|
-
`verify_xml` infers the profile from BT-24 and validates the document against that profile's XSD. It returns `true` on success and raises a typed `Facturx::Error` on failure.
|
|
76
|
-
|
|
77
|
-
`attach` always validates the XML first. It converts the input PDF to PDF/A-3b, embeds the original XML bytes as `factur-x.xml`, and verifies the resulting attachment and metadata. Signed and encrypted PDFs are rejected because rewriting them would invalidate their protection.
|
|
91
|
+
source_pdf = File.binread('invoice.pdf')
|
|
92
|
+
facturx_pdf = Facturx.generate(pdf: source_pdf, document:, profile: :minimum)
|
|
78
93
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
`read` accepts either XML or PDF bytes and returns an immutable `Facturx::Reading`. Its `document` contains typed immutable values: dates are `Date`, decimals are `BigDecimal`, identifiers retain their schemes, and repeating groups are frozen arrays in XML order. `source` always contains the exact embedded XML bytes and `source_type` is either `:xml` or `:pdf`.
|
|
82
|
-
|
|
83
|
-
Reading is intentionally tolerant and never performs implicit XSD validation. Missing, duplicate, empty, invalid, or unmapped values are reported through immutable diagnostics while usable fields remain accessible. Call `verify_xml` separately when strict structural validation is required.
|
|
84
|
-
|
|
85
|
-
Unknown or missing BT-24 values fall back to the EN16931 intersection supported for EXTENDED documents and add a diagnostic. Pass `on_unknown_profile: :raise` to reject them instead:
|
|
86
|
-
|
|
87
|
-
```ruby
|
|
88
|
-
Facturx.read(xml, on_unknown_profile: :raise)
|
|
89
|
-
```
|
|
90
|
-
|
|
91
|
-
The semantic registry covers all 184 EN16931 business terms and the MINIMUM, BASIC WL, and BASIC subsets. EXTENDED-only fields remain available in `Reading#source` and are reported as unmapped until their model is added.
|
|
92
|
-
|
|
93
|
-
Reading is not a fidelity round-trip. To reissue an incoming invoice, retain and reuse `Reading#source`; the writer cannot reproduce fields outside the semantic model.
|
|
94
|
-
|
|
95
|
-
The byte-string API materializes PDF streams in memory. Process untrusted PDFs in a resource-limited worker; limiting only the input file size does not prevent amplification by a compressed embedded stream.
|
|
96
|
-
|
|
97
|
-
## Ghostscript discovery
|
|
98
|
-
|
|
99
|
-
The composer searches common installation paths. Override discovery when necessary:
|
|
100
|
-
|
|
101
|
-
```bash
|
|
102
|
-
export GHOSTSCRIPT_BIN=/opt/ghostscript/bin/gs
|
|
103
|
-
export FACTURX_ZUGFERD_PS=/opt/ghostscript/share/ghostscript/lib/zugferd.ps
|
|
104
|
-
export FACTURX_ICC_PROFILE=/opt/ghostscript/share/ghostscript/iccprofiles/default_rgb.icc
|
|
94
|
+
File.binwrite('invoice-facturx.pdf', facturx_pdf)
|
|
105
95
|
```
|
|
106
96
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
## Validation scope
|
|
110
|
-
|
|
111
|
-
The gem validates generated documents against its EN16931 semantic registry and the official profile XSDs. It does not yet run the Factur-X Schematron business rules, calculate totals, or evaluate accounting consistency. These checks must not be presented as complete regulatory validation.
|
|
97
|
+
`generate` validates the document and generated XML, embeds `factur-x.xml`, and verifies the resulting PDF metadata and attachment.
|
|
112
98
|
|
|
113
|
-
|
|
99
|
+
## Documentation
|
|
114
100
|
|
|
115
|
-
|
|
101
|
+
See [DOCUMENTATION.md](DOCUMENTATION.md) to:
|
|
116
102
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
bundle exec rake build
|
|
123
|
-
```
|
|
124
|
-
|
|
125
|
-
Maintainers can verify the registry hashes, D22B element names, semantic diagnostics, official XML examples, and paired PDF attachments against an extracted upstream package:
|
|
126
|
-
|
|
127
|
-
```bash
|
|
128
|
-
FACTURX_REFERENCE_ROOT=/path/to/ZUGFeRD_2.5.2_EN bundle exec rake reference:verify
|
|
129
|
-
```
|
|
103
|
+
- build richer typed invoices;
|
|
104
|
+
- validate or attach existing XML;
|
|
105
|
+
- read invoices and handle diagnostics;
|
|
106
|
+
- understand profiles, validation boundaries, and typed errors;
|
|
107
|
+
- configure Ghostscript for PDF/A-3b composition.
|
|
130
108
|
|
|
131
109
|
## License
|
|
132
110
|
|
|
133
|
-
The gem code is available under the MIT License. Bundled standard artefacts retain their respective upstream notices; see
|
|
111
|
+
The gem code is available under the [MIT License](LICENSE.txt). Bundled standard artefacts retain their respective upstream notices; see [NOTICE.md](NOTICE.md).
|
data/lib/facturx/attach.rb
CHANGED
|
@@ -1,34 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative 'xml/verifier'
|
|
4
|
-
require_relative 'pdf/
|
|
5
|
-
require_relative 'pdf/extractor'
|
|
6
|
-
require_relative 'pdf/verifier'
|
|
7
|
-
require_relative 'composers/ghostscript'
|
|
4
|
+
require_relative 'pdf/composer'
|
|
8
5
|
|
|
9
6
|
module Facturx
|
|
10
7
|
class Attach
|
|
11
|
-
def initialize(
|
|
12
|
-
xml_verifier: Xml::Verifier.new,
|
|
13
|
-
pdf_inspector: Pdf::Inspector.new,
|
|
14
|
-
composer: Composers::Ghostscript.new,
|
|
15
|
-
extractor: Pdf::Extractor.new,
|
|
16
|
-
pdf_verifier: Pdf::Verifier.new
|
|
17
|
-
)
|
|
8
|
+
def initialize(xml_verifier: Xml::Verifier.new, pdf_composer: Pdf::Composer.new)
|
|
18
9
|
@xml_verifier = xml_verifier
|
|
19
|
-
@
|
|
20
|
-
@composer = composer
|
|
21
|
-
@extractor = extractor
|
|
22
|
-
@pdf_verifier = pdf_verifier
|
|
10
|
+
@pdf_composer = pdf_composer
|
|
23
11
|
end
|
|
24
12
|
|
|
25
13
|
def call(pdf:, xml:)
|
|
26
14
|
profile = @xml_verifier.call(xml:)
|
|
27
|
-
|
|
28
|
-
output = @composer.call(pdf:, xml:, profile:)
|
|
29
|
-
result = @extractor.call(output)
|
|
30
|
-
@pdf_verifier.call(result:, expected_xml: xml, expected_page_count: inspection.page_count, profile:)
|
|
31
|
-
output
|
|
15
|
+
@pdf_composer.call(pdf:, xml:, profile:)
|
|
32
16
|
end
|
|
33
17
|
end
|
|
34
18
|
end
|
data/lib/facturx/error.rb
CHANGED
|
@@ -10,12 +10,15 @@ module Facturx
|
|
|
10
10
|
end
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
-
class
|
|
13
|
+
class ValidationError < Error; end
|
|
14
|
+
class InvalidDocumentError < ValidationError; end
|
|
15
|
+
class InvalidXmlError < ValidationError; end
|
|
14
16
|
class UnknownProfileError < InvalidXmlError; end
|
|
15
17
|
class XsdValidationError < InvalidXmlError; end
|
|
18
|
+
class SchemaLoadError < Error; end
|
|
19
|
+
class InvalidSourceError < Error; end
|
|
16
20
|
class UnsupportedProfileError < Error; end
|
|
17
21
|
class FormattingError < Error; end
|
|
18
|
-
class ConformanceError < Error; end
|
|
19
22
|
class InvalidPdfError < Error; end
|
|
20
23
|
class ProtectedPdfError < InvalidPdfError; end
|
|
21
24
|
class ComposerUnavailableError < Error; end
|
data/lib/facturx/generate.rb
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative '
|
|
3
|
+
require_relative 'pdf/composer'
|
|
4
4
|
require_relative 'profile_resolver'
|
|
5
5
|
require_relative 'writer'
|
|
6
6
|
|
|
7
7
|
module Facturx
|
|
8
8
|
class Generate
|
|
9
|
-
def initialize(writer: Writer.new,
|
|
9
|
+
def initialize(writer: Writer.new, pdf_composer: Pdf::Composer.new, profile_resolver: ProfileResolver.new)
|
|
10
10
|
@writer = writer
|
|
11
|
-
@
|
|
11
|
+
@pdf_composer = pdf_composer
|
|
12
12
|
@profile_resolver = profile_resolver
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
def call(pdf:, document:, profile:)
|
|
16
16
|
canonical_profile = @profile_resolver.call(profile)
|
|
17
17
|
xml = @writer.call(document:, profile: canonical_profile)
|
|
18
|
-
@
|
|
18
|
+
@pdf_composer.call(pdf:, xml:, profile: canonical_profile)
|
|
19
19
|
end
|
|
20
20
|
end
|
|
21
21
|
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'inspector'
|
|
4
|
+
require_relative 'extractor'
|
|
5
|
+
require_relative 'verifier'
|
|
6
|
+
require_relative '../composers/ghostscript'
|
|
7
|
+
|
|
8
|
+
module Facturx
|
|
9
|
+
module Pdf
|
|
10
|
+
class Composer
|
|
11
|
+
def initialize(
|
|
12
|
+
inspector: Inspector.new,
|
|
13
|
+
backend: Composers::Ghostscript.new,
|
|
14
|
+
extractor: Extractor.new,
|
|
15
|
+
verifier: Verifier.new
|
|
16
|
+
)
|
|
17
|
+
@inspector = inspector
|
|
18
|
+
@backend = backend
|
|
19
|
+
@extractor = extractor
|
|
20
|
+
@verifier = verifier
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call(pdf:, xml:, profile:)
|
|
24
|
+
inspection = @inspector.call(pdf)
|
|
25
|
+
output = @backend.call(pdf:, xml:, profile:)
|
|
26
|
+
result = @extractor.call(output)
|
|
27
|
+
@verifier.call(result:, expected_xml: xml, expected_page_count: inspection.page_count, profile:)
|
|
28
|
+
output
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
data/lib/facturx/profile.rb
CHANGED