einvoicing 0.3.0 → 0.4.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: 01ba6a9d7f94ac75ba98a3c64141ae25293b27323e8f8c8b55c147bd8ae5ff4c
4
- data.tar.gz: 9e929b17b7f00a01b23186b9e8ebded133bb5e3a7fc60da9f6cbbb271cab2074
3
+ metadata.gz: e57cfd36d8de0852320a7b72d1fe1ced81ff64eb024199b31733be06859ee509
4
+ data.tar.gz: 03a602eb77133a12a8493939b8292749f91fd9c2c834c4777108b8f4d0793d9d
5
5
  SHA512:
6
- metadata.gz: 4c70ee9bfa6505caa0d17993a166bdf9220bd435649893ca349cb0e3efc2d8948db40fb691293f833a3ad0eb0318a9924eadd53415872c47b552a99e30d2153d
7
- data.tar.gz: 4adc977fe39f03b2aa1395ca0ade81ff3d942425f1e580994b48b212c5dba458e305d1fb78776f5d36138c73d30239aa3e8e82b268db1f2fac4193633838f15f
6
+ metadata.gz: fca0526e61db40a9125f9a5060c75db8f5bcefa69b158b55ce58d2c824ccbc2868594aa77946308919b8aea7922f364f3d0d9890fe63b77f21ecac673619ecd1
7
+ data.tar.gz: c57a78e842ca6beac1f0b98dfa52659e436eb2b03929c7938bdf9e615b009d28a24018c55ed28023853591054657e124099330c4c99d8f078b4b4885fe6cbc1c
data/CHANGELOG.md CHANGED
@@ -5,6 +5,19 @@ 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
+ ## [0.4.0] - 2026-03-13
9
+
10
+ ### Added
11
+ - `Einvoicing.xml(invoice, format: :cii | :ubl)` — top-level XML generation
12
+ - `Einvoicing.embed(pdf, invoice_or_xml)` — top-level Factur-X embedding
13
+ - `Einvoicing.validate(invoice, market: :fr)` — top-level validation
14
+ - `Einvoicing.process(invoice, format:, market:, pdf:)` — full pipeline, never raises
15
+ - `Einvoicing::FR::SiretLookup.find(siren)` — SIRET lookup via French government API (no auth, stdlib only)
16
+ - `Einvoicing::FR::SiretLookup.enrich!(party)` — auto-fills SIRET on a Party from its SIREN
17
+ - `Einvoicing::Validators::Peppol.validate_ubl(xml)` — Peppol BIS 3.0 Schematron validation (requires Java + Saxon-HE 12)
18
+ - `Einvoicing::Errors::JavaNotFound`, `Einvoicing::Errors::ValidationError`
19
+ - `lib/einvoicing/fr.rb` — FR submodule entrypoint
20
+
8
21
  ## [0.3.0] - 2026-03-13
9
22
 
