croatia 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: 91babeb96c8b99906844e087b9599ec530c4b972b6d1bdf2cded59dfbbb178d1
4
- data.tar.gz: cd5b1dece16ed4f0d9ec3e2b8ceac8b2b44ad251f7eb2f07e119491bc638cb9f
3
+ metadata.gz: 950fcecdaee0439b153f3bccc88c0dcc1bd65707200d6f88b7c08729752d0384
4
+ data.tar.gz: 56d5d0073b5059d2a70767ff82be379c92183f16a37bfcbb318c576cccc3b797
5
5
  SHA512:
6
- metadata.gz: f103227a100270c0d53c8bf45edb30532a852719866900fed949996e1afbfcb4fd3525c0e7ae4828bd8e16a1137357f3345810600b24cd3db174774ba2f4a5fd
7
- data.tar.gz: cec02f409ded5d60f9535464669219314f4da1ab516ef5139b550d033552ac9393cd6105e37486427cfe6ee606c93636caa85c2a3f7994dc783cf9984643b110
6
+ metadata.gz: b6388cb5613f183c1bbea376ab1eb2975b9e6f90c00c98c1e272a94a69ca19819b509aadc86d27fd63f1bf194b9597b5d391598015366294dbf6c1cbebdea306
7
+ data.tar.gz: 8f27f7baabdbb619e7b3048fc34f204b7279137437e0983de40492b5e5af2c1b23ba80c03e01b3c471a035756bfc7d6cb5106a147534e4a372220b46328a3ca0
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Croatia::Config
4
+ DEFAULT_TAX_RATES = {
5
+ value_added_tax: {
6
+ standard: 0.25,
7
+ lower_rate: 0.13,
8
+ exempt: 0.0,
9
+ zero_rated: 0.0,
10
+ outside_scope: 0.0,
11
+ reverse_charge: 0.0
12
+ },
13
+ consumption_tax: Hash.new(0.0),
14
+ other: Hash.new(0.0)
15
+ }
16
+ DEFAULT_FISCALIZATION = {}
17
+
18
+ attr_accessor :tax_rates, :fiscalization
19
+
20
+ def initialize(**options)
21
+ self.tax_rates = options.delete(:tax_rates) { deep_dup(DEFAULT_TAX_RATES) }
22
+ self.fiscalization = options.delete(:fiscalization) { deep_dup(DEFAULT_FISCALIZATION) }
23
+ end
24
+
25
+ private
26
+
27
+ def deep_dup(object)
28
+ Marshal.load(Marshal.dump(object))
29
+ end
30
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rexml/document"
4
+
5
+ module Croatia::Fiscalizer::XMLBuilder
6
+ TNS = "http://www.apis-it.hr/fin/2012/types/f73"
7
+ XSI = "http://www.w3.org/2001/XMLSchema-instance"
8
+ SEQUENCE_MARK = {
9
+ register: "N", # N - sequential by register
10
+ business_location: "P" # P - sequential by business location
11
+ }.freeze
12
+ PAYMENT_METHODS = {
13
+ cash: "G", # G - gotovina
14
+ card: "K", # K - kartica
15
+ check: "C", # C - ček
16
+ transfer: "T", # T - prijenos
17
+ other: "O" # O - ostalo
18
+ }.freeze
19
+
20
+ class << self
21
+ def invoice_request(invoice:, message_id:, specific_purpose: nil, subsequent_delivery: false, timezone: Croatia::Fiscalizer::TZ)
22
+ REXML::Document.new.tap do |doc|
23
+ envelope = doc.add_element("tns:RacunZahtjev", {
24
+ "xmlns:tns" => TNS,
25
+ "xmlns:xsi" => XSI
26
+ })
27
+
28
+ envelope.add_element("tns:Zaglavlje").tap do |header|
29
+ header.add_element("tns:IdPoruke").text = message_id
30
+ header.add_element("tns:DatumVrijemeSlanja").text = timezone.now.strftime("%d.%m.%YT%H:%M:%S")
31
+ end
32
+
33
+ envelope.add_element("tns:Racun").tap do |payload|
34
+ payload.add_element("tns:Oib").text = invoice.seller.pin
35
+ payload.add_element("tns:USustPdv").text = invoice.seller.pays_vat ? "true" : "false"
36
+ payload.add_element("tns:DatVrijeme").text = timezone.to_local(invoice.issue_date).strftime("%d.%m.%YT%H:%M:%S")
37
+ payload.add_element("tns:OznSlijed").text = SEQUENCE_MARK[invoice.sequential_by]
38
+
39
+ payload.add_element("tns:BrRac").tap do |invoice_number|
40
+ invoice_number.add_element("tns:BrOznRac").text = invoice.sequential_number.to_s
41
+ invoice_number.add_element("tns:OznPosPr").text = invoice.business_location_identifier.to_s
42
+ invoice_number.add_element("tns:OznNapUr").text = invoice.register_identifier.to_s
43
+ end
44
+
45
+ # TODO: Add taxes
46
+
47
+ payload.add_element("tns:IznosUkupno").text = invoice.total.to_f.to_s
48
+ payload.add_element("tns:NacinPlac").text = PAYMENT_METHODS[invoice.payment_method]
49
+ payload.add_element("tns:OibOper").text = invoice.issuer.pin
50
+ payload.add_element("tns:ZastKod").text = invoice.issuer_protection_code
51
+ payload.add_element("tns:NakDost").text = subsequent_delivery ? "true" : "false"
52
+ payload.add_element("tns:ParagonBrRac").text = invoice.number
53
+ payload.add_element("tns:SpecNamj").text = specific_purpose if specific_purpose
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest/md5"
4
+ require "openssl"
5
+ require "securerandom"
6
+ require "tzinfo"
7
+ require "uri"
8
+
9
+ class Croatia::Fiscalizer
10
+ autoload :XMLBuilder, "croatia/fiscalizer/xml_builder"
11
+
12
+ TZ = TZInfo::Timezone.get("Europe/Zagreb")
13
+ QR_CODE_BASE_URL = "https://porezna.gov.hr/rn"
14
+
15
+ attr_reader :certificate
16
+
17
+ def initialize(certificate: nil, password: nil)
18
+ certificate ||= Croatia.config.fiscalization[:certificate]
19
+ password ||= Croatia.config.fiscalization[:password]
20
+
21
+ @certificate = load_certificate(certificate, password)
22
+ end
23
+
24
+ def fiscalize(invoice:, message_id: SecureRandom.uuid)
25
+ _document = XMLBuilder.invoice_request(invoice: invoice, message_id: message_id, timezone: TZ)
26
+ raise NotImplementedError, "Fiscalization XML generation is not implemented yet"
27
+ end
28
+
29
+ def generate_issuer_protection_code(invoice)
30
+ buffer = []
31
+ buffer << invoice.issuer.pin
32
+ buffer << TZ.to_local(invoice.issue_date).strftime("%d.%m.%Y %H:%M:%S")
33
+ buffer << invoice.sequential_number
34
+ buffer << invoice.business_location_identifier
35
+ buffer << invoice.register_identifier
36
+ buffer << invoice.total.to_f
37
+
38
+ digest = OpenSSL::Digest::SHA1.new
39
+ signature = certificate.sign(digest, buffer.join)
40
+
41
+ Digest::MD5.hexdigest(signature).downcase
42
+ end
43
+
44
+ def generate_verification_qr_code(invoice)
45
+ Croatia::QRCode.ensure_supported!
46
+
47
+ params = {
48
+ datv: TZ.to_local(invoice.issue_date).strftime("%Y%m%d_%H%M"),
49
+ izn: invoice.total_cents.to_s
50
+ }
51
+
52
+ if params[:izn].length > 10
53
+ raise ArgumentError, "Total amount exceeds 10 digits: #{params[:izn]}"
54
+ end
55
+
56
+ if invoice.unique_invoice_identifier
57
+ params[:jir] = invoice.unique_invoice_identifier
58
+ else
59
+ params[:zki] = generate_issuer_protection_code(invoice)
60
+ end
61
+
62
+ if params[:jir].nil? && params[:zki].nil?
63
+ raise ArgumentError, "Either unique_invoice_identifier or issuer_protection_code must be provided"
64
+ end
65
+
66
+ query_string = URI.encode_www_form(params)
67
+ url = "#{QR_CODE_BASE_URL}?#{query_string}"
68
+
69
+ Croatia::QRCode.new(url)
70
+ end
71
+
72
+ private
73
+
74
+ def load_certificate(cert, password)
75
+ if cert.is_a?(OpenSSL::PKCS12)
76
+ cert.key
77
+ elsif cert.is_a?(OpenSSL::PKey::PKey)
78
+ cert
79
+ else
80
+ begin
81
+ cert = File.read(cert) if cert.respond_to?(:to_s) && File.exist?(cert.to_s)
82
+ rescue ArgumentError
83
+ end
84
+
85
+ begin
86
+ OpenSSL::PKey.read(cert)
87
+ rescue OpenSSL::PKey::PKeyError
88
+ OpenSSL::PKCS12.new(cert, password).key
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Croatia::Invoice::EInvoicable
4
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Croatia::Invoice::Fiscalizable
4
+ def self.included(base)
5
+ base.include InstanceMethods
6
+ end
7
+
8
+ module InstanceMethods
9
+ def fiscalize!(**options)
10
+ Croatia::Fiscalizer.new(**options).fiscalize(invoice: self)
11
+ end
12
+
13
+ def reverse!(**options)
14
+ line_items.each(&:reverse)
15
+ Croatia::Fiscalizer.new(**options).fiscalize(invoice: self)
16
+ end
17
+
18
+ def issuer_protection_code(**options)
19
+ Croatia::Fiscalizer
20
+ .new(**options)
21
+ .generate_issuer_protection_code(self)
22
+ end
23
+
24
+ def fiscalization_qr_code(**options)
25
+ Croatia::Fiscalizer
26
+ .new(**options)
27
+ .generate_verification_qr_code(self)
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+ require "bigdecimal/util"
5
+
6
+ class Croatia::Invoice::LineItem
7
+ include Croatia::Enum
8
+
9
+ attr_accessor :description, :unit, :taxes
10
+ attr_reader :quantity, :unit_price, :discount_rate
11
+
12
+ def initialize(**options)
13
+ self.description = options[:description]
14
+ self.quantity = options.fetch(:quantity, 1)
15
+ self.unit = options[:unit]
16
+ self.unit_price = options.fetch(:unit_price, 0.0)
17
+ self.taxes = options.fetch(:taxes, {})
18
+ end
19
+
20
+ def quantity=(value)
21
+ unless value.is_a?(Numeric)
22
+ raise ArgumentError, "Quantity must be a number"
23
+ end
24
+
25
+ @quantity = value.to_d
26
+ end
27
+
28
+ def unit_price=(value)
29
+ unless value.is_a?(Numeric) && value >= 0
30
+ raise ArgumentError, "Unit price must be a non-negative number"
31
+ end
32
+
33
+ @unit_price = value.to_d
34
+ end
35
+
36
+
37
+ def discount_rate=(value)
38
+ if value.nil?
39
+ @discount_rate = nil
40
+ return
41
+ end
42
+
43
+ unless value.is_a?(Numeric) && value >= 0 && value <= 1
44
+ raise ArgumentError, "Discount rate must be a non-negative number between 0 and 1"
45
+ end
46
+
47
+ @discount_rate = value.to_d
48
+ end
49
+
50
+ def discount=(value)
51
+ if value.nil?
52
+ @discount = nil
53
+ return
54
+ end
55
+
56
+ unless value.is_a?(Numeric) && value >= 0
57
+ raise ArgumentError, "Discount must be a non-negative number"
58
+ end
59
+
60
+ @discount = value.to_d.round(2, BigDecimal::ROUND_HALF_UP)
61
+ end
62
+
63
+ def discount
64
+ if @discount
65
+ @discount
66
+ elsif @discount_rate
67
+ (gross * @discount_rate).round(2, BigDecimal::ROUND_HALF_UP)
68
+ else
69
+ BigDecimal("0.0")
70
+ end
71
+ end
72
+
73
+ def reverse
74
+ self.quantity *= -1
75
+ end
76
+
77
+ def gross
78
+ (quantity * unit_price).round(2, BigDecimal::ROUND_HALF_UP)
79
+ end
80
+
81
+ def subtotal
82
+ gross - discount
83
+ end
84
+
85
+ def tax
86
+ taxes.values.sum { |tax_obj| (subtotal * tax_obj.rate).round(2, BigDecimal::ROUND_HALF_UP) }
87
+ end
88
+
89
+ def total
90
+ (subtotal + tax).round(2, BigDecimal::ROUND_HALF_UP)
91
+ end
92
+
93
+ def add_tax(tax = nil, **options, &block)
94
+ if tax.nil?
95
+ tax = Croatia::Invoice::Tax.new(**options)
96
+ end
97
+
98
+ tax.tap(&block) if block_given?
99
+
100
+ taxes[tax.type] = tax
101
+ tax
102
+ end
103
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Croatia::Invoice::Party
4
+ attr_accessor \
5
+ :address,
6
+ :city,
7
+ :country_code,
8
+ :einvoice_id,
9
+ :iban,
10
+ :name,
11
+ :pays_vat,
12
+ :pin,
13
+ :postal_code
14
+
15
+ def initialize(**options)
16
+ options.each do |key, value|
17
+ public_send("#{key}=", value)
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Croatia::Invoice::Payable
4
+ def self.included(base)
5
+ base.include InstanceMethods
6
+ end
7
+
8
+ module InstanceMethods
9
+ def payment_barcode(description: nil, model: nil, reference_number: nil, payment_purpose_code: nil)
10
+ if buyer.nil? || seller.nil?
11
+ raise ArgumentError, "Both buyer and seller must be set before generating a payment barcode"
12
+ end
13
+
14
+ description ||= "Račun #{number}"
15
+
16
+ options = {
17
+ currency: currency,
18
+ total_cents: total_cents,
19
+ buyer_name: buyer.name,
20
+ buyer_address: buyer.address,
21
+ buyer_postal_code: buyer.postal_code,
22
+ buyer_city: buyer.city,
23
+ seller_name: seller.name,
24
+ seller_address: seller.address,
25
+ seller_postal_code: seller.postal_code,
26
+ seller_city: seller.city,
27
+ seller_iban: seller.iban,
28
+ model: model,
29
+ reference_number: reference_number,
30
+ payment_purpose_code: payment_purpose_code,
31
+ description: description
32
+ }
33
+
34
+ Croatia::PaymentBarcode.new(**options)
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+ require "bigdecimal/util"
5
+
6
+ class Croatia::Invoice::Tax
7
+ include Croatia::Enum
8
+
9
+ attr_reader :rate
10
+
11
+ enum :type, %i[ value_added_tax consumption_tax other ]
12
+ enum :category, %i[ standard lower_rate exempt zero_rated outside_scope reverse_charge ]
13
+
14
+ def initialize(rate: nil, type: :value_added_tax, category: :standard)
15
+ self.type = type
16
+ self.category = category
17
+ self.rate = rate ? rate : Croatia.config.tax_rates[type][category]
18
+ end
19
+
20
+ def rate=(value)
21
+ if !value.is_a?(Numeric) || value < 0 || value > 1
22
+ raise ArgumentError, "Tax rate must be a number between 0 and 1"
23
+ end
24
+
25
+ @rate = value.to_d
26
+ end
27
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+ require "bigdecimal/util"
5
+
6
+ class Croatia::Invoice
7
+ autoload :Fiscalizable, "croatia/invoice/fiscalizable"
8
+ autoload :EInvoicable, "croatia/invoice/e_invoicable"
9
+ autoload :Payable, "croatia/invoice/payable"
10
+ autoload :Party, "croatia/invoice/party"
11
+ autoload :Tax, "croatia/invoice/tax"
12
+ autoload :LineItem, "croatia/invoice/line_item"
13
+
14
+ include Croatia::Enum
15
+ include Fiscalizable
16
+ include EInvoicable
17
+ include Payable
18
+
19
+ attr_reader :issue_date, :due_date
20
+ attr_writer :issuer, :seller, :buyer
21
+ attr_accessor \
22
+ :business_location_identifier, # oznaka poslovnog prostora
23
+ :currency,
24
+ :line_items,
25
+ :register_identifier, # oznaka naplatnog uredaja
26
+ :sequential_number, # redni broj racuna
27
+ :unique_invoice_identifier # jir
28
+
29
+ enum :payment_method, %i[ cash card check transfer other ].freeze, allow_nil: true, prefix: :payment_method
30
+ enum :sequential_by, %i[ register business_location ].freeze, allow_nil: true, prefix: :sequential_by
31
+
32
+ def initialize(**options)
33
+ self.line_items = options.delete(:line_items) { [] }
34
+ self.payment_method = :card
35
+ self.sequential_by = :register
36
+
37
+ options.each do |key, value|
38
+ public_send("#{key}=", value)
39
+ end
40
+ end
41
+
42
+ def number
43
+ [ sequential_number, business_location_identifier, register_identifier ].join("/")
44
+ end
45
+
46
+ def subtotal
47
+ line_items.sum(&:subtotal).to_d
48
+ end
49
+
50
+ def tax
51
+ line_items.sum(&:tax).to_d
52
+ end
53
+
54
+ def total
55
+ line_items.sum(&:total).to_d
56
+ end
57
+
58
+ def total_cents
59
+ (total * 100).to_i
60
+ end
61
+
62
+ def add_line_item(line_item = nil, &block)
63
+ if line_item.nil? && block.nil?
64
+ raise ArgumentError, "You must provide a line item or a block"
65
+ end
66
+
67
+ line_item ||= LineItem.new.tap(&block)
68
+
69
+ self.line_items ||= []
70
+ line_items << line_item
71
+
72
+ line_item
73
+ end
74
+
75
+ def issuer(&block)
76
+ if block_given?
77
+ self.issuer = Party.new.tap(&block)
78
+ else
79
+ @issuer
80
+ end
81
+ end
82
+
83
+ def buyer(&block)
84
+ if block_given?
85
+ self.buyer = Party.new.tap(&block)
86
+ else
87
+ @buyer
88
+ end
89
+ end
90
+
91
+ def seller(&block)
92
+ if block_given?
93
+ self.seller = Party.new.tap(&block)
94
+ else
95
+ @seller
96
+ end
97
+ end
98
+
99
+ def issue_date=(value)
100
+ @issue_date = value.nil? ? nil : parse_date(value)
101
+ end
102
+
103
+ def due_date=(value)
104
+ @due_date = value.nil? ? nil : parse_date(value)
105
+ end
106
+
107
+ private
108
+
109
+ def parse_date(value)
110
+ case value
111
+ when Date, DateTime
112
+ value
113
+ else
114
+ DateTime.parse(value.to_s)
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Croatia::PaymentBarcode
4
+ HEADER = "HRVHUB30"
5
+ EXACT_LENGTH_FIELDS = %i[ header currency total_cents model payment_purpose_code ].freeze
6
+ FIELD_MAX_LENGTHS = {
7
+ header: 8,
8
+ currency: 3,
9
+ total_cents: 15,
10
+ buyer_name: 30,
11
+ buyer_address: 27,
12
+ buyer_postal_code_and_city: 27,
13
+ seller_name: 25,
14
+ seller_address: 25,
15
+ seller_postal_code_and_city: 27,
16
+ seller_iban: 21,
17
+ model: 4,
18
+ reference_number: 22,
19
+ payment_purpose_code: 4,
20
+ description: 35
21
+ }.freeze
22
+
23
+ attr_accessor \
24
+ :buyer_address,
25
+ :buyer_city,
26
+ :buyer_name,
27
+ :buyer_postal_code,
28
+ :currency,
29
+ :description,
30
+ :model,
31
+ :payment_purpose_code,
32
+ :reference_number,
33
+ :seller_address,
34
+ :seller_city,
35
+ :seller_iban,
36
+ :seller_name,
37
+ :seller_postal_code,
38
+ :total_cents
39
+
40
+ def initialize(**options)
41
+ options.each do |key, value|
42
+ public_send("#{key}=", value)
43
+ end
44
+ end
45
+
46
+ def data
47
+ data = {
48
+ header: HEADER,
49
+ currency: currency,
50
+ total_cents: total_cents.to_s.rjust(FIELD_MAX_LENGTHS[:total_cents], "0"),
51
+ buyer_name: buyer_name,
52
+ buyer_address: buyer_address,
53
+ buyer_postal_code_and_city: "#{buyer_postal_code} #{buyer_city}".strip,
54
+ seller_name: seller_name,
55
+ seller_address: seller_address,
56
+ seller_postal_code_and_city: "#{seller_postal_code} #{seller_city}".strip,
57
+ seller_iban: seller_iban,
58
+ model: model,
59
+ reference_number: reference_number,
60
+ payment_purpose_code: payment_purpose_code,
61
+ description: description
62
+ }
63
+
64
+ data.each do |key, value|
65
+ next if value.nil?
66
+
67
+ max_length = FIELD_MAX_LENGTHS[key]
68
+
69
+ if EXACT_LENGTH_FIELDS.include?(key) && value.length != max_length
70
+ raise ArgumentError, "Value '#{value}' of field '#{key}' must be exactly #{max_length} characters long"
71
+ elsif value.length > max_length
72
+ raise ArgumentError, "Value '#{value}' of field '#{key}' exceeds maximum length of #{max_length} characters"
73
+ end
74
+ end
75
+
76
+ if data[:seller_iban] && (!data[:seller_iban].match?(/\A[a-z]{2}\d{19}\Z/i) && !data[:seller_iban].match?(/\A\d{7}-\d{10}\Z/i))
77
+ raise ArgumentError, "Invalid IBAN format '#{data[:seller_iban]}' expected IBAN or account number"
78
+ end
79
+
80
+ data.values.join("\n")
81
+ end
82
+
83
+ def barcode
84
+ Croatia::PDF417.ensure_supported!
85
+ Croatia::PDF417.new(data)
86
+ end
87
+
88
+ def to_png(...)
89
+ barcode.to_png(...)
90
+ end
91
+
92
+ def to_svg(...)
93
+ barcode.to_svg(...)
94
+ end
95
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Croatia::PDF417
4
+ def self.ensure_supported!
5
+ return if supported?
6
+
7
+ raise LoadError, "Zint library is not loaded. Please ensure you have the ruby-zint gem installed and required."
8
+ end
9
+
10
+ def self.supported?
11
+ defined?(Zint)
12
+ end
13
+
14
+ attr_reader :data, :options
15
+
16
+ def initialize(data, **options)
17
+ @data = data
18
+ @options = options
19
+ end
20
+
21
+ def to_svg(**options)
22
+ vec = barcode.to_vector
23
+
24
+ foreground_color = options[:foreground_color] || "black"
25
+ background_color = options[:background_color] || "white"
26
+
27
+ svg = []
28
+ svg << %Q(<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="#{vec.width.to_i}" height="#{vec.height.to_i}" viewBox="0 0 #{vec.width.to_i} #{vec.height.to_i}">)
29
+ svg << %Q(<rect width="#{vec.width.to_i}" height="#{vec.height.to_i}" fill="#{background_color}" />)
30
+
31
+ vec.each_rectangle do |rect|
32
+ svg << %Q(<rect x="#{rect.x.to_i}" y="#{rect.y.to_i}" width="#{rect.width.to_i}" height="#{rect.height.to_i}" fill="#{foreground_color}" />)
33
+ end
34
+
35
+ svg << "</svg>"
36
+ svg.join("\n")
37
+ end
38
+
39
+ def to_png(**options)
40
+ barcode.to_memory_file(extension: ".png")
41
+ end
42
+
43
+ private
44
+
45
+ def barcode
46
+ @barcode ||= Zint::Barcode.new(**options, value: data, symbology: Zint::BARCODE_PDF417)
47
+ end
48
+ end
data/lib/croatia/pin.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # OIB - Osobni Identifikacijski Broj
4
+ # PIN - Personal Identification Number
3
5
  module Croatia::PIN
