einvoicing 0.5.0 → 0.7.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: c103791ffce05b80340cbae5a1b09a00f4b5408edd8a70bfac567898be951ee9
4
- data.tar.gz: 7db7faa121b1e2ab57c6ed9d467602eef8771a61acd57d0ea28eb53050ad9301
3
+ metadata.gz: 7581f06decdc863a7f8c7509a3ff696f73ca8d223579aa4695a3eee160f3e196
4
+ data.tar.gz: 1355d8b9034f874d7f3bd7c38315428c90477c6f987ef9642e0deca071f17587
5
5
  SHA512:
6
- metadata.gz: 5812428fa6b471a0cc5a34787e74fd473e3ffd5520226e736ce0fc6b144b054caf70c636f8b8b2a2acd834e4ff0d1f8db4bf47747b934e5dfa29e0d217333117
7
- data.tar.gz: 1cf58d2a1cc101430435611c5091510ec62e48d9c2d0e33e01a827853aecf92ed69d1a57b9cc20a1d245e97768014ff350b669bf51c928d9892b18ebb21c13e4
6
+ metadata.gz: '0146294e7c5ea5515211eabf88d54dde12525052409b69a29aa4206df02762622a96b92bfd2c9e5afef820a50b4c34d939ba2e52baa4c217adf843b324bb90b6'
7
+ data.tar.gz: 19dac11dd07c6dafb9856fe0878201de2ddc259678bbddef425107413a2d4649058568d6fdae988002df71223b4165b1aee38d3692bf4f7ec390a874b61a8b45
data/CHANGELOG.md CHANGED
@@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [0.7.0] - 2026-07-14
11
+
12
+ ### Added
13
+ - `prepaid_amount` on `Invoice` (BT-113 `TotalPrepaidAmount` in CII / `PrepaidAmount` in UBL) so a retained/prepaid amount (e.g. retenue de garantie) reduces the amount due (BR-CO-16: `DuePayableAmount = GrandTotal − TotalPrepaidAmount`) without affecting the taxable base or VAT totals
14
+ - CII profile support: `:chorus_pro` and `:en16931` selectable per invoice
15
+
16
+ ### Fixed
17
+ - CII `schemeID` SIRET correctly emitted for Chorus Pro
18
+ - `PaymentMeans` is now required when generating CII/UBL
19
+
20
+ ## [0.6.0] - 2026-03-14
21
+
22
+ ### Changed
23
+ - Codebase aligned on `rubocop-rails-omakase` standards
24
+ - Locale strings extracted into `config/locales/*.yml` (English + French)
25
+
8
26
  ## [0.5.0] - 2026-03-14
9
27
 