10
23
  ### Added
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Einvoicing
4
+ module Errors
5
+ # Raised when Java is not found in PATH and is required for validation.
6
+ JavaNotFound = Class.new(StandardError)
7
+
8
+ # Raised when an external validator (e.g. Saxon) fails unexpectedly.
9
+ ValidationError = Class.new(StandardError)
10
+ end
11
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+
7
+ module Einvoicing
8
+ module FR
9
+ module SiretLookup
10
+ API_URL = "https://recherche-entreprises.api.gouv.fr/search"
11
+
12
+ # Find SIRET for a given SIREN using the French government Sirene API.
13
+ # Returns { siret:, name:, address: } or nil on any error.
14
+ def self.find(siren)
15
+ return nil unless siren.to_s.match?(/\A\d{9}\z/)
16
+
17
+ uri = URI(API_URL)
18
+ uri.query = URI.encode_www_form(q: siren.to_s, mtq: "true")
19
+
20
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: true,
21
+ open_timeout: 5, read_timeout: 10) do |http|
22
+ http.get(uri.request_uri)
23
+ end
24
+
25
+ return nil unless response.code == "200"
26
+
27
+ data = JSON.parse(response.body)
28
+ result = data["results"]&.first
29
+ return nil unless result
30
+
31
+ siege = result["siege"] || {}
32
+ siret = siege["siret"]
33
+ return nil if siret.nil? || siret.empty?
34
+
35
+ { siret: siret, name: result["nom_complet"], address: siege["adresse"] }
36
+ rescue StandardError
37
+ nil
38
+ end
39
+
40
+ # Enrich a Party object by fetching and setting its SIRET from the API.
41
+ # Only calls the API if party.siren is present and party.siret is blank.
42
+ # Returns the party.
43
+ def self.enrich!(party)
44
+ return party if party.siren.to_s.strip.empty?
45
+ return party if party.respond_to?(:siret) && !party.siret.to_s.strip.empty?
46
+
47
+ result = find(party.siren.to_s.gsub(/\s/, ""))
48
+ return party unless result&.dig(:siret)
49
+
50
+ party.with(siret: result[:siret])
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "fr/siret_lookup"
4
+
5
+ module Einvoicing
6
+ module FR
7
+ # France-specific features for the einvoicing gem.
8
+ # Market: France (FR) — Factur-X, PPF/PDP mandate (Sept 2026)
9
+ #
10
+ # Usage:
11
+ # Einvoicing::FR::SiretLookup.find("898208145")
12
+ # Einvoicing::FR::SiretLookup.enrich!(party)
13
+ end
14
+ end
@@ -25,5 +25,18 @@ module Einvoicing
25
25
  def siren_number
26
26
  siren || (siret && siret[0, 9])
27
27
  end
28
+
29
+ # Look up SIRET via the Sirene API and return a new Party with siret filled in.
30
+ # No-op (returns self) if siren is blank or siret is already set.
31
+ #
32
+ # @return [Party] self or new Party with siret populated
33
+ def fetch_siret!
34
+ return self unless siren_number && siret.nil?
35
+
36
+ result = SiretLookup.find(siren_number)
37
+ return self unless result
38
+
39
+ with(siret: result[:siret])
40
+ end
28
41
  end
29
42
  end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+
