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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d6bcd9f49244d9bbf84368950614affcd7aa4d5c1b0c04d916fccb7f7fc746f1
4
- data.tar.gz: f545fef84047395303df7de366869e9141f902aafcb36ddb4d6950ea1a418437
3
+ metadata.gz: 01ba6a9d7f94ac75ba98a3c64141ae25293b27323e8f8c8b55c147bd8ae5ff4c
4
+ data.tar.gz: 9e929b17b7f00a01b23186b9e8ebded133bb5e3a7fc60da9f6cbbb271cab2074
5
5
  SHA512:
6
- metadata.gz: f38dec217a71dd543058aa3e0fc92f66f92250eda2f64a6c23dccf247689e47ff5891bcd99e6b8186a6791b7814fca95347b3ec9a14dfdd05c42c1c0d2d5741a
7
- data.tar.gz: cf284e33d542d80acc5e693d5d46f4b0639f8b9f8c99887d98316e086efaaf6919514c66b12af8b4cc19a40f96b396948f856fe5de40b554e546efce3615efd3
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
+ [![Gem Version](https://badge.fury.io/rb/einvoicing.svg)](https://rubygems.org/gems/einvoicing)
4
+ [![CI](https://github.com/sxnlabs/einvoicing/actions/workflows/ci.yml/badge.svg)](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