Class: ZATCA::UBL::Invoice

Inherits:
BaseComponent show all
Defined in:
lib/zatca/ubl/invoice.rb

Constant Summary collapse

TYPES =
{
  invoice: "388",
  debit: "383",
  credit: "381"
}.freeze
PAYMENT_MEANS =
{
  cash: "10",
  credit: "30",
  bank_account: "42",
  bank_card: "48"
}.freeze

Constants inherited from BaseComponent

BaseComponent::ArrayOfBaseComponentOrNil

Instance Attribute Summary collapse

Attributes inherited from BaseComponent

#index, #value

Instance Method Summary collapse

Methods inherited from BaseComponent

#[], build, #build_xml, #dig, #find_nested_element_by_path, #schema, #to_h, #to_xml

Instance Attribute Details

#certificate_signatureObject (readonly)

Returns the value of attribute certificate_signature.



18
19
20
# File 'lib/zatca/ubl/invoice.rb', line 18

def certificate_signature
  @certificate_signature
end

#public_key_bytesObject (readonly)

Returns the value of attribute public_key_bytes.



17
18
19
# File 'lib/zatca/ubl/invoice.rb', line 17

def public_key_bytes
  @public_key_bytes
end

#qr_codeObject

Returns the value of attribute qr_code.



21
22
23
# File 'lib/zatca/ubl/invoice.rb', line 21

def qr_code
  @qr_code
end

#qualifying_propertiesObject (readonly)

Returns the value of attribute qualifying_properties.



19
20
21
# File 'lib/zatca/ubl/invoice.rb', line 19

def qualifying_properties
  @qualifying_properties
end

#signatureObject

Returns the value of attribute signature.



21
22
23
# File 'lib/zatca/ubl/invoice.rb', line 21

def signature
  @signature
end

#signed_hashObject (readonly)

Returns the value of attribute signed_hash.



15
16
17
# File 'lib/zatca/ubl/invoice.rb', line 15

def signed_hash
  @signed_hash
end

#signed_hash_bytesObject (readonly)

Returns the value of attribute signed_hash_bytes.



16
17
18
# File 'lib/zatca/ubl/invoice.rb', line 16

def signed_hash_bytes
  @signed_hash_bytes
end

Instance Method Details

#attributesObject



77
78
79
80
81
82
83
84
# File 'lib/zatca/ubl/invoice.rb', line 77

def attributes
  {
    "xmlns" => "urn:oasis:names:specification:ubl:schema:xsd:Invoice-2",
    "xmlns:cac" => "urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2",
    "xmlns:cbc" => "urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2",
    "xmlns:ext" => "urn:oasis:names:specification:ubl:schema:xsd:CommonExtensionComponents-2"
  }
end

#elementsObject



86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
# File 'lib/zatca/ubl/invoice.rb', line 86

def elements
  add_sequential_ids

  [
    # Invoice signature
    ubl_extensions_element,

    # Metadata
    ZATCA::UBL::BaseComponent.new(name: "cbc:ProfileID", value: "reporting:1.0"),
    ZATCA::UBL::BaseComponent.new(name: "cbc:ID", value: id),
    ZATCA::UBL::BaseComponent.new(name: "cbc:UUID", value: uuid),
    ZATCA::UBL::BaseComponent.new(name: "cbc:IssueDate", value: issue_date),
    ZATCA::UBL::BaseComponent.new(name: "cbc:IssueTime", value: issue_time),

    # Invoice type
    ZATCA::UBL::BaseComponent.new(
      name: "cbc:InvoiceTypeCode",
      attributes: {"name" => subtype},
      value: type
    ),

    # Note
    note_element,

    # Currency codes
    ZATCA::UBL::BaseComponent.new(name: "cbc:DocumentCurrencyCode", value: currency_code),
    ZATCA::UBL::BaseComponent.new(name: "cbc:TaxCurrencyCode", value: currency_code),

    # Billing reference for debit and credit notes
    billing_reference_element,

    # Line Count Numeric (Standard Invoice only)
    line_count_numeric_element,

    # Additional document references
    # Invoice counter value (ICV)
    ZATCA::UBL::BaseComponent.new(name: "cac:AdditionalDocumentReference", elements: [
      ZATCA::UBL::BaseComponent.new(name: "cbc:ID", value: "ICV"),
      ZATCA::UBL::BaseComponent.new(name: "cbc:UUID", value: invoice_counter_value)
    ]),

    # Previous invoice hash (PIH)
    previous_invoice_hash_document_reference,

    # QR code
    qr_code_document_reference,

    # Static: signature
    static_signature_element,

    # AccountingSupplierParty
    ZATCA::UBL::BaseComponent.new(name: "cac:AccountingSupplierParty", elements: [
      accounting_supplier_party
    ]),

    # AccountingCustomerParty
    ZATCA::UBL::BaseComponent.new(name: "cac:AccountingCustomerParty", elements: [
      accounting_customer_party
    ]),

    # Delivery
    delivery,

    # PaymentMeans
    ZATCA::UBL::BaseComponent.new(name: "cac:PaymentMeans", elements: [
      ZATCA::UBL::BaseComponent.new(name: "cbc:PaymentMeansCode", value: payment_means_code),
      instruction_note_element
    ]),

    # AllowanceCharges
    # TODO: Figure out how this ties to invoice lines
    *allowance_charges,

    # TaxTotals
    *tax_totals,

    # LegalMonetaryTotal
    legal_monetary_total,

    # InvoiceLines
    *invoice_lines
  ]