7
+ module Einvoicing
8
+ # Looks up SIRET and company name from a SIREN number using the free French
9
+ # government API (no authentication required).
10
+ module SiretLookup
11
+ API_URL = "https://recherche-entreprises.api.gouv.fr/search"
12
+ SIREN_RE = /\A\d{9}\z/
13
+
14
+ # Find company info for a given SIREN number.
15
+ #
16
+ # @param siren [String, nil] 9-digit SIREN number
17
+ # @return [Hash, nil] { siret:, name:, address: } or nil on any error
18
+ def self.find(siren)
19
+ return nil unless siren.to_s.match?(SIREN_RE)
20
+
21
+ uri = URI(API_URL)
22
+ uri.query = URI.encode_www_form(q: siren, mtq: "true")
23
+
24
+ response = fetch(uri)
25
+ return nil if response.nil?
26
+
27
+ parse(response)
28
+ rescue StandardError
29
+ nil
30
+ end
31
+
32
+ def self.fetch(uri)
33
+ Net::HTTP.start(uri.host, uri.port,
34
+ use_ssl: uri.scheme == "https",
35
+ open_timeout: 5,
36
+ read_timeout: 10) do |http|
37
+ res = http.get("#{uri.path}?#{uri.query}")
38
+ return nil unless res.is_a?(Net::HTTPSuccess)
39
+
40
+ res.body
41
+ end
42
+ rescue StandardError
43
+ nil
44
+ end
45
+ private_class_method :fetch
46
+
47
+ def self.parse(body)
48
+ data = JSON.parse(body)
49
+ result = Array(data["results"]).first
50
+ return nil unless result
51
+
52
+ siege = result["siege"] || {}
53
+ siret = siege["siret"]
54
+ return nil if siret.nil? || siret.empty?
55
+
56
+ { siret: siret, name: result["nom_complet"], address: siege["adresse"] }
57
+ rescue JSON::ParserError
58
+ nil
59
+ end
60
+ private_class_method :parse
61
+ end
62
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "tempfile"
5
+ require "net/http"
6
+ require "uri"
7
+
8
+ module Einvoicing
9
+ module Validators
10
+ # Validates UBL 2.1 invoices against Peppol BIS Billing 3.0 Schematron rules
11
+ # using Saxon-HE via the system Java runtime.
12
+ #
13
+ # Requirements:
14
+ # - Java in PATH
15
+ # - Saxon-HE 12 CLI jar (auto-downloaded to /tmp/saxon-he.jar on first use)
16
+ # - Peppol XSLT (bundled at lib/einvoicing/data/; auto-downloaded on first use)
17
+ #
18
+ # @example
19
+ # errors = Einvoicing::Validators::Peppol.validate_ubl(xml_string)
20
+ # errors #=> [] when valid, or [{ field:, error:, message: }] when invalid
21
+ module Peppol
22
+ XSLT_URL = "https://github.com/OpenPEPPOL/peppol-bis-invoice-3/releases/download/3.0.21/PEPPOL-EN16931-UBL.xslt"
23
+ SAXON_URL = "https://repo1.maven.org/maven2/net/sf/saxon/Saxon-HE/12.5/Saxon-HE-12.5.jar"
24
+ SAXON_JAR = "/tmp/saxon-he.jar"
25
+ XSLT_PATH = File.expand_path("../data/PEPPOL-EN16931-UBL.xslt", __dir__)
26
+
27
+ # Validate a UBL 2.1 XML string against Peppol BIS 3.0 rules.
28
+ #
29
+ # @param xml_string [String] UBL 2.1 invoice XML
30
+ # @return [Array<Hash>] errors — empty array means valid
31
+ # @raise [Einvoicing::Errors::JavaNotFound] if java is not in PATH
32
+ # @raise [Einvoicing::Errors::ValidationError] if Saxon fails unexpectedly
33
+ def self.validate_ubl(xml_string)
34
+ ensure_java!
35
+ ensure_saxon!
36
+ ensure_xslt!
37
+
38
+ svrl = run_saxon(xml_string)
39
+ parse_svrl(svrl)
40
+ end
41
+
42
+ def self.java_available?
43
+ _, _, status = Open3.capture3("java -version")
44
+ status.success?
45
+ end
46
+
47
+ def self.ensure_java!
48
+ raise Einvoicing::Errors::JavaNotFound, "java not found in PATH" unless java_available?
49
+ end
50
+ private_class_method :ensure_java!
51
+
52
+ def self.ensure_saxon!
53
+ return if File.exist?(SAXON_JAR)
54
+
55
+ download(SAXON_URL, SAXON_JAR)
56
+ return if File.exist?(SAXON_JAR)
57
+
58
+ raise Einvoicing::Errors::ValidationError,
59
+ "Saxon JAR not available. Download from #{SAXON_URL} and place at #{SAXON_JAR}"
60
+ end
61
+ private_class_method :ensure_saxon!
62
+
63
+ def self.ensure_xslt!
64
+ return if File.exist?(XSLT_PATH) && File.size(XSLT_PATH) > 100
65
+
66
+ download(XSLT_URL, XSLT_PATH)
67
+ return if File.exist?(XSLT_PATH) && File.size(XSLT_PATH) > 100
68
+
69
+ raise Einvoicing::Errors::ValidationError,
70
+ "Peppol XSLT not available at #{XSLT_PATH}. Download from #{XSLT_URL}"
71
+ end
72
+ private_class_method :ensure_xslt!
73
+
74
+ def self.download(url, dest)
75
+ uri = URI(url)
76
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
77
+ open_timeout: 30, read_timeout: 120) do |http|
78
+ res = http.get(uri.request_uri)
79
+ return unless res.is_a?(Net::HTTPSuccess)
80
+
81
+ File.binwrite(dest, res.body)
82
+ end
83
+ rescue StandardError
84
+ nil # caller checks if file exists
85
+ end
86
+ private_class_method :download
87
+
88
+ def self.run_saxon(xml_string)
89
+ input = Tempfile.new(["peppol-input", ".xml"])
90
+ output = Tempfile.new(["peppol-output", ".svrl"])
91
+
92
+ begin
93
+ input.write(xml_string)
94
+ input.close
95
+ output.close
96
+
97
+ cmd = ["java", "-jar", SAXON_JAR,
98
+ "-s:#{input.path}",
99
+ "-xsl:#{XSLT_PATH}",
100
+ "-o:#{output.path}"]
101
+
102
+ _, stderr, status = Open3.capture3(*cmd)
103
+
104
+ unless status.success?
105
+ raise Einvoicing::Errors::ValidationError,
106
+ "Saxon failed (exit #{status.exitstatus}): #{stderr.strip}"
107
+ end
108
+
109
+ File.read(output.path)
110
+ ensure
111
+ input.unlink
112
+ output.unlink
113
+ end
114
+ end
115
+ private_class_method :run_saxon
116
+
117
+ def self.parse_svrl(svrl_xml)
118
+ require "rexml/document"
119
+
120
+ doc = REXML::Document.new(svrl_xml)
121
+ errors = []
122
+
123
+ REXML::XPath.each(doc, "//svrl:failed-assert",
124
+ "svrl" => "http://purl.oclc.org/dsdl/svrl") do |node|
125
+ errors << {
126
+ field: node.attributes["id"] || node.attributes["location"] || "",
127
+ error: node.attributes["test"] || "",
128
+ message: node.text.to_s.strip
129
+ }
130
+ end
131
+
132
+ errors
133
+ end
134
+ private_class_method :parse_svrl
135
+ end
136
+ end
137
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Einvoicing
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/einvoicing.rb CHANGED
@@ -5,8 +5,11 @@ require "bigdecimal"
5
5
  require "bigdecimal/util"