10
28
  ### Fixed
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SXN Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md CHANGED
@@ -55,7 +55,7 @@ seller = Einvoicing::Party.new(
55
55
 
56
56
  buyer = Einvoicing::Party.new(
57
57
  name: "Gecobat",
58
- street: "12 rue du Bâtiment",
58
+ street: "12 Construction Street",
59
59
  city: "Paris",
60
60
  postal_code: "75001",
61
61
  country_code: "FR",
@@ -65,13 +65,13 @@ buyer = Einvoicing::Party.new(
65
65
 
66
66
  lines = [
67
67
  Einvoicing::LineItem.new(
68
- description: "Développement backend — API REST (forfait)",
68
+ description: "Backend developmentREST API (fixed fee)",
69
69
  quantity: 1,
70
70
  unit_price: BigDecimal("2500.00"),
71
71
  vat_rate: 0.20
72
72
  ),
73
73
  Einvoicing::LineItem.new(
74
- description: "Intégration Factur-X",
74
+ description: "Factur-X integration",
75
75
  quantity: 5,
76
76
  unit_price: BigDecimal("350.00"),
77
77
  vat_rate: 0.20
@@ -86,7 +86,7 @@ invoice = Einvoicing::Invoice.new(
86
86
  buyer: buyer,
87
87
  lines: lines,
88
88
  payment_reference: "FAC-2024-0042",
89
- note: "30 jours net",
89
+ note: "Net 30",
90
90
  payment_means_code: 30,
91
91
  iban: "FR7630006000011234567890189",
92
92
  bic: "BNPAFRPP"
@@ -175,6 +175,20 @@ ubl = Einvoicing::Formats::UBL.generate(invoice)
175
175
 
176
176
  Produces a UBL 2.1 `Invoice` document with Peppol BIS Billing 3.0 customization ID.
177
177
 
178
+ ## Chorus Pro / PPF (France)
179
+
180
+ When submitting to Chorus Pro (French B2G portal), generate CII XML with the `:chorus_pro` profile:
181
+
182
+ ```ruby
183
+ xml = Einvoicing.xml(invoice, format: :cii, profile: :chorus_pro)
184
+ ```
185
+
186
+ Chorus Pro-specific requirements:
187
+ - `schemeID` for SIRET identifiers must be `"SIRET"` (not the ISO 6523 code `"0002"`)
188
+ - `SpecifiedTradeSettlementPaymentMeans` is mandatory — set `payment_means_code:` on the invoice (30 = credit transfer)
189
+ - Invoice ID (`invoice_number`) must not exceed 20 characters
190
+ - Use `einvoicing-connect` for the actual API submission
191
+
178
192
  ## Rails Integration
179
193
 
180
194
  Include `Einvoicing::Invoiceable` in your ActiveRecord model and implement three methods:
@@ -10,6 +10,8 @@ en:
10
10
  original_invoice_number_missing: "Original invoice number is required for credit notes"
11
11
  iban_invalid: "IBAN format or checksum is invalid"
12
12
  bic_invalid: "BIC format is invalid (8 or 11 characters)"
13
+ prepaid_amount_negative: "Prepaid amount cannot be negative"
14
+ prepaid_amount_exceeds_total: "Prepaid amount cannot exceed the invoice total"
13
15
  seller:
14
16
  name_missing: "Seller name is required"
15
17
  siren_invalid: "Seller SIREN must be 9 digits (Luhn check failed)"
@@ -25,3 +27,9 @@ en:
25
27
  quantity_invalid: "Line %{index}: quantity must be positive"
26
28
  unit_price_invalid: "Line %{index}: unit price must be non-negative"
27
29
  vat_rate_invalid: "Line %{index}: VAT rate must be a known French rate (0%%, 5.5%%, 10%%, 20%%)"
30
+ formats:
31
+ unknown_format: "Unknown format: %{fmt}. Use :cii or :ubl"
32
+ unknown_market: "Unknown market: %{market}. Use :fr"
33
+ invalid_pdf: "pdf_data does not appear to be a valid PDF (missing %PDF- magic bytes)"
34
+ tax:
35
+ invalid_rate: "rate must be >= 0, got %{rate}"
@@ -10,6 +10,8 @@ fr:
10
10
  original_invoice_number_missing: "Le numéro de facture d'origine est requis pour les avoirs"
11
11
  iban_invalid: "Le format ou la somme de contrôle de l'IBAN est invalide"
12
12
  bic_invalid: "Le format du BIC est invalide (8 ou 11 caractères)"
13
+ prepaid_amount_negative: "Le montant déjà payé ne peut pas être négatif"
14
+ prepaid_amount_exceeds_total: "Le montant déjà payé ne peut pas dépasser le total de la facture"
13
15
  seller:
14
16
  name_missing: "Le nom du vendeur est requis"
15
17
  siren_invalid: "Le SIREN du vendeur doit comporter 9 chiffres (vérification Luhn échouée)"
@@ -25,3 +27,9 @@ fr:
25
27
  quantity_invalid: "Ligne %{index} : la quantité doit être positive"
26
28
  unit_price_invalid: "Ligne %{index} : le prix unitaire doit être non négatif"
27
29
  vat_rate_invalid: "Ligne %{index} : le taux de TVA doit être un taux français standard (0%%, 5,5%%, 10%%, 20%%)"
30
+ formats:
31
+ unknown_format: "Format inconnu : %{fmt}. Utilisez :cii ou :ubl"
32
+ unknown_market: "Marché inconnu : %{market}. Utilisez :fr"
33
+ invalid_pdf: "pdf_data ne semble pas être un PDF valide (octets magiques %PDF- manquants)"
34
+ tax:
35
+ invalid_rate: "le taux doit être >= 0, valeur reçue : %{rate}"
@@ -16,7 +16,7 @@ module Einvoicing
16
16
  UDT_NS = "urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100"
17
17
  QDT_NS = "urn:un:unece:uncefact:data:standard:QualifiedDataType:100"
18
18
 
19
- def self.generate(invoice)
19
+ def self.generate(invoice, profile: :en16931)
20
20
  b = XMLBuilder.new
21
21
  b.tag(
22
22
  "rsm:CrossIndustryInvoice",
@@ -28,7 +28,7 @@ module Einvoicing
28
28
  ) do
29
29
  exchanged_document_context(b)
30
30
  exchanged_document(b, invoice)
31
- supply_chain_trade_transaction(b, invoice)
31
+ supply_chain_trade_transaction(b, invoice, profile)
32
32
  end
33
33
  b.to_xml
34
34
  end
@@ -53,8 +53,8 @@ module Einvoicing
53
53
  end
54
54
  if invoice.document_type == :credit_note && invoice.original_invoice_number
55
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
56
+ note = "Credit note for invoice #{invoice.original_invoice_number}"
57
+ note += " dated #{invoice.original_invoice_date.strftime('%d/%m/%Y')}" if invoice.original_invoice_date
58
58
  b.text("ram:Content", note)
59
59
  end
60
60
  end
@@ -67,12 +67,12 @@ module Einvoicing
67
67
  end
68
68
  private_class_method :exchanged_document
69
69
 
70
- def self.supply_chain_trade_transaction(b, invoice)
70
+ def self.supply_chain_trade_transaction(b, invoice, profile)
71
71
  b.tag("rsm:SupplyChainTradeTransaction") do
72
72
  invoice.lines.each_with_index do |line, idx|
73
73
  trade_line_item(b, line, idx + 1, invoice.currency)
74
74
  end
75
- header_trade_agreement(b, invoice)
75
+ header_trade_agreement(b, invoice, profile)
76
76
  b.tag("ram:ApplicableHeaderTradeDelivery")
77
77
  header_trade_settlement(b, invoice)
78
78
  end
@@ -109,21 +109,23 @@ module Einvoicing
109
109
  end
110
110
  private_class_method :trade_line_item
111
111
 
112
- def self.header_trade_agreement(b, invoice)
112
+ def self.header_trade_agreement(b, invoice, profile)
113
113
  b.tag("ram:ApplicableHeaderTradeAgreement") do
114
114
  # BuyerReference must be first in the sequence (EN 16931 BR-10 / XSD order).
115
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) }
116
+ b.tag("ram:SellerTradeParty") { party_xml(b, invoice.seller, profile) }
117
+ b.tag("ram:BuyerTradeParty") { party_xml(b, invoice.buyer, profile) }
118
118
  end
119
119
  end
120
120
  private_class_method :header_trade_agreement
121
121
 
122
- def self.party_xml(b, party)
122
+ def self.party_xml(b, party, profile)
123
123
  b.text("ram:Name", party.name)
124
- if party.siren_number
124
+ legal_id = party.siret || party.siren
125
+ if legal_id
125
126
  b.tag("ram:SpecifiedLegalOrganization") do
126
- b.text("ram:ID", party.siren_number, "schemeID" => "0002")
127
+ scheme = (profile == :chorus_pro && party.siret) ? "SIRET" : "0002"
128
+ b.text("ram:ID", legal_id, "schemeID" => scheme)
127
129
  end
128
130
  end
129
131
  b.tag("ram:PostalTradeAddress") do
@@ -190,6 +192,7 @@ module Einvoicing
190
192
  b.text("ram:TaxTotalAmount", format_amount(invoice.tax_total),
191
193
  "currencyID" => invoice.currency)
192
194
  b.text("ram:GrandTotalAmount", format_amount(invoice.gross_total))
195
+ b.text("ram:TotalPrepaidAmount", format_amount(invoice.prepaid_amount)) if invoice.prepaid_amount.positive?
193
196
  b.text("ram:DuePayableAmount", format_amount(invoice.due_amount))
194
197
  end
195
198
  end
@@ -31,7 +31,7 @@ module Einvoicing
31
31
  # @return [String] binary Factur-X PDF/A-3 content
32
32
  def self.embed(pdf_data, xml_string, profile: CONFORMANCE)
33
33
  unless pdf_data.to_s.b.start_with?("%PDF-")
34
- raise ArgumentError, "pdf_data does not appear to be a valid PDF (missing %PDF- magic bytes)"
34
+ raise ArgumentError, Einvoicing::I18n.t("formats.invalid_pdf")
35
35
  end
36
36
 
37
37
  require "hexapdf"
@@ -66,7 +66,7 @@ module Einvoicing
66
66
  names_dict[:EmbeddedFiles][:Names] << FILENAME << filespec
67
67
 
68
68
  # 3. Set AF array on the catalog.
69
- doc.catalog[:AF] = [filespec]
69
+ doc.catalog[:AF] = [ filespec ]
70
70
 
71
71
  # 4. Add OutputIntent (required for PDF/A-3 conformance).
72
72
  add_output_intent(doc)
@@ -183,7 +183,7 @@ module Einvoicing
183
183
  DestOutputProfile: icc_stream
184
184
  })
185
185
 
186
- doc.catalog[:OutputIntents] = [output_intent]
186
+ doc.catalog[:OutputIntents] = [ output_intent ]
187
187
  end
188
188
 
189
189
  private_class_method def self.md5(bytes)
@@ -159,6 +159,10 @@ module Einvoicing
159
159
  "currencyID" => invoice.currency)
160
160
  b.text("cbc:TaxInclusiveAmount", format_amount(invoice.gross_total),
161
161
  "currencyID" => invoice.currency)
162
+ if invoice.prepaid_amount.positive?
163
+ b.text("cbc:PrepaidAmount", format_amount(invoice.prepaid_amount),
164
+ "currencyID" => invoice.currency)
165
+ end
162
166
  b.text("cbc:PayableAmount", format_amount(invoice.due_amount),
163
167
  "currencyID" => invoice.currency)
164
168
  end
@@ -1,22 +1,33 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "i18n"
4
+
3
5
  module Einvoicing
4
- # Thin wrapper around ::I18n with graceful fallback for standalone use.
5
- # When used outside Rails, ::I18n may not be available; in that case the
6
- # dotted key string is returned as-is.
6
+ # Thin wrapper around ::I18n for gem-internal translations.
7
+ # Loads the gem's own locale files on setup; in Rails apps the engine
8
+ # already handles the load_path, so duplicates are skipped.
7
9
  module I18n
8
10
  DEFAULT_LOCALE = :en
11
+ LOCALES_PATH = File.expand_path("../../config/locales", __dir__)
9
12
 
10
- def self.t(key, **options)
11
- return key.to_s unless defined?(::I18n)
13
+ def self.setup
14
+ locale_files = Dir[File.join(LOCALES_PATH, "*.yml")]
15
+ new_files = locale_files - ::I18n.load_path
16
+ return if new_files.empty?
17
+
18
+ ::I18n.load_path += new_files
19
+ ::I18n.backend.load_translations
20
+ end
12
21
 
13
- locale = options.delete(:locale) { ::I18n.locale rescue DEFAULT_LOCALE }
22
+ def self.t(key, **options)
23
+ locale = options.delete(:locale) { ::I18n.locale }
14
24
  ::I18n.t("einvoicing.#{key}", locale: locale, **options)
15
25
  rescue ::I18n::MissingTranslationData
16
- # Fallback to English if translation missing in current locale
17
26
  ::I18n.t("einvoicing.#{key}", locale: DEFAULT_LOCALE, **options)
18
27
  rescue StandardError
19
28
  key.to_s
20
29
  end
21
30
  end
22
31
  end
32
+
33
+ Einvoicing::I18n.setup
@@ -35,13 +35,15 @@ module Einvoicing
35
35
  :bic,
36
36
  :document_type,
37
37
  :original_invoice_number,
38
- :original_invoice_date
38
+ :original_invoice_date,
39
+ :prepaid_amount
39
40
  ) do