end

#generate_hashObject



170
171
172
173
174
175
176
177
178
179
180
181
182
# File 'lib/zatca/ubl/invoice.rb', line 170

def generate_hash
  # We don't need to apply the hacks here because they only apply to the
  # QualifyingProperties block which is not present when generating the hash
  canonicalized_xml = generate_unsigned_xml(
    canonicalized: true,
    apply_invoice_hacks: false,
    remove_root_xml_tag: true
  )

  File.write("xml_for_signing.xml", canonicalized_xml)

  ZATCA::Hashing.generate_hashes(canonicalized_xml)[:base64]
end

#generate_unsigned_xml(canonicalized: true, apply_invoice_hacks: false, remove_root_xml_tag: false) ⇒ Object



222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
# File 'lib/zatca/ubl/invoice.rb', line 222

def generate_unsigned_xml(
  canonicalized: true,
  apply_invoice_hacks: false,
  remove_root_xml_tag: false
)
  # HACK: Set signature and QR code to nil temporarily so they get removed
  # from the XML before generating the unsigned XML. An unsigned einvoice
  # should not have a signature or QR code, we additionally remove the qualifying
  # properties because it is a replacement that happens on the generated XML and
  # we only want that replacement on the version we submit to ZATCA.
  original_signature = signature
  original_qr_code = qr_code
  original_qualifying_properties = @qualifying_properties

  self.signature = nil
  self.qr_code = nil
  @qualifying_properties = nil

  unsigned_xml = generate_xml(
    canonicalized: canonicalized,
    apply_invoice_hacks: apply_invoice_hacks,
    remove_root_xml_tag: remove_root_xml_tag
  )

  self.signature = original_signature
  self.qr_code = original_qr_code
  @qualifying_properties = original_qualifying_properties

  unsigned_xml
end

#generate_xml(canonicalized: true, spaces: 4, apply_invoice_hacks: true, remove_root_xml_tag: false) ⇒ Object

HACK: Override this method because dry-initializer isn’t helping us by having an after_initialize callback. We just need to set the qualifying properties at any point before generating the XML.



201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
# File 'lib/zatca/ubl/invoice.rb', line 201

def generate_xml(
  canonicalized: true,
  spaces: 4,
  apply_invoice_hacks: true,
  remove_root_xml_tag: false
)
  set_qualifying_properties(
    signing_time: @signature&.signing_time,
    cert_digest_value: @signature&.cert_digest_value,
    cert_issuer_name: @signature&.cert_issuer_name,
    cert_serial_number: @signature&.cert_serial_number
  )

  super(
    canonicalized: canonicalized,
    spaces: spaces,
    apply_invoice_hacks: apply_invoice_hacks,
    remove_root_xml_tag: remove_root_xml_tag
  )
end

#nameObject



73
74
75
# File 'lib/zatca/ubl/invoice.rb', line 73

def name
  "Invoice"
end