6
6
 
7
7
  require_relative "einvoicing/version"
8
+ require_relative "einvoicing/errors"
8
9
  require_relative "einvoicing/tax"
9
10
  require_relative "einvoicing/party"
11
+ require_relative "einvoicing/siret_lookup"
12
+ require_relative "einvoicing/fr"
10
13
  require_relative "einvoicing/line_item"
11
14
  require_relative "einvoicing/invoice"
12
15
  require_relative "einvoicing/xml_builder"
@@ -16,6 +19,7 @@ require_relative "einvoicing/formats/facturx"
16
19
  require_relative "einvoicing/i18n"
17
20
  require_relative "einvoicing/validators/base"
18
21
  require_relative "einvoicing/validators/fr"
22
+ require_relative "einvoicing/validators/peppol"
19
23
  require_relative "einvoicing/invoiceable"
20
24
  require_relative "einvoicing/rails/concern"
21
25
  require_relative "einvoicing/ppf"
@@ -47,4 +51,53 @@ end
47
51
  # xml = Einvoicing::Formats::CII.generate(invoice)
48
52
  # ubl = Einvoicing::Formats::UBL.generate(invoice)
49
53
  module Einvoicing
54
+ # ─── Top-level convenience API ────────────────────────────────────────────
55
+
56
+ # Generate XML from an invoice.
57
+ # @param invoice [Einvoicing::Invoice]
58
+ # @param format [Symbol] :cii (default, Factur-X) or :ubl (Peppol BIS 3.0)
59
+ # @return [String] XML document
60
+ def self.xml(invoice, format: :cii)
61
+ case format
62
+ when :cii then Formats::CII.generate(invoice)
63
+ when :ubl then Formats::UBL.generate(invoice)
64
+ else raise ArgumentError, "Unknown format: #{format.inspect}. Use :cii or :ubl"
65
+ end
66
+ end
67
+
68
+ # Embed a Factur-X CII XML into a PDF, returning a PDF/A-3 binary.
69
+ # @param pdf_data [String] raw PDF binary
70
+ # @param invoice_or_xml [Invoice, String] Invoice (CII generated internally) or raw XML string
71
+ # @return [String] Factur-X PDF/A-3 binary
72
+ def self.embed(pdf_data, invoice_or_xml)
73
+ xml_str = invoice_or_xml.is_a?(String) ? invoice_or_xml : xml(invoice_or_xml, format: :cii)
74
+ Formats::FacturX.embed(pdf_data, xml_str)
75
+ end
76
+
77
+ # Validate an invoice against a market's rules.
78
+ # @param invoice [Einvoicing::Invoice]
79
+ # @param market [Symbol] :fr (default)
80
+ # @return [Array<Hash>] array of { field:, error:, message: } — empty means valid
81
+ def self.validate(invoice, market: :fr)
82
+ case market
83
+ when :fr then Validators::FR.validate(invoice)
84
+ else raise ArgumentError, "Unknown market: #{market.inspect}. Use :fr"
85
+ end
86
+ end
87
+
88
+ # Full pipeline: validate → generate XML → optionally embed in PDF.
89
+ # Never raises — errors are returned in the result hash.
90
+ # @param invoice [Einvoicing::Invoice]
91
+ # @param format [Symbol] :cii or :ubl
92
+ # @param market [Symbol] :fr
93
+ # @param pdf [String, nil] optional raw PDF binary to embed into
94
+ # @return [Hash] { valid:, errors:, xml:, pdf: }
95
+ def self.process(invoice, format: :cii, market: :fr, pdf: nil)
96
+ errors = validate(invoice, market: market)
97
+ xml_str = xml(invoice, format: format)
98
+ pdf_out = pdf ? embed(pdf, xml_str) : nil
99
+ { valid: errors.empty?, errors: errors, xml: xml_str, pdf: pdf_out }
100
+ rescue StandardError => e
101
+ { valid: false, errors: [{ field: :unknown, error: :exception, message: e.message }], xml: nil, pdf: nil }
102
+ end
50
103
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: einvoicing
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nathan Le Ray
@@ -124,9 +124,12 @@ files:
124
124
  - config/locales/einvoicing.fr.yml
