sat_mx 0.1.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: c1d5bf99d58cf85c782f3b4154b95ec56f8e3dcc1d3b8081d5199052e43decb9
4
- data.tar.gz: 3e2b16a973bebaddec739002feaa9bfe144d276bdb3ca81d3d57a84e903f3af6
3
+ metadata.gz: 24d8984a200bf9e2d95a2fcb90e10097f54a4ed496e418c3880df1848dd5c302
4
+ data.tar.gz: 01a221e8d1bcab95919dc4990677ca9c63bc775e1e1f03281c5c07be2653805c
5
5
  SHA512:
6
- metadata.gz: 8e29de979da7288e094a4f668d2944c8b4af02193a9c85912101edd383cfc7494bb03074b4f3ed5e4fd103d2d09e5a76a8b0fb558f087a3bf93610ecb2e27490
7
- data.tar.gz: 9dbc83b0df6d6af8d2c06296fb727ca19566d3bded5b3d77f949a048f36b60024bfdcb3aa50cafc4150f664f5df0ca4d4b5a24c7b1235e47239aedf7aeb4ecf2
6
+ metadata.gz: 3e1905940a68f281204884f848fea09806e6cc7ce242389aba92154e95777d5563b0b400dd731832982f824c626f4353bf204138f75a5ca26b1f4db063adabbe
7
+ data.tar.gz: 3535c79dafbb37cc18fc887a3d266a7ce9f1474e4f0d5add1ae6cfae652c88e64dd72b7fc73867c4d9a82b941c5179a2f447943df63170ee8ad01a0c69f1e18c
data/README.md CHANGED
@@ -1,39 +1,98 @@
1
1
  # SatMx