#sign(private_key_path:, certificate_path:, signing_time: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S"), decode_private_key_from_base64: false) ⇒ Object



253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
# File 'lib/zatca/ubl/invoice.rb', line 253

def sign(
  private_key_path:,
  certificate_path:,
  signing_time: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S"),
  decode_private_key_from_base64: false
)
  # ZATCA does not like signing_times ending with Z
  signing_time = signing_time.delete_suffix("Z")

  canonicalized_xml = generate_unsigned_xml(canonicalized: true)
  generated_hashes = ZATCA::Hashing.generate_hashes(canonicalized_xml)

  # Sign the invoice hash using the private key
  signature = ZATCA::Signing::ECDSA.sign(
    content: generated_hashes[:hexdigest],
    private_key_path: private_key_path,
    decode_from_base64: decode_private_key_from_base64
  )

  @signed_hash = signature[:base64]
  @signed_hash_bytes = signature[:bytes]

  # Parse and hash the certificate
  parsed_certificate = ZATCA::Signing::Certificate.read_certificate(certificate_path)
  @public_key_bytes = parsed_certificate.public_key_bytes

  # Current Version
  @certificate_signature = parsed_certificate.signature

  # ZATCA requires a different set of attributes when hashing the SignedProperties
  # attributes and does not want those attributes present in the actual XML.
  # So we'll have two sets of signed properties for this purpose, one just
  # to generate a hash out of, and one to actually include in the XML.
  # See: https://zatca1.discourse.group/t/what-do-signed-properties-look-like-when-hashing/717
  #
  # The other SignedProperties that's in the XML is generated when we construct
  # the Signature element below

  signed_properties_for_hashing = ZATCA::UBL::Signing::SignedProperties.new(
    signing_time: signing_time,
    cert_digest_value: parsed_certificate.hash,
    cert_issuer_name: parsed_certificate.issuer_name,
    cert_serial_number: parsed_certificate.serial_number
  )

  set_qualifying_properties(
    signing_time: signing_time,
    cert_digest_value: parsed_certificate.hash,
    cert_issuer_name: parsed_certificate.issuer_name,
    cert_serial_number: parsed_certificate.serial_number
  )

  # ZATCA uses very specific whitespace also for the version of this block
  # that we need to submit to their servers, so we will keep a copy of the XML
  # as it should be spaced, and then after building the XML we will replace
  # the QualifyingProperties block with this one.
  #
  # See: https://zatca1.discourse.group/t/what-do-signed-properties-look-like-when-hashing/717
  #
  # If their server is ever updated to format the block before hashing it on their
  # end, we can safely remove this behavior.
  @qualifying_properties = ZATCA::Hacks.zatca_indented_qualifying_properties(
    signing_time: signing_time,
    cert_digest_value: parsed_certificate.hash,
    cert_issuer_name: parsed_certificate.issuer_name,
    cert_serial_number: parsed_certificate.serial_number
  )

  signed_properties_hash = signed_properties_for_hashing.generate_hash

  # Create the signature element using the certficiate, invoice hash, and signed
  # properties hash
  signature_element = ZATCA::UBL::Signing::Signature.new(
    invoice_hash: generated_hashes[:base64],
    signed_properties_hash: signed_properties_hash,

    # Current Version
    signature_value: @signed_hash,

    # GPT4 Version
    # signature_value: @signed_hash[:base64],

    certificate: parsed_certificate.cert_content_without_headers,
    signing_time: signing_time,
    cert_digest_value: parsed_certificate.hash,
    cert_issuer_name: parsed_certificate.issuer_name,
    cert_serial_number: parsed_certificate.serial_number
  )

  self.signature = signature_element
end

#to_base64(canonicalized: true) ⇒ Object

When submitting to ZATCA, we need to submit the XML in Base64 format, and it needs to be pretty-printed matching their indentation style. The canonicalized option here is left only for debugging purposes.



187
188
189
190
191
192
193
194
195
# File 'lib/zatca/ubl/invoice.rb', line 187

def to_base64(canonicalized: true)
  canonicalized_xml_with_hacks_applied = generate_xml(
    canonicalized: canonicalized,
    apply_invoice_hacks: true,
    remove_root_xml_tag: false
  )

  Base64.strict_encode64(canonicalized_xml_with_hacks_applied)
end