4
6
  def self.valid?(pin)
5
7
  return false unless pin
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Croatia::QRCode
4
+ def self.ensure_supported!
5
+ return if supported?
6
+
7
+ raise LoadError, "RQRCode library is not loaded. Please ensure you have the rqrcode gem installed and required."
8
+ end
9
+
10
+ def self.supported?
11
+ defined?(RQRCode)
12
+ end
13
+
14
+ attr_reader :data, :options
15
+
16
+ def initialize(data, **options)
17
+ @data = data
18
+ @options = options
19
+ end
20
+
21
+ def to_svg(**options)
22
+ qr_code.as_svg(**options)
23
+ end
24
+
25
+ def to_png(**options)
26
+ qr_code.as_png(**options)
27
+ end
28
+
29
+ private
30
+
31
+ def qr_code
32
+ @qr_code ||= RQRCode::QRCode.new(data, **options)
33
+ end
34
+ end
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ # JMBG - Jedinstveni Matični Broj Građana
4
+ # UMCN - Unique Master Citizen Number
5
+ class Croatia::UMCN
6
+ WEIGHTS = [ 7, 6, 5, 4, 3, 2, 7, 6, 5, 4, 3, 2 ]
7
+ REGION_CODES = {
8
+ # Special / Foreign
9
+ 0 => "Yugoslavia",
10
+ 1 => "Foreigner in BiH",
11
+ 2 => "Foreigner in Montenegro",
12
+ 3 => "Foreigner in Croatia",
13
+ 4 => "Foreigner in North Macedonia",
14
+ 5 => "Foreigner in Slovenia",
15
+ 6 => "Foreigner in Serbia",
16
+ 7 => "Foreigner in Vojvodina",
17
+ 8 => "Foreigner in Kosovo",
18
+ 9 => "Yugoslavia",
19
+
20
+ # Bosnia and Herzegovina (10–19)
21
+ 10 => "Banja Luka",
22
+ 11 => "Bihac",
23
+ 12 => "Doboj",
24
+ 13 => "Gorazde",
25
+ 14 => "Livno",
26
+ 15 => "Mostar",
27
+ 16 => "Prijedor",
28
+ 17 => "Sarajevo",
29
+ 18 => "Tuzla",
30
+ 19 => "Zenica",
31
+
32
+ # Montenegro (21–29)
33
+ 21 => "Podgorica",
34
+ 22 => "Bar",
35
+ 23 => "Budva",
36
+ 24 => "Herceg Novi",
37
+ 25 => "Cetinje",
38
+ 26 => "Niksic",
39
+ 27 => "Berane",
40
+ 28 => "Bijelo Polje",
41
+ 29 => "Pljevlja",
42
+
43
+ # Croatia (30–39)
44
+ 30 => "Slavonia",
45
+ 31 => "Podravina",
46
+ 32 => "Medimurje",
47
+ 33 => "Zagreb",
48
+ 34 => "Kordun",
49
+ 35 => "Lika",
50
+ 36 => "Istria",
51
+ 37 => "Banovina",
52
+ 38 => "Dalmatia",
53
+ 39 => "Zagorje",
54
+
55
+ # North Macedonia (41–49)
56
+ 41 => "Bitola",
57
+ 42 => "Kumanovo",
58
+ 43 => "Ohrid",
59
+ 44 => "Prilep",
60
+ 45 => "Skopje",
61
+ 46 => "Strumica",
62
+ 47 => "Tetovo",
63
+ 48 => "Veles",
64
+ 49 => "Stip",
65
+
66
+ # Slovenia (50)
67
+ 50 => "Slovenia",
68
+
69
+ # Serbia (70–79)
70
+ 70 => "Serbia Abroad",
71
+ 71 => "Belgrade",
72
+ 72 => "Sumadija",
73
+ 73 => "Nis",
74
+ 74 => "Morava",
75
+ 75 => "Zajecar",
76
+ 76 => "Podunavlje",
77
+ 77 => "Kolubara",
78
+ 78 => "Kraljevo",
79
+ 79 => "Uzice",
80
+
81
+ # Vojvodina (80–89)
82
+ 80 => "Novi Sad",
83
+ 81 => "Sombor",
84
+ 82 => "Subotica",
85
+ 83 => "Zrenjanin",
86
+ 84 => "Kikinda",
87
+ 85 => "Pancevo",
88
+ 86 => "Vrbas",
89
+ 87 => "Sremska Mitrovica",
90
+ 88 => "Ruma",
91
+ 89 => "Backa Topola",
92
+
93
+ # Kosovo (90–99)
94
+ 90 => "Pristina",
95
+ 91 => "Prizren",
96
+ 92 => "Pec",
97
+ 93 => "Djakovica",
98
+ 94 => "Mitrovica",
99
+ 95 => "Gnjilane",
100
+ 96 => "Ferizaj",
101
+ 97 => "Decan",
102
+ 98 => "Klina",
103
+ 99 => "Malisevo"
104
+ }.freeze
105
+
106
+
107
+ attr_accessor :birthday, :region_code, :sequence_number, :checkusm
108
+
109
+ def self.valid?(umcn)
110
+ return false if umcn.nil?
111
+ return false unless umcn.match?(/\A\d{13}\Z/)
112
+
113
+ parse(umcn).checksum == umcn.strip[-1].to_i
114
+ rescue Date::Error, ArgumentError
115
+ false
116
+ end
117
+
118
+ def self.parse(umcn)
119
+ digits = umcn.chars.map(&:to_i)
120
+
121
+ day = digits[0..1].join.to_i
122
+ month = digits[2..3].join.to_i
123
+ year = digits[4..6].join.to_i
124
+ millenium = case digits[4]
125
+ when 0 then 2000
126
+ else 1000
127
+ end
128
+ full_year = millenium + year
129
+
130
+ birthday = Date.new(full_year, month, day)
131
+ region_code = digits[7..8].join.to_i
132
+ sequence_number = digits[9..11].join.to_i
133
+
134
+ new(birthday: birthday, region_code: region_code, sequence_number: sequence_number)
135
+ end
136
+
137
+ def initialize(birthday:, region_code:, sequence_number:)
138
+ @birthday = birthday
139
+ @region_code = region_code
140
+ @sequence_number = sequence_number
141
+ end
142
+
143
+ def region_code=(value)
144
+ value = value.to_i
145
+
146
+ if REGION_CODES.key?(value)
147
+ @region_code = value
148
+ else
149
+ raise ArgumentError, "Invalid region code: #{value}"
150
+ end
151
+ end
152
+
153
+ def sequence_number=(value)
154
+ value = value.to_i
155
+
156
+ if value < 0 || value > 999
157
+ raise ArgumentError, "Sequence number must be between 0 and 999"
158
+ end
159
+
160
+ @sequence_number = value
161
+ end
162
+
163
+ def sex
164
+ sequence_number <= 499 ? :male : :female
165
+ end
166
+
167
+ def region_of_birth
168
+ REGION_CODES[region_code]
169
+ end
170
+
171
+ def to_s
172
+ parts = []
173
+ parts << birthday.strftime("%d%m")
174
+ parts << format("%03d", birthday.year % 1000)
175
+ parts << format("%02d", region_code)
176
+ parts << format("%03d", sequence_number)
177
+
178
+ digits = parts.join.chars.map(&:to_i)
179
+ sum = digits.each_with_index.sum { |digit, i| digit * WEIGHTS[i] }
180
+ mod = sum % 11
181
+
182
+ checksum = (mod == 0 || mod == 1) ? 0 : (11 - mod)
183
+
184
+ parts << checksum.to_s
185
+ parts.join
186
+ end
187
+
188
+ def checksum
189
+ to_s[-1].to_i
190
+ end
191
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Croatia::Enum
4
+ def self.included(base)
5
+ base.extend(ClassMethods)
6
+ end
7
+
8
+ module ClassMethods
9
+ def enum(name, values, **options)
10
+ name = name.to_s.to_sym unless name.is_a?(Symbol)
11
+ values = values.zip(values) if values.is_a?(Array)
12
+
13
+ unless values.respond_to?(:to_h)
14
+ raise ArgumentError, "Enum values must be defined as a Hash or as an Array"
15
+ end
16
+
17
+ values = values.to_h.freeze
18
+ values_method_name = "#{name}_values".to_sym
19
+
20
+ define_singleton_method(values_method_name) do
21
+ values
22
+ end
23
+
24
+ define_method("#{name}=") do |value|
25
+ values = self.class.public_send(values_method_name)
26
+
27
+ enum_value = values[value]
28
+ enum_value = value if enum_value.nil? && values.has_value?(value)
29
+
30
+ if enum_value.nil? && !options[:allow_nil]
31
+ raise ArgumentError, "Invalid value for enum #{name}: #{value.inspect}"
32
+ end
33
+
34
+ instance_variable_set("@#{name}", enum_value)
35
+ end
36
+
37
+ define_method(name) do
38
+ instance_variable_get("@#{name}")
39
+ end
40
+
41
+ prefix = options[:prefix] ? "#{options[:prefix]}_" : ""
42
+ suffix = options[:suffix] ? "_#{options[:suffix]}" : ""
43
+
44
+ values.each do |key, value|
45
+ value_method_name = "#{prefix}#{key}#{suffix}"
46
+
47
+ define_method("#{value_method_name}?") do
48
+ instance_variable_get("@#{name}") == value
49
+ end
50
+
51
+ define_method("#{value_method_name}!") do
52
+ public_send("#{name}=", value)
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Croatia
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/croatia.rb CHANGED
@@ -4,4 +4,39 @@ require_relative "croatia/version"
4
4
 
