croatia 0.1.0 → 0.2.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: 2e22a48f786d2268182526ab25ca8ef664847694e843b164afdbf38214f937bc
4
+ data.tar.gz: 8c36543cca8688fdccda47197ebc5b66f5554b3342629e04e03cafe528aaa88e
5
5
  SHA512:
6
- metadata.gz: f103227a100270c0d53c8bf45edb30532a852719866900fed949996e1afbfcb4fd3525c0e7ae4828bd8e16a1137357f3345810600b24cd3db174774ba2f4a5fd
7
- data.tar.gz: cec02f409ded5d60f9535464669219314f4da1ab516ef5139b550d033552ac9393cd6105e37486427cfe6ee606c93636caa85c2a3f7994dc783cf9984643b110
6
+ metadata.gz: 93126e3a11b8e23414a3bf6b3ab051aeb47092380022b4a978b0f478c693ff5d62bb143f478bb6f55464c4a1a92d8df77890b3bff6332c8934f109ce05e86851
7
+ data.tar.gz: 840c3dd7f3ef96143bb5c694237fba3d4c6f7270a2aadb4e270746c0787bcd170c8ae193d83ace677c216f44a26357bc414e8888603b61a8f424ba8337ab2c0b
@@ -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,30 @@
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
+
9
+ class << self
10
+ def invoice_request(invoice:, message_id:, timezone: Croatia::Invoice::Fiscalizer::TZ)
11
+ REXML::Document.new.tap do |doc|
12
+ envelope = doc.add_element("tns:RacunZahtjev", {
13
+ "xmlns:tns" => TNS,
14
+ "xmlns:xsi" => XSI
15
+ })
16
+
17
+ envelope.add_element("tns:Zaglavlje").tap do |header|
18
+ header.add_element("tns:IdPoruke").text = message_id
19
+ header.add_element("tns:DatumVrijemeSlanja").text = timezone.now.strftime("%d.%m.%YT%H:%M:%S")
20
+ end
21
+
22
+ envelope.add_element("tns:Racun").tap do |payload|
23
+ payload.add_element("tns:Oib").text = invoice.seller.pin
24
+ payload.add_element("tns:USustPdv").text = invoice.seller.pays_vat ? "true" : "false"
25
+ payload.add_element("tns:DatVrijeme").text = timezone.to_local(invoice.issue_date).strftime("%d.%m.%YT%H:%M:%S")
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest/md5"
4
+ require "openssl"
5
+ require "securerandom"
6
+ require "tzinfo"
7
+
8
+ class Croatia::Fiscalizer
9
+ autoload :XMLBuilder, "croatia/fiscalizer/xml_builder"
10
+
11
+ TZ = TZInfo::Timezone.get("Europe/Zagreb")
12
+
13
+ attr_reader :certificate
14
+
15
+ def initialize(certificate: nil, password: nil)
16
+ certificate ||= Croatia.config.fiscalization[:certificate]
17
+ password ||= Croatia.config.fiscalization[:password]
18
+
19
+ @certificate = load_certificate(certificate, password)
20
+ end
21
+
22
+ def fiscalize(invoice:, message_id: SecureRandom.uuid)
23
+ document = XMLBuilder.invoice_request(invoice: invoice, message_id: message_id, timezone: TZ)
24
+
25
+ # TODO: Implement the fiscalization logic here
26
+ puts "TODO: Fiscalize invoice #{invoice}"
27
+ puts "GENERATED XML:\n#{document}"
28
+ end
29
+
30
+ def generate_issuer_protection_code(invoice)
31
+ buffer = []
32
+ buffer << invoice.issuer.pin
33
+ buffer << TZ.to_local(invoice.issue_date).strftime("%d.%m.%Y %H:%M:%S")
34
+ buffer << invoice.sequential_number
35
+ buffer << invoice.business_location_identifier
36
+ buffer << invoice.register_identifier
37
+ buffer << invoice.total.to_f
38
+
39
+ digest = OpenSSL::Digest::SHA1.new
40
+ signature = certificate.sign(digest, buffer.join)
41
+
42
+ Digest::MD5.hexdigest(signature).downcase
43
+ end
44
+
45
+ private
46
+
47
+ def load_certificate(cert, password)
48
+ if cert.is_a?(OpenSSL::PKCS12)
49
+ cert.key
50
+ elsif cert.is_a?(OpenSSL::PKey::PKey)
51
+ cert
52
+ else
53
+ cert = File.read(cert) if cert.respond_to?(:to_s) && File.exist?(cert.to_s)
54
+
55
+ begin
56
+ OpenSSL::PKey.read(cert)
57
+ rescue OpenSSL::PKey::PKeyError
58
+ OpenSSL::PKCS12.new(cert, password).key
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Croatia::Invoice::EInvoicable
4
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Croatia::Invoice::Fiscalizable
6
+ QR_CODE_BASE_URL = "https://porezna.gov.hr/rn"
7
+
8
+ def self.included(base)
9
+ base.include InstanceMethods
10
+ end
11
+
12
+ module InstanceMethods
13
+ def fiscalize!(**options)
14
+ Croatia::Fiscalizer.new(**options).fiscalize(invoice: self)
15
+ end
16
+
17
+ def reverse!(**options)
18
+ line_items.each(&:reverse)
19
+ Croatia::Fiscalizer.new(**options).fiscalize(invoice: self)
20
+ end
21
+
22
+ def issuer_protection_code(**options)
23
+ Croatia::Fiscalizer
24
+ .new(**options)
25
+ .generate_issuer_protection_code(self)
26
+ end
27
+
28
+ def fiscalization_qr_code(**options)
29
+ Croatia::QRCode.ensure_supported!
30
+
31
+ params = {
32
+ datv: issue_date.strftime("%Y%m%d_%H%M"),
33
+ izn: total_cents.to_s
34
+ }
35
+
36
+ if params[:izn].length > 10
37
+ raise ArgumentError, "Total amount exceeds 10 digits: #{params[:izn]}"
38
+ end
39
+
40
+ uii = options[:unique_invoice_identifier] || unique_invoice_identifier
41
+
42
+ if uii
43
+ params[:jir] = uii
44
+ elsif !params[:zki]
45
+ ipc = issuer_protection_code(**options)
46
+ params[:zki] = ipc
47
+ end
48
+
49
+ if (params[:jir] && params[:zki]) || (params[:jir].nil? && params[:zki].nil?)
50
+ raise ArgumentError, "Either unique_invoice_identifier or issuer_protection_code must be provided"
51
+ end
52
+
53
+ query_string = URI.encode_www_form(params)
54
+ url = "#{QR_CODE_BASE_URL}?#{query_string}"
55
+
56
+ Croatia::QRCode.new(url)
57
+ end
58
+ end
59
+ 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 ||= "Racun #{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,121 @@
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
+ autoload :Fiscalizer, "croatia/invoice/fiscalizer"
14
+
15
+ include Croatia::Enum
16
+ include Fiscalizable
17
+ include EInvoicable
18
+ include Payable
19
+
20
+ attr_reader :issue_date, :due_date
21
+ attr_writer :issuer, :seller, :buyer
22
+ attr_accessor \
23
+ :business_location_identifier, # oznaka poslovnog prostora
24
+ :currency,
25
+ :line_items,
26
+ :register_identifier, # oznaka naplatnog uredaja
27
+ :sequential_number, # redni broj racuna
28
+ :unique_invoice_identifier # jir
29
+
30
+ enum :payment_method, {
31
+ cash: "G",
32
+ card: "K",
33
+ check: "C",
34
+ transfer: "T",
35
+ other: "O"
36
+ }.freeze, allow_nil: true, prefix: :payment_method
37
+
38
+ def initialize(**options)
39
+ self.line_items = options.delete(:line_items) { [] }
40
+
41
+ options.each do |key, value|
42
+ public_send("#{key}=", value)
43
+ end
44
+ end
45
+
46
+ def number
47
+ [ sequential_number, business_location_identifier, register_identifier ].join("/")
48
+ end
49
+
50
+ def subtotal
51
+ line_items.sum(&:subtotal).to_d
52
+ end
53
+
54
+ def tax
55
+ line_items.sum(&:tax).to_d
56
+ end
57
+
58
+ def total
59
+ line_items.sum(&:total).to_d
60
+ end
61
+
62
+ def total_cents
63
+ (total * 100).to_i
64
+ end
65
+
66
+ def add_line_item(line_item = nil, &block)
67
+ if line_item.nil? && block.nil?
68
+ raise ArgumentError, "You must provide a line item or a block"
69
+ end
70
+
71
+ line_item ||= LineItem.new.tap(&block)
72
+
73
+ self.line_items ||= []
74
+ line_items << line_item
75
+
76
+ line_item
77
+ end
78
+
79
+ def issuer(&block)
80
+ if block_given?
81
+ self.issuer = Party.new.tap(&block)
82
+ else
83
+ @issuer
84
+ end
85
+ end
86
+
87
+ def buyer(&block)
88
+ if block_given?
89
+ self.buyer = Party.new.tap(&block)
90
+ else
91
+ @buyer
92
+ end
93
+ end
94
+
95
+ def seller(&block)
96
+ if block_given?
97
+ self.seller = Party.new.tap(&block)
98
+ else
99
+ @seller
100
+ end
101
+ end
102
+
103
+ def issue_date=(value)
104
+ @issue_date = value.nil? ? nil : parse_date(value)
105
+ end
106
+
107
+ def due_date=(value)
108
+ @due_date = value.nil? ? nil : parse_date(value)
109
+ end
110
+
111
+ private
112
+
113
+ def parse_date(value)
114
+ case value
115
+ when Date, DateTime
116
+ value
117
+ else
118
+ DateTime.parse(value.to_s)
119
+ end
120
+ end
121
+ 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,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Croatia::PDF417
4
+ def self.ensure_supported!
5
+ return if supported?
6
+
7
+ raise LoadError, "PDF417 library is not loaded. Please ensure you have the pdf-417 gem installed."
8
+ end
9
+
10
+ def self.supported?
11
+ defined?(PDF417)
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
+ ary = bar
23
+
24
+ unless ary
25
+ raise ArgumentError, "Data is not valid for PDF417 encoding"
26
+ end
27
+
28
+ options[:x_scale] ||= 1
29
+ options[:y_scale] ||= 1
30
+ options[:margin] ||= 10
31
+
32
+ full_width = (ary.first.size * options[:x_scale]) + (options[:margin] * 2)
33
+ full_height = (ary.size * options[:y_scale]) + (options[:margin] * 2)
34
+
35
+ dots = ary.map { |l| l.chars.map { |c| c == "1" } }
36
+
37
+ svg = []
38
+ svg << %Q(<?xml version="1.0" standalone="no"?>)
39
+ svg << %Q(<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="#{full_width}" height="#{full_height}">)
40
+ svg << %Q(<rect width="100%" height="100%" fill="white" />)
41
+
42
+ y = options[:margin]
43
+ dots.each do |line|
44
+ x = options[:margin]
45
+ line.each do |bar|
46
+ if bar
47
+ svg << %Q(<rect x="#{x}" y="#{y}" width="#{options[:x_scale]}" height="#{options[:y_scale]}" fill="black" />)
48
+ end
49
+ x += options[:x_scale]
50
+ end
51
+ y += options[:y_scale]
52
+ end
53
+
54
+ svg << "</svg>"
55
+ svg.join("\n")
56
+ end
57
+
58
+ def to_png(**options)
59
+ barcode.to_png(**options)
60
+ end
61
+
62
+ private
63
+
64
+ def barcode
65
+ @barcode ||= PDF417.new(data).tap(&:generate)
66
+ end
67
+
68
+ def bar
69
+ barcode.instance_variable_get(:@bar)
70
+ end
71
+ 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."
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,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ # JMBG - Jedinstveni Matični Broj Građana
4
+ # UMCN - Unique Master Citizen Number
5
+ module Croatia::UMCN
6
+ def self.valid?(umcn)
7
+ return false unless umcn =~ /\A\d{13}\Z/
8
+
9
+ digits = umcn.chars.map(&:to_i)
10
+
11
+ day = digits[0..1].join.to_i
12
+ month = digits[2..3].join.to_i
13
+ year = digits[4..6].join.to_i
14
+ century = case digits[4]
15
+ when 0 then 2000
16
+ when 9 then 1800
17
+ else 1900
18
+ end
19
+ full_year = century + year
20
+
21
+ return false unless Date.valid_date?(full_year, month, day)
22
+
23
+ weights = [ 7, 6, 5, 4, 3, 2, 7, 6, 5, 4, 3, 2 ]
24
+ sum = digits[0..11].each_with_index.sum { |d, i| d * weights[i] }
25
+ mod = sum % 11
26
+ checksum = mod == 0 || mod == 1 ? 0 : 11 - mod
27
+
28
+ digits[12] == checksum
29
+ end
30
+ 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.2.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.2.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-03 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