ticketbai 0.1.1

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.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +32 -0
  4. data/CHANGELOG.md +13 -0
  5. data/Gemfile +6 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +107 -0
  8. data/Rakefile +12 -0
  9. data/bin/console +15 -0
  10. data/bin/setup +8 -0
  11. data/lib/ticketbai/api/client.rb +63 -0
  12. data/lib/ticketbai/api/registry.rb +34 -0
  13. data/lib/ticketbai/api/request.rb +125 -0
  14. data/lib/ticketbai/api/response_parser.rb +36 -0
  15. data/lib/ticketbai/checksum_calculator.rb +15 -0
  16. data/lib/ticketbai/document.rb +21 -0
  17. data/lib/ticketbai/document_validator.rb +42 -0
  18. data/lib/ticketbai/documents/annulment.rb +45 -0
  19. data/lib/ticketbai/documents/api_payload.rb +47 -0
  20. data/lib/ticketbai/documents/issuance.rb +55 -0
  21. data/lib/ticketbai/documents/issuance_unsigned.rb +58 -0
  22. data/lib/ticketbai/errors.rb +27 -0
  23. data/lib/ticketbai/nodes/breakdown_type.rb +72 -0
  24. data/lib/ticketbai/nodes/invoice_chaining.rb +22 -0
  25. data/lib/ticketbai/nodes/invoice_data.rb +24 -0
  26. data/lib/ticketbai/nodes/invoice_header.rb +26 -0
  27. data/lib/ticketbai/nodes/issuer.rb +18 -0
  28. data/lib/ticketbai/nodes/lroe_header.rb +29 -0
  29. data/lib/ticketbai/nodes/lroe_issued_invoices.rb +28 -0
  30. data/lib/ticketbai/nodes/receiver.rb +36 -0
  31. data/lib/ticketbai/nodes/software.rb +17 -0
  32. data/lib/ticketbai/operation.rb +59 -0
  33. data/lib/ticketbai/operations/annulment.rb +41 -0
  34. data/lib/ticketbai/operations/issuance.rb +93 -0
  35. data/lib/ticketbai/operations/issuance_unsigned.rb +89 -0
  36. data/lib/ticketbai/signer.rb +273 -0
  37. data/lib/ticketbai/tbai_identifier.rb +33 -0
  38. data/lib/ticketbai/tbai_qr.rb +50 -0
  39. data/lib/ticketbai/version.rb +5 -0
  40. data/lib/ticketbai/xmldsig-core-schema.xsd +318 -0
  41. data/lib/ticketbai/xsd_validators/annulment.xsd +392 -0
  42. data/lib/ticketbai/xsd_validators/issuance.xsd +865 -0
  43. data/lib/ticketbai.rb +89 -0
  44. data/ticketbai.gemspec +42 -0
  45. metadata +186 -0
