zatca-sdk 1.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 +7 -0
- data/.rspec +2 -0
- data/Gemfile +8 -0
- data/LICENSE +21 -0
- data/README.md +60 -0
- data/Rakefile +4 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/lib/zatca/client.rb +211 -0
- data/lib/zatca/hacks.rb +45 -0
- data/lib/zatca/hashing.rb +18 -0
- data/lib/zatca/qr_code_extractor.rb +31 -0
- data/lib/zatca/qr_code_generator.rb +28 -0
- data/lib/zatca/signing/certificate.rb +78 -0
- data/lib/zatca/signing/csr.rb +220 -0
- data/lib/zatca/signing/ecdsa.rb +59 -0
- data/lib/zatca/tag.rb +44 -0
- data/lib/zatca/tags.rb +46 -0
- data/lib/zatca/tags_schema.rb +22 -0
- data/lib/zatca/types.rb +7 -0
- data/lib/zatca/ubl/base_component.rb +142 -0
- data/lib/zatca/ubl/builder.rb +166 -0
- data/lib/zatca/ubl/common_aggregate_components/allowance_charge.rb +64 -0
- data/lib/zatca/ubl/common_aggregate_components/classified_tax_category.rb +25 -0
- data/lib/zatca/ubl/common_aggregate_components/delivery.rb +27 -0
- data/lib/zatca/ubl/common_aggregate_components/invoice_line.rb +63 -0
- data/lib/zatca/ubl/common_aggregate_components/item.rb +21 -0
- data/lib/zatca/ubl/common_aggregate_components/legal_monetary_total.rb +59 -0
- data/lib/zatca/ubl/common_aggregate_components/party.rb +28 -0
- data/lib/zatca/ubl/common_aggregate_components/party_identification.rb +25 -0
- data/lib/zatca/ubl/common_aggregate_components/party_legal_entity.rb +19 -0
- data/lib/zatca/ubl/common_aggregate_components/party_tax_scheme.rb +30 -0
- data/lib/zatca/ubl/common_aggregate_components/postal_address.rb +59 -0
- data/lib/zatca/ubl/common_aggregate_components/price.rb +20 -0
- data/lib/zatca/ubl/common_aggregate_components/tax_category.rb +56 -0
- data/lib/zatca/ubl/common_aggregate_components/tax_total.rb +58 -0
- data/lib/zatca/ubl/common_aggregate_components.rb +2 -0
- data/lib/zatca/ubl/invoice.rb +481 -0
- data/lib/zatca/ubl/invoice_subtype_builder.rb +50 -0
- data/lib/zatca/ubl/signing/cert.rb +48 -0
- data/lib/zatca/ubl/signing/invoice_signed_data_reference.rb +44 -0
- data/lib/zatca/ubl/signing/key_info.rb +25 -0
- data/lib/zatca/ubl/signing/object.rb +20 -0
- data/lib/zatca/ubl/signing/qualifying_properties.rb +27 -0
- data/lib/zatca/ubl/signing/signature.rb +50 -0
- data/lib/zatca/ubl/signing/signature_information.rb +19 -0
- data/lib/zatca/ubl/signing/signature_properties_reference.rb +26 -0
- data/lib/zatca/ubl/signing/signed_info.rb +21 -0
- data/lib/zatca/ubl/signing/signed_properties.rb +81 -0
- data/lib/zatca/ubl/signing/signed_signature_properties.rb +23 -0
- data/lib/zatca/ubl/signing/ubl_document_signatures.rb +25 -0
- data/lib/zatca/ubl/signing/ubl_extension.rb +22 -0
- data/lib/zatca/ubl/signing/ubl_extensions.rb +17 -0
- data/lib/zatca/ubl/signing.rb +2 -0
- data/lib/zatca/ubl.rb +2 -0
- data/lib/zatca/version.rb +5 -0
- data/lib/zatca.rb +48 -0
- data/zatca_sdk.gemspec +52 -0
- metadata +301 -0
@@ -0,0 +1,220 @@
|
|
1
|
+
class ZATCA::Signing::CSR
|
2
|
+
attr_reader :key, :csr_options, :mode
|
3
|
+
|
4
|
+
# For security purposes, please provide your own private key.
|
5
|
+
# If you don't provide one, a new unsecure one will be generated for testing purposes.
|
6
|
+
def initialize(
|
7
|
+
csr_options:,
|
8
|
+
mode: :production,
|
9
|
+
private_key_path: nil,
|
10
|
+
private_key_password: nil
|
11
|
+
)
|
12
|
+
@csr_options = default_csr_options.merge(csr_options)
|
13
|
+
@mode = mode.to_sym
|
14
|
+
@private_key_path = private_key_path
|
15
|
+
@private_key_password = private_key_password
|
16
|
+
@generated_private_key_path = nil
|
17
|
+
end
|
18
|
+
|
19
|
+
# Returns a hash with two keys
|
20
|
+
# - csr
|
21
|
+
# - csr_without_headers
|
22
|
+
def generate(csr_options: {})
|
23
|
+
set_key
|
24
|
+
write_csr_config
|
25
|
+
|
26
|
+
command = generate_openssl_csr_command
|
27
|
+
|
28
|
+
# Run the command and return the output
|
29
|
+
output = `#{command}`
|
30
|
+
|
31
|
+
cleanup_leftover_files
|
32
|
+
|
33
|
+
output
|
34
|
+
|
35
|
+
# TODO: Ruby version
|
36
|
+
# request = OpenSSL::X509::Request.new
|
37
|
+
# request.version = 0
|
38
|
+
# request.subject = OpenSSL::X509::Name.new([
|
39
|
+
# ["C", csr_options[:country], OpenSSL::ASN1::PRINTABLESTRING],
|
40
|
+
# ["O", csr_options[:organization], OpenSSL::ASN1::UTF8STRING],
|
41
|
+
# ["OU", csr_options[:organization_unit], OpenSSL::ASN1::UTF8STRING],
|
42
|
+
# ["CN", csr_options[:common_name], OpenSSL::ASN1::UTF8STRING]
|
43
|
+
# ])
|
44
|
+
|
45
|
+
# extensions = [
|
46
|
+
# OpenSSL::X509::ExtensionFactory.new.create_extension("subjectAltName")
|
47
|
+
# ]
|
48
|
+
|
49
|
+
# # add SAN extension to the CSR
|
50
|
+
# attribute_values = OpenSSL::ASN1::Set [OpenSSL::ASN1::Sequence(extensions)]
|
51
|
+
# [
|
52
|
+
# OpenSSL::X509::Attribute.new("SN", attribute_values),
|
53
|
+
# OpenSSL::X509::Attribute.new("UID", attribute_values),
|
54
|
+
# OpenSSL::X509::Attribute.new("title", attribute_values),
|
55
|
+
# OpenSSL::X509::Attribute.new("registeredAddress", attribute_values),
|
56
|
+
# OpenSSL::X509::Attribute.new("businessCategory", attribute_values)
|
57
|
+
# ]
|
58
|
+
|
59
|
+
# attribute_values.each do |attribute|
|
60
|
+
# request.add_attribute attribute
|
61
|
+
# end
|
62
|
+
|
63
|
+
# request.public_key = public_key
|
64
|
+
# csr = request.sign(key, OpenSSL::Digest.new("SHA256"))
|
65
|
+
# csr_pem = csr.to_pem
|
66
|
+
# csr_without_headers = csr_pem
|
67
|
+
# .to_s
|
68
|
+
# .gsub("-----BEGIN CERTIFICATE REQUEST-----", "")
|
69
|
+
# .gsub("-----END CERTIFICATE REQUEST-----", "")
|
70
|
+
# .delete("\n")
|
71
|
+
|
72
|
+
# {
|
73
|
+
# csr: csr_pem,
|
74
|
+
# csr_without_headers: csr_without_headers
|
75
|
+
# }
|
76
|
+
end
|
77
|
+
|
78
|
+
private
|
79
|
+
|
80
|
+
def default_csr_options
|
81
|
+
{
|
82
|
+
common_name: "",
|
83
|
+
organization_identifier: "",
|
84
|
+
organization_name: "",
|
85
|
+
organization_unit: "",
|
86
|
+
country: "SA",
|
87
|
+
invoice_type: "1100",
|
88
|
+
address: "",
|
89
|
+
business_category: "",
|
90
|
+
egs_solution_name: "",
|
91
|
+
egs_model: "",
|
92
|
+
egs_serial_number: ""
|
93
|
+
}
|
94
|
+
end
|
95
|
+
|
96
|
+
def set_key
|
97
|
+
if private_key_provided? && @key.blank?
|
98
|
+
@key = OpenSSL::PKey::EC.new(
|
99
|
+
File.read(@private_key_path),
|
100
|
+
@private_key_password
|
101
|
+
)
|
102
|
+
else
|
103
|
+
generate_key
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
def generated_key?
|
108
|
+
@key.present?
|
109
|
+
end
|
110
|
+
|
111
|
+
def private_key_provided?
|
112
|
+
@private_key_path.present?
|
113
|
+
end
|
114
|
+
|
115
|
+
def generate_key
|
116
|
+
return if private_key_provided?
|
117
|
+
|
118
|
+
temp_key = OpenSSL::PKey::EC.new("secp256k1").generate_key
|
119
|
+
@generated_private_key_path = "./#{SecureRandom.uuid}.pem"
|
120
|
+
@key = temp_key
|
121
|
+
|
122
|
+
File.write(@generated_private_key_path, @key.to_pem)
|
123
|
+
end
|
124
|
+
|
125
|
+
def delete_generated_key
|
126
|
+
File.delete(@generated_private_key_path) if @generated_private_key_path.present?
|
127
|
+
end
|
128
|
+
|
129
|
+
def cert_environment
|
130
|
+
case mode
|
131
|
+
when :production
|
132
|
+
"ZATCA-Code-Signing"
|
133
|
+
when :simulation
|
134
|
+
"PREZATCA-Code-Signing"
|
135
|
+
when :sandbox
|
136
|
+
"TSTZATCA-Code-Signing"
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
def generate_openssl_csr_command
|
141
|
+
"openssl req -new -sha256 -key #{@private_key_path || @generated_private_key_path} -config #{@csr_config_path}"
|
142
|
+
end
|
143
|
+
|
144
|
+
def write_csr_config
|
145
|
+
@csr_config_path = "./#{SecureRandom.uuid}.conf"
|
146
|
+
File.write(@csr_config_path, generate_csr_config)
|
147
|
+
end
|
148
|
+
|
149
|
+
def cleanup_leftover_files
|
150
|
+
delete_generated_key
|
151
|
+
delete_csr_config_file
|
152
|
+
end
|
153
|
+
|
154
|
+
def delete_csr_config_file
|
155
|
+
File.delete(@csr_config_path) if @csr_config_path.present?
|
156
|
+
end
|
157
|
+
|
158
|
+
def egs_serial_number
|
159
|
+
"1-#{csr_options[:egs_solution_name]}|2-#{csr_options[:egs_model]}|3-#{csr_options[:egs_serial_number]}"
|
160
|
+
end
|
161
|
+
|
162
|
+
# Adapted from:
|
163
|
+
# https://github.com/wes4m/zatca-xml-js/blob/main/src/zatca/templates/csr_template.ts
|
164
|
+
def generate_csr_config
|
165
|
+
<<~TEMPLATE
|
166
|
+
# ------------------------------------------------------------------
|
167
|
+
# Default section for "req" command csr_options
|
168
|
+
# ------------------------------------------------------------------
|
169
|
+
[req]
|
170
|
+
|
171
|
+
# Password for reading in existing private key file
|
172
|
+
# input_password = todo_private_key_password
|
173
|
+
|
174
|
+
# Prompt for DN field values and CSR attributes in ASCII
|
175
|
+
prompt = no
|
176
|
+
utf8 = no
|
177
|
+
|
178
|
+
# Section pointer for DN field csr_options
|
179
|
+
distinguished_name = my_req_dn_prompt
|
180
|
+
|
181
|
+
# Extensions
|
182
|
+
req_extensions = v3_req
|
183
|
+
|
184
|
+
[ v3_req ]
|
185
|
+
#basicConstraints=CA:FALSE
|
186
|
+
#keyUsage = digitalSignature, keyEncipherment
|
187
|
+
# Production or Testing Template (TSTZATCA-Code-Signing - ZATCA-Code-Signing)
|
188
|
+
1.3.6.1.4.1.311.20.2 = ASN1:PRINTABLESTRING:#{cert_environment}
|
189
|
+
subjectAltName=dirName:dir_sect
|
190
|
+
|
191
|
+
[ dir_sect ]
|
192
|
+
# EGS Serial number (1-SolutionName|2-ModelOrVersion|3-serialNumber)
|
193
|
+
SN = #{egs_serial_number}
|
194
|
+
# VAT Registration number of TaxPayer (Organization identifier [15 digits begins with 3 and ends with 3])
|
195
|
+
UID = #{csr_options[:organization_identifier]}
|
196
|
+
# Invoice type (TSCZ)(1 = supported, 0 not supported) (Tax, Simplified, future use, future use)
|
197
|
+
title = #{csr_options[:invoice_type]}
|
198
|
+
# Location (branch address or website)
|
199
|
+
registeredAddress = #{csr_options[:address]}
|
200
|
+
# Industry (industry sector name)
|
201
|
+
businessCategory = #{csr_options[:business_category]}
|
202
|
+
|
203
|
+
# ------------------------------------------------------------------
|
204
|
+
# Section for prompting DN field values to create "subject"
|
205
|
+
# ------------------------------------------------------------------
|
206
|
+
[my_req_dn_prompt]
|
207
|
+
# Common name (EGS TaxPayer PROVIDED ID [FREE TEXT])
|
208
|
+
commonName = #{csr_options[:common_name]}
|
209
|
+
|
210
|
+
# Organization Unit (Branch name)
|
211
|
+
organizationalUnitName = #{csr_options[:organization_unit]}
|
212
|
+
|
213
|
+
# Organization name (Tax payer name)
|
214
|
+
organizationName = #{csr_options[:organization_name]}
|
215
|
+
|
216
|
+
# ISO2 country code is required with US as default
|
217
|
+
countryName = #{csr_options[:country]}
|
218
|
+
TEMPLATE
|
219
|
+
end
|
220
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
require "starkbank-ecdsa"
|
2
|
+
|
3
|
+
class ZATCA::Signing::ECDSA
|
4
|
+
def self.sign(content:, private_key: nil, private_key_path: nil, decode_from_base64: false)
|
5
|
+
private_key = parse_private_key(key: private_key, key_path: private_key_path, decode_from_base64: decode_from_base64)
|
6
|
+
|
7
|
+
ecdsa_signature = EllipticCurve::Ecdsa.sign(content, private_key)
|
8
|
+
|
9
|
+
{
|
10
|
+
base64: ecdsa_signature.toBase64,
|
11
|
+
bytes: ecdsa_signature.toDer
|
12
|
+
}
|
13
|
+
end
|
14
|
+
|
15
|
+
def self.add_header_blocks(key_content)
|
16
|
+
# If key is missing header blocks, add them, otherwise return it as is
|
17
|
+
header = "-----BEGIN EC PRIVATE KEY-----"
|
18
|
+
footer = "-----END EC PRIVATE KEY-----"
|
19
|
+
|
20
|
+
unless key_content.include?(header) && key_content.include?(footer)
|
21
|
+
key_content = "#{header}\n#{key_content}\n#{footer}"
|
22
|
+
end
|
23
|
+
|
24
|
+
key_content
|
25
|
+
end
|
26
|
+
|
27
|
+
def self.read_private_key_from_pem(pem)
|
28
|
+
EllipticCurve::PrivateKey.fromPem(add_header_blocks(pem))
|
29
|
+
end
|
30
|
+
|
31
|
+
# Returns a parsed private key
|
32
|
+
# If decode_from_base64 is set to true, the key will be decoded from base64
|
33
|
+
# before passing to OpenSSL to read it. # This is necessary because that's how
|
34
|
+
# ZATCA's sample key is provided.
|
35
|
+
def self.parse_private_key(key: nil, key_path: nil, decode_from_base64: false)
|
36
|
+
parsed_key = if key.is_a?(EllipticCurve::PrivateKey)
|
37
|
+
key
|
38
|
+
elsif key.is_a?(String)
|
39
|
+
key_content = if decode_from_base64
|
40
|
+
Base64.decode64(key)
|
41
|
+
else
|
42
|
+
key
|
43
|
+
end
|
44
|
+
|
45
|
+
read_private_key_from_pem(key_content)
|
46
|
+
elsif key_path.present?
|
47
|
+
key_content = File.read(key_path)
|
48
|
+
key_content = Base64.decode64(key_content) if decode_from_base64
|
49
|
+
|
50
|
+
read_private_key_from_pem(key_content)
|
51
|
+
end
|
52
|
+
|
53
|
+
if parsed_key.blank?
|
54
|
+
raise ArgumentError.new("private_key or private_key_path is required")
|
55
|
+
end
|
56
|
+
|
57
|
+
parsed_key
|
58
|
+
end
|
59
|
+
end
|
data/lib/zatca/tag.rb
ADDED
@@ -0,0 +1,44 @@
|
|
1
|
+
module ZATCA
|
2
|
+
class Tag
|
3
|
+
TAG_IDS = {
|
4
|
+
seller_name: 1,
|
5
|
+
vat_registration_number: 2,
|
6
|
+
timestamp: 3,
|
7
|
+
invoice_total: 4,
|
8
|
+
vat_total: 5,
|
9
|
+
xml_invoice_hash: 6,
|
10
|
+
ecdsa_signature: 7,
|
11
|
+
ecdsa_public_key: 8,
|
12
|
+
ecdsa_stamp_signature: 9 # TODO: is this needed ?
|
13
|
+
}.freeze
|
14
|
+
|
15
|
+
PHASE_1_TAGS = [
|
16
|
+
:seller_name,
|
17
|
+
:vat_registration_number,
|
18
|
+
:timestamp,
|
19
|
+
:invoice_total,
|
20
|
+
:vat_total
|
21
|
+
].freeze
|
22
|
+
|
23
|
+
attr_accessor :id, :key, :value
|
24
|
+
|
25
|
+
def initialize(key:, value:)
|
26
|
+
@id = TAG_IDS[key]
|
27
|
+
@key = key
|
28
|
+
@value = value.to_s
|
29
|
+
end
|
30
|
+
|
31
|
+
def to_h
|
32
|
+
{id: @id, key: @key, value: @value}
|
33
|
+
end
|
34
|
+
|
35
|
+
def should_be_utf8_encoded?
|
36
|
+
PHASE_1_TAGS.include?(key)
|
37
|
+
end
|
38
|
+
|
39
|
+
def to_tlv
|
40
|
+
tlv = @id.chr + @value.bytesize.chr + @value
|
41
|
+
tlv.force_encoding("ASCII-8BIT")
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
data/lib/zatca/tags.rb
ADDED
@@ -0,0 +1,46 @@
|
|
1
|
+
require "base64"
|
2
|
+
|
3
|
+
module ZATCA
|
4
|
+
class Tags
|
5
|
+
def initialize(tags)
|
6
|
+
keys_to_coerce_to_string = tags.except(:timestamp).keys
|
7
|
+
stringified_tags = tags.map do |key, value|
|
8
|
+
if keys_to_coerce_to_string.include?(key)
|
9
|
+
[key, value.to_s]
|
10
|
+
else
|
11
|
+
[key, value]
|
12
|
+
end
|
13
|
+
end.to_h
|
14
|
+
|
15
|
+
schema_result = TagsSchema.call(stringified_tags)
|
16
|
+
if schema_result.failure?
|
17
|
+
raise(Error, "Parsing tags failed due to:\n#{schema_result.errors(full: true).to_h}")
|
18
|
+
end
|
19
|
+
|
20
|
+
stringified_tags[:timestamp] = tags[:timestamp].to_s
|
21
|
+
|
22
|
+
# Create tags, then sort them by ID
|
23
|
+
@tags = stringified_tags.map do |key, value|
|
24
|
+
Tag.new(key: key, value: value)
|
25
|
+
end.sort_by(&:id)
|
26
|
+
end
|
27
|
+
|
28
|
+
def [](index)
|
29
|
+
@tags[index]
|
30
|
+
end
|
31
|
+
|
32
|
+
def to_base64
|
33
|
+
Base64.strict_encode64(to_tlv)
|
34
|
+
end
|
35
|
+
|
36
|
+
# This is helpful for debugging only, for ZATCA's requirements just call `to_base64`
|
37
|
+
def to_hex_tlv
|
38
|
+
to_tlv.unpack1("H*")
|
39
|
+
end
|
40
|
+
|
41
|
+
# This is helpful for debugging only, for ZATCA's requirements just call `to_base64`
|
42
|
+
def to_tlv
|
43
|
+
@tags.map(&:to_tlv).join("")
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
require "dry-schema"
|
2
|
+
|
3
|
+
module ZATCA
|
4
|
+
TagsSchema = Dry::Schema.Params do
|
5
|
+
required(:seller_name).filled(:string)
|
6
|
+
required(:vat_registration_number).filled(:string)
|
7
|
+
|
8
|
+
required(:timestamp).filled([:date_time, :string])
|
9
|
+
|
10
|
+
# Using strings to avoid any floating point approximations, we're not doing
|
11
|
+
# any calculations with these values, it's for display purposes only. A
|
12
|
+
# string is a good fit.
|
13
|
+
required(:invoice_total).filled(:string)
|
14
|
+
required(:vat_total).filled(:string)
|
15
|
+
|
16
|
+
# Data types required for Phase 2 by 1 January 2023
|
17
|
+
optional(:xml_invoice_hash).filled(:string)
|
18
|
+
optional(:ecdsa_signature).filled(:string)
|
19
|
+
optional(:ecdsa_public_key).filled(:string)
|
20
|
+
optional(:ecdsa_stamp_signature).filled(:string)
|
21
|
+
end
|
22
|
+
end
|
data/lib/zatca/types.rb
ADDED
@@ -0,0 +1,142 @@
|
|
1
|
+
require "dry-initializer"
|
2
|
+
require_relative "../types"
|
3
|
+
|
4
|
+
class ZATCA::UBL::BaseComponent
|
5
|
+
extend Dry::Initializer
|
6
|
+
attr_accessor :elements, :attributes, :name, :value, :index
|
7
|
+
|
8
|
+
# def self.guard_dig(obj)
|
9
|
+
# unless obj.respond_to?(:dig)
|
10
|
+
# raise TypeError, "#{obj.class.name} does not have #dig method"
|
11
|
+
# end
|
12
|
+
# end
|
13
|
+
|
14
|
+
ArrayOfBaseComponentOrNil = ZATCA::Types::Array.of(ZATCA::Types.Instance(ZATCA::UBL::BaseComponent))
|
15
|
+
.default { [] }
|
16
|
+
.constructor { |value| value.blank? ? [] : value.compact }
|
17
|
+
|
18
|
+
option :elements,
|
19
|
+
type: ArrayOfBaseComponentOrNil,
|
20
|
+
default: proc { [] },
|
21
|
+
optional: true
|
22
|
+
|
23
|
+
option :attributes, type: ZATCA::Types::Strict::Hash, default: proc { {} }, optional: true
|
24
|
+
option :value, type: ZATCA::Types::Coercible::String, default: proc { "" }, optional: true
|
25
|
+
option :name, type: ZATCA::Types::Strict::String, default: proc { "" }, optional: true
|
26
|
+
option :index, type: ZATCA::Types::Coercible::Integer.optional, default: proc {}, optional: true
|
27
|
+
|
28
|
+
# def initialize(elements: [], attributes: {}, value: "", name: "", index: nil)
|
29
|
+
# @elements = elements
|
30
|
+
# @attributes = attributes
|
31
|
+
# @value = value
|
32
|
+
# @name = name
|
33
|
+
|
34
|
+
# # HACK: Add a nil index property to be used for cases where we need
|
35
|
+
# # sequential IDs, this list can be populated after the array is built
|
36
|
+
# @index = index
|
37
|
+
# end
|
38
|
+
|
39
|
+
# There are cases where we end up constructing elements with no content
|
40
|
+
# and we don't want to include them in the final XML.
|
41
|
+
#
|
42
|
+
# This method helps us to return nil if the element has no attributes,
|
43
|
+
# elements or value.
|
44
|
+
#
|
45
|
+
# which is then caught in the `build_xml` method (using `elements.compact`)
|
46
|
+
# and ignored.
|
47
|
+
def self.build(elements: [], attributes: {}, value: "", name: "", index: nil)
|
48
|
+
return nil if elements.blank? && attributes.blank? && value.blank?
|
49
|
+
|
50
|
+
new(elements: elements, attributes: attributes, value: value, name: name, index: index)
|
51
|
+
end
|
52
|
+
|
53
|
+
def [](name)
|
54
|
+
elements.find { |element| element.name == name }
|
55
|
+
end
|
56
|
+
|
57
|
+
def dig(key, *args)
|
58
|
+
value = self[key]
|
59
|
+
return value if args.length == 0 || value.nil?
|
60
|
+
# DigRb.guard_dig(value)
|
61
|
+
value.dig(*args)
|
62
|
+
end
|
63
|
+
|
64
|
+
# TODO: Under Active Development
|
65
|
+
def find_nested_element_by_path(path)
|
66
|
+
path_parts = path.split("/")
|
67
|
+
nested_element = self
|
68
|
+
|
69
|
+
path_parts.each_with_index do |path_part, index|
|
70
|
+
# byebug
|
71
|
+
# next_path_part = path_parts[index + 1]
|
72
|
+
# found_element = nested_element[path_part]
|
73
|
+
# found_next_path_part = found_element[next_path_part]
|
74
|
+
|
75
|
+
# element_with_next_path_part = found_element.find do |child_element|
|
76
|
+
# child_element.name == next_path_part
|
77
|
+
# end
|
78
|
+
# byebug
|
79
|
+
# nested_element.elements.each do |element|
|
80
|
+
# byebug
|
81
|
+
# next_element = element[path_part]
|
82
|
+
|
83
|
+
# if next_element && next_element[next_path_part]
|
84
|
+
# nested_element = next_element[next_path_part]
|
85
|
+
# break
|
86
|
+
# elsif next_element.present?
|
87
|
+
# nested_element = next_element
|
88
|
+
# end
|
89
|
+
# end
|
90
|
+
|
91
|
+
# nested_element = found_element if found_element.present?
|
92
|
+
end
|
93
|
+
|
94
|
+
nested_element
|
95
|
+
end
|
96
|
+
|
97
|
+
def schema
|
98
|
+
self.class.schema
|
99
|
+
end
|
100
|
+
|
101
|
+
def to_h
|
102
|
+
{
|
103
|
+
name => {
|
104
|
+
attributes: attributes,
|
105
|
+
**elements.map(&:to_h),
|
106
|
+
value: value
|
107
|
+
}
|
108
|
+
}
|
109
|
+
end
|
110
|
+
|
111
|
+
def to_xml
|
112
|
+
builder = Nokogiri::XML::Builder.new(encoding: "UTF-8") do |xml|
|
113
|
+
xml.root { build_xml(xml) }
|
114
|
+
end
|
115
|
+
|
116
|
+
builder.to_xml
|
117
|
+
end
|
118
|
+
|
119
|
+
def build_xml(xml)
|
120
|
+
xml.send(name, attributes) do
|
121
|
+
if elements.length > 0
|
122
|
+
elements.compact.each { |element| element.build_xml(xml) }
|
123
|
+
elsif value.present?
|
124
|
+
xml.text(value)
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
def generate_xml(
|
130
|
+
canonicalized: false,
|
131
|
+
spaces: 2,
|
132
|
+
apply_invoice_hacks: false,
|
133
|
+
remove_root_xml_tag: false
|
134
|
+
)
|
135
|
+
ZATCA::UBL::Builder.new(element: self).build(
|
136
|
+
canonicalized: canonicalized,
|
137
|
+
spaces: spaces,
|
138
|
+
apply_invoice_hacks: apply_invoice_hacks,
|
139
|
+
remove_root_xml_tag: remove_root_xml_tag
|
140
|
+
)
|
141
|
+
end
|
142
|
+
end
|