125
125
  - lib/einvoicing.rb
126
126
  - lib/einvoicing/data/srgb.icc
127
+ - lib/einvoicing/errors.rb
127
128
  - lib/einvoicing/formats/cii.rb
128
129
  - lib/einvoicing/formats/facturx.rb
129
130
  - lib/einvoicing/formats/ubl.rb
131
+ - lib/einvoicing/fr.rb
132
+ - lib/einvoicing/fr/siret_lookup.rb
130
133
  - lib/einvoicing/i18n.rb
131
134
  - lib/einvoicing/invoice.rb
132
135
  - lib/einvoicing/invoiceable.rb
@@ -139,9 +142,11 @@ files:
139
142
  - lib/einvoicing/ppf/submitter.rb
140
143
  - lib/einvoicing/rails/concern.rb
141
144
  - lib/einvoicing/rails/engine.rb
145
+ - lib/einvoicing/siret_lookup.rb
142
146
  - lib/einvoicing/tax.rb
143
147
  - lib/einvoicing/validators/base.rb
144
148
  - lib/einvoicing/validators/fr.rb
149
+ - lib/einvoicing/validators/peppol.rb
145
150
  - lib/einvoicing/version.rb
146
151
  - lib/einvoicing/xml_builder.rb
147
152
  homepage: https://github.com/sxnlabs/einvoicing