5
5
  module Croatia
6
6
  class Error < StandardError; end
7
+
8
+ autoload :Config, "croatia/config"
9
+ autoload :Enum, "croatia/utils/enum"
10
+ autoload :QRCode, "croatia/qr_code"
11
+ autoload :PDF417, "croatia/pdf417"
12
+ autoload :PIN, "croatia/pin"
13
+ autoload :UMCN, "croatia/umcn"
14
+ autoload :PaymentBarcode, "croatia/payment_barcode"
15
+ autoload :Fiscalizer, "croatia/fiscalizer"
16
+ autoload :Invoice, "croatia/invoice"
17
+
18
+ class << self
19
+ def with_config(config = nil, &block)
20
+ Thread.current[:tmp_croatia_config] = config
21
+ block.call
22
+ ensure
23
+ Thread.current[:tmp_croatia_config] = nil
24
+ end
25
+
26
+ def config
27
+ return Thread.current[:tmp_croatia_config] if Thread.current[:tmp_croatia_config]
28
+
29
+ @config ||= Croatia::Config.new
30
+ end
31
+
32
+ def configure(config = nil, &block)
33
+ if config.is_a?(Croatia::Config)
34
+ @config = config
35
+ elsif block
36
+ self.config.tap(&block)
37
+ else
38
+ raise ArgumentError, "Either a Croatia::Config instance or a block is required"
39
+ end
40
+ end
41
+ end
7
42
  end
