einvoicing 0.6.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: 24047fa12d3baaca75ebea76468a98ee023e5716bdee7dfa90f7f532e13ab5d1
4
- data.tar.gz: 934ca40e5c588c9593d39b839e13eabd413b34b3f36f56d5481e99dddb5a1b69
3
+ metadata.gz: 7581f06decdc863a7f8c7509a3ff696f73ca8d223579aa4695a3eee160f3e196
4
+ data.tar.gz: 1355d8b9034f874d7f3bd7c38315428c90477c6f987ef9642e0deca071f17587
5
5
  SHA512:
6
- metadata.gz: 54accf1d18eb7e32a35aba28d1c5c9f362eb1de5f19025b89c784724285ce59535394c603b9272a678a8bda7b1b13ae6f6a4dfca949528a303cf41a070681c43
7
- data.tar.gz: 2eebc04105e663f581c83c6e81523aceba000188f0fc4b00c15138094d7659008bf59ce98b7afa5f867d875e307b806dbbea5281824d7d8c3eb11659976f7e86
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)"
@@ -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)"
@@ -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
@@ -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
@@ -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,9 +83,11 @@ 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
@@ -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)
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Einvoicing
4
- VERSION = "0.6.0"
4
+ VERSION = "0.7.0"
5
5
  end
data/lib/einvoicing.rb CHANGED
@@ -56,10 +56,11 @@ 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
65
  else raise ArgumentError, Einvoicing::I18n.t("formats.unknown_format", fmt: format.inspect)
65
66
  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.6.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
@@ -161,6 +160,7 @@ extensions: []
161
160
  extra_rdoc_files: []
162
161
  files:
163
162
  - CHANGELOG.md
163
+ - LICENSE
164
164
  - README.md
165
165
  - config/locales/einvoicing.en.yml
166
166
  - config/locales/einvoicing.fr.yml
@@ -200,7 +200,6 @@ metadata:
200
200
  source_code_uri: https://github.com/sxnlabs/einvoicing
201
201
  changelog_uri: https://github.com/sxnlabs/einvoicing/blob/master/CHANGELOG.md
202
202
  documentation_uri: https://www.sxnlabs.com/en/gems/einvoicing/
203
- post_install_message:
204
203
  rdoc_options: []
205
204
  require_paths:
206
205
  - lib
@@ -215,8 +214,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
215
214
  - !ruby/object:Gem::Version
216
215
  version: '0'
217
216
  requirements: []
218
- rubygems_version: 3.5.22
219
- signing_key:
217
+ rubygems_version: 4.0.16
220
218
  specification_version: 4
221
219
  summary: EU electronic invoicing for Ruby — EN 16931, Factur-X, UBL 2.1
222
220
  test_files: []