sri_facturacion 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 1170ef72498adfd8a5d6d70e5f1e2cfd82de731ac498bea02ed99e6c530ee27c
4
+ data.tar.gz: 327bc92c3a73681503b258a5a39a3ea82f59a7c6a8d029369c5b3000c6a53b5b
5
+ SHA512:
6
+ metadata.gz: 9e94464e4cb250549f7f0c22eddf98076b17355930493dfe0c2f42f09bfab14676b756676a3f56a8636d3451c4a793bd80c54d446d7fef42c852f03508f37506
7
+ data.tar.gz: ba6c925fb681289c08366ff4099cbccac405b533107f464e434b11c3857c2d0ea5fee0b52980c0452f8a9a0fe623165f7975a0ad06cd5f7cbae2565a113b51cb
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 ImTheo
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
13
+ all 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
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,39 @@
1
+ # SriFacturacion
2
+
3
+ TODO: Delete this and the text below, and describe your gem
4
+
5
+ Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/sri_facturacion`. To experiment with that code, run `bin/console` for an interactive prompt.
6
+
7
+ ## Installation
8
+
9
+ TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
10
+
11
+ Install the gem and add to the application's Gemfile by executing:
12
+
13
+ ```bash
14
+ bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
15
+ ```
16
+
17
+ If bundler is not being used to manage dependencies, install the gem by executing:
18
+
19
+ ```bash
20
+ gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ TODO: Write usage instructions here
26
+
27
+ ## Development
28
+
29
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
30
+
31
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
32
+
33
+ ## Contributing
34
+
35
+ Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/sri_facturacion.
36
+
37
+ ## License
38
+
39
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+ require 'openssl'
5
+
6
+ module Sri
7
+ module InvoiceService
8
+ class AccessKeyGenerator
9
+ WEIGHTS = [2, 3, 4, 5, 6, 7].freeze
10
+
11
+ # Generates an access key from an Nokogiri XML document
12
+ def self.generate!(doc:, sequential:)
13
+ ruc = doc.at_xpath('//infoTributaria/ruc').text.strip
14
+ ambiente = doc.at_xpath('//infoTributaria/ambiente').text.strip
15
+ estab = doc.at_xpath('//infoTributaria/estab').text.strip
16
+ pto_emi = doc.at_xpath('//infoTributaria/ptoEmi').text.strip
17
+ cod_doc = doc.at_xpath('//infoTributaria/codDoc').text.strip
18
+ tipo_emision = doc.at_xpath('//infoTributaria/tipoEmision').text.strip
19
+
20
+ fecha_str = doc.at_xpath('//infoFactura/fechaEmision').text.strip
21
+ dd, mm, yyyy = fecha_str.split('/').map(&:to_i)
22
+ fecha = Date.new(yyyy, mm, dd)
23
+
24
+ codigo_numerico = random8
25
+ ddmmaaaa = fecha.is_a?(Date) ? fecha.strftime('%d%m%Y') : fecha.to_s
26
+ serie = "#{estab}#{pto_emi}"
27
+ sec9 = sequential.to_s.rjust(9, '0')
28
+ cn8 = codigo_numerico.to_s.rjust(8, '0')
29
+
30
+ base48 = "#{ddmmaaaa}#{cod_doc}#{ruc}#{ambiente}#{serie}#{sec9}#{cn8}#{tipo_emision}"
31
+ raise "Base debe ser 48 dígitos, es #{base48.length}" unless base48.length == 48
32
+
33
+ base48 + mod11(base48)
34
+ end
35
+
36
+ def self.mod11(base48)
37
+ sum = 0
38
+ wi = 0
39
+ base48.reverse.each_char do |ch|
40
+ sum += (ch.ord - 48) * WEIGHTS[wi]
41
+ wi = (wi + 1) % WEIGHTS.length
42
+ end
43
+
44
+ dv = 11 - (sum % 11)
45
+ return '0' if dv == 11
46
+ return '1' if dv == 10
47
+
48
+ dv.to_s
49
+ end
50
+
51
+ def self.random8
52
+ n = OpenSSL::Random.random_bytes(4).unpack1('N') # 0..2^32-1
53
+ (n % 100_000_000).to_s.rjust(8, '0')
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sri
4
+ module InvoiceService
5
+ class Error < StandardError; end
6
+ class MissingXmlError < Error; end
7
+ class XmlsecNotInstalledError < Error; end
8
+ class InvalidPkcs12Error < Error; end
9
+ class SoapError < Error; end
10
+ end
11
+ end
@@ -0,0 +1,338 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'nokogiri'
5
+ require 'open3'
6
+ require 'tempfile'
7
+ require 'openssl'
8
+ require 'securerandom'
9
+ require 'time'
10
+
11
+ module Sri
12
+ module InvoiceService
13
+ # Builds a signed electronic invoice XML for SRI.
14
+ # Responsibilities:
15
+ # - compute access key (claveAcceso)
16
+ # - ensure signature template
17
+ # - sign XML with xmlsec1 using a PKCS#12
18
+ class InvoiceBuilder
19
+ DS_NS = 'http://www.w3.org/2000/09/xmldsig#'
20
+ XADES_NS = 'http://uri.etsi.org/01903/v1.3.2#'
21
+
22
+ # @param doc [Nokogiri::XML::Document] Unsigned invoice XML document
23
+ def initialize(doc:, sequential:, p12_base64:, p12_password:, root_id: 'comprobante')
24
+ @doc = doc
25
+ @sequential = sequential
26
+ @p12_base64 = p12_base64
27
+ @p12_password = p12_password
28
+ @root_id = root_id
29
+ end
30
+
31
+ def call
32
+ ensure_xmlsec1!
33
+
34
+ unsigned_xml = @doc.to_s
35
+ raise 'Debe proporcionar xml_string' if unsigned_xml.strip.empty?
36
+
37
+ clave = Sri::InvoiceService::AccessKeyGenerator.generate!(doc: @doc, sequential: @sequential)
38
+ signed_xml = sign_xml(@doc, clave)
39
+
40
+ { clave_acceso: clave, signed_xml: signed_xml, signed_xml_path: @last_signed_path }
41
+ end
42
+
43
+ private
44
+
45
+ def ensure_xmlsec1!
46
+ _out, _err, st = Open3.capture3('xmlsec1', '--version')
47
+ raise 'xmlsec1 no está instalado o no está en PATH' unless st.success?
48
+ end
49
+
50
+ def ensure_invoice_root_structure!(doc)
51
+ root = doc.root
52
+
53
+ root.name = 'factura' unless root.name == 'factura'
54
+ root['id'] = @root_id if root['id'].to_s.strip.empty?
55
+ root['version'] = '1.1.0' if root['version'].to_s.strip.empty?
56
+ end
57
+
58
+ # Minimal XAdES-BES enveloped template for SRI.
59
+ def ensure_signature_template!(doc)
60
+ return if doc.at_xpath('//ds:Signature', 'ds' => DS_NS)
61
+
62
+ root = doc.root
63
+ root['id'] ||= @root_id
64
+
65
+ signature_id = "Signature-#{SecureRandom.uuid}"
66
+ signed_props_id = "SignedProperties-#{SecureRandom.uuid}"
67
+
68
+ sig = Nokogiri::XML::Node.new('ds:Signature', doc)
69
+ sig.add_namespace_definition('ds', DS_NS)
70
+ sig['Id'] = signature_id
71
+
72
+ signed_info = Nokogiri::XML::Node.new('ds:SignedInfo', doc)
73
+
74
+ canon = Nokogiri::XML::Node.new('ds:CanonicalizationMethod', doc)
75
+ canon['Algorithm'] = 'http://www.w3.org/2001/10/xml-exc-c14n#'
76
+
77
+ sig_method = Nokogiri::XML::Node.new('ds:SignatureMethod', doc)
78
+ sig_method['Algorithm'] = 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256'
79
+
80
+ # Reference #1: the root document (enveloped)
81
+ ref_doc = Nokogiri::XML::Node.new('ds:Reference', doc)
82
+ ref_doc['URI'] = "##{@root_id}"
83
+
84
+ transforms = Nokogiri::XML::Node.new('ds:Transforms', doc)
85
+ t1 = Nokogiri::XML::Node.new('ds:Transform', doc)
86
+ t1['Algorithm'] = "#{DS_NS}enveloped-signature"
87
+ t2 = Nokogiri::XML::Node.new('ds:Transform', doc)
88
+ t2['Algorithm'] = 'http://www.w3.org/2001/10/xml-exc-c14n#'
89
+ transforms.add_child(t1)
90
+ transforms.add_child(t2)
91
+
92
+ digest_method = Nokogiri::XML::Node.new('ds:DigestMethod', doc)
93
+ digest_method['Algorithm'] = 'http://www.w3.org/2001/04/xmlenc#sha256'
94
+
95
+ digest_value = Nokogiri::XML::Node.new('ds:DigestValue', doc)
96
+ digest_value.content = ''
97
+
98
+ ref_doc.add_child(transforms)
99
+ ref_doc.add_child(digest_method)
100
+ ref_doc.add_child(digest_value)
101
+
102
+ # Reference #2: SignedProperties (required for XAdES)
103
+ ref_sp = Nokogiri::XML::Node.new('ds:Reference', doc)
104
+ ref_sp['URI'] = "##{signed_props_id}"
105
+ ref_sp['Type'] = 'http://uri.etsi.org/01903#SignedProperties'
106
+
107
+ dm2 = Nokogiri::XML::Node.new('ds:DigestMethod', doc)
108
+ dm2['Algorithm'] = 'http://www.w3.org/2001/04/xmlenc#sha256'
109
+ dv2 = Nokogiri::XML::Node.new('ds:DigestValue', doc)
110
+ dv2.content = ''
111
+ ref_sp.add_child(dm2)
112
+ ref_sp.add_child(dv2)
113
+
114
+ signed_info.add_child(canon)
115
+ signed_info.add_child(sig_method)
116
+ signed_info.add_child(ref_doc)
117
+ signed_info.add_child(ref_sp)
118
+
119
+ sig_value = Nokogiri::XML::Node.new('ds:SignatureValue', doc)
120
+ sig_value.content = ''
121
+
122
+ key_info = Nokogiri::XML::Node.new('ds:KeyInfo', doc)
123
+ x509_data = Nokogiri::XML::Node.new('ds:X509Data', doc)
124
+ key_info.add_child(x509_data)
125
+
126
+ # XAdES Object
127
+ obj = Nokogiri::XML::Node.new('ds:Object', doc)
128
+
129
+ qp = Nokogiri::XML::Node.new('xades:QualifyingProperties', doc)
130
+ qp.add_namespace_definition('xades', XADES_NS)
131
+ qp['Target'] = "##{signature_id}"
132
+
133
+ sp = Nokogiri::XML::Node.new('xades:SignedProperties', doc)
134
+ sp['Id'] = signed_props_id
135
+
136
+ ssp = Nokogiri::XML::Node.new('xades:SignedSignatureProperties', doc)
137
+
138
+ signing_time = Nokogiri::XML::Node.new('xades:SigningTime', doc)
139
+ signing_time.content = Time.now.utc.iso8601
140
+
141
+ signing_cert = Nokogiri::XML::Node.new('xades:SigningCertificate', doc)
142
+ cert_node = Nokogiri::XML::Node.new('xades:Cert', doc)
143
+
144
+ cert_digest = Nokogiri::XML::Node.new('xades:CertDigest', doc)
145
+ dm = Nokogiri::XML::Node.new('ds:DigestMethod', doc)
146
+ dm['Algorithm'] = 'http://www.w3.org/2001/04/xmlenc#sha256'
147
+ dv = Nokogiri::XML::Node.new('ds:DigestValue', doc)
148
+ dv.content = ''
149
+ cert_digest.add_child(dm)
150
+ cert_digest.add_child(dv)
151
+
152
+ issuer_serial = Nokogiri::XML::Node.new('xades:IssuerSerial', doc)
153
+ x509_issuer = Nokogiri::XML::Node.new('ds:X509IssuerName', doc)
154
+ x509_serial = Nokogiri::XML::Node.new('ds:X509SerialNumber', doc)
155
+ x509_issuer.content = ''
156
+ x509_serial.content = ''
157
+ issuer_serial.add_child(x509_issuer)
158
+ issuer_serial.add_child(x509_serial)
159
+
160
+ cert_node.add_child(cert_digest)
161
+ cert_node.add_child(issuer_serial)
162
+ signing_cert.add_child(cert_node)
163
+
164
+ ssp.add_child(signing_time)
165
+ ssp.add_child(signing_cert)
166
+
167
+ sp.add_child(ssp)
168
+ qp.add_child(sp)
169
+ obj.add_child(qp)
170
+
171
+ sig.add_child(signed_info)
172
+ sig.add_child(sig_value)
173
+ sig.add_child(key_info)
174
+ sig.add_child(obj)
175
+
176
+ root.add_child(sig)
177
+
178
+ @signature_id = signature_id
179
+ @signed_properties_id = signed_props_id
180
+ end
181
+
182
+ def inject_access_key!(doc, clave_acceso)
183
+ return if clave_acceso.to_s.strip.empty?
184
+
185
+ root = doc.root
186
+ info_tributaria = root.at_xpath('./infoTributaria') || root.at_xpath('//infoTributaria')
187
+
188
+ unless info_tributaria
189
+ info_tributaria = Nokogiri::XML::Node.new('infoTributaria', doc)
190
+ if root.children.any?
191
+ root.children.first.add_previous_sibling(info_tributaria)
192
+ else
193
+ root.add_child(info_tributaria)
194
+ end
195
+ end
196
+
197
+ clave_node = info_tributaria.at_xpath('./claveAcceso')
198
+ clave_node ||= Nokogiri::XML::Node.new('claveAcceso', doc)
199
+ clave_node.content = clave_acceso.to_s
200
+ ruc_node = info_tributaria.at_xpath('./ruc')
201
+ cod_doc_node = info_tributaria.at_xpath('./codDoc')
202
+
203
+ if ruc_node
204
+ clave_node.remove if clave_node.parent
205
+ ruc_node.add_next_sibling(clave_node)
206
+ elsif cod_doc_node
207
+ clave_node.remove if clave_node.parent
208
+ cod_doc_node.add_previous_sibling(clave_node)
209
+ else
210
+ info_tributaria.add_child(clave_node) unless clave_node.parent
211
+ end
212
+ nodes = info_tributaria.xpath('./claveAcceso')
213
+ nodes.drop(1).each(&:remove) if nodes.size > 1
214
+ end
215
+
216
+ def inject_secuencial!(doc)
217
+ sec = @sequential.to_s.rjust(9, '0')
218
+
219
+ info_tributaria = doc.root.at_xpath('./infoTributaria') || doc.at_xpath('//infoTributaria')
220
+ return unless info_tributaria
221
+
222
+ sec_node = info_tributaria.at_xpath('./secuencial')
223
+ sec_node ||= Nokogiri::XML::Node.new('secuencial', doc)
224
+ sec_node.content = sec
225
+
226
+ pto_emi_node = info_tributaria.at_xpath('./ptoEmi')
227
+ if pto_emi_node
228
+ sec_node.remove if sec_node.parent
229
+ pto_emi_node.add_next_sibling(sec_node)
230
+ else
231
+ info_tributaria.add_child(sec_node) unless sec_node.parent
232
+ end
233
+
234
+ # Remove duplicates
235
+ nodes = info_tributaria.xpath('./secuencial')
236
+ nodes.drop(1).each(&:remove) if nodes.size > 1
237
+ end
238
+
239
+ def sign_xml(doc, clave_acceso)
240
+ ensure_invoice_root_structure!(doc)
241
+ ensure_signature_template!(doc)
242
+
243
+ inject_access_key!(doc, clave_acceso)
244
+ inject_secuencial!(doc)
245
+
246
+ p12_path = resolve_p12_path!
247
+ validate_pkcs12!(p12_path)
248
+
249
+ inject_cert_chain_and_xades_data!(doc, p12_path)
250
+
251
+ root_name = doc.root.name
252
+ input_xml = doc.to_xml
253
+
254
+ Tempfile.create(%w[sri_unsigned .xml]) do |in_xml|
255
+ in_xml.write(input_xml)
256
+ in_xml.flush
257
+
258
+ Tempfile.create(%w[sri_signed .xml]) do |out_xml|
259
+ cmd = [
260
+ 'xmlsec1', '--sign',
261
+ '--pkcs12', p12_path,
262
+ '--id-attr:id', root_name,
263
+ '--output', out_xml.path,
264
+ in_xml.path
265
+ ]
266
+
267
+ if @p12_password && !@p12_password.to_s.empty?
268
+ cmd.insert(4, '--pwd')
269
+ cmd.insert(5, @p12_password)
270
+ end
271
+
272
+ _stdout, stderr, status = Open3.capture3(*cmd)
273
+ raise "Fallo firmando con xmlsec1: #{stderr.to_s.strip}" unless status.success?
274
+
275
+ @last_signed_path = out_xml.path.dup
276
+ File.read(out_xml.path)
277
+ end
278
+ end
279
+ end
280
+
281
+ def inject_cert_chain_and_xades_data!(doc, p12_path)
282
+ pkcs12 = OpenSSL::PKCS12.new(File.binread(p12_path), @p12_password.to_s)
283
+ leaf = pkcs12.certificate
284
+ chain = Array(pkcs12.ca_certs)
285
+
286
+ x509_data = doc.at_xpath('//ds:Signature/ds:KeyInfo/ds:X509Data', 'ds' => DS_NS)
287
+ raise 'No se encontró ds:X509Data para inyectar certificados' unless x509_data
288
+
289
+ x509_data.children.remove
290
+ ([leaf] + chain).compact.each do |cert|
291
+ n = Nokogiri::XML::Node.new('ds:X509Certificate', doc)
292
+ n.content = Base64.strict_encode64(cert.to_der)
293
+ x509_data.add_child(n)
294
+ end
295
+
296
+ digest_value_node = doc.at_xpath('//xades:SigningCertificate//xades:CertDigest/ds:DigestValue',
297
+ 'xades' => XADES_NS, 'ds' => DS_NS)
298
+ issuer_node = doc.at_xpath('//xades:SigningCertificate//xades:IssuerSerial/ds:X509IssuerName',
299
+ 'xades' => XADES_NS, 'ds' => DS_NS)
300
+ serial_node = doc.at_xpath('//xades:SigningCertificate//xades:IssuerSerial/ds:X509SerialNumber',
301
+ 'xades' => XADES_NS, 'ds' => DS_NS)
302
+
303
+ digest_value_node.content = Base64.strict_encode64(OpenSSL::Digest::SHA256.digest(leaf.to_der)) if digest_value_node
304
+ issuer_node.content = leaf.issuer.to_s(OpenSSL::X509::Name::RFC2253) if issuer_node
305
+ serial_node.content = leaf.serial.to_s if serial_node
306
+ end
307
+
308
+ def validate_pkcs12!(p12_path)
309
+ passin = if @p12_password && !@p12_password.to_s.empty?
310
+ "pass:#{@p12_password}"
311
+ else
312
+ 'pass:'
313
+ end
314
+
315
+ cmd = ['openssl', 'pkcs12', '-in', p12_path, '-noout', '-passin', passin]
316
+ _out, err, st = Open3.capture3(*cmd)
317
+ return if st.success?
318
+
319
+ raise "Certificado PKCS#12 inválido o password incorrecta (openssl): #{err.to_s.strip}"
320
+ end
321
+
322
+ def resolve_p12_path!
323
+ raise 'Debe proporcionar p12_base64' if @p12_base64.to_s.strip.empty?
324
+
325
+ tmp = Tempfile.create(%w[sri_p12 .p12])
326
+ tmp.binmode
327
+ tmp.write(Base64.strict_decode64(@p12_base64))
328
+ tmp.flush
329
+ tmp.close
330
+
331
+ @tmp_p12_file = tmp
332
+ tmp.path
333
+ rescue ArgumentError => e
334
+ raise "p12_base64 inválido (base64): #{e.message}"
335
+ end
336
+ end
337
+ end
338
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'nokogiri'
4
+
5
+ module Sri
6
+ module InvoiceService
7
+ # Orchestrates: build access key + sign + send to SRI sandbox endpoints.
8
+ # Implementation details are delegated to:
9
+ # - Sri::InvoiceService::InvoiceBuilder
10
+ # - Sri::InvoiceService::InvoiceSender
11
+ class InvoiceOrchestrator
12
+ RECEPCION_WSDL = 'https://celcer.sri.gob.ec/comprobantes-electronicos-ws/RecepcionComprobantesOffline?wsdl'
13
+ AUTORIZACION_WSDL = 'https://celcer.sri.gob.ec/comprobantes-electronicos-ws/AutorizacionComprobantesOffline?wsdl'
14
+ DS_NS = 'http://www.w3.org/2000/09/xmldsig#'
15
+ XADES_NS = 'http://uri.etsi.org/01903/v1.3.2#'
16
+
17
+ def initialize(p12_base64:, p12_password:, xml_string:, sequential:, root_id: 'comprobante')
18
+ @p12_base64 = p12_base64
19
+ @p12_password = p12_password
20
+ @xml_string = xml_string
21
+ @root_id = root_id
22
+ @sequential = sequential
23
+ end
24
+
25
+ def call
26
+ unsigned_xml = @xml_string.to_s
27
+ raise 'Debe proporcionar xml_string' if unsigned_xml.strip.empty?
28
+
29
+ doc = Nokogiri::XML(@xml_string.to_s) { |cfg| cfg.strict.noblanks }
30
+ environment = doc.at_xpath('//infoTributaria/ambiente').text.strip
31
+ builder = Sri::InvoiceService::InvoiceBuilder.new(
32
+ doc:,
33
+ sequential: @sequential,
34
+ p12_base64: @p12_base64,
35
+ p12_password: @p12_password,
36
+ root_id: @root_id
37
+ )
38
+
39
+ built = builder.call
40
+
41
+ sender = Sri::InvoiceService::InvoiceSender.new(environment:)
42
+ sent = sender.call(clave_acceso: built.fetch(:clave_acceso), signed_xml: built.fetch(:signed_xml))
43
+
44
+ {
45
+ clave_acceso: built.fetch(:clave_acceso),
46
+ signed_xml_path: built[:signed_xml_path],
47
+ recepcion: sent.fetch(:recepcion),
48
+ autorizacion: sent.fetch(:autorizacion)
49
+ }
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'savon'
5
+
6
+ module Sri
7
+ module InvoiceService
8
+ # Sends a signed invoice XML to SRI sandbox endpoints.
9
+ class InvoiceSender
10
+ TEST_RECEPTION_WSDL = 'https://celcer.sri.gob.ec/comprobantes-electronicos-ws/RecepcionComprobantesOffline?wsdl'
11
+ TEST_AUTHORIZATION_WSDL = 'https://celcer.sri.gob.ec/comprobantes-electronicos-ws/AutorizacionComprobantesOffline?wsdl'
12
+
13
+ PRODUCTION_RECEPTION_WSDL = 'https://cel.sri.gob.ec/comprobantes-electronicos-ws/RecepcionComprobantesOffline?wsdl'
14
+ PRODUCTION_AUTHORIZATION_WSDL = 'https://cel.sri.gob.ec/comprobantes-electronicos-ws/AutorizacionComprobantesOffline?wsdl'
15
+
16
+ TEST_ENVIRONMENT = 1
17
+ PRODUCTION_ENVIRONMENT = 2
18
+
19
+ def initialize(reception_client: nil, authorization_client: nil, environment: 1)
20
+ @reception_client = reception_client
21
+ @authorization_client = authorization_client
22
+ @environment = environment == PRODUCTION_ENVIRONMENT ? PRODUCTION_ENVIRONMENT : TEST_ENVIRONMENT
23
+ end
24
+
25
+ def call(clave_acceso:, signed_xml:)
26
+ recep = reception_client.call(
27
+ :validar_comprobante,
28
+ headers: { 'SOAPAction' => '""' },
29
+ message: { 'xml' => Base64.strict_encode64(signed_xml.to_s) }
30
+ ).body
31
+
32
+ recep_parsed = parse_recepcion(recep)
33
+
34
+ auth = authorization_client.call(
35
+ :autorizacion_comprobante,
36
+ headers: { 'SOAPAction' => '""' },
37
+ message: { 'claveAccesoComprobante' => clave_acceso }
38
+ ).body
39
+
40
+ auth_parsed = parse_autorizacion(auth)
41
+
42
+ {
43
+ recepcion: recep_parsed.merge(raw: recep),
44
+ autorizacion: auth_parsed.merge(raw: auth)
45
+ }
46
+ end
47
+
48
+ private
49
+
50
+ def reception_client
51
+ wsdl = @environment == :production ? PRODUCTION_RECEPTION_WSDL : TEST_RECEPTION_WSDL
52
+ @reception_client ||= Savon.client(
53
+ wsdl:,
54
+ convert_request_keys_to: :none,
55
+ open_timeout: 10,
56
+ read_timeout: 30,
57
+ log: false
58
+ )
59
+ end
60
+
61
+ def authorization_client
62
+ wsdl = @environment == :production ? PRODUCTION_AUTHORIZATION_WSDL : TEST_AUTHORIZATION_WSDL
63
+ @authorization_client ||= Savon.client(
64
+ wsdl:,
65
+ convert_request_keys_to: :none,
66
+ open_timeout: 10,
67
+ read_timeout: 30,
68
+ log: false
69
+ )
70
+ end
71
+
72
+ def parse_recepcion(body)
73
+ resp = dig_any(body, :validarComprobanteResponse, :validar_comprobante_response) || body
74
+ rr = dig_any(resp, :RespuestaRecepcionComprobante, :respuesta_recepcion_comprobante) || resp
75
+
76
+ estado = dig_any(rr, :estado) || dig_any(resp, :estado)
77
+ mensajes = extract_mensajes(dig_any(rr, :comprobantes, :comprobante) || rr)
78
+
79
+ { estado: estado, mensajes: mensajes }
80
+ end
81
+
82
+ def parse_autorizacion(body)
83
+ resp = dig_any(body, :autorizacionComprobanteResponse, :autorizacion_comprobante_response) || body
84
+ ra = dig_any(resp, :RespuestaAutorizacionComprobante, :respuesta_autorizacion_comprobante) || resp
85
+
86
+ auts = dig_any(ra, :autorizaciones, :autorizacion) || []
87
+ aut = auts.is_a?(Array) ? auts.first : auts
88
+
89
+ estado = dig_any(aut, :estado) || dig_any(ra, :estado)
90
+ mensajes = extract_mensajes(aut || ra)
91
+
92
+ { estado: estado, mensajes: mensajes }
93
+ end
94
+
95
+ def extract_mensajes(node)
96
+ msgs = dig_any(node, :mensajes, :mensaje) || dig_any(node, :mensajes) || []
97
+ msgs = [msgs] if msgs.is_a?(Hash)
98
+ msgs = Array(msgs)
99
+
100
+ msgs.map do |m|
101
+ [
102
+ dig_any(m, :identificador),
103
+ dig_any(m, :mensaje),
104
+ dig_any(m, :informacionAdicional, :informacion_adicional),
105
+ dig_any(m, :tipo)
106
+ ].compact.join(' | ')
107
+ end
108
+ rescue StandardError
109
+ []
110
+ end
111
+
112
+ def dig_any(obj, *keys)
113
+ keys.each do |k|
114
+ v = obj.is_a?(Hash) ? (obj[k] || obj[k.to_s]) : nil
115
+ return v unless v.nil?
116
+ end
117
+ nil
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "invoice_service/errors"
4
+ require_relative "invoice_service/access_key_generator"
5
+ require_relative "invoice_service/invoice_builder"
6
+ require_relative "invoice_service/invoice_sender"
7
+ require_relative "invoice_service/invoice_orchestrator"
8
+
9
+ module Sri
10
+ module InvoiceService
11
+ end
12
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SriFacturacion
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "sri_facturacion/version"
4
+ require_relative "sri/invoice_service"
metadata ADDED
@@ -0,0 +1,98 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sri_facturacion
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Theo Rosero
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-01-13 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: net-http
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0.4'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0.4'
27
+ - !ruby/object:Gem::Dependency
28
+ name: nokogiri
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '1.14'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '1.14'
41
+ - !ruby/object:Gem::Dependency
42
+ name: savon
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '2.15'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '2.15'
55
+ description: Firma XAdES-BES y envío a SRI.
56
+ email:
57
+ - theomrosero@gmail.com
58
+ executables: []
59
+ extensions: []
60
+ extra_rdoc_files: []
61
+ files:
62
+ - LICENSE.txt
63
+ - README.md
64
+ - lib/sri/invoice_service.rb
65
+ - lib/sri/invoice_service/access_key_generator.rb
66
+ - lib/sri/invoice_service/errors.rb
67
+ - lib/sri/invoice_service/invoice_builder.rb
68
+ - lib/sri/invoice_service/invoice_orchestrator.rb
69
+ - lib/sri/invoice_service/invoice_sender.rb
70
+ - lib/sri_facturacion.rb
71
+ - lib/sri_facturacion/version.rb
72
+ homepage:
73
+ licenses:
74
+ - MIT
75
+ metadata:
76
+ homepage_uri: https://github.com/ImTheo/sri_facturacion
77
+ source_code_uri: https://github.com/ImTheo/sri_facturacion
78
+ changelog_uri: https://github.com/ImTheo/sri_facturacion/blob/main/CHANGELOG.md
79
+ post_install_message:
80
+ rdoc_options: []
81
+ require_paths:
82
+ - lib
83
+ required_ruby_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: '3.0'
88
+ required_rubygems_version: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: '0'
93
+ requirements: []
94
+ rubygems_version: 3.5.11
95
+ signing_key:
96
+ specification_version: 4
97
+ summary: Facturación electrónica SRI (firma y envío)
98
+ test_files: []