@@ -0,0 +1,89 @@
1
+ module Ticketbai
2
+ module Operations
3
+ # In this operation, the document is not signed and it is not encoded in Base64 when making the request to the API as issuance and annulment
4
+ # operations, instead it is directly appended to the tag FacturaEmitida of the api payload document.
5
+ class IssuanceUnsigned
6
+ # Sujetos > Destinatario
7
+ attr_reader :receiver_nif, :receiver_name
8
+ # Factura > CabeceraFactura
9
+ attr_reader :invoice_serial, :invoice_number, :invoice_date, :invoice_time
10
+ # Factura > DatosFactura
11
+ attr_reader :invoice_description, :invoice_total, :invoice_vat_key
12
+ # Factura > TipoDesglose
13
+ attr_reader :invoice_amount, :invoice_vat, :invoice_vat_total
14
+
15
+ OPERATION_NAME = :issuance_unsigned
16
+
17
+ ###
18
+ # @param [String] receiver_nif Spanish NIF or official identification document in case of being another country
19
+ # @param [String] receiver_name Name of the receiver
20
+ # @param [String] receiver_country Country code of the receiver (Ex: ES|DE)
21
+ # @param [Boolean] receiver_in_eu The receiver residence is in Europe?
22
+ # @param [String] invoice_serial Invoices serial number
23
+ # @param [String] invoice_number Invoices number
24
+ # @param [String] invoice_date Invoices emission date (Format: d-m-Y)
25
+ # @param [Boolean] simplified_invoice is a simplified invoice?
26
+ # @param [String] invoice_description Invoices description text
27
+ # @param [String] invoice_total Total invoice amount
28
+ # @param [String] invoice_vat_key Key of the VAT regime
29
+ # @param [String] invoice_amount Taxable base of the invoice
30
+ # @param [Float] invoice_vat Invoice VAT (Ex: 21.0)
31
+ # @param [String] invoice_vat_total Invoices number
32
+ ###
33
+ def initialize(**args)
34
+ @receiver_nif = args[:receiver_nif].strip
35
+ @receiver_name = args[:receiver_name]
36
+ @receiver_country = args[:receiver_country]&.upcase || 'ES'
37
+ @receiver_in_eu = args[:receiver_in_eu]
38
+ @invoice_serial = args[:invoice_serial]
39
+ @invoice_number = args[:invoice_number]
40
+ @invoice_date = args[:invoice_date]
41
+ @simplified_invoice = args[:simplified_invoice]
42
+ @invoice_description = args[:invoice_description]
43
+ @invoice_total = args[:invoice_total]
44
+ @invoice_vat_key = args[:invoice_vat_key]
45
+ @invoice_amount = args[:invoice_amount]
46
+ @invoice_vat = args[:invoice_vat]
47
+ @invoice_vat_total = args[:invoice_vat_total]
48
+ end
49
+
50
+ def create
51
+ build_document
52
+ end
53
+
54
+ def build_document
55
+ if @receiver_nif && @receiver_name
56
+ @receiver = Ticketbai::Nodes::Receiver.new(receiver_country: @receiver_country, receiver_nif: @receiver_nif, receiver_name: @receiver_name)
57
+ end
58
+
59
+ @invoice_header = Ticketbai::Nodes::InvoiceHeader.new(
60
+ invoice_serial: @invoice_serial,
61
+ invoice_number: @invoice_number,
62
+ invoice_date: @invoice_date,
63
+ invoice_time: @invoice_time,
64
+ simplified_invoice: @simplified_invoice
65
+ )
66
+ @invoice_data = Ticketbai::Nodes::InvoiceData.new(
67
+ invoice_description: @invoice_description,
68
+ invoice_total: @invoice_total,
69
+ invoice_vat_key: @invoice_vat_key
70
+ )
71
+ @breakdown_type = Ticketbai::Nodes::BreakdownType.new(
72
+ receiver_country: @receiver_country,
73
+ invoice_amount: @invoice_amount,
74
+ invoice_vat: @invoice_vat,
75
+ invoice_vat_total: @invoice_vat_total,
76
+ receiver_in_eu: @receiver_in_eu,
77
+ simplified_invoice: @simplified_invoice
78
+ )
79
+
80
+ Ticketbai::Documents::IssuanceUnsigned.new(
81
+ receiver: @receiver,
82
+ invoice_header: @invoice_header,
83
+ invoice_data: @invoice_data,
84
+ breakdown_type: @breakdown_type
85
+ ).create
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,273 @@
1
+ require 'openssl'
2
+ require 'base64'
3
+ require 'rexml/document'
4
+ require 'rexml/xpath'
5
+ require 'securerandom'
6
+ require 'date'
7
+ require 'nokogiri'
8
+
9
+ module Ticketbai
10
+ class Signer
11
+ C14N = 'http://www.w3.org/TR/2001/REC-xml-c14n-20010315'.freeze # "http://www.w3.org/2001/10/xml-exc-c14n#"
12
+ DSIG = 'http://www.w3.org/2000/09/xmldsig#'.freeze
13
+ NOKOGIRI_OPTIONS = Nokogiri::XML::ParseOptions::STRICT | Nokogiri::XML::ParseOptions::NONET | Nokogiri::XML::ParseOptions::NOENT
14
+ RSA_SHA256 = 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256'.freeze
15
+ SHA1 = 'http://www.w3.org/2000/09/xmldsig#sha1'.freeze
16
+ SHA256 = 'http://www.w3.org/2001/04/xmlenc#sha256'.freeze
17
+ SHA384 = 'http://www.w3.org/2001/04/xmldsig-more#sha384'.freeze
18
+ SHA512 = 'http://www.w3.org/2001/04/xmlenc#sha512'.freeze
19
+ ENVELOPED_SIG = 'http://www.w3.org/2000/09/xmldsig#enveloped-signature'.freeze
20
+ NAMESPACES = '#default ds xs xsi xades xsd'.freeze
21
+ XADES = 'http://uri.etsi.org/01903/v1.3.2#'.freeze
22
+
23
+ POLITICA_NAME = 'Politica de firma TicketBAI 1.0'.freeze
24
+ POLITICA_URL = 'https://www.batuz.eus/fitxategiak/batuz/ticketbai/sinadura_elektronikoaren_zehaztapenak_especificaciones_de_la_firma_electronica_v1_0.pdf'.freeze
25
+ POLITICA_DIGEST = 'Quzn98x3PMbSHwbUzaj5f5KOpiH0u8bvmwbbbNkO9Es='.freeze
26
+
27
+ def initialize(args = {})
28
+ xml = args[:xml]
29
+ certificate = args[:certificate]
30
+ key = args[:key]
31
+ @output_path = args[:output_path]
32
+
33
+ raise ArgumentError, 'Invalid arguments' if xml.nil? || certificate.nil? || key.nil?
34
+
35
+ @doc = Nokogiri::XML(xml) do |config|
36
+ config.options = Nokogiri::XML::ParseOptions::NOBLANKS | Nokogiri::XML::ParseOptions::NOENT | Nokogiri::XML::ParseOptions::NOENT
37
+ end
38
+
39
+ @p12 = OpenSSL::PKCS12.new(certificate, key)
40
+
41
+ @x509 = @p12.certificate
42
+ @document_tag = @doc.elements.first.name
43
+ end
44
+
45
+ def sign
46
+ # Build parts for Digest Calculation
47
+ key_info = build_key_info_element
48
+ signed_properties = build_signed_properties_element
49
+ signed_info_element = build_signed_info_element(key_info, signed_properties)
50
+
51
+ # Compute Signature
52
+ signed_info_canon = canonicalize_document(signed_info_element)
53
+ signature_value = compute_signature(@p12.key, algorithm(RSA_SHA256).new, signed_info_canon)
54
+
55
+ ds = Nokogiri::XML::Node.new('ds:Signature', @doc)
56
+
57
+ ds['xmlns:ds'] = DSIG
58
+ ds['Id'] = "xmldsig-#{uuid}"
59
+ ds.add_child(signed_info_element.root)
60
+
61
+ sv = Nokogiri::XML::Node.new('ds:SignatureValue', @doc)
62
+ sv['Id'] = "xmldsig-#{uuid}-sigvalue"
63
+ sv.content = signature_value
64
+ ds.add_child(sv)
65
+
66
+ ds.add_child(key_info.root)
67
+
68
+ dsobj = Nokogiri::XML::Node.new('ds:Object', @doc)
69
+ dsobj['Id'] = "XadesObjectId-xmldsig-#{uuid}" # XADES_OBJECT_ID
70
+ qp = Nokogiri::XML::Node.new('xades:QualifyingProperties', @doc)
71
+ qp['Id'] = "QualifyingProperties-xmldsig-#{uuid}"
72
+ qp['xmlns:ds'] = DSIG
73
+ qp['Target'] = "#xmldsig-#{uuid}"
74
+ qp['xmlns:xades'] = XADES
75
+
76
+ qp.add_child(signed_properties.root)
77
+
78
+ dsobj.add_child(qp)
79
+ ds.add_child(dsobj)
80
+
81
+ name = @doc.root.namespace
82
+ @doc.root.namespace = nil
83
+ @doc.root.add_child(ds)
84
+ @doc.root.namespace = name
85
+
86
+ @doc.to_xml(save_with: Nokogiri::XML::Node::SaveOptions::AS_XML).gsub(/\r|\n/, '')
87
+ end
88
+
89
+ private
90
+
91
+ def build_key_info_element
92
+ builder = Nokogiri::XML::Builder.new
93
+ attributes = {
94
+ 'xmlns:T' => 'urn:ticketbai:emision',
95
+ 'xmlns:ds' => 'http://www.w3.org/2000/09/xmldsig#',
96
+ 'xmlns:xsi' => 'http://www.w3.org/2001/XMLSchema-instance',
97
+ 'Id' => "KeyInfoId-xmldsig-#{uuid}"
98
+ }
99
+
100
+ builder.send('ds:KeyInfo', attributes) do |ki|
101
+ ki.send('ds:X509Data') do |kd|
102
+ kd.send('ds:X509Certificate', @x509.to_pem.to_s.gsub('-----BEGIN CERTIFICATE-----', '').gsub('-----END CERTIFICATE-----', '').gsub(/\n|\r/, ''))
103
+ end
104
+ ki.send('ds:KeyValue') do |kv|
105
+ kv.send('ds:RSAKeyValue') do |rv|
106
+ rv.send('ds:Modulus', Base64.encode64(@x509.public_key.params['n'].to_s(2)).gsub("\n", ''))
107
+ rv.send('ds:Exponent', Base64.encode64(@x509.public_key.params['e'].to_s(2)).gsub("\n", ''))
108
+ end
109
+ end
110
+ end
111
+
112
+ builder.doc
113
+ end
114
+
115
+ def build_signed_properties_element
116
+ cert_digest = compute_digest(@x509.to_der, algorithm(SHA256))
117
+ signing_time = DateTime.now.rfc3339
118
+
119
+ builder = Nokogiri::XML::Builder.new
120
+ attributes = {
121
+ 'xmlns:T' => 'urn:ticketbai:emision',
122
+ 'xmlns:ds' => 'http://www.w3.org/2000/09/xmldsig#',
123
+ 'xmlns:xades' => 'http://uri.etsi.org/01903/v1.3.2#',
124
+ 'xmlns:xsi' => 'http://www.w3.org/2001/XMLSchema-instance',
125
+ 'Id' => "xmldsig-#{uuid}-signedprops"
126
+ }
127
+
128
+ builder.send('xades:SignedProperties', attributes) do |sp|
129
+ sp.send('xades:SignedSignatureProperties') do |ssp|
130
+ ssp.send('xades:SigningTime', signing_time)
131
+ ssp.send('xades:SigningCertificate') do |sc|
132
+ sc.send('xades:Cert') do |c|
133
+ c.send('xades:CertDigest') do |xcd|
134
+ xcd.send('ds:DigestMethod', { 'Algorithm' => SHA256 })
135
+ xcd.send('ds:DigestValue', cert_digest)
136
+ end
137
+ c.send('xades:IssuerSerial') do |is|
138
+ is.send('ds:X509IssuerName', @x509.issuer.to_a.reverse.map { |x| x[0..1].join('=') }.join(', '))
139
+ is.send('ds:X509SerialNumber', @x509.serial.to_s)
140
+ end
141
+ end
142
+ end
143
+
144
+ ssp.send('xades:SignaturePolicyIdentifier') do |spi|
145
+ spi.send('xades:SignaturePolicyId') do |spi2|
146
+ spi2.send('xades:SigPolicyId') do |spi3|
147
+ spi3.send('xades:Identifier', POLITICA_URL)
148
+ spi3.send('xades:Description', POLITICA_NAME)
149
+ end
150
+
151
+ spi2.send('xades:SigPolicyHash') do |sph|
152
+ sph.send('ds:DigestMethod', { 'Algorithm' => 'http://www.w3.org/2001/04/xmlenc#sha256' })
153
+ sph.send('ds:DigestValue', POLITICA_DIGEST)
154
+ end
155
+
156
+ spi2.send('xades:SigPolicyQualifiers') do |spqs|
157
+ spqs.send('xades:SigPolicyQualifier') do |spq|
158
+ spq.send('xades:SPURI', POLITICA_URL)
159
+ end
160
+ end
161
+ end
162
+ end
163
+ end
164
+ sp.send('xades:SignedDataObjectProperties') do |sdop|
165
+ sdop.send('xades:DataObjectFormat', { 'ObjectReference' => "#xmldsig-#{uuid}-ref0" }) do |dof|
166
+ dof.send('xades:ObjectIdentifier') do |oi|
167
+ oi.send('xades:Identifier', { 'Qualifier' => 'OIDAsURN' }, 'urn:oid:1.2.840.10003.5.109.10')
168
+ end
169
+ dof.send('xades:MimeType', 'text/xml')
170
+ dof.send('xades:Encoding', 'UTF-8')
171
+ end
172
+ end
173
+ end
174
+
175
+ builder.doc
176
+ end
177
+
178
+ def build_signed_info_element(key_info_element, signed_props_element)
179
+ builder = Nokogiri::XML::Builder.new
180
+
181
+ builder.send('ds:SignedInfo',
182
+ { 'xmlns:T' => 'urn:ticketbai:emision', 'xmlns:ds' => 'http://www.w3.org/2000/09/xmldsig#',
183
+ 'xmlns:xsi' => 'http://www.w3.org/2001/XMLSchema-instance' }) do |si|
184
+ si.send('ds:CanonicalizationMethod', { 'Algorithm' => C14N })
185
+ si.send('ds:SignatureMethod', { 'Algorithm' => RSA_SHA256 })
186
+
187
+ si.send('ds:Reference', { 'Type' => 'http://uri.etsi.org/01903#SignedProperties', 'URI' => "#xmldsig-#{uuid}-signedprops" }) do |r|
188
+ r.send('ds:Transforms') do |t|
189
+ t.send('ds:Transform', { 'Algorithm' => C14N })
190
+ end
191
+ r.send('ds:DigestMethod', { 'Algorithm' => SHA256 })
192
+ r.send('ds:DigestValue', digest_document(signed_props_element, SHA256))
193
+ end
194
+
195
+ si.send('ds:Reference', { 'Id' => 'ReferenceKeyInfo', 'URI' => "#KeyInfoId-xmldsig-#{uuid}" }) do |r|
196
+ r.send('ds:Transforms') do |t|
197
+ t.send('ds:Transform', { 'Algorithm' => C14N })
198
+ end
199
+ r.send('ds:DigestMethod', { 'Algorithm' => SHA256 })
200
+ r.send('ds:DigestValue', digest_document(key_info_element, SHA256))
201
+ end
202
+
203
+ si.send('ds:Reference', { 'Id' => "xmldsig-#{uuid}-ref0", 'Type' => 'http://www.w3.org/2000/09/xmldsig#Object', 'URI' => '' }) do |r|
204
+ r.send('ds:Transforms') do |t|
205
+ t.send('ds:Transform', { 'Algorithm' => ENVELOPED_SIG })
206
+ t.send('ds:Transform', { 'Algorithm' => C14N })
207
+ end
208
+ r.send('ds:DigestMethod', { 'Algorithm' => SHA256 })
209
+ r.send('ds:DigestValue', digest_document(@doc, SHA256))
210
+ end
211
+ end
212
+
213
+ builder.doc
214
+ end
215
+
216
+ def digest_document(doc, digest_algorithm = SHA256)
217
+ compute_digest(canonicalize_document(doc), algorithm(digest_algorithm))
218
+ end
219
+
220
+ def canonicalize_document(doc)
221
+ doc.canonicalize(canon_algorithm(C14N), NAMESPACES.split)
222
+ end
223
+
224
+ def uuid
225
+ @uuid ||= SecureRandom.uuid
226
+ end
227
+
228
+ def canon_algorithm(element)
229
+ algorithm = element
230
+
231
+ case algorithm
232
+ when 'http://www.w3.org/TR/2001/REC-xml-c14n-20010315',
233
+ 'http://www.w3.org/TR/2001/REC-xml-c14n-20010315#WithComments'
234
+ Nokogiri::XML::XML_C14N_1_0
235
+ when 'http://www.w3.org/2006/12/xml-c14n11',
236
+ 'http://www.w3.org/2006/12/xml-c14n11#WithComments'
237
+ Nokogiri::XML::XML_C14N_1_1
238
+ else
239
+ Nokogiri::XML::XML_C14N_EXCLUSIVE_1_0
240
+ end
241
+ end
242
+
243
+ def algorithm(element)
244
+ algorithm = element
245
+
246
+ case algorithm
247
+ when REXML::Element
248
+ algorithm = element.attribute('Algorithm').value
249
+ when Nokogiri::XML::Element
250
+ algorithm = element.xpath('//@Algorithm', 'xmlns:ds' => 'http://www.w3.org/2000/09/xmldsig#').first.value
251
+ end
252
+
253
+ algorithm = algorithm && algorithm =~ /(rsa-)?sha(.*?)$/i && Regexp.last_match(2).to_i
254
+
255
+ case algorithm
256
+ when 256 then OpenSSL::Digest::SHA256
257
+ when 384 then OpenSSL::Digest::SHA384
258
+ when 512 then OpenSSL::Digest::SHA512
259
+ else
260
+ OpenSSL::Digest::SHA1
261
+ end
262
+ end
263
+
264
+ def compute_signature(private_key, signature_algorithm, document)
265
+ Base64.encode64(private_key.sign(signature_algorithm, document)).gsub(/\r|\n/, '')
266
+ end
267
+
268
+ def compute_digest(document, digest_algorithm)
269
+ digest = digest_algorithm.digest(document)
270
+ Base64.encode64(digest).strip!
271
+ end
272
+ end
273
+ end
@@ -0,0 +1,33 @@
1
+ module Ticketbai
2
+ class TbaiIdentifier
3
+ ID = 'TBAI'.freeze
4
+
5
+ attr_accessor :nif, :invoice_date, :signature_value
6
+
7
+ ###
8
+ # @param [String] nif The issuer NIF
9
+ # @param [String] invoice_date Format DDMMYY
10
+ # @param [String] signature_value First 13 characters of the signatureValue present in the signed xml
11
+ ###
12
+ def initialize(**args)
13
+ self.nif = args[:nif]
14
+ self.invoice_date = args[:invoice_date]
15
+ self.signature_value = args[:signature_value][0..12]
16
+ end
17
+
18
+ ###
19
+ # Builds the TBAI Identifier with the following format: TBAI-NIF-FechaExpedicionFactura(DDMMAA)-SignatureValue(13)-CRC(3)
20
+ # @return [String] The TBAI identifier.
21
+ ###
22
+ def create
23
+ identifier = [ID, @nif, @invoice_date, @signature_value].join('-')
24
+ identifier << '-'
25
+
26
+ crc = ChecksumCalculator.new(identifier).calculate
27
+
28
+ identifier << crc
29
+
30
+ identifier
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,50 @@
1
+ module Ticketbai
2
+ class TbaiQr
3
+ BASE_URL = 'https://batuz.eus/QRTBAI/?'.freeze
4
+
5
+ # param names
6
+ ID_TBAI_NAME = 'id'.freeze
7
+ SERIAL_NAME = 's'.freeze
8
+ NUMBER_NAME = 'nf'.freeze
9
+ TOTAL_NAME = 'i'.freeze
10
+ CRC_NAME = 'cr'.freeze
11
+
12
+ attr_accessor :id_tbai, :number, :serial, :total
13
+
14
+ ###
15
+ # @param [String] id_tbai The Tbai Identifier
16
+ # @param [String] number Invoice number
17
+ # @param [String] total Invoice total amount (Max 2 decimals) Ex: 14.20
18
+ # @param [String] serial The invoice serial number
19
+ ###
20
+ def initialize(**args)
21
+ self.id_tbai = args[:id_tbai]
22
+ self.serial = args[:serial]
23
+ self.number = args[:number]
24
+ self.total = args[:total]
25
+ end
26
+
27
+ ###
28
+ # Builds the Ticketbai QR URL. Example: https://batuz.eus/QRTBAI/?id=TBAI-00000006Y-251019-btFpwP8dcLGAF-237&s=T&nf=27174&i=4.70&cr=007
29
+ # @return [String] Ticketbai QR URL
30
+ ###
31
+ def create
32
+ encoded_params = [
33
+ format_param(ID_TBAI_NAME, @id_tbai),
34
+ format_param(SERIAL_NAME, @serial),
35
+ format_param(NUMBER_NAME, @number),
36
+ format_param(TOTAL_NAME, @total)
37
+ ].join('&')
38
+
39
+ crc = ChecksumCalculator.new(BASE_URL + encoded_params).calculate
40
+
41
+ BASE_URL + [encoded_params, format_param(CRC_NAME, crc)].join('&')
42
+ end
43
+
44
+ private
45
+
46
+ def format_param(name, value)
47
+ "#{name}=#{ERB::Util.url_encode(value)}"
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ticketbai
4
+ VERSION = '0.1.1'
5
+ end