unova_factur_x 0.1.4 → 0.1.5
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/README.md +157 -144
- data/THIRD-PARTY_COMPONENTS.txt +29 -0
- data/lib/unova_factur_x/factur_x_generator.rb +8 -8
- data/lib/unova_factur_x/version.rb +1 -1
- data/lib/unova_factur_x/xml_generator.rb +83 -216
- data/lib/unova_factur_x.rb +2 -2
- metadata +7 -7
- data/LOGICIELS_TIER.txt +0 -29
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ea40c89024db0ae23f9c9ae3810842a3287fca547b7e71173cdad0a76484cef2
|
|
4
|
+
data.tar.gz: 8e7fa13a6e7231e3c45b54d3d79c295355712546aac6341e0d055ed05171fcf5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a77cfcd8c5d379ed7db10607c23f992cc3e09d335b84a1bf3286611ef89b4997395ccfbde90e68f84ecde8672420d9a394d0b08f0b6e0dd2d6c293ba8e98da5d
|
|
7
|
+
data.tar.gz: 0bb7a2cbc4cd9bedc7ff7a5e69e1048fabde9484d91f1e45001fd558dc11df0bf02b3b9feb1c21693310c2400a651c2068488b1ae197a70fe295df2e3e1b3268
|
data/README.md
CHANGED
|
@@ -1,181 +1,194 @@
|
|
|
1
1
|
# UnovaFacturX
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Factur-X is a hybrid electronic invoice format with a human-readable PDF file that contains, as an attachment, a structured XML file.
|
|
4
|
+
This gem allows you to transform invoices and credits notes from the PDF format to the Factur-X format.
|
|
4
5
|
|
|
5
|
-
##
|
|
6
|
+
## Setup
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
Add the gem to your gemfile and `bundle install`:
|
|
8
9
|
```ruby
|
|
9
|
-
gem "unova_factur_x"
|
|
10
|
+
gem "unova_factur_x"
|
|
10
11
|
```
|
|
12
|
+
If you want to use the validator for the generated XML file of the Factur-X, you also need to have Java installed on your computer.
|
|
11
13
|
|
|
12
|
-
##
|
|
14
|
+
## Usage
|
|
13
15
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
Simply call the `generate` method of the gem when you want to send the PDF to the user from your controller.
|
|
17
|
+
The method accepts the following parameters:
|
|
18
|
+
- pdf: The PDF file of the invoice/credit note to transform to Factur-X. It can be provided in two ways:
|
|
19
|
+
- From a file:
|
|
17
20
|
```ruby
|
|
18
21
|
path = ActiveStorage::Blob.service.send(:path_for, @invoice.file.key)
|
|
19
22
|
pdf = File.open(path, 'rb')
|
|
20
23
|
```
|
|
21
|
-
-
|
|
24
|
+
- From a Prawn-generated PDF:
|
|
22
25
|
```ruby
|
|
23
|
-
pdf =
|
|
26
|
+
pdf = PdfDocument.new(**options).render
|
|
24
27
|
```
|
|
25
|
-
- document_hash:
|
|
26
|
-
- [
|
|
27
|
-
-
|
|
28
|
-
-
|
|
29
|
-
- [
|
|
30
|
-
- [
|
|
28
|
+
- document_hash: The hash required to generate the XML part of the Factur-X. See below for more details.
|
|
29
|
+
- [optional] type: The document type:
|
|
30
|
+
- `:invoice` for an invoice (default value),
|
|
31
|
+
- `:credit` for a credit note,
|
|
32
|
+
- [optional] with_validations: `true` or `false`, default as `true`. If true, the generated XML file will be checked using the validator. **WARNING: Java is required for this to work**
|
|
33
|
+
- [optional] currency: To configure the used currency (Default is euros 'EUR').
|
|
31
34
|
```ruby
|
|
32
|
-
#
|
|
33
|
-
send_data UnovaFacturX.generate(pdf: pdf, document_hash: document_hash, type: :invoice, with_validations: true,
|
|
35
|
+
# Usage example:
|
|
36
|
+
send_data UnovaFacturX.generate(pdf: pdf, document_hash: document_hash, type: :invoice, with_validations: true, currency: "USD"),
|
|
34
37
|
filename: "Factur-X.pdf",
|
|
35
38
|
type: 'application/pdf',
|
|
36
39
|
disposition: 'attachment'
|
|
37
40
|
```
|
|
38
41
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
42
|
+
### Document hash structure overview
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
For the expected document hash:
|
|
46
|
+
- The document hash is a structured Ruby hash composed of the following main sections:
|
|
47
|
+
- `seller`
|
|
48
|
+
- `buyer`
|
|
49
|
+
- `items`
|
|
50
|
+
- `payment_means`
|
|
51
|
+
- `vat_breakdown`
|
|
52
|
+
- `totals`
|
|
53
|
+
- Provided amounts must be arithmetically consistent; no automatic correction is performed.
|
|
54
|
+
- All attributes must be provided as strings.
|
|
55
|
+
- Follow the structure below:
|
|
43
56
|
```ruby
|
|
44
|
-
#
|
|
57
|
+
# Example hash for an invoice (the same applies to a credit note; IMPORTANT: do not use negative values for credit note):
|
|
45
58
|
document_hash = {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
59
|
+
id: "Unique invoice number (BT-1) [REQUIRED]",
|
|
60
|
+
issue_date: "Issue date in YYYYMMDD format (BT-2) [REQUIRED]",
|
|
61
|
+
|
|
62
|
+
seller: {
|
|
63
|
+
name: "Seller legal name (BT-27) [REQUIRED]",
|
|
64
|
+
legal_id: "Legal identifier (SIREN/SIRET) (BT-30) [OPTIONAL]",
|
|
65
|
+
vat_number: "VAT number including buyer country prefix (e.g. FR123...) (BT-31) [OPTIONAL]",
|
|
66
|
+
address: {
|
|
67
|
+
line1: "Street address (BT-35) [REQUIRED]",
|
|
68
|
+
line2: "Address complement [OPTIONAL]",
|
|
69
|
+
postcode: "Postal code (BT-38) [REQUIRED]",
|
|
70
|
+
city: "City (BT-37) [REQUIRED]",
|
|
71
|
+
country: "ISO 3166-1 alpha-2 country code (BT-40) [REQUIRED]",
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
# [REQUIRED BLOCK]
|
|
76
|
+
buyer: {
|
|
77
|
+
id: "Internal customer identifier (BT-46) [OPTIONAL]",
|
|
78
|
+
name: "Customer legal name (BT-44) [REQUIRED]",
|
|
79
|
+
vat_number: "VAT number including buyer country prefix (e.g. FR123...) (BT-48) [OPTIONAL]",
|
|
80
|
+
contact: { # [OPTIONAL]
|
|
81
|
+
name: "Customer contact name (BT-56) [OPTIONAL]",
|
|
60
82
|
},
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
83
|
+
address: {
|
|
84
|
+
line1: "Street address (BT-50) [REQUIRED]",
|
|
85
|
+
line2: "Address complement [OPTIONAL]",
|
|
86
|
+
postcode: "Postal code (BT-53) [REQUIRED]",
|
|
87
|
+
city: "City (BT-52) [REQUIRED]",
|
|
88
|
+
country: "ISO 3166-1 alpha-2 country code (BT-55) [REQUIRED]",
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
# [OPTIONAL BLOCK]
|
|
93
|
+
delivery: {
|
|
94
|
+
gln: "GLN identifier (schemeID 0088) (BT-71) [OPTIONAL]",
|
|
95
|
+
gln_scheme: "0088: GLN (GS1), 0002: SIRENE (France), 9906: SIRET, 9915: French intra-community VAT, 0060: DUNS [OPTIONAL | REQUIRED IF GLN IS PROVIDED]",
|
|
96
|
+
date: "Actual delivery date in YYYYMMDD format (BT-72) [OPTIONAL]",
|
|
97
|
+
address: {
|
|
98
|
+
line1: "Delivery street address (BT-75) [OPTIONAL]",
|
|
99
|
+
line2: "Delivery address complement [OPTIONAL]",
|
|
100
|
+
postcode: "Delivery postal code (BT-75) [OPTIONAL]",
|
|
101
|
+
city: "Delivery city (BT-74) [OPTIONAL]",
|
|
102
|
+
country: "ISO 3166-1 alpha-2 country code (BT-76) [OPTIONAL]",
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
# [REQUIRED BLOCK] (minimum 1 item)
|
|
107
|
+
items: [
|
|
108
|
+
{
|
|
109
|
+
line_id: "Line number (BT-126) [REQUIRED]",
|
|
110
|
+
seller_assigned_id: "Internal product identifier (BT-155) [OPTIONAL]",
|
|
111
|
+
name: "Product/service description (BT-153) [REQUIRED]",
|
|
112
|
+
quantity: "Quantity (BT-129) [REQUIRED]",
|
|
113
|
+
unit_code: "UN/ECE Rec20 unit code (e.g. H87, C62, DAY) (BT-130) [REQUIRED]",
|
|
114
|
+
price_ht: "Net unit price excluding VAT (BT-146) [REQUIRED]",
|
|
115
|
+
vat_rate: "VAT rate (BT-152) [REQUIRED]",
|
|
116
|
+
vat_category: "VAT category (S, Z, E, AE...) (BT-151) [REQUIRED]",
|
|
117
|
+
discount: { # [OPTIONAL]
|
|
118
|
+
total_amount: "Discount amount applicable to the invoice line (BT-136) [OPTIONAL unless discount block is present]",
|
|
119
|
+
percentage: "Discount percentage applicable to the invoice line (BT-138) [OPTIONAL unless discount block is present]",
|
|
120
|
+
# reason OR reason_code [REQUIRED] if block is present
|
|
121
|
+
reason: "Reason for the invoice line discount (BT-139) [OPTIONAL unless discount block is present]",
|
|
122
|
+
reason_code: "Reason code for the invoice line discount (BT-140) [OPTIONAL unless discount block is present]"
|
|
69
123
|
},
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
vat_category: "Catégorie TVA (S, Z, E, AE...) (BT-151) [OBLIGATOIRE]",
|
|
104
|
-
discount: { # [OPTIONNEL]
|
|
105
|
-
total_amount: "Montant de la remise applicable à la ligne de facture (BT-136) [OPTIONNEL sauf si discount]",
|
|
106
|
-
percentage: "Pourcentage de remise applicable à la ligne de facture (BT-138) [OPTIONNEL sauf si discount]",
|
|
107
|
-
# reason OU reason_code [OBLIGATOIRE] si bloc présent
|
|
108
|
-
reason: "Motif de la remise applicable à la ligne de facture (BT-139) [OPTIONNEL sauf si discount]",
|
|
109
|
-
reason_code: "Code de motif de la remise applicable à la ligne de facture (BT-140) [OPTIONNEL sauf si discount]"
|
|
110
|
-
},
|
|
111
|
-
line_total: "Montant net de la ligne HT = Quantité × Prix unitaire net (BT-131)"
|
|
112
|
-
}
|
|
113
|
-
],
|
|
114
|
-
|
|
115
|
-
# [BLOC OBLIGATOIRE]
|
|
116
|
-
payment_means: {
|
|
117
|
-
type_code: "Code UNCL 4461 (30 = virement) (BT-81) [OBLIGATOIRE]",
|
|
118
|
-
iban: "IBAN bénéficiaire (BT-84) [OBLIGATOIRE si virement]",
|
|
119
|
-
},
|
|
120
|
-
|
|
121
|
-
# [BLOC OBLIGATOIRE]
|
|
122
|
-
vat_breakdown: [
|
|
123
|
-
{
|
|
124
|
-
vat_category: "Catégorie TVA (BT-118) [OBLIGATOIRE]",
|
|
125
|
-
vat_rate: "Taux TVA % (BT-119) [OBLIGATOIRE]",
|
|
126
|
-
taxable_amount: "Base HT pour ce taux (BT-116) [OBLIGATOIRE]",
|
|
127
|
-
tax_amount: "Montant TVA pour ce taux (BT-117) [OBLIGATOIRE]",
|
|
128
|
-
# exemption_reason OU exemption_reason_code [OBLIGATOIRE] si vat_category = "E" (Exempt)
|
|
129
|
-
exemption_reason: "Motif d'exonération de la TVA (BT-120)",
|
|
130
|
-
exemption_reason_code: "Code de motif d'exonération de la TVA (BT-121)"
|
|
131
|
-
}
|
|
132
|
-
],
|
|
133
|
-
|
|
134
|
-
# [BLOC OPTIONNEL]
|
|
135
|
-
discount: [ # Ce bloc est un tableau avec un item par taux de TVA d'item. Il doit donc avoir la même longueur que le bloc vat_breakdown
|
|
136
|
-
{
|
|
137
|
-
vat_category: "Catégorie TVA (BT-118) [OBLIGATOIRE si le bloc est présent]",
|
|
138
|
-
vat_rate: "Taux TVA % (BT-119) [OBLIGATOIRE si le bloc est présent]",
|
|
139
|
-
total_amount: "Montant total de la remise pour le taux de TVA [OBLIGATOIRE si percentage présent]",
|
|
140
|
-
percentage: "% de remise au niveau du document si la remise est en % (BT-94) [OPTIONNEL]",
|
|
141
|
-
# reason OU reason_code [OBLIGATOIRE] si bloc présent
|
|
142
|
-
reason: "Motif de la remise au niveau du document (BT-97)",
|
|
143
|
-
reason_code: "Code de motif de la remise au niveau du document (BT-98)",
|
|
144
|
-
}
|
|
145
|
-
],
|
|
146
|
-
|
|
147
|
-
# [BLOC OBLIGATOIRE]
|
|
148
|
-
totals: {
|
|
149
|
-
line_total_ht: "Total HT lignes (BT-106) [OBLIGATOIRE]",
|
|
150
|
-
total_discount: "Somme des remises au niveau du document (BT-107) [OBLIGATOIRE si bloc discount présent]",
|
|
151
|
-
tax_basis_total_ht: "Total bases taxables (BT-109) [OBLIGATOIRE]",
|
|
152
|
-
tax_total: "Total TVA (BT-110) [OBLIGATOIRE]",
|
|
153
|
-
grand_total_ttc: "Total TTC (BT-112) [OBLIGATOIRE]",
|
|
154
|
-
amount_due: "Montant à payer (BT-115) [OPTIONNEL]",
|
|
155
|
-
# due_date OU description [OBLIGATOIRE] si amount_due est défini et positif
|
|
156
|
-
due_date: "Date due du paiement format YYYYMMDD (BT-9)",
|
|
157
|
-
description: "Termes du paiement (BT-20)"
|
|
124
|
+
line_total: "Net line amount excluding VAT = Quantity × Net unit price (BT-131)"
|
|
125
|
+
}
|
|
126
|
+
],
|
|
127
|
+
|
|
128
|
+
# [REQUIRED BLOCK]
|
|
129
|
+
payment_means: {
|
|
130
|
+
type_code: "UNCL 4461 code (30 = bank transfer) (BT-81) [REQUIRED]",
|
|
131
|
+
iban: "Beneficiary IBAN (BT-84) [REQUIRED for bank transfer]",
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
# [REQUIRED BLOCK]
|
|
135
|
+
vat_breakdown: [
|
|
136
|
+
{
|
|
137
|
+
vat_category: "VAT category (BT-118) [REQUIRED]",
|
|
138
|
+
vat_rate: "VAT rate % (BT-119) [REQUIRED]",
|
|
139
|
+
taxable_amount: "Taxable amount excluding VAT for this rate (BT-116) [REQUIRED]",
|
|
140
|
+
tax_amount: "VAT amount for this rate (BT-117) [REQUIRED]",
|
|
141
|
+
# exemption_reason OR exemption_reason_code [REQUIRED] if vat_category = 'E' (Exempt)
|
|
142
|
+
exemption_reason: "VAT exemption reason (BT-120)",
|
|
143
|
+
exemption_reason_code: "VAT exemption reason code (BT-121)"
|
|
144
|
+
}
|
|
145
|
+
],
|
|
146
|
+
|
|
147
|
+
# [OPTIONAL BLOCK]
|
|
148
|
+
discount: [ # This block is an array with one item per item VAT rate. Therefore, it must have the same length as the vat_breakdown block.
|
|
149
|
+
{
|
|
150
|
+
vat_category: "VAT category (BT-118) [REQUIRED if block is present]",
|
|
151
|
+
vat_rate: "VAT rate % (BT-119) [REQUIRED if block is present]",
|
|
152
|
+
total_amount: "Total discount amount for the VAT rate [REQUIRED if percentage is present]",
|
|
153
|
+
percentage: "Document-level discount percentage if the discount is expressed as a percentage (BT-94) [OPTIONAL]",
|
|
154
|
+
# reason OR reason_code [REQUIRED] if block is present
|
|
155
|
+
reason: "Reason for the document-level discount (BT-97)",
|
|
156
|
+
reason_code: "Reason code for the document-level discount (BT-98)",
|
|
158
157
|
}
|
|
158
|
+
],
|
|
159
|
+
|
|
160
|
+
# [REQUIRED BLOCK]
|
|
161
|
+
totals: {
|
|
162
|
+
line_total_ht: "Total line amount excluding VAT (BT-106) [REQUIRED]",
|
|
163
|
+
total_discount: "Sum of document-level discounts (BT-107) [REQUIRED if discount block is present]",
|
|
164
|
+
tax_basis_total_ht: "Total taxable amount (BT-109) [REQUIRED]",
|
|
165
|
+
tax_total: "Total VAT amount (BT-110) [REQUIRED]",
|
|
166
|
+
grand_total_ttc: "Grand total including VAT (BT-112) [REQUIRED]",
|
|
167
|
+
amount_due: "Amount due for payment (BT-115) [OPTIONAL]",
|
|
168
|
+
# due_date OR description [REQUIRED] if amount_due is defined and positive
|
|
169
|
+
due_date: "Payment due date in YYYYMMDD format (BT-9)",
|
|
170
|
+
description: "Payment terms (BT-20)"
|
|
171
|
+
}
|
|
159
172
|
}
|
|
160
173
|
```
|
|
161
174
|
|
|
162
|
-
##
|
|
175
|
+
## Third-Party Components
|
|
163
176
|
|
|
164
|
-
|
|
177
|
+
This gem includes the following third-party component:
|
|
165
178
|
|
|
166
|
-
- Saxon-HE (
|
|
179
|
+
- Saxon-HE (XSLT and XQuery processor), developed by Saxonica Limited and licensed under the Mozilla Public License 2.0 (MPL-2.0).
|
|
167
180
|
|
|
168
|
-
|
|
169
|
-
|
|
181
|
+
The Saxon-HE JAR is redistributed as-is and remains subject to its original license terms.
|
|
182
|
+
A copy of the MPL-2.0 license is included in this repository under the LICENSES directory.
|
|
170
183
|
|
|
171
|
-
##
|
|
184
|
+
## License
|
|
172
185
|
|
|
173
|
-
|
|
186
|
+
This project is licensed under the Apache License 2.0.
|
|
174
187
|
|
|
175
|
-
|
|
188
|
+
The Apache License 2.0 permits the use, modification, distribution, and commercialization of the software, provided that copyright notices are retained and the terms of the license are respected.
|
|
176
189
|
|
|
177
|
-
|
|
190
|
+
Unless required by applicable law or agreed to in writing, this software is distributed on an "AS IS" basis, without warranties or conditions of any kind, either express or implied.
|
|
178
191
|
|
|
179
|
-
|
|
192
|
+
For the full license terms, refer to the LICENSE file located at the root of the repository or to the official license text: https://www.apache.org/licenses/LICENSE-2.0
|
|
180
193
|
|
|
181
|
-
Copyright (
|
|
194
|
+
Copyright (c) 2026 UNOVA
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
NOTICE AND INFORMATION ABOUT THIRD-PARTY SOFTWARE
|
|
2
|
+
|
|
3
|
+
This project includes third-party software components.
|
|
4
|
+
|
|
5
|
+
------------------------------------------------------------
|
|
6
|
+
1. Saxon-HE (XSLT and XQuery processor)
|
|
7
|
+
------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
Name: Saxon-HE
|
|
10
|
+
Version: 12.4
|
|
11
|
+
Author: Saxonica Limited
|
|
12
|
+
Official website: https://www.saxonica.com/
|
|
13
|
+
|
|
14
|
+
License: Mozilla Public License 2.0 (MPL-2.0)
|
|
15
|
+
|
|
16
|
+
Copyright:
|
|
17
|
+
Copyright (c) Saxonica Limited
|
|
18
|
+
|
|
19
|
+
Saxon-HE is included in this distribution as a precompiled JAR file and remains subject to its original license terms.
|
|
20
|
+
|
|
21
|
+
The full text of the MPL-2.0 license is available here:
|
|
22
|
+
https://www.mozilla.org/en-US/MPL/2.0/
|
|
23
|
+
|
|
24
|
+
A copy of the MPL-2.0 license is included in this project:
|
|
25
|
+
LICENSES/MPL-2.0.txt
|
|
26
|
+
|
|
27
|
+
------------------------------------------------------------
|
|
28
|
+
END OF NOTICE
|
|
29
|
+
------------------------------------------------------------
|
|
@@ -6,7 +6,7 @@ module UnovaFacturX
|
|
|
6
6
|
end
|
|
7
7
|
|
|
8
8
|
def call
|
|
9
|
-
# Override
|
|
9
|
+
# Override HexaPDF default rules to prevent it from forcing PDF version 2.0
|
|
10
10
|
begin
|
|
11
11
|
::HexaPDF::Type::Catalog.send(:remove_field, :AF)
|
|
12
12
|
rescue StandardError
|
|
@@ -14,20 +14,20 @@ module UnovaFacturX
|
|
|
14
14
|
end
|
|
15
15
|
::HexaPDF::Type::Catalog.define_field :AF, type: ::HexaPDF::PDFArray
|
|
16
16
|
|
|
17
|
-
#
|
|
17
|
+
# Generate the XML using the generator
|
|
18
18
|
xml_doc = @xml
|
|
19
19
|
xml_string = xml_doc.to_xml
|
|
20
20
|
xml_io = StringIO.new(xml_string)
|
|
21
21
|
|
|
22
|
-
#
|
|
22
|
+
# Convert the PDF into a StringIO object
|
|
23
23
|
pdf_io = @pdf.is_a?(File) ? @pdf : StringIO.new(@pdf)
|
|
24
24
|
pdf_io.rewind
|
|
25
25
|
|
|
26
|
-
#
|
|
26
|
+
# Create a new PDF using HexaPDF
|
|
27
27
|
doc = ::HexaPDF::Document.new(io: pdf_io)
|
|
28
28
|
doc.task(:pdfa, level: "3b") # PDF format A/3
|
|
29
29
|
|
|
30
|
-
#
|
|
30
|
+
# Attach the XML file to the PDF
|
|
31
31
|
file_spec = doc.files.add(
|
|
32
32
|
xml_io,
|
|
33
33
|
name: "factur-x.xml",
|
|
@@ -39,16 +39,16 @@ module UnovaFacturX
|
|
|
39
39
|
doc.catalog[:AF] ||= []
|
|
40
40
|
doc.catalog[:AF] << file_spec
|
|
41
41
|
|
|
42
|
-
#
|
|
42
|
+
# Rewrite the metadata to comply with Factur-X requirements
|
|
43
43
|
doc.metadata.custom_metadata(metadata_xml)
|
|
44
44
|
|
|
45
|
-
#
|
|
45
|
+
# Return the PDF as a StringIO object
|
|
46
46
|
doc.write_to_string
|
|
47
47
|
end
|
|
48
48
|
|
|
49
49
|
private
|
|
50
50
|
|
|
51
|
-
def metadata_xml # TODO :
|
|
51
|
+
def metadata_xml # TODO : Use invoice values
|
|
52
52
|
<<~XML
|
|
53
53
|
<rdf:Description rdf:about=""
|
|
54
54
|
xmlns:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/"
|
|
@@ -1,147 +1,14 @@
|
|
|
1
1
|
module UnovaFacturX
|
|
2
2
|
class XmlGenerator
|
|
3
|
-
#
|
|
3
|
+
# @param document [Hash] A hash representing an invoice or a credit note.
|
|
4
|
+
# @param type [Symbol] Document type: :invoice for a standard invoice, :credit for a credit note (:invoice by default).
|
|
5
|
+
# @param currency [String] ISO 4217 currency code.
|
|
4
6
|
#
|
|
5
|
-
#
|
|
6
|
-
|
|
7
|
-
# Les montants fournis doivent être arithmétiquement cohérents, aucune correction automatique n’est effectuée.
|
|
8
|
-
# Aussi, pour plus de simplicité, tous les attributs de la facture/du crédit sont attendus en String.
|
|
9
|
-
# @example
|
|
10
|
-
# # Générer un XML de facture standard
|
|
11
|
-
# xml = XmlGenerator.new(invoice).call
|
|
12
|
-
#
|
|
13
|
-
# # Générer un XML pour un avoir
|
|
14
|
-
# xml = XmlGenerator.new(credit, type: :credit).call
|
|
15
|
-
#
|
|
16
|
-
# # Configurer la monnaie utilisée sur la facture
|
|
17
|
-
# xml = XmlGenerator.new(invoice, devise: "USD") # 'EUR' par défaut
|
|
18
|
-
#
|
|
19
|
-
# # Exemple de hash pour une facture (Même chose pour un avoir /!\ Ne pas mettre les valeurs de l'avoir en négatif /!\) :
|
|
20
|
-
# document = {
|
|
21
|
-
# id: "Numéro unique de facture (BT-1) [OBLIGATOIRE]",
|
|
22
|
-
# issue_date: "Date d'émission format YYYYMMDD (BT-2) [OBLIGATOIRE]",
|
|
23
|
-
#
|
|
24
|
-
# seller: {
|
|
25
|
-
# name: "Nom légal du vendeur (BT-27) [OBLIGATOIRE]",
|
|
26
|
-
# legal_id: "Identifiant légal (SIREN/SIRET) (BT-30) [OPTIONNEL]",
|
|
27
|
-
# vat_number: "Numéro TVA avec préfixe pays acheteur (ex: FR123...) (BT-31) [OPTIONNEL]",
|
|
28
|
-
# address: {
|
|
29
|
-
# line1: "Rue (BT-35) [OBLIGATOIRE]",
|
|
30
|
-
# line2: "Complément adresse [OPTIONNEL]",
|
|
31
|
-
# postcode: "Code postal (BT-38) [OBLIGATOIRE]",
|
|
32
|
-
# city: "Ville (BT-37) [OBLIGATOIRE]",
|
|
33
|
-
# country: "Code pays ISO 3166-1 alpha-2 (BT-40) [OBLIGATOIRE]",
|
|
34
|
-
# }
|
|
35
|
-
# },
|
|
36
|
-
#
|
|
37
|
-
# # [BLOC OBLIGATOIRE]
|
|
38
|
-
# buyer: {
|
|
39
|
-
# id: "Identifiant interne client (BT-46) [OPTIONNEL]",
|
|
40
|
-
# name: "Nom légal du client (BT-44) [OBLIGATOIRE]",
|
|
41
|
-
# vat_number: "Numéro TVA avec préfixe pays acheteur (ex: FR123...) (BT-48) [OPTIONNEL]",
|
|
42
|
-
# contact: { # [OPTIONNEL]
|
|
43
|
-
# name: "Nom du contact client (BT-56) [OPTIONNEL]",
|
|
44
|
-
# },
|
|
45
|
-
# address: {
|
|
46
|
-
# line1: "Rue (BT-50) [OBLIGATOIRE]",
|
|
47
|
-
# line2: "Complément adresse [OPTIONNEL]",
|
|
48
|
-
# postcode: "Code postal (BT-53) [OBLIGATOIRE]",
|
|
49
|
-
# city: "Ville (BT-52) [OBLIGATOIRE]",
|
|
50
|
-
# country: "Code pays ISO 3166-1 alpha-2 (BT-55) [OBLIGATOIRE]",
|
|
51
|
-
# }
|
|
52
|
-
# },
|
|
53
|
-
#
|
|
54
|
-
# # [BLOC OPTIONNEL]
|
|
55
|
-
# delivery: {
|
|
56
|
-
# gln: "Identifiant GLN (schemeID 0088) (BT-71) [OPTIONNEL]",
|
|
57
|
-
# gln_scheme: "0088: GLN (GS1), 0002: SIRENE (France), 9906: SIRET, 9915: TVA intracom FR, 0060: DUNS [OPTIONNEL | OBLIGATOIRE SI GLN]",
|
|
58
|
-
# date: "Date réelle de livraison format YYYYMMDD (BT-72) [OPTIONNEL]",
|
|
59
|
-
# address: {
|
|
60
|
-
# line1: "Rue livraison (BT-75) [OPTIONNEL]",
|
|
61
|
-
# line2: "Complément adresse livraison [OPTIONNEL]",
|
|
62
|
-
# postcode: "Code postal livraison (BT-75) [OPTIONNEL]",
|
|
63
|
-
# city: "Ville livraison (BT-74) [OPTIONNEL]",
|
|
64
|
-
# country: "Code pays ISO 3166-1 alpha-2 (BT-76) [OPTIONNEL]",
|
|
65
|
-
# }
|
|
66
|
-
# },
|
|
67
|
-
#
|
|
68
|
-
# # [BLOC OBLIGATOIRE] (minimum 1 item)
|
|
69
|
-
# items: [
|
|
70
|
-
# {
|
|
71
|
-
# line_id: "Numéro de ligne (BT-126) [OBLIGATOIRE]",
|
|
72
|
-
# seller_assigned_id: "Identifiant interne produit (BT-155) [OPTIONNEL]",
|
|
73
|
-
# name: "Désignation produit/service (BT-153) [OBLIGATOIRE]",
|
|
74
|
-
# quantity: "Quantité (BT-129) [OBLIGATOIRE]",
|
|
75
|
-
# unit_code: "Code unité UN/ECE Rec20 (ex: H87, C62, DAY) (BT-130) [OBLIGATOIRE]",
|
|
76
|
-
# price_ht: "Prix unitaire net HT (BT-146) [OBLIGATOIRE]",
|
|
77
|
-
# vat_rate: "Taux TVA (BT-152) [OBLIGATOIRE]",
|
|
78
|
-
# vat_category: "Catégorie TVA (S, Z, E, AE...) (BT-151) [OBLIGATOIRE]",
|
|
79
|
-
# discount: { # [OPTIONNEL]
|
|
80
|
-
# total_amount: "Montant de la remise applicable à la ligne de facture (BT-136) [OPTIONNEL sauf si discount]",
|
|
81
|
-
# percentage: "Pourcentage de remise applicable à la ligne de facture (BT-138) [OPTIONNEL sauf si discount]",
|
|
82
|
-
# # reason OU reason_code [OBLIGATOIRE] si bloc présent
|
|
83
|
-
# reason: "Motif de la remise applicable à la ligne de facture (BT-139) [OPTIONNEL sauf si discount]",
|
|
84
|
-
# reason_code: "Code de motif de la remise applicable à la ligne de facture (BT-140) [OPTIONNEL sauf si discount]"
|
|
85
|
-
# }
|
|
86
|
-
# line_total: "Montant net de la ligne HT = Quantité × Prix unitaire net (BT-131)"
|
|
87
|
-
# }
|
|
88
|
-
# ],
|
|
89
|
-
#
|
|
90
|
-
# # [BLOC OBLIGATOIRE]
|
|
91
|
-
# payment_means: {
|
|
92
|
-
# type_code: "Code UNCL 4461 (30 = virement) (BT-81) [OBLIGATOIRE]",
|
|
93
|
-
# iban: "IBAN bénéficiaire (BT-84) [OBLIGATOIRE si virement]",
|
|
94
|
-
# },
|
|
95
|
-
#
|
|
96
|
-
# # [BLOC OBLIGATOIRE]
|
|
97
|
-
# vat_breakdown: [
|
|
98
|
-
# {
|
|
99
|
-
# vat_category: "Catégorie TVA (BT-118) [OBLIGATOIRE]",
|
|
100
|
-
# vat_rate: "Taux TVA % (BT-119) [OBLIGATOIRE]",
|
|
101
|
-
# taxable_amount: "Base HT pour ce taux (BT-116) [OBLIGATOIRE]",
|
|
102
|
-
# tax_amount: "Montant TVA pour ce taux (BT-117) [OBLIGATOIRE]",
|
|
103
|
-
# # exemption_reason OU exemption_reason_code [OBLIGATOIRE] si vat_category = "E" (Exempt)
|
|
104
|
-
# exemption_reason: "Motif d'exonération de la TVA (BT-120)",
|
|
105
|
-
# exemption_reason_code: "Code de motif d'exonération de la TVA (BT-121)"
|
|
106
|
-
# }
|
|
107
|
-
# ],
|
|
108
|
-
#
|
|
109
|
-
# # [BLOC OPTIONNEL]
|
|
110
|
-
# discount: [ # Ce bloc est un tableau avec un item par taux de TVA d'item. Il doit donc avoir la même longueur que le bloc vat_breakdown
|
|
111
|
-
# {
|
|
112
|
-
# vat_category: "Catégorie TVA (BT-118) [OBLIGATOIRE si le bloc est présent]",
|
|
113
|
-
# vat_rate: "Taux TVA % (BT-119) [OBLIGATOIRE si le bloc est présent]",
|
|
114
|
-
# total_amount: "Montant total de la remise pour le taux de TVA [OBLIGATOIRE si percentage présent]",
|
|
115
|
-
# percentage: "% de remise au niveau du document si la remise est en % (BT-94) [OPTIONNEL]",
|
|
116
|
-
# # reason OU reason_code [OBLIGATOIRE] si bloc présent
|
|
117
|
-
# reason: "Motif de la remise au niveau du document (BT-97)",
|
|
118
|
-
# reason_code: "Code de motif de la remise au niveau du document (BT-98)",
|
|
119
|
-
# }
|
|
120
|
-
# ],
|
|
121
|
-
#
|
|
122
|
-
# # [BLOC OBLIGATOIRE]
|
|
123
|
-
# totals: {
|
|
124
|
-
# line_total_ht: "Total HT lignes (BT-106) [OBLIGATOIRE]",
|
|
125
|
-
# total_discount: "Somme des remises au niveau du document (BT-107) [OBLIGATOIRE si bloc discount présent]",
|
|
126
|
-
# tax_basis_total_ht: "Total bases taxables (BT-109) [OBLIGATOIRE]",
|
|
127
|
-
# tax_total: "Total TVA (BT-110) [OBLIGATOIRE]",
|
|
128
|
-
# grand_total_ttc: "Total TTC (BT-112) [OBLIGATOIRE]",
|
|
129
|
-
# amount_due: "Montant à payer (BT-115) [OPTIONNEL]",
|
|
130
|
-
# # due_date OU description [OBLIGATOIRE] si amount_due est défini et positif
|
|
131
|
-
# due_date: "Date due du paiement format YYYYMMDD (BT-9)",
|
|
132
|
-
# description: "Termes du paiement (BT-20)"
|
|
133
|
-
# }
|
|
134
|
-
# }
|
|
135
|
-
#
|
|
136
|
-
# @param document [Hash] Un hash représentant une facture ou un crédit.
|
|
137
|
-
# @param type [Symbol] Type de document : `:invoice` pour une facture normale, `:credit` pour un avoir. (`:invoice` par défaut)
|
|
138
|
-
# @param devise [String] Code ISO 4217
|
|
139
|
-
#
|
|
140
|
-
# @return [Nokogiri::XML::Document] Le document XML généré
|
|
141
|
-
def initialize(document, type: :invoice, devise: "EUR", validate: true)
|
|
7
|
+
# @return [Nokogiri::XML::Document] The generated XML document
|
|
8
|
+
def initialize(document, type: :invoice, currency: "EUR", validate: true)
|
|
142
9
|
@document = document
|
|
143
10
|
@type = type
|
|
144
|
-
@
|
|
11
|
+
@currency = currency
|
|
145
12
|
@validate = validate
|
|
146
13
|
end
|
|
147
14
|
|
|
@@ -161,7 +28,7 @@ module UnovaFacturX
|
|
|
161
28
|
|
|
162
29
|
validate_with_xslt!(builder.doc) if @validate
|
|
163
30
|
|
|
164
|
-
#
|
|
31
|
+
# Returns the XML document
|
|
165
32
|
builder.doc
|
|
166
33
|
end
|
|
167
34
|
|
|
@@ -170,18 +37,18 @@ module UnovaFacturX
|
|
|
170
37
|
def build_exchanged_document_context(xml)
|
|
171
38
|
xml['rsm'].ExchangedDocumentContext do
|
|
172
39
|
xml['ram'].GuidelineSpecifiedDocumentContextParameter do
|
|
173
|
-
xml['ram'].ID("urn:cen.eu:en16931:2017") #
|
|
40
|
+
xml['ram'].ID("urn:cen.eu:en16931:2017") # Identifier of the invoice specification (European EN16931 standard)
|
|
174
41
|
end
|
|
175
42
|
end
|
|
176
43
|
end
|
|
177
44
|
|
|
178
45
|
def build_exchanged_document(xml)
|
|
179
46
|
xml['rsm'].ExchangedDocument do
|
|
180
|
-
xml['ram'].ID(@document[:id]) #
|
|
181
|
-
xml['ram'].TypeCode(@type == :credit ? "381" : "380") #
|
|
47
|
+
xml['ram'].ID(@document[:id]) # Invoice number (BT-1) - must be unique per seller
|
|
48
|
+
xml['ram'].TypeCode(@type == :credit ? "381" : "380") # Document type (380 = invoice, 381 = credit note) (BT-3)
|
|
182
49
|
if @document[:issue_date].present?
|
|
183
50
|
xml['ram'].IssueDateTime do
|
|
184
|
-
xml['udt'].DateTimeString(@document[:issue_date], format: "102") #
|
|
51
|
+
xml['udt'].DateTimeString(@document[:issue_date], format: "102") # Invoice issue date in YYYYMMDD format (BT-2)
|
|
185
52
|
end
|
|
186
53
|
end
|
|
187
54
|
end
|
|
@@ -197,47 +64,47 @@ module UnovaFacturX
|
|
|
197
64
|
end
|
|
198
65
|
|
|
199
66
|
def build_line_items(xml)
|
|
200
|
-
raise StandardError, "
|
|
67
|
+
raise StandardError, "At least one item is required" if @document[:items].blank?
|
|
201
68
|
|
|
202
69
|
@document[:items].each do |item|
|
|
203
70
|
xml['ram'].IncludedSupplyChainTradeLineItem do
|
|
204
71
|
xml['ram'].AssociatedDocumentLineDocument do
|
|
205
|
-
xml['ram'].LineID(item[:line_id]) #
|
|
72
|
+
xml['ram'].LineID(item[:line_id]) # Line number (BT-126)
|
|
206
73
|
end
|
|
207
74
|
xml['ram'].SpecifiedTradeProduct do
|
|
208
|
-
xml['ram'].SellerAssignedID(item[:seller_assigned_id]) if item[:seller_assigned_id].present? #
|
|
209
|
-
xml['ram'].Name(item[:name]) #
|
|
75
|
+
xml['ram'].SellerAssignedID(item[:seller_assigned_id]) if item[:seller_assigned_id].present? # Internal product identifier assigned by the seller (BT-155)
|
|
76
|
+
xml['ram'].Name(item[:name]) # Product/service description (BT-153)
|
|
210
77
|
end
|
|
211
78
|
xml['ram'].SpecifiedLineTradeAgreement do
|
|
212
79
|
xml['ram'].NetPriceProductTradePrice do
|
|
213
|
-
xml['ram'].ChargeAmount(item[:price_ht]) #
|
|
80
|
+
xml['ram'].ChargeAmount(item[:price_ht]) # Net unit price excluding VAT (BT-146)
|
|
214
81
|
end
|
|
215
82
|
end
|
|
216
83
|
xml['ram'].SpecifiedLineTradeDelivery do
|
|
217
|
-
xml['ram'].BilledQuantity(item[:quantity], unitCode: item[:unit_code]) #
|
|
84
|
+
xml['ram'].BilledQuantity(item[:quantity], unitCode: item[:unit_code]) # Quantity invoiced (BT-129) with unit code UN/ECE Rec. 20 (BT-130)
|
|
218
85
|
end
|
|
219
86
|
|
|
220
87
|
xml['ram'].SpecifiedLineTradeSettlement do
|
|
221
88
|
xml['ram'].ApplicableTradeTax do
|
|
222
|
-
xml['ram'].TypeCode("VAT") #
|
|
223
|
-
xml['ram'].CategoryCode(item[:vat_category]) #
|
|
224
|
-
xml['ram'].RateApplicablePercent(item[:vat_rate]) #
|
|
89
|
+
xml['ram'].TypeCode("VAT") # VAT type (always "VAT" in the EU) (BT-151)
|
|
90
|
+
xml['ram'].CategoryCode(item[:vat_category]) # VAT category code for the line item (BT-151). One among these: {'A', 'AA', 'AB', 'AC', 'AD', 'AE', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'O', 'S', 'Z'}
|
|
91
|
+
xml['ram'].RateApplicablePercent(item[:vat_rate]) # VAT rate applied (%) (BT-152)
|
|
225
92
|
end
|
|
226
93
|
if item[:discount].present?
|
|
227
94
|
discount = item[:discount]
|
|
228
95
|
xml['ram'].SpecifiedTradeAllowanceCharge do
|
|
229
96
|
xml['ram'].ChargeIndicator do
|
|
230
|
-
xml['udt'].Indicator("false") # true = charge (
|
|
97
|
+
xml['udt'].Indicator("false") # true = charge (additional fees), false = allowance (discount/allowance)
|
|
231
98
|
end
|
|
232
|
-
xml['ram'].CalculationPercent(discount[:percentage]) if discount[:percentage].present? #
|
|
233
|
-
xml['ram'].ActualAmount(discount[:total_amount]) #
|
|
234
|
-
xml['ram'].Reason(discount[:reason]) if discount[:reason].present? #
|
|
235
|
-
xml['ram'].ReasonCode(discount[:reason_code]) if discount[:reason_code].present? #
|
|
236
|
-
# xml['ram'].BasisAmount(discount[:base]) #
|
|
99
|
+
xml['ram'].CalculationPercent(discount[:percentage]) if discount[:percentage].present? # Discount percentage applicable to the invoice line (BT-138)
|
|
100
|
+
xml['ram'].ActualAmount(discount[:total_amount]) # Discount amount applicable to the invoice line (BT-136)
|
|
101
|
+
xml['ram'].Reason(discount[:reason]) if discount[:reason].present? # Reason for the discount on the invoice line (BT-139)
|
|
102
|
+
xml['ram'].ReasonCode(discount[:reason_code]) if discount[:reason_code].present? # Reason code for the discount on the invoice line (BT-140)
|
|
103
|
+
# xml['ram'].BasisAmount(discount[:base]) # Discount basis applicable to invoice line (BT-137)
|
|
237
104
|
end
|
|
238
105
|
end
|
|
239
106
|
xml['ram'].SpecifiedTradeSettlementLineMonetarySummation do
|
|
240
|
-
xml['ram'].LineTotalAmount(item[:line_total]) #
|
|
107
|
+
xml['ram'].LineTotalAmount(item[:line_total]) # Net line amount excluding VAT = Quantity × Net unit price (BT-131)
|
|
241
108
|
end
|
|
242
109
|
end
|
|
243
110
|
end
|
|
@@ -260,23 +127,23 @@ module UnovaFacturX
|
|
|
260
127
|
|
|
261
128
|
xml['ram'].ApplicableHeaderTradeDelivery do
|
|
262
129
|
xml['ram'].ShipToTradeParty do
|
|
263
|
-
xml['ram'].GlobalID(delivery[:gln], schemeID: delivery[:gln_scheme]) if delivery[:gln].present? && delivery[:gln_scheme].present? #
|
|
130
|
+
xml['ram'].GlobalID(delivery[:gln], schemeID: delivery[:gln_scheme]) if delivery[:gln].present? && delivery[:gln_scheme].present? # Global identifier of the delivery location (BT-71) | Scheme identifiers: 0088: GLN (GS1), 0002: SIRENE (France), 9906: SIRET, 9915: EU VAT number (FR), 0060: DUNS
|
|
264
131
|
|
|
265
132
|
if delivery[:address].present?
|
|
266
133
|
address = delivery[:address]
|
|
267
134
|
xml['ram'].PostalTradeAddress do
|
|
268
|
-
xml['ram'].PostcodeCode(address[:postcode]) if address[:postcode].present? #
|
|
269
|
-
xml['ram'].LineOne(address[:line1]) if address[:line1].present? #
|
|
270
|
-
xml['ram'].LineTwo(address[:line2]) if address[:line2].present? #
|
|
271
|
-
xml['ram'].CityName(address[:city]) if address[:city].present? #
|
|
272
|
-
xml['ram'].CountryID(address[:country]) if address[:country].present? #
|
|
135
|
+
xml['ram'].PostcodeCode(address[:postcode]) if address[:postcode].present? # Delivery postal code
|
|
136
|
+
xml['ram'].LineOne(address[:line1]) if address[:line1].present? # Street address
|
|
137
|
+
xml['ram'].LineTwo(address[:line2]) if address[:line2].present? # Address complement
|
|
138
|
+
xml['ram'].CityName(address[:city]) if address[:city].present? # City
|
|
139
|
+
xml['ram'].CountryID(address[:country]) if address[:country].present? # Country code
|
|
273
140
|
end
|
|
274
141
|
end
|
|
275
142
|
end
|
|
276
143
|
if delivery[:date].present?
|
|
277
144
|
xml['ram'].ActualDeliverySupplyChainEvent do
|
|
278
145
|
xml['ram'].OccurrenceDateTime do
|
|
279
|
-
xml['udt'].DateTimeString(delivery[:date], format: "102") #
|
|
146
|
+
xml['udt'].DateTimeString(delivery[:date], format: "102") # Actual delivery date in YYYYMMDD format (BT-72)
|
|
280
147
|
end
|
|
281
148
|
end
|
|
282
149
|
end
|
|
@@ -287,45 +154,45 @@ module UnovaFacturX
|
|
|
287
154
|
xml['ram'].ApplicableHeaderTradeAgreement do
|
|
288
155
|
xml['ram'].SellerTradeParty do
|
|
289
156
|
seller = @document[:seller]
|
|
290
|
-
xml['ram'].Name(seller[:name]) #
|
|
157
|
+
xml['ram'].Name(seller[:name]) # Seller legal name (BT-27)
|
|
291
158
|
xml['ram'].SpecifiedLegalOrganization do
|
|
292
|
-
xml['ram'].ID(seller[:legal_id]) #
|
|
159
|
+
xml['ram'].ID(seller[:legal_id]) # Seller legal identifier (SIREN/SIRET or equivalent) (BT-30)
|
|
293
160
|
end
|
|
294
161
|
xml['ram'].PostalTradeAddress do
|
|
295
162
|
address = seller[:address]
|
|
296
|
-
xml['ram'].PostcodeCode(address[:postcode]) #
|
|
297
|
-
xml['ram'].LineOne(address[:line1]) #
|
|
298
|
-
xml['ram'].LineTwo(address[:line2]) if address[:line2].present? #
|
|
299
|
-
xml['ram'].CityName(address[:city]) #
|
|
300
|
-
xml['ram'].CountryID(address[:country]) #
|
|
163
|
+
xml['ram'].PostcodeCode(address[:postcode]) # Postal code
|
|
164
|
+
xml['ram'].LineOne(address[:line1]) # Street address
|
|
165
|
+
xml['ram'].LineTwo(address[:line2]) if address[:line2].present? # Address complement
|
|
166
|
+
xml['ram'].CityName(address[:city]) # City
|
|
167
|
+
xml['ram'].CountryID(address[:country]) # Country code
|
|
301
168
|
end
|
|
302
169
|
if seller[:vat_number].present?
|
|
303
170
|
xml['ram'].SpecifiedTaxRegistration do
|
|
304
|
-
xml['ram'].ID(seller[:vat_number], schemeID: "VA") #
|
|
171
|
+
xml['ram'].ID(seller[:vat_number], schemeID: "VA") # Seller VAT number (BT-31)
|
|
305
172
|
end
|
|
306
173
|
end
|
|
307
174
|
end
|
|
308
175
|
xml['ram'].BuyerTradeParty do
|
|
309
176
|
buyer = @document[:buyer]
|
|
310
|
-
xml['ram'].ID(buyer[:id]) #
|
|
311
|
-
xml['ram'].Name(buyer[:name]) #
|
|
177
|
+
xml['ram'].ID(buyer[:id]) # Buyer identifier (BT-46)
|
|
178
|
+
xml['ram'].Name(buyer[:name]) # Buyer legal name (BT-44)
|
|
312
179
|
if buyer[:contact].present?
|
|
313
180
|
contact = buyer[:contact]
|
|
314
181
|
xml['ram'].DefinedTradeContact do
|
|
315
|
-
xml['ram'].PersonName(contact[:name]) #
|
|
182
|
+
xml['ram'].PersonName(contact[:name]) # Contact name for the buyer (BT-56)
|
|
316
183
|
end
|
|
317
184
|
end
|
|
318
185
|
xml['ram'].PostalTradeAddress do
|
|
319
186
|
address = buyer[:address]
|
|
320
|
-
xml['ram'].PostcodeCode(address[:postcode]) #
|
|
321
|
-
xml['ram'].LineOne(address[:line1]) #
|
|
322
|
-
xml['ram'].LineTwo(address[:line2]) if address[:line2].present? #
|
|
323
|
-
xml['ram'].CityName(address[:city]) #
|
|
324
|
-
xml['ram'].CountryID(address[:country]) #
|
|
187
|
+
xml['ram'].PostcodeCode(address[:postcode]) # Buyer postal code
|
|
188
|
+
xml['ram'].LineOne(address[:line1]) # Street address
|
|
189
|
+
xml['ram'].LineTwo(address[:line2]) if address[:line2].present? # Address complement
|
|
190
|
+
xml['ram'].CityName(address[:city]) # City
|
|
191
|
+
xml['ram'].CountryID(address[:country]) # Country code
|
|
325
192
|
end
|
|
326
193
|
if buyer[:vat_number].present?
|
|
327
194
|
xml['ram'].SpecifiedTaxRegistration do
|
|
328
|
-
xml['ram'].ID(buyer[:vat_number], schemeID: "VA") #
|
|
195
|
+
xml['ram'].ID(buyer[:vat_number], schemeID: "VA") # Buyer VAT number (BT-48)
|
|
329
196
|
end
|
|
330
197
|
end
|
|
331
198
|
end
|
|
@@ -335,25 +202,25 @@ module UnovaFacturX
|
|
|
335
202
|
def build_applicable_header_trade_settlement(xml)
|
|
336
203
|
xml['ram'].ApplicableHeaderTradeSettlement do
|
|
337
204
|
payment = @document[:payment_means]
|
|
338
|
-
# xml['ram'].PaymentReference("XXXX") #
|
|
339
|
-
xml['ram'].InvoiceCurrencyCode(@
|
|
205
|
+
# xml['ram'].PaymentReference("XXXX") # Payment reference (e.g. bank transfer reference) (BT-83)
|
|
206
|
+
xml['ram'].InvoiceCurrencyCode(@currency) # Invoice currency code
|
|
340
207
|
xml['ram'].SpecifiedTradeSettlementPaymentMeans do
|
|
341
|
-
xml['ram'].TypeCode(payment[:type_code]) #
|
|
342
|
-
if %w[30 49].include?(payment[:type_code].to_s) && payment[:iban].present? # 30
|
|
208
|
+
xml['ram'].TypeCode(payment[:type_code]) # Payment method code UNCL 4461 (BT-81)
|
|
209
|
+
if %w[30 49].include?(payment[:type_code].to_s) && payment[:iban].present? # 30: Bank transfer, 49: Direct debit
|
|
343
210
|
xml['ram'].PayeePartyCreditorFinancialAccount do
|
|
344
|
-
xml['ram'].IBANID(payment[:iban]) # IBAN
|
|
211
|
+
xml['ram'].IBANID(payment[:iban]) # Payee IBAN (BT-84)
|
|
345
212
|
end
|
|
346
213
|
end
|
|
347
214
|
end
|
|
348
215
|
@document[:vat_breakdown].each do |vat|
|
|
349
216
|
xml['ram'].ApplicableTradeTax do
|
|
350
|
-
xml['ram'].CalculatedAmount(vat[:tax_amount]) #
|
|
351
|
-
xml['ram'].TypeCode("VAT") #
|
|
352
|
-
xml['ram'].ExemptionReason(vat[:exemption_reason]) if vat[:vat_category] == "E" && vat[:exemption_reason].present? #
|
|
353
|
-
xml['ram'].ExemptionReasonCode(vat[:exemption_reason_code]) if vat[:vat_category] == "E" && vat[:exemption_reason_code].present? #
|
|
354
|
-
xml['ram'].BasisAmount(vat[:taxable_amount]) #
|
|
355
|
-
xml['ram'].CategoryCode(vat[:vat_category]) #
|
|
356
|
-
xml['ram'].RateApplicablePercent(vat[:vat_rate]) #
|
|
217
|
+
xml['ram'].CalculatedAmount(vat[:tax_amount]) # VAT calculated amount for the given rate (BT-117)
|
|
218
|
+
xml['ram'].TypeCode("VAT") # VAT type (always "VAT" in the EU)
|
|
219
|
+
xml['ram'].ExemptionReason(vat[:exemption_reason]) if vat[:vat_category] == "E" && vat[:exemption_reason].present? # VAT exemption reason (BT-120)
|
|
220
|
+
xml['ram'].ExemptionReasonCode(vat[:exemption_reason_code]) if vat[:vat_category] == "E" && vat[:exemption_reason_code].present? # VAT exemption reason code (BT-121)
|
|
221
|
+
xml['ram'].BasisAmount(vat[:taxable_amount]) # Taxable base amount for the VAT rate (BT-116)
|
|
222
|
+
xml['ram'].CategoryCode(vat[:vat_category]) # VAT category code (BT-118). One among these : {'A', 'AA', 'AB', 'AC', 'AD', 'AE', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'O', 'S', 'Z'}
|
|
223
|
+
xml['ram'].RateApplicablePercent(vat[:vat_rate]) # VAT rate applied (%) (BT-119)
|
|
357
224
|
end
|
|
358
225
|
end
|
|
359
226
|
|
|
@@ -361,19 +228,19 @@ module UnovaFacturX
|
|
|
361
228
|
@document[:discount].each do |discount|
|
|
362
229
|
xml['ram'].SpecifiedTradeAllowanceCharge do
|
|
363
230
|
xml['ram'].ChargeIndicator do
|
|
364
|
-
xml['udt'].Indicator("false") # true = charge (
|
|
231
|
+
xml['udt'].Indicator("false") # true = charge (additional fees), false = allowance (discount/allowance)
|
|
365
232
|
end
|
|
366
|
-
xml['ram'].CalculationPercent(discount[:percentage]) if discount[:percentage].present? #
|
|
367
|
-
xml['ram'].ActualAmount(discount[:total_amount]) #
|
|
368
|
-
xml['ram'].Reason(discount[:reason]) if discount[:reason].present? #
|
|
369
|
-
xml['ram'].ReasonCode(discount[:reason_code]) if discount[:reason_code].present? #
|
|
370
|
-
# xml['ram'].BasisAmount(discount[:base]) # Base
|
|
233
|
+
xml['ram'].CalculationPercent(discount[:percentage]) if discount[:percentage].present? # Discount percentage at document level (BT-94)
|
|
234
|
+
xml['ram'].ActualAmount(discount[:total_amount]) # Discount amount at document level for VAT rate (BT-92)
|
|
235
|
+
xml['ram'].Reason(discount[:reason]) if discount[:reason].present? # Reason for document-level discount (BT-97)
|
|
236
|
+
xml['ram'].ReasonCode(discount[:reason_code]) if discount[:reason_code].present? # Reason code for document-level discount (BT-98)
|
|
237
|
+
# xml['ram'].BasisAmount(discount[:base]) # Base amount used for discount calculation (BT-93)
|
|
371
238
|
xml['ram'].CategoryTradeTax do
|
|
372
|
-
xml['ram'].TypeCode("VAT") #
|
|
373
|
-
xml['ram'].CategoryCode(discount[:vat_category]) #
|
|
374
|
-
xml['ram'].RateApplicablePercent(discount[:vat_rate]) #
|
|
239
|
+
xml['ram'].TypeCode("VAT") # VAT type (always "VAT" in the EU) (BT-95)
|
|
240
|
+
xml['ram'].CategoryCode(discount[:vat_category]) # VAT category for discount (BT-118). One among these : {'A', 'AA', 'AB', 'AC', 'AD', 'AE', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'O', 'S', 'Z'}
|
|
241
|
+
xml['ram'].RateApplicablePercent(discount[:vat_rate]) # VAT rate applied to discount (BT-119)
|
|
375
242
|
end
|
|
376
|
-
# xml['ram'].RateApplicablePercent("0.00") #
|
|
243
|
+
# xml['ram'].RateApplicablePercent("0.00") # VAT rate for the document-level discount (BT-96)
|
|
377
244
|
end
|
|
378
245
|
end
|
|
379
246
|
end
|
|
@@ -386,16 +253,16 @@ module UnovaFacturX
|
|
|
386
253
|
xml['udt'].DateTimeString(totals[:due_date], format: "102") # Payment due date (BT-9)
|
|
387
254
|
end
|
|
388
255
|
end
|
|
389
|
-
xml['ram'].Description(totals[:description]) if totals[:description].present? # Payment terms (BT-20)
|
|
256
|
+
xml['ram'].Description(totals[:description]) if totals[:description].present? # Payment terms description (BT-20)
|
|
390
257
|
end
|
|
391
258
|
end
|
|
392
259
|
xml['ram'].SpecifiedTradeSettlementHeaderMonetarySummation do
|
|
393
|
-
xml['ram'].LineTotalAmount(totals[:line_total_ht]) # Total
|
|
394
|
-
xml['ram'].AllowanceTotalAmount(totals[:total_discount]) if @document[:discount].present? && totals[:total_discount].present? #
|
|
395
|
-
xml['ram'].TaxBasisTotalAmount(totals[:tax_basis_total_ht]) # Total
|
|
396
|
-
xml['ram'].TaxTotalAmount(totals[:tax_total], currencyID: @
|
|
397
|
-
xml['ram'].GrandTotalAmount(totals[:grand_total_ttc]) # Total
|
|
398
|
-
xml['ram'].DuePayableAmount(totals[:amount_due]) if totals[:amount_due].present? #
|
|
260
|
+
xml['ram'].LineTotalAmount(totals[:line_total_ht]) # Total line amount excluding VAT (BT-106)
|
|
261
|
+
xml['ram'].AllowanceTotalAmount(totals[:total_discount]) if @document[:discount].present? && totals[:total_discount].present? # Total document-level discounts (BT-107)
|
|
262
|
+
xml['ram'].TaxBasisTotalAmount(totals[:tax_basis_total_ht]) # Total taxable base amount (BT-109)
|
|
263
|
+
xml['ram'].TaxTotalAmount(totals[:tax_total], currencyID: @currency) # Total VAT amount (BT-110)
|
|
264
|
+
xml['ram'].GrandTotalAmount(totals[:grand_total_ttc]) # Total amount including VAT (BT-112)
|
|
265
|
+
xml['ram'].DuePayableAmount(totals[:amount_due]) if totals[:amount_due].present? # Amount remaining to be paid (BT-115)
|
|
399
266
|
end
|
|
400
267
|
end
|
|
401
268
|
end
|
|
@@ -415,7 +282,7 @@ module UnovaFacturX
|
|
|
415
282
|
)
|
|
416
283
|
classpath = "#{jar_path}:#{resolver_path}"
|
|
417
284
|
|
|
418
|
-
#
|
|
285
|
+
# Writes the XML to a temporary file.
|
|
419
286
|
tmp_xml = Tempfile.new(['invoice', '.xml'])
|
|
420
287
|
tmp_xml.write(doc.to_xml)
|
|
421
288
|
tmp_xml.flush
|
|
@@ -429,7 +296,7 @@ module UnovaFacturX
|
|
|
429
296
|
tmp_xml.close
|
|
430
297
|
tmp_xml.unlink
|
|
431
298
|
|
|
432
|
-
raise "Java
|
|
299
|
+
raise "Java not available" if status.exitstatus == 127
|
|
433
300
|
|
|
434
301
|
report = ::Nokogiri::XML(stdout)
|
|
435
302
|
|
|
@@ -445,7 +312,7 @@ module UnovaFacturX
|
|
|
445
312
|
e.at_xpath('svrl:text', svrl_namespace)&.text&.strip
|
|
446
313
|
end
|
|
447
314
|
|
|
448
|
-
raise "EN16931 validation
|
|
315
|
+
raise "EN16931 validation failed:\n#{messages.join("\n")}"
|
|
449
316
|
end
|
|
450
317
|
end
|
|
451
318
|
end
|
data/lib/unova_factur_x.rb
CHANGED
|
@@ -10,13 +10,13 @@ require_relative "unova_factur_x/factur_x_generator"
|
|
|
10
10
|
module UnovaFacturX
|
|
11
11
|
class Error < StandardError; end
|
|
12
12
|
|
|
13
|
-
def self.generate(pdf:, document_hash:, type: :invoice, with_validations: true,
|
|
13
|
+
def self.generate(pdf:, document_hash:, type: :invoice, with_validations: true, currency: "EUR")
|
|
14
14
|
unless %i[invoice credit].include?(type)
|
|
15
15
|
raise ArgumentError, "Type must be :invoice or :credit (default is :invoice)"
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
# Génération du XML à partir du hash en entré
|
|
19
|
-
xml = UnovaFacturX::XmlGenerator.new(document_hash, type: type, validate: with_validations,
|
|
19
|
+
xml = UnovaFacturX::XmlGenerator.new(document_hash, type: type, validate: with_validations, currency: currency).call
|
|
20
20
|
|
|
21
21
|
# Génération et retour du PDF FacturX à partir du PDF en entré et du XML généré
|
|
22
22
|
UnovaFacturX::FacturXGenerator.new(pdf: pdf, xml: xml).call
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: unova_factur_x
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Rodolphe Limousin
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-06-
|
|
11
|
+
date: 2026-06-09 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: hexapdf
|
|
@@ -38,8 +38,8 @@ dependencies:
|
|
|
38
38
|
- - "~>"
|
|
39
39
|
- !ruby/object:Gem::Version
|
|
40
40
|
version: '1.18'
|
|
41
|
-
description:
|
|
42
|
-
|
|
41
|
+
description: Takes a PDF and a data hash as input and returns a Factur-X-compliant
|
|
42
|
+
PDF
|
|
43
43
|
email:
|
|
44
44
|
- rodolphe.limousin@unova.fr
|
|
45
45
|
executables: []
|
|
@@ -54,9 +54,9 @@ files:
|
|
|
54
54
|
- CHANGELOG.md
|
|
55
55
|
- LICENSE.txt
|
|
56
56
|
- LICENSES/MPL-2.0.txt
|
|
57
|
-
- LOGICIELS_TIER.txt
|
|
58
57
|
- README.md
|
|
59
58
|
- Rakefile
|
|
59
|
+
- THIRD-PARTY_COMPONENTS.txt
|
|
60
60
|
- lib/unova_factur_x.rb
|
|
61
61
|
- lib/unova_factur_x/factur_x_generator.rb
|
|
62
62
|
- lib/unova_factur_x/java/Saxon-HE-12.4.jar
|
|
@@ -67,7 +67,7 @@ files:
|
|
|
67
67
|
- sig/unova_factur_x.rbs
|
|
68
68
|
homepage: https://github.com/unovafr/unova_factur_x
|
|
69
69
|
licenses:
|
|
70
|
-
-
|
|
70
|
+
- Apache-2.0
|
|
71
71
|
metadata:
|
|
72
72
|
homepage_uri: https://github.com/unovafr/unova_factur_x
|
|
73
73
|
source_code_uri: https://github.com/unovafr/unova_factur_x
|
|
@@ -89,5 +89,5 @@ requirements: []
|
|
|
89
89
|
rubygems_version: 3.5.22
|
|
90
90
|
signing_key:
|
|
91
91
|
specification_version: 4
|
|
92
|
-
summary:
|
|
92
|
+
summary: Generation of invoices/credit notes in Factur-X format
|
|
93
93
|
test_files: []
|
data/LOGICIELS_TIER.txt
DELETED
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
AVIS ET INFORMATIONS SUR LES LOGICIELS TIERS
|
|
2
|
-
|
|
3
|
-
Ce projet inclut des composants logiciels tiers.
|
|
4
|
-
|
|
5
|
-
------------------------------------------------------------
|
|
6
|
-
1. Saxon-HE (processeur XSLT et XQuery)
|
|
7
|
-
------------------------------------------------------------
|
|
8
|
-
|
|
9
|
-
Nom : Saxon-HE
|
|
10
|
-
Version : 12.4
|
|
11
|
-
Auteur : Saxonica Limited
|
|
12
|
-
Site officiel : https://www.saxonica.com/
|
|
13
|
-
|
|
14
|
-
Licence : Mozilla Public License 2.0 (MPL-2.0)
|
|
15
|
-
|
|
16
|
-
Droits d’auteur :
|
|
17
|
-
Copyright (c) Saxonica Limited
|
|
18
|
-
|
|
19
|
-
Saxon-HE est inclus dans cette distribution sous forme de fichier JAR précompilé et reste soumis à sa licence d’origine.
|
|
20
|
-
|
|
21
|
-
Le texte complet de la licence MPL-2.0 est disponible ici :
|
|
22
|
-
https://www.mozilla.org/en-US/MPL/2.0/
|
|
23
|
-
|
|
24
|
-
Une copie de la licence MPL-2.0 est incluse dans ce projet :
|
|
25
|
-
LICENSES/MPL-2.0.txt
|
|
26
|
-
|
|
27
|
-
------------------------------------------------------------
|
|
28
|
-
FIN DES AVIS
|
|
29
|
-
------------------------------------------------------------
|