2
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/sat_mx`. To experiment with that code, run `bin/console` for an interactive prompt.
3
+ Ruby client for SAT (Mexican Tax Administration) web services to download CFDI invoices.
6
4
 
7
5
  ## Installation
8
6
 
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.
7
+ ```bash
8
+ gem install sat_mx
9
+ ```
10
10
 
11
- Install the gem and add to the application's Gemfile by executing:
11
+ Or add to your Gemfile:
12
12
 
13
- $ bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
13
+ ```ruby
14
+ gem "sat_mx"
15
+ ```
14
16
 
15
- If bundler is not being used to manage dependencies, install the gem by executing:
17
+ ## Configuration
16
18
 
17
- $ gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
19
+ ```ruby
20
+ SatMx.configure do |config|
21
+ config[:certificate] = "path/to/certificate.cer"
22
+ config[:private_key] = "path/to/private.key"
23
+ config[:password] = "key_password"
24
+ end
25
+ ```
18
26
 
19
27
  ## Usage
20
28
 
21
- TODO: Write usage instructions here
22
-
23
- ## Development
24
-
25
- 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.
26
-
27
- 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).
28
-
29
- ## Contributing
30
-
31
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/sat_mx. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/[USERNAME]/sat_mx/blob/master/CODE_OF_CONDUCT.md).
29
+ ```ruby
30
+ # 1. Authenticate
31
+ result = SatMx.authenticate
32
+ raise "Auth failed" unless result.success?
33
+
34
+ token = result.value
35
+
36
+ # 2. Request download
37
+ result = SatMx.download_request(
38
+ start_date: Time.new(2024, 1, 1),
39
+ end_date: Time.new(2024, 1, 31),
40
+ request_type: :cfdi,
41
+ issuing_rfc: "ABC010101ABC",
42
+ recipient_rfcs: ["XYZ020202XYZ"],
43
+ requester_rfc: "ABC010101ABC",
44
+ access_token: token
45
+ )
46
+ raise "Request failed" unless result.success?
47
+
48
+ request_id = result.value
49
+
50
+ # 3. Verify status (poll until ready)
51
+ loop do
52
+ result = SatMx.verify_request(
53
+ request_id: request_id,
54
+ requester_rfc: "ABC010101ABC",
55
+ access_token: token
56
+ )
57
+
58
+ case result.value[:request_status]
59
+ when :finished
60
+ break result.value[:package_ids]
61
+ when :error, :rejected, :expired
62
+ raise "Request failed: #{result.value}"
63
+ end
64
+
65
+ sleep 5
66
+ end
67
+
68
+ # 4. Download packages
69
+ package_ids.each do |package_id|
70
+ result = SatMx.download_petition(
71
+ package_id: package_id,
72
+ requester_rfc: "ABC010101ABC",
73
+ access_token: token
74
+ )
75
+
76
+ File.write("#{package_id}.zip", result.value) if result.success?
77
+ end
78
+ ```
79
+
80
+ ## API
81
+
82
+ | Method | Description |
83
+ |--------|-------------|
84
+ | `SatMx.configure` | Configure certificate and private key |
85
+ | `SatMx.configuration` | Get current configuration |
86
+ | `SatMx.authenticate` | Get access token |
87
+ | `SatMx.download_request` | Request CFDI download |
88
+ | `SatMx.verify_request` | Check request status |
89
+ | `SatMx.download_petition` | Download package |
90
+
91
+ All methods return a `Result` object with:
92
+ - `success?` - Boolean indicating success
93
+ - `value` - Data on success, error hash `{:cod_estatus, :mensaje}` on failure
94
+ - `xml` - Raw XML response
32
95
 
33
96
  ## License
34
97
 
35
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
36
-
37
- ## Code of Conduct
38
-
39
- Everyone interacting in the SatMx project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/sat_mx/blob/master/CODE_OF_CONDUCT.md).
98
+ MIT
data/Rakefile CHANGED
@@ -1,5 +1,3 @@
1
- # frozen_string_literal: true
2
-
3
1
  require "bundler/gem_tasks"
4
2
  require "rspec/core/rake_task"
5
3
 
@@ -1,43 +1,39 @@
1
- require "openssl"
2
1
  require "httpx"
3
- require "xmldsig"
4
2
  require "time"
3
+ require "base64"
5
4
 
6
5
  module SatMx
7
- Result = Data.define(:success?, :value)
8
-
9
- class BulkDownload
10
- AUTH_URL = "https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/Autenticacion/Autenticacion.svc".freeze
11
- HEADERS = {
12
- "content-type" => "text/xml; charset=utf-8",
13
- "accept" => "text/xml",
14
- "SOAPAction" => "http://DescargaMasivaTerceros.gob.mx/IAutenticacion/Autentica"
15
- }.freeze
16
- private_constant :AUTH_URL
17
- private_constant :HEADERS
18
-
19
- def self.authenticate(certificate:, private_key:, id: nil)
6
+ # @api private
7
+ class Authentication
8
+ def self.authenticate(certificate:, private_key:, uuid: SecureRandom.uuid)
20
9
  new(
21
- xml_auth_body: XmlAuthBody.new(certificate:, private_key:, id:)
10
+ xml_auth_body: XmlAuthBody.new(
11
+ certificate:,
12
+ uuid:
13
+ ),
14
+ client: Client.new(
15
+ private_key:,
16
+ access_token: ""
17
+ )
22
18
  ).authenticate
23
19
  end
24
20
 
25
- def initialize(xml_auth_body:)
21
+ def initialize(xml_auth_body:, client:)
26
22
  @xml_auth_body = xml_auth_body
23
+ @client = client
27
24
  end
28
25
 
29
26
  def authenticate
30
- response = HTTPX.post(
31
- AUTH_URL,
32
- headers: HEADERS,
33
- body: xml_auth_body.sign
34
- )
27
+ response = client.authenticate(xml_auth_body.generate)
35
28
 
36
29
  case response.status
37
30
  when 200..299
38
- Result.new(success?: true, value: response.xml)
31
+ Result.new(success?: true,
32
+ value: response.xml.xpath("//xmlns:AutenticaResult",
33
+ xmlns: "http://DescargaMasivaTerceros.gob.mx").inner_text,
34
+ xml: response.xml)
39
35
  when 400..599
40
- Result.new(success?: false, value: response.xml)
36
+ Result.new(success?: false, value: nil, xml: response.xml)
41
37
  else
42
38
  SatMx::Error
43
39
  end
@@ -45,39 +41,29 @@ module SatMx
45
41
 
46
42
  private
47
43
 
48
- attr_reader :xml_auth_body
44
+ attr_reader :xml_auth_body, :client
49
45
  end
50
46
 
47
+ # @api private
51
48
  class XmlAuthBody
52
- def initialize(certificate:, private_key:, id: "uuid-#{SecureRandom.uuid}-1")
49
+ def initialize(certificate:, uuid:)
53
50
  @certificate = certificate
54
- @private_key = private_key
55
- @id = id
51
+ @uuid = uuid
56
52
  end
57
53
 
58
- def sign
59
- Xmldsig::SignedDocument.new(xml_document).sign do |data|
60
- private_key.sign(OpenSSL::Digest.new("SHA1"), data)
61
- end
62
- end
63
-
64
- private
65
-
66
- attr_reader :private_key, :certificate, :id
67
-
68
- def xml_document
54
+ def generate
69
55
  <<~XML
70
56
  <S11:Envelope xmlns:S11="http://schemas.xmlsoap.org/soap/envelope/" xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">
71
57
  <S11:Header>
72
58
  <wsse:Security S11:mustUnderstand="1" xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd">
73
59
  #{timestamp}
74
- <wsse:BinarySecurityToken wsu:Id="#{id}" ValueType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-x509-token-profile-1.0#X509v3" EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary">#{Base64.strict_encode64(certificate.to_der)}</wsse:BinarySecurityToken>
60
+ <wsse:BinarySecurityToken wsu:Id="#{uuid}" ValueType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-x509-token-profile-1.0#X509v3" EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary">#{Base64.strict_encode64(certificate.to_der)}</wsse:BinarySecurityToken>
75
61
  <Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
76
62
  #{signed_info}
77
63
  <SignatureValue></SignatureValue>
78
64
  <KeyInfo>
79
65
  <wsse:SecurityTokenReference>
80
- <wsse:Reference ValueType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-x509-token-profile-1.0#X509v3" URI="##{id}" />
66
+ <wsse:Reference ValueType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-x509-token-profile-1.0#X509v3" URI="##{uuid}" />
81
67
  </wsse:SecurityTokenReference>
82
68
  </KeyInfo>
83
69
  </Signature>
@@ -90,6 +76,10 @@ module SatMx
90
76
  XML
91
77
  end
92
78
 
79
+ private
80
+
81
+ attr_reader :certificate
82
+
93
83
  def timestamp
94
84
  current_time = Time.now.utc
95
85
  <<~XML
@@ -115,5 +105,9 @@ module SatMx
115
105
  </SignedInfo>
116
106
  XML
117
107
  end
108
+
109
+ def uuid
110
+ "uuid-#{@uuid}-1"
111
+ end
118
112
  end
119
113
  end
@@ -0,0 +1,66 @@
1
+ require "base64"
2
+
3
+ module SatMx
4
+ # @api private
5
+ module Body
6
+ S11 = "S11"
7
+ XMLNS = "xmlns"
8
+ DES = "des"
9
+ DS = "ds"
10
+
11
+ ENVELOPE_ATTRS = {
12
+ "#{XMLNS}:#{S11}" => "http://schemas.xmlsoap.org/soap/envelope/",
13
+ "#{XMLNS}:#{DES}" => "http://DescargaMasivaTerceros.sat.gob.mx",
14
+ "#{XMLNS}:#{DS}" => "http://www.w3.org/2000/09/xmldsig#"
15
+ }.freeze
16
+
17
+ NAMESPACE = ENVELOPE_ATTRS["#{XMLNS}:#{DES}"]
18
+
19
+ private
20
+
21
+ def envelope
22
+ Nokogiri::XML::Builder.new do |xml|
23
+ xml[S11].Envelope(
24
+ ENVELOPE_ATTRS
25
+ ) do
26
+ xml[S11].Header
27
+ xml[S11].Body do
28
+ yield xml
29
+ end
30
+ end
31
+ end.doc.root.to_xml
32
+ end
33
+
34
+ def signature(xml)
35
+ xml.Signature(XMLNS => "http://www.w3.org/2000/09/xmldsig#") do
36
+ xml.SignedInfo do
37
+ xml.CanonicalizationMethod("Algorithm" => "http://www.w3.org/TR/2001/REC-xml-c14n-20010315")
38
+ xml.SignatureMethod("Algorithm" => "http://www.w3.org/2000/09/xmldsig#rsa-sha1")
39
+ xml.Reference("URI" => "") do
40
+ xml.Transforms do
41
+ xml.Transform("Algorithm" => "http://www.w3.org/2000/09/xmldsig#enveloped-signature")
42
+ end
43
+ xml.DigestMethod("Algorithm" => "http://www.w3.org/2000/09/xmldsig#sha1")
44
+ xml.DigestValue
45
+ end
46
+ end
47
+ xml.SignatureValue
48
+ xml.KeyInfo do
49
+ xml.X509Data do
50
+ xml.X509IssuerSerial do
51
+ xml.X509IssuerName(certificate_issuer)
52
+ xml.X509SerialNumber(certificate_serial)
53
+ end
54
+ xml.X509Certificate(encoded_certificate)
55
+ end
56
+ end
57
+ end
58
+ end
59
+
60
+ def certificate_issuer = certificate.issuer.to_s(OpenSSL::X509::Name::RFC2253)
61
+
62
+ def certificate_serial = certificate.serial
63
+
64
+ def encoded_certificate = Base64.strict_encode64(certificate.to_der)
65
+ end
66
+ end
@@ -0,0 +1,70 @@
1
+ module SatMx
2
+ # @api private
3
+ class Client
4
+ HEADERS = {
5
+ "content-type" => "text/xml; charset=utf-8",
6
+ "accept" => "text/xml"
7
+ }.freeze
8
+ private_constant :HEADERS
9
+
10
+ def initialize(private_key:, access_token:)
11
+ @private_key = private_key
12
+ @access_token = access_token
13
+ end
14
+
15
+ def authenticate(payload)
16
+ HTTPX.post(
17
+ "https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/Autenticacion/Autenticacion.svc",
18
+ headers: {
19
+ "SOAPAction" => "http://DescargaMasivaTerceros.gob.mx/IAutenticacion/Autentica"
20
+ }.merge(HEADERS),
21
+ body: sign(payload)
22
+ )
23
+ end
24
+
25
+ def download_request(payload)
26
+ HTTPX.post(
27
+ "https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/SolicitaDescargaService.svc",
28
+ headers: {
29
+ "SOAPAction" => "http://DescargaMasivaTerceros.sat.gob.mx/ISolicitaDescargaService/SolicitaDescarga"
30
+ }.merge(authorization)
31
+ .merge(HEADERS),
32
+ body: sign(payload)
33
+ )
34
+ end
35
+
36
+ def verify_request(payload)
37
+ HTTPX.post(
38
+ "https://cfdidescargamasivasolicitud.clouda.sat.gob.mx/VerificaSolicitudDescargaService.svc",
39
+ headers: {
40
+ "SOAPAction" => "http://DescargaMasivaTerceros.sat.gob.mx/IVerificaSolicitudDescargaService/VerificaSolicitudDescarga"
41
+ }.merge(authorization)
42
+ .merge(HEADERS),
43
+ body: sign(payload)
44
+ )
45
+ end
46
+
47
+ def download_petition(payload)
48
+ HTTPX.post(
49
+ "https://cfdidescargamasiva.clouda.sat.gob.mx/DescargaMasivaService.svc",
50
+ headers: {
51
+ "SOAPAction" => "http://DescargaMasivaTerceros.sat.gob.mx/IDescargaMasivaTercerosService/Descargar"
52
+ }.merge(authorization)
53
+ .merge(HEADERS),
54
+ body: sign(payload)
55
+ )
56
+ end
57
+
58
+ private
59
+
60
+ attr_reader :private_key, :access_token
61
+
62
+ def authorization
63
+ {"Authorization" => "WRAP access_token=\"#{access_token}\""}
64
+ end
65
+
66
+ def sign(payload)
67
+ Signer.sign(document: payload, private_key:)
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,14 @@
1
+ module SatMx
2
+ # @api private
3
+ Configuration = Data.define(:certificate, :private_key) do
4
+ def initialize(certificate:, private_key:, password:)
5
+ super(
6
+ certificate: OpenSSL::X509::Certificate.new(File.read(certificate)),
7
+ private_key: OpenSSL::PKey::RSA.new(
8
+ File.read(private_key),
9
+ password
10
+ )
11
+ )
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,65 @@
1
+ module SatMx
2
+ # @api private
3
+ class DownloadPetition
4
+ def self.call(package_id:, requester_rfc:, access_token:, certificate:, private_key:)
5
+ new(
6
+ body: DownloadPetitionBody.new(
7
+ package_id:,
8
+ requester_rfc:,
9
+ certificate:
10
+ ),
11
+ client: Client.new(private_key:, access_token:)
12
+ ).call
13
+ end
14
+
15
+ def initialize(body:, client:)
16
+ @body = body
17
+ @client = client
18
+ end
19
+
20
+ def call
21
+ response = client.download_petition(body.generate)
22
+
23
+ case response.status
24
+ when 200..299
25
+ xml = response.xml
26
+ response_tag = xml.xpath(
27
+ "//xmlns:respuesta",
28
+ xmlns: "http://DescargaMasivaTerceros.sat.gob.mx"
29
+ )[0]
30
+
31
+ if response_tag["CodEstatus"] == "5000"
32
+ Result.new(
33
+ success?: true,
34
+ xml: response.xml,
35
+ value: response.xml.xpath(
36
+ "//xmlns:Paquete",
37
+ xmlns: "http://DescargaMasivaTerceros.sat.gob.mx"
38
+ ).inner_text
39
+ )
40
+ else
41
+ Result.new(
42
+ success?: false,
43
+ xml: response.xml,
44
+ value: {
45
+ cod_estatus: response_tag["CodEstatus"],
46
+ mensaje: response_tag["Mensaje"]
47
+ }
48
+ )
49
+ end
50
+ when 400..599
51
+ Result.new(
52
+ success?: false,
53
+ xml: nil,
54
+ value: nil
55
+ )
56
+ else
57
+ SatMx::Error
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ attr_reader :client, :body
64
+ end
65
+ end
@@ -0,0 +1,29 @@
1
+ module SatMx
2
+ # @api private
3
+ class DownloadPetitionBody
4
+ include Body
5
+
6
+ def initialize(package_id:, requester_rfc:, certificate:)
7
+ @package_id = package_id
8
+ @requester_rfc = requester_rfc
9
+ @certificate = certificate
10
+ end
11
+
12
+ def generate
13
+ envelope do |xml|
14
+ xml[Body::DES].PeticionDescargaMasivaTercerosEntrada do
15
+ xml[Body::DES].peticionDescarga(
16
+ IdPaquete: package_id,
17
+ RfcSolicitante: requester_rfc
18
+ ) do
19
+ signature(xml)
20
+ end
21
+ end
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ attr_reader :package_id, :requester_rfc, :certificate
28
+ end
29
+ end
@@ -0,0 +1,68 @@
1
+ module SatMx
2
+ # @api private
3
+ class DownloadRequest
4
+ def self.call(start_date:,
5
+ end_date:,
6
+ request_type:,
7
+ issuing_rfc:,
8
+ recipient_rfcs:,
9
+ requester_rfc:,
10
+ access_token:,
11
+ certificate:,
12
+ private_key:)
13
+ new(
14
+ download_request_body: DownloadRequestBody.new(
15
+ start_date:,
16
+ end_date:,
17
+ request_type:,
18
+ issuing_rfc:,
19
+ recipient_rfcs:,
20
+ requester_rfc:,
21
+ certificate:
22
+ ),
23
+ client: Client.new(private_key:, access_token:)
24
+ ).call
25
+ end
26
+
27
+ def initialize(download_request_body:, client:)
28
+ @download_request_body = download_request_body
29
+ @client = client
30
+ end
31
+
32
+ def call
33
+ response = client.download_request(download_request_body.generate)
34
+
35
+ case response.status
36
+ when 200..299
37
+ check_body_status response.xml
38
+ when 400..599
39
+ Result.new(success?: false, value: nil, xml: response.xml)
40
+ else
41
+ SatMx::Error
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ attr_reader :download_request_body, :client
48
+
49
+ def check_body_status(xml)
50
+ download_result_tag = xml.xpath("//xmlns:SolicitaDescargaResult",
51
+ xmlns: Body::NAMESPACE)
52
+ if download_result_tag.attr("CodEstatus").value == "5000"
53
+ Result.new(success?: true,
54
+ value: download_result_tag.attr("IdSolicitud").value,
55
+ xml: xml)
56
+ else
57
+ Result.new(
58
+ success?: false,
59
+ value: {
60
+ cod_estatus: download_result_tag.attr("CodEstatus").value,
61
+ mensaje: download_result_tag.attr("Mensaje").value
62
+ },
63
+ xml:
64
+ )
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,60 @@
1
+ require "time"
2
+
3
+ module SatMx
4
+ # @api private
5
+ class DownloadRequestBody
6
+ include Body
7
+
8
+ REQUEST_TYPES = {
9
+ cfdi: "CFDI",
10
+ metadata: "Metadata"
11
+ }.freeze
12
+
13
+ def initialize(start_date:,
14
+ end_date:,
15
+ request_type:,
16
+ issuing_rfc:,
17
+ recipient_rfcs:,
18
+ requester_rfc:,
19
+ certificate:)
20
+ @start_date = start_date
21
+ @end_date = end_date
22
+ @request_type = request_type
23
+ @issuing_rfc = issuing_rfc
24
+ @recipient_rfcs = recipient_rfcs
25
+ @requester_rfc = requester_rfc
26
+ @certificate = certificate
27
+ end
28
+
29
+ def generate
30
+ envelope do |xml|
31
+ xml[Body::DES].SolicitaDescarga do
32
+ xml[Body::DES].solicitud(
33
+ "FechaInicial" => start_date,
34
+ "FechaFinal" => end_date,
35
+ "RfcEmisor" => issuing_rfc,
36
+ "RfcSolicitante" => requester_rfc,
37
+ "TipoSolicitud" => request_type
38
+ ) do
39
+ xml[Body::DES].RfcReceptores do
40
+ @recipient_rfcs.each do |rfc|
41
+ xml[Body::DES].RfcReceptor(rfc)
42
+ end
43
+ end
44
+ signature(xml)
45
+ end
46
+ end
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ attr_reader :issuing_rfc, :requester_rfc, :certificate
53
+
54
+ def start_date = @start_date.strftime("%Y-%m-%dT%H:%M:%S")
55
+
56
+ def end_date = @end_date.strftime("%Y-%m-%dT%H:%M:%S")
57
+
58
+ def request_type = REQUEST_TYPES.fetch(@request_type)
59
+ end
60
+ end
@@ -0,0 +1,4 @@
1
+ module SatMx
2
+ # @api private
3
+ Result = Data.define(:success?, :value, :xml)
4
+ end
@@ -0,0 +1,26 @@
1
+ require "openssl"
2
+ require "xmldsig"
3
+
4
+ module SatMx
5
+ # @api private
6
+ class Signer
7
+ def self.sign(document:, private_key:)
8
+ new(document:, private_key:).sign
9
+ end
10
+
11
+ def initialize(document:, private_key:)
12
+ @unsigned_document = Xmldsig::SignedDocument.new(document)
13
+ @private_key = private_key
14
+ end
15
+
16
+ def sign
17
+ unsigned_document.sign do |data|
18
+ private_key.sign(OpenSSL::Digest.new("SHA1"), data)
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ attr_reader :unsigned_document, :private_key
25
+ end
26
+ end
@@ -0,0 +1,70 @@
1
+ module SatMx
2
+ # @api private
3
+ class VerifyRequest
4
+ STATUS = {
5
+ "1" => :accepted,
6
+ "2" => :in_proccess,
7
+ "3" => :finished,
8
+ "4" => :error,
9
+ "5" => :rejected,
10
+ "6" => :expired
11
+ }
12
+ private_constant :STATUS
13
+
14
+ def self.call(request_id:, requester_rfc:, access_token:, private_key:, certificate:)
15
+ new(
16
+ body: VerifyRequestBody.new(request_id:, requester_rfc:, certificate:),
17
+ client: Client.new(private_key:, access_token:)
18
+ ).call
19
+ end
20
+
21
+ def initialize(body:, client:)
22
+ @body = body
23
+ @client = client
24
+ end
25
+
26
+ def call
27
+ response = client.verify_request(body.generate)
28
+ case response.status
29
+ when 200..299
30
+ check_body_status(response.xml)
31
+ when 400..599
32
+ Result.new(success?: false, value: nil, xml: response.xml)
33
+ else
34
+ SatMx::Error
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ attr_reader :body, :client
41
+
42
+ def check_body_status(xml)
43
+ download_result_tag = xml.xpath("//xmlns:VerificaSolicitudDescargaResult",
44
+ xmlns: Body::NAMESPACE)
45
+ if download_result_tag.attr("CodEstatus").value == "5000"
46
+ Result.new(success?: true,
47
+ value: value(download_result_tag, xml),
48
+ xml: xml)
49
+ else
50
+ Result.new(
51
+ success?: false,
52
+ value: {
53
+ cod_status: download_result_tag.attr("CodEstatus").value,
54
+ mensaje: download_result_tag.attr("Mensaje").value
55
+ },
56
+ xml:
57
+ )
58
+ end
59
+ end
60
+
61
+ def value(tag, xml)
62
+ {
63
+ request_status: STATUS.fetch(
64
+ tag.attribute("EstadoSolicitud").value
65
+ ),
66
+ package_ids: xml.xpath("//xmlns:IdsPaquetes", xmlns: Body::NAMESPACE).map(&:inner_text)
67
+ }
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,29 @@
1
+ module SatMx
2
+ # @api private
3
+ class VerifyRequestBody
4
+ include Body
5
+
6
+ def initialize(certificate:, request_id:, requester_rfc:)
7
+ @certificate = certificate
8
+ @request_id = request_id
9
+ @requester_rfc = requester_rfc
10
+ end
11
+
12
+ def generate
13
+ envelope do |xml|
14
+ xml[Body::DES].VerificaSolicitudDescarga do
15
+ xml[Body::DES].solicitud(
16
+ "IdSolicitud" => request_id,
17
+ "RfcSolicitante" => requester_rfc
18
+ ) do
19
+ signature(xml)
20
+ end
21
+ end
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ attr_reader :certificate, :request_id, :requester_rfc
28
+ end
29
+ end
@@ -1,5 +1,3 @@
1
- # frozen_string_literal: true
2
-
3
1
  module SatMx
4
- VERSION = "0.1.0"
2
+ VERSION = "0.3.0"
5
3
  end
data/lib/sat_mx.rb CHANGED
@@ -1,8 +1,195 @@
1
- # frozen_string_literal: true
2
-
1
+ require "httpx"
3
2
  require_relative "sat_mx/version"
4
3
 
5
4
  module SatMx
6
5
  class Error < StandardError; end
7
- autoload(:BulkDownload, "sat_mx/bulk_download")
6
+ autoload(:Configuration, "sat_mx/configuration")
7
+ autoload(:Authentication, "sat_mx/authentication")
8
+ autoload(:DownloadRequest, "sat_mx/download_request")
9
+ autoload(:DownloadRequestBody, "sat_mx/download_request_body")
10
+ autoload(:VerifyRequest, "sat_mx/verify_request")
11
+ autoload(:VerifyRequestBody, "sat_mx/verify_request_body")
12
+ autoload(:DownloadPetition, "sat_mx/download_petition")
13
+ autoload(:DownloadPetitionBody, "sat_mx/download_petition_body")
14
+ autoload(:Body, "sat_mx/body")
15
+ autoload(:Result, "sat_mx/result")
16
+ autoload(:Signer, "sat_mx/signer")
17
+ autoload(:Client, "sat_mx/client")
18
+
19
+ class << self
20
+ # Configures the gem using a block, its not threadsafe, so its recommended call only when you're initializing
21
+ # your application, e.g. in your initializers directory of your rails app
22
+ # @example
23
+ # SatMx.configure do |config|
24
+ # config[:certificate] = "path/to/certificate.cer"
25
+ # config[:private_key] = "path/to/private.key"
26
+ # config[:password] = "key_password"
27
+ # end
28
+ def configure
29
+ config = {}
30
+ yield(config)
31
+ @configuration = Configuration.new(**config)
32
+ end
33
+
34
+ def configuration
35
+ @configuration ||= Configuration.new
36
+ end
37
+
38
+ # Authenticates with the SAT web service using the configured certificate and private key.
39
+ # This method uses SOAP to communicate with the SAT authentication service and returns
40
+ # a token that can be used for subsequent requests.
41
+ #
42
+ # result = SatMx.authenticate
43
+ # if result.success?
44
+ # puts "Authentication token: #{result.value}"
45
+ # else
46
+ # puts "Authentication failed"
47
+ # end
48
+ #
49
+ # @param certificate [OpenSSL::X509::Certificate, nil] Certificate object (uses configuration if nil)
50
+ # @param private_key [OpenSSL::PKey::RSA, nil] Private key object (uses configuration if nil)
51
+ #
52
+ # @return [SatMx::Result] A Result object containing:
53
+ # - success?: [Boolean] whether the authentication was successful
54
+ # - value: [String, nil] the authentication token if successful, nil otherwise
55
+ # - xml: [Nokogiri::XML::Document] the raw XML response from the service
56
+ #
57
+ # @see SatMx::Authentication
58
+ # @see SatMx::Result
59
+ def authenticate(certificate: nil, private_key: nil)
60
+ cert = certificate || configuration.certificate
61
+ key = private_key || configuration.private_key
62
+ Authentication.authenticate(
63
+ certificate: cert,
64
+ private_key: key
65
+ )
66
+ end
67
+
68
+ # Requests a download of CFDI documents from the SAT web service.
69
+ #
70
+ # result = SatMx.download_request(
71
+ # start_date: Time.new(2024, 1, 1),
72
+ # end_date: Time.new(2024, 1, 31),
73
+ # request_type: :cfdi,
74
+ # issuing_rfc: "ABC010101ABC",
75
+ # recipient_rfcs: ["XYZ020202XYZ"],
76
+ # requester_rfc: "ABC010101ABC",
77
+ # access_token: "your_access_token"
78
+ # )
79
+ # if result.success?
80
+ # puts "Request ID: #{result.value}"
81
+ # else
82
+ # puts "Request failed: #{result.value}"
83
+ # end
84
+ #
85
+ # @param start_date [Time] Start date for the search range
86
+ # @param end_date [Time] End date for the search range
87
+ # @param request_type [Symbol] Type of request (:cfdi or :retentions)
88
+ # @param issuing_rfc [String] RFC of the issuer
89
+ # @param recipient_rfcs [Array<String>] RFCs of the recipients
90
+ # @param requester_rfc [String] RFC of the requester
91
+ # @param access_token [String] Authentication token from SatMx.authenticate
92
+ # @param certificate [String, nil] Path to certificate file (uses configuration if nil)
93
+ # @param private_key [String, nil] Path to private key file (uses configuration if nil)
94
+ #
95
+ # @return [SatMx::Result] A Result object containing:
96
+ # - success?: [Boolean] whether the request was successful
97
+ # - value: [String, nil] the request ID if successful, or {cod_estatus:, mensaje:} on failure
98
+ # - xml: [Nokogiri::XML::Document] the raw XML response from the service
99
+ #
100
+ # @see SatMx::DownloadRequest
101
+ # @see SatMx::Result
102
+ def download_request(start_date:, end_date:, request_type:, issuing_rfc:, recipient_rfcs:, requester_rfc:, access_token:, **options)
103
+ certificate = options[:certificate] || configuration.certificate
104
+ private_key = options[:private_key] || configuration.private_key
105
+ DownloadRequest.call(
106
+ start_date:,
107
+ end_date:,
108
+ request_type:,
109
+ issuing_rfc:,
110
+ recipient_rfcs:,
111
+ requester_rfc:,
112
+ access_token:,
113
+ certificate:,
114
+ private_key:
115
+ )
116
+ end
117
+
118
+ # Verifies the status of a previously submitted download request.
119
+ #
120
+ # result = SatMx.verify_request(
121
+ # request_id: "606c5667-345a-4630-8979-0769734ac80b",
122
+ # requester_rfc: "ABC010101ABC",
123
+ # access_token: "your_access_token"
124
+ # )
125
+ # if result.success?
126
+ # puts "Status: #{result.value[:request_status]}"
127
+ # puts "Packages: #{result.value[:package_ids]}"
128
+ # else
129
+ # puts "Verification failed: #{result.value}"
130
+ # end
131
+ #
132
+ # @param request_id [String] The ID returned from SatMx.download_request
133
+ # @param requester_rfc [String] RFC of the requester
134
+ # @param access_token [String] Authentication token from SatMx.authenticate
135
+ # @param certificate [String, nil] Path to certificate file (uses configuration if nil)
136
+ # @param private_key [String, nil] Path to private key file (uses configuration if nil)
137
+ #
138
+ # @return [SatMx::Result] A Result object containing:
139
+ # - success?: [Boolean] whether the verification was successful
140
+ # - value: [Hash, nil] containing :request_status and :package_ids if successful, or {cod_estatus:, mensaje:} on failure
141
+ # - xml: [Nokogiri::XML::Document] the raw XML response from the service
142
+ #
143
+ # @see SatMx::VerifyRequest
144
+ # @see SatMx::Result
145
+ def verify_request(request_id:, requester_rfc:, access_token:, **options)
146
+ certificate = options[:certificate] || configuration.certificate
147
+ private_key = options[:private_key] || configuration.private_key
148
+ VerifyRequest.call(
149
+ request_id:,
150
+ requester_rfc:,
151
+ access_token:,
152
+ certificate:,
153
+ private_key:
154
+ )
155
+ end
156
+
157
+ # Downloads a package of CFDI documents from the SAT web service.
158
+ #
159
+ # result = SatMx.download_petition(
160
+ # package_id: "18015570-C084-4BE8-BE36-476F5D46A133_01",
161
+ # requester_rfc: "ABC010101ABC",
162
+ # access_token: "your_access_token"
163
+ # )
164
+ # if result.success?
165
+ # File.write("package.zip", result.value)
166
+ # else
167
+ # puts "Download failed: #{result.value}"
168
+ # end
169
+ #
170
+ # @param package_id [String] The package ID from SatMx.verify_request
171
+ # @param requester_rfc [String] RFC of the requester
172
+ # @param access_token [String] Authentication token from SatMx.authenticate
173
+ # @param certificate [String, nil] Path to certificate file (uses configuration if nil)
174
+ # @param private_key [String, nil] Path to private key file (uses configuration if nil)
175
+ #
176
+ # @return [SatMx::Result] A Result object containing:
177
+ # - success?: [Boolean] whether the download was successful
178
+ # - value: [String, nil] Base64 encoded ZIP content if successful, or {cod_estatus:, mensaje:} on failure
179
+ # - xml: [Nokogiri::XML::Document] the raw XML response from the service
180
+ #
181
+ # @see SatMx::DownloadPetition
182
+ # @see SatMx::Result
183
+ def download_petition(package_id:, requester_rfc:, access_token:, **options)
184
+ certificate = options[:certificate] || configuration.certificate
185
+ private_key = options[:private_key] || configuration.private_key
186
+ DownloadPetition.call(
187
+ package_id:,
188
+ requester_rfc:,
189
+ access_token:,
190
+ certificate:,
191
+ private_key:
192
+ )
193
+ end
194
+ end
8
195
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sat_mx
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Oscar Rivas
8
- autorequire:
8
+ autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-10-03 00:00:00.000000000 Z
11
+ date: 2026-02-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: xmldsig
@@ -38,6 +38,20 @@ dependencies:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
40
  version: '1.3'
41
+ - !ruby/object:Gem::Dependency
42
+ name: base64
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "<"
46
+ - !ruby/object:Gem::Version
47
+ version: '1'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "<"
53
+ - !ruby/object:Gem::Version
54
+ version: '1'
41
55
  - !ruby/object:Gem::Dependency
42
56
  name: standard
43
57
  requirement: !ruby/object:Gem::Requirement
@@ -109,7 +123,18 @@ files:
109
123
  - README.md
110
124
  - Rakefile
111
125
  - lib/sat_mx.rb
112
- - lib/sat_mx/bulk_download.rb
126
+ - lib/sat_mx/authentication.rb
127
+ - lib/sat_mx/body.rb
128
+ - lib/sat_mx/client.rb
129
+ - lib/sat_mx/configuration.rb
130
+ - lib/sat_mx/download_petition.rb
131
+ - lib/sat_mx/download_petition_body.rb
132
+ - lib/sat_mx/download_request.rb
133
+ - lib/sat_mx/download_request_body.rb
134
+ - lib/sat_mx/result.rb
135
+ - lib/sat_mx/signer.rb
136
+ - lib/sat_mx/verify_request.rb
137
+ - lib/sat_mx/verify_request_body.rb
113
138
  - lib/sat_mx/version.rb
114
139
  - sig/sat_mx.rbs
115
140
  homepage: https://github.com/kadru/sat_mx
@@ -118,7 +143,7 @@ licenses:
118
143
  metadata:
119
144
  homepage_uri: https://github.com/kadru/sat_mx
120
145
  changelog_uri: https://github.com/kadru/sat_mx/CHANGELOG.md
121
- post_install_message:
146
+ post_install_message:
122
147
  rdoc_options: []
123
148
  require_paths:
124
149
  - lib
@@ -134,7 +159,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
134
159
  version: '0'
135
160
  requirements: []
136
161
  rubygems_version: 3.5.11
137
- signing_key:
162
+ signing_key:
138
163
  specification_version: 4
139
164
  summary: a client to connect to SAT web services
140
165
  test_files: []