metadata CHANGED
@@ -1,29 +1,84 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: croatia
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
  - Stanko K.R.
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2025-05-29 00:00:00.000000000 Z
11
- dependencies: []
12
- description: 'Croatia is a gem that contains various utilities for performing Croatia-specific
13
- actions - like PIN/OIB validation
14
-
15
- '
10
+ date: 2025-06-04 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: openssl
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rexml
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: tzinfo
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ description: |
55
+ Croatia is a gem that contains various utilities for performing Croatia-specific actions like:
56
+ - PIN/OIB validation
16
57
  email:
17
58
  - hey@stanko.io
18
59
  executables: []
19
60
  extensions: []
20
61
  extra_rdoc_files: []
21
62
  files:
22
- - ".rubocop.yml"
23
63
  - LICENSE.txt
24
64
  - Rakefile
25
65
  - lib/croatia.rb
66
+ - lib/croatia/config.rb
67
+ - lib/croatia/fiscalizer.rb
68
+ - lib/croatia/fiscalizer/xml_builder.rb
69
+ - lib/croatia/invoice.rb
70
+ - lib/croatia/invoice/e_invoicable.rb
71
+ - lib/croatia/invoice/fiscalizable.rb
72
+ - lib/croatia/invoice/line_item.rb
73
+ - lib/croatia/invoice/party.rb
74
+ - lib/croatia/invoice/payable.rb
75
+ - lib/croatia/invoice/tax.rb
76
+ - lib/croatia/payment_barcode.rb
77
+ - lib/croatia/pdf417.rb
26
78
  - lib/croatia/pin.rb
79
+ - lib/croatia/qr_code.rb
80
+ - lib/croatia/umcn.rb
81
+ - lib/croatia/utils/enum.rb
27
82
  - lib/croatia/version.rb
28
83
  - sig/croatia.rbs
29
84
  homepage: https://github.com/monorkin/croatia
data/.rubocop.yml DELETED
@@ -1,5 +0,0 @@
1
- inherit_gem:
2
- rubocop-rails-omakase: rubocop.yml
3
-
4
- AllCops:
5
- TargetRubyVersion: 3.1