40
41
  def initialize(invoice_number:, issue_date:, seller:, buyer:, lines:,
41
42
  due_date: nil, currency: "EUR", tax_currency: nil, tax_breakdown: nil,
42
43
  payment_reference: nil, note: nil,
43
44
  payment_means_code: nil, iban: nil, bic: nil,
44
- document_type: :invoice, original_invoice_number: nil, original_invoice_date: nil)
45
+ document_type: :invoice, original_invoice_number: nil, original_invoice_date: nil,
46
+ prepaid_amount: BigDecimal(0))
45
47
  computed_breakdown = tax_breakdown || compute_tax_breakdown(lines)
46
48
  super(
47
49
  invoice_number: invoice_number,
@@ -60,7 +62,8 @@ module Einvoicing
60
62
  bic: bic,
61
63
  document_type: document_type,
62
64
  original_invoice_number: original_invoice_number,
63
- original_invoice_date: original_invoice_date
65
+ original_invoice_date: original_invoice_date,
66
+ prepaid_amount: prepaid_amount.nil? ? BigDecimal(0) : BigDecimal(prepaid_amount.to_s)
64
67
  )
65
68
  end
66
69
 
@@ -80,15 +83,17 @@ module Einvoicing
80
83
  lines.sum(BigDecimal("0"), &:gross_amount).round(2, :half_up)
81
84
  end
82
85
 
83
- # Amount due (same as gross_total; override for prepayments).
86
+ # Amount due after deducting any retained/prepaid amount (BT-113).
87
+ # VAT remains due on the full gross_total — only the payable balance is reduced
88
+ # (EN 16931 BR-CO-16: DuePayableAmount = GrandTotal − TotalPrepaidAmount).
84
89
  def due_amount
85
- gross_total
90
+ gross_total - prepaid_amount
86
91
  end
87
92
 
88
93
  private
89
94
 
90
95
  def compute_tax_breakdown(lines)
91
- grouped = lines.group_by { |l| [l.vat_rate, l.category] }
96
+ grouped = lines.group_by { |l| [ l.vat_rate, l.category ] }
92
97
  grouped.map do |(rate, category), rate_lines|
93
98
  taxable = rate_lines.sum(BigDecimal("0"), &:net_amount).round(2, :half_up)
94
99
  tax_amt = rate_lines.sum(BigDecimal("0"), &:vat_amount).round(2, :half_up)
@@ -27,13 +27,13 @@ module Einvoicing
27
27
  resolved_endpoint_id = endpoint_id || siret || email
28
28
  resolved_endpoint_scheme = if endpoint_scheme
29
29
  endpoint_scheme
30
- elsif endpoint_id
30
+ elsif endpoint_id
31
31
  nil # caller must supply scheme when explicit
32
- elsif siret
32
+ elsif siret
33
33
  "0002" # SIRET scheme — Peppol EAS FR standard
34
- elsif email
34
+ elsif email
35
35
  "EM" # email fallback (not in Peppol EAS, use for non-Peppol)
36
- end
36
+ end
37
37
  super(name: name, street: street, city: city, postal_code: postal_code,
38
38
  country_code: country_code, siren: siren, siret: siret,
39
39
  vat_number: vat_number, email: email,
@@ -46,9 +46,9 @@ module Einvoicing
46
46
  end
47
47
 
48
48
  # POST /v1/soumettre/factures — submit an invoice.
49
- # facture_hash: Hash matching the Chorus Pro invoice schema.
50
- def submit_invoice(facture_hash)
51
- post("/v1/soumettre/factures", facture_hash)
49
+ # invoice_hash: Hash matching the Chorus Pro invoice schema.
50
+ def submit_invoice(invoice_hash)
51
+ post("/v1/soumettre/factures", invoice_hash)
52
52
  end
53
53
 
54
54
  private
@@ -8,12 +8,12 @@ module Einvoicing
8
8
  # @param invoice [Einvoicing::Invoice]
9
9
  # @param id_structure_cpp [Integer] from find_structure()
10
10
  # @param code_service [String, nil] from list_services() — optional
11
- # @param numero_engagement [String, nil] buyer PO/engagement number — optional for B2B
12
- def self.to_chorus_payload(invoice, id_structure_cpp:, code_service: nil, numero_engagement: nil)
11
+ # @param engagement_number [String, nil] buyer PO/engagement number — optional for B2B
12
+ def self.to_chorus_payload(invoice, id_structure_cpp:, code_service: nil, engagement_number: nil)
13
13
  {
14
14
  idStructureCPP: id_structure_cpp,
15
15
  codeService: code_service,
16
- numeroEngagement: numero_engagement,
16
+ numeroEngagement: engagement_number,
17
17
  cadreFacturation: {
18
18
  codeCadreFacturation: "FACTURE_FOURNISSEUR",
19
19
  codeServiceValideur: nil
@@ -12,17 +12,17 @@ module Einvoicing
12
12
  #
13
13
  # @param invoice [Einvoicing::Invoice]
14
14
  # @param code_service [String, nil] optional service code from list_services
15
- # @param numero_engagement [String, nil] optional engagement number
16
- def submit(invoice, code_service: nil, numero_engagement: nil)
15
+ # @param engagement_number [String, nil] optional engagement number
16
+ def submit(invoice, code_service: nil, engagement_number: nil)
17
17
  structure = @client.find_structure(siret: invoice.buyer.siret)
18
18
  id_structure = structure["idStructureCPP"] || structure.dig("parametres", "idStructureCPP")
19
19
  raise ValidationError, "Buyer SIRET #{invoice.buyer.siret} not found in Chorus Pro" unless id_structure
20
20
 
21
21
  payload = InvoiceAdapter.to_chorus_payload(
22
22
  invoice,
23
- id_structure_cpp: id_structure,
24
- code_service: code_service,
25
- numero_engagement: numero_engagement
23
+ id_structure_cpp: id_structure,
24
+ code_service: code_service,
25
+ engagement_number: engagement_number
26
26
  )
27
27
 
28
28
  @client.submit_invoice(payload)
@@ -11,7 +11,7 @@ module Einvoicing
11
11
  # @param tax_amount [Numeric] VAT amount for this rate
12
12
  # @param category [Symbol, nil] nil for standard/zero, :reverse_charge for AE
13
13
  def initialize(rate:, taxable_amount:, tax_amount:, category: nil)
14
- raise ArgumentError, "rate must be >= 0, got #{rate}" if rate.to_f.negative?
14
+ raise ArgumentError, Einvoicing::I18n.t("tax.invalid_rate", rate: rate) if rate.to_f.negative?
15
15
 
16
16
  super
17
17
  end
@@ -124,6 +124,13 @@ module Einvoicing
124
124
  errors << { field: :bic, error: :bic_invalid,
125
125
  message: Einvoicing::I18n.t("errors.invoice.bic_invalid") }
126
126
  end
127
+ if invoice.prepaid_amount.negative?
128
+ errors << { field: :prepaid_amount, error: :prepaid_amount_negative,
129
+ message: Einvoicing::I18n.t("errors.invoice.prepaid_amount_negative") }
130
+ elsif invoice.prepaid_amount > invoice.gross_total
131
+ errors << { field: :prepaid_amount, error: :prepaid_amount_exceeds_total,
132
+ message: Einvoicing::I18n.t("errors.invoice.prepaid_amount_exceeds_total") }
133
+ end
127
134
  errors
128
135
  end
129
136
  private_class_method :validate_invoice_fields
@@ -159,8 +166,8 @@ module Einvoicing
159
166
 
160
167
  def self.validate_lines(lines)
161
168
  if lines.nil? || lines.empty?
162
- return [{ field: :lines, error: :lines_empty,
163
- message: Einvoicing::I18n.t("errors.invoice.lines_empty") }]
169
+ return [ { field: :lines, error: :lines_empty,
170
+ message: Einvoicing::I18n.t("errors.invoice.lines_empty") } ]
164
171
  end
165
172
 
166
173
  lines.each_with_index.flat_map do |line, idx|
@@ -178,7 +185,7 @@ module Einvoicing
178
185
  { field: :"line_#{n}_unit_price", error: :unit_price_invalid,
179
186
  message: Einvoicing::I18n.t("errors.line.unit_price_invalid", index: n) }
180
187
  end),
181
- (unless [0.0, 0.055, 0.10, 0.20].include?(line.vat_rate.to_f.round(3))
188
+ (unless [ 0.0, 0.055, 0.10, 0.20 ].include?(line.vat_rate.to_f.round(3))
182
189
  { field: :"line_#{n}_vat_rate", error: :vat_rate_invalid,
183
190
  message: Einvoicing::I18n.t("errors.line.vat_rate_invalid", index: n) }
184
191
  end)
@@ -86,18 +86,18 @@ module Einvoicing
86
86
  private_class_method :download
87
87
 
88
88
  def self.run_saxon(xml_string)
89
- input = Tempfile.new(["peppol-input", ".xml"])
90
- output = Tempfile.new(["peppol-output", ".svrl"])
89
+ input = Tempfile.new([ "peppol-input", ".xml" ])
90
+ output = Tempfile.new([ "peppol-output", ".svrl" ])
91
91
 
92
92
  begin
93
93
  input.write(xml_string)
94
94
  input.close
95
95
  output.close
96
96
 
97
- cmd = ["java", "-jar", SAXON_JAR,
97
+ cmd = [ "java", "-jar", SAXON_JAR,
98
98
  "-s:#{input.path}",
99
99
  "-xsl:#{XSLT_PATH}",
100
- "-o:#{output.path}"]
100
+ "-o:#{output.path}" ]
101
101
 
102
102
  _, stderr, status = Open3.capture3(*cmd)
103
103
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Einvoicing
4
- VERSION = "0.5.0"
4
+ VERSION = "0.7.0"
5
5
  end
@@ -5,7 +5,7 @@ module Einvoicing
5
5
  # Produces indented XML with proper attribute and text escaping.
6
6
  class XMLBuilder
7
7
  def initialize
8
- @parts = ['<?xml version="1.0" encoding="UTF-8"?>']
8
+ @parts = [ '<?xml version="1.0" encoding="UTF-8"?>' ]
9
9
  @depth = 0
10
10
  end
11
11
 
data/lib/einvoicing.rb CHANGED
@@ -56,12 +56,13 @@ module Einvoicing
56
56
  # Generate XML from an invoice.
57
57
  # @param invoice [Einvoicing::Invoice]
58
58
  # @param format [Symbol] :cii (default, Factur-X) or :ubl (Peppol BIS 3.0)
59
+ # @param profile [Symbol] :en16931 (default) or :chorus_pro (French B2G portal)
59
60
  # @return [String] XML document
60
- def self.xml(invoice, format: :cii)
61
+ def self.xml(invoice, format: :cii, profile: :en16931)
61
62
  case format
62
- when :cii then Formats::CII.generate(invoice)
63
+ when :cii then Formats::CII.generate(invoice, profile: profile)
63
64
  when :ubl then Formats::UBL.generate(invoice)
64
- else raise ArgumentError, "Unknown format: #{format.inspect}. Use :cii or :ubl"
65
+ else raise ArgumentError, Einvoicing::I18n.t("formats.unknown_format", fmt: format.inspect)
65
66
  end
66
67
  end
67
68
 
@@ -81,7 +82,7 @@ module Einvoicing
81
82
  def self.validate(invoice, market: :fr)
82
83
  case market
83
84
  when :fr then Validators::FR.validate(invoice)
84
- else raise ArgumentError, "Unknown market: #{market.inspect}. Use :fr"
85
+ else raise ArgumentError, Einvoicing::I18n.t("formats.unknown_market", market: market.inspect)
85
86
  end
86
87
  end
87
88
 
@@ -98,6 +99,6 @@ module Einvoicing
98
99
  pdf_out = pdf ? embed(pdf, xml_str) : nil
99
100
  { valid: errors.empty?, errors: errors, xml: xml_str, pdf: pdf_out }
100
101
  rescue StandardError => e
101
- { valid: false, errors: [{ field: :unknown, error: :exception, message: e.message }], xml: nil, pdf: nil }
102
+ { valid: false, errors: [ { field: :unknown, error: :exception, message: e.message } ], xml: nil, pdf: nil }
102
103
  end
103
104
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: einvoicing
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nathan Le Ray
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2026-03-14 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: hexapdf
@@ -24,6 +23,20 @@ dependencies:
24
23
  - - "~>"
25
24
  - !ruby/object:Gem::Version
26
25
  version: '1.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: i18n
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.0'
27
40
  - !ruby/object:Gem::Dependency
28
41
  name: rspec
29
42
  requirement: !ruby/object:Gem::Requirement
@@ -44,14 +57,42 @@ dependencies:
44
57
  requirements:
45
58
  - - "~>"
46
59
  - !ruby/object:Gem::Version
47
- version: '1.65'
60
+ version: '1.70'
48
61
  type: :development
49
62
  prerelease: false
50
63
  version_requirements: !ruby/object:Gem::Requirement
51
64
  requirements:
52
65
  - - "~>"
53
66
  - !ruby/object:Gem::Version
54
- version: '1.65'
67
+ version: '1.70'
68
+ - !ruby/object:Gem::Dependency
69
+ name: rubocop-rails-omakase
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: rubocop-rspec
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '3.0'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '3.0'
55
96
  - !ruby/object:Gem::Dependency
56
97
  name: nokogiri
57
98
  requirement: !ruby/object:Gem::Requirement
@@ -119,6 +160,7 @@ extensions: []
119
160
  extra_rdoc_files: []
120
161
  files:
121
162
  - CHANGELOG.md
163
+ - LICENSE
122
164
  - README.md
123
165
  - config/locales/einvoicing.en.yml
124
166
  - config/locales/einvoicing.fr.yml
@@ -158,7 +200,6 @@ metadata:
158
200
  source_code_uri: https://github.com/sxnlabs/einvoicing
159
201
  changelog_uri: https://github.com/sxnlabs/einvoicing/blob/master/CHANGELOG.md
160
202
  documentation_uri: https://www.sxnlabs.com/en/gems/einvoicing/
161
- post_install_message:
162
203
  rdoc_options: []
163
204
  require_paths:
164
205
  - lib
@@ -173,8 +214,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
173
214
  - !ruby/object:Gem::Version
174
215
  version: '0'
175
216
  requirements: []
176
- rubygems_version: 3.5.22
177
- signing_key:
217
+ rubygems_version: 4.0.16
178
218
  specification_version: 4
179
219
  summary: EU electronic invoicing for Ruby — EN 16931, Factur-X, UBL 2.1
180
220
  test_files: []