croatia 0.2.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: 2e22a48f786d2268182526ab25ca8ef664847694e843b164afdbf38214f937bc
4
- data.tar.gz: 8c36543cca8688fdccda47197ebc5b66f5554b3342629e04e03cafe528aaa88e
3
+ metadata.gz: 950fcecdaee0439b153f3bccc88c0dcc1bd65707200d6f88b7c08729752d0384
4
+ data.tar.gz: 56d5d0073b5059d2a70767ff82be379c92183f16a37bfcbb318c576cccc3b797
5
5
  SHA512:
6
- metadata.gz: 93126e3a11b8e23414a3bf6b3ab051aeb47092380022b4a978b0f478c693ff5d62bb143f478bb6f55464c4a1a92d8df77890b3bff6332c8934f109ce05e86851
7
- data.tar.gz: 840c3dd7f3ef96143bb5c694237fba3d4c6f7270a2aadb4e270746c0787bcd170c8ae193d83ace677c216f44a26357bc414e8888603b61a8f424ba8337ab2c0b
6
+ metadata.gz: b6388cb5613f183c1bbea376ab1eb2975b9e6f90c00c98c1e272a94a69ca19819b509aadc86d27fd63f1bf194b9597b5d391598015366294dbf6c1cbebdea306
7
+ data.tar.gz: 8f27f7baabdbb619e7b3048fc34f204b7279137437e0983de40492b5e5af2c1b23ba80c03e01b3c471a035756bfc7d6cb5106a147534e4a372220b46328a3ca0
@@ -5,9 +5,20 @@ require "rexml/document"
5
5
  module Croatia::Fiscalizer::XMLBuilder
6
6
  TNS = "http://www.apis-it.hr/fin/2012/types/f73"
7
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
8
19
 
9
20
  class << self
10
- def invoice_request(invoice:, message_id:, timezone: Croatia::Invoice::Fiscalizer::TZ)
21
+ def invoice_request(invoice:, message_id:, specific_purpose: nil, subsequent_delivery: false, timezone: Croatia::Fiscalizer::TZ)
11
22
  REXML::Document.new.tap do |doc|
12
23
  envelope = doc.add_element("tns:RacunZahtjev", {
13
24
  "xmlns:tns" => TNS,
@@ -23,6 +34,23 @@ module Croatia::Fiscalizer::XMLBuilder
23
34
  payload.add_element("tns:Oib").text = invoice.seller.pin
24
35
  payload.add_element("tns:USustPdv").text = invoice.seller.pays_vat ? "true" : "false"
25
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
26
54
  end
27
55
  end
28
56
  end
@@ -4,11 +4,13 @@ require "digest/md5"
4
4
  require "openssl"
5
5
  require "securerandom"
6
6
  require "tzinfo"
7
+ require "uri"
7
8
 
8
9
  class Croatia::Fiscalizer
9
10
  autoload :XMLBuilder, "croatia/fiscalizer/xml_builder"
10
11
 
11
12
  TZ = TZInfo::Timezone.get("Europe/Zagreb")
13
+ QR_CODE_BASE_URL = "https://porezna.gov.hr/rn"
12
14
 
13
15
  attr_reader :certificate
14
16
 
@@ -20,11 +22,8 @@ class Croatia::Fiscalizer
20
22
  end
21
23
 
22
24
  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}"
25
+ _document = XMLBuilder.invoice_request(invoice: invoice, message_id: message_id, timezone: TZ)
26
+ raise NotImplementedError, "Fiscalization XML generation is not implemented yet"
28
27
  end
29
28
 
30
29
  def generate_issuer_protection_code(invoice)
@@ -42,6 +41,34 @@ class Croatia::Fiscalizer
42
41
  Digest::MD5.hexdigest(signature).downcase
43
42
  end
44
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
+
45
72
  private
46
73
 
47
74
  def load_certificate(cert, password)
@@ -50,7 +77,10 @@ class Croatia::Fiscalizer
50
77
  elsif cert.is_a?(OpenSSL::PKey::PKey)
51
78
  cert
52
79
  else
53
- cert = File.read(cert) if cert.respond_to?(:to_s) && File.exist?(cert.to_s)
80
+ begin
81
+ cert = File.read(cert) if cert.respond_to?(:to_s) && File.exist?(cert.to_s)
82
+ rescue ArgumentError
83
+ end
54
84
 
55
85
  begin
56
86
  OpenSSL::PKey.read(cert)
@@ -1,10 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "uri"
4
-
5
3
  module Croatia::Invoice::Fiscalizable
6
- QR_CODE_BASE_URL = "https://porezna.gov.hr/rn"
7
-
8
4
  def self.included(base)
9
5
  base.include InstanceMethods
10
6
  end
@@ -26,34 +22,9 @@ module Croatia::Invoice::Fiscalizable
26
22
  end
27
23
 
28
24
  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)
25
+ Croatia::Fiscalizer
26
+ .new(**options)
27
+ .generate_verification_qr_code(self)
57
28
  end
58
29
  end
59
30
  end
@@ -11,7 +11,7 @@ module Croatia::Invoice::Payable
11
11
  raise ArgumentError, "Both buyer and seller must be set before generating a payment barcode"
12
12
  end
13
13
 
14
- description ||= "Racun #{number}"
14
+ description ||= "Račun #{number}"
15
15
 
16
16
  options = {
17
17
  currency: currency,
@@ -10,7 +10,6 @@ class Croatia::Invoice
10
10
  autoload :Party, "croatia/invoice/party"
11
11
  autoload :Tax, "croatia/invoice/tax"
12
12
  autoload :LineItem, "croatia/invoice/line_item"
13
- autoload :Fiscalizer, "croatia/invoice/fiscalizer"
14
13
 
15
14
  include Croatia::Enum
16
15
  include Fiscalizable
@@ -27,16 +26,13 @@ class Croatia::Invoice
27
26
  :sequential_number, # redni broj racuna
28
27
  :unique_invoice_identifier # jir
29
28
 
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
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
37
31
 
38
32
  def initialize(**options)
39
33
  self.line_items = options.delete(:line_items) { [] }
34
+ self.payment_method = :card
35
+ self.sequential_by = :register
40
36
 
41
37
  options.each do |key, value|
42
38
  public_send("#{key}=", value)
@@ -4,11 +4,11 @@ class Croatia::PDF417
4
4
  def self.ensure_supported!
5
5
  return if supported?
6
6
 
7
- raise LoadError, "PDF417 library is not loaded. Please ensure you have the pdf-417 gem installed."
7
+ raise LoadError, "Zint library is not loaded. Please ensure you have the ruby-zint gem installed and required."
8
8
  end
9
9
 
10
10
  def self.supported?
11
- defined?(PDF417)
11
+ defined?(Zint)
12
12
  end
13
13
 
14
14
  attr_reader :data, :options
@@ -19,36 +19,17 @@ class Croatia::PDF417
19
19
  end
20
20
 
21
21
  def to_svg(**options)
22
- ary = bar
22
+ vec = barcode.to_vector
23
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" } }
24
+ foreground_color = options[:foreground_color] || "black"
25
+ background_color = options[:background_color] || "white"
36
26
 
37
27
  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" />)
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}" />)
41
30
 
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]
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}" />)
52
33
  end
53
34
 
54
35
  svg << "</svg>"
@@ -56,16 +37,12 @@ class Croatia::PDF417
56
37
  end
57
38
 
58
39
  def to_png(**options)
59
- barcode.to_png(**options)
40
+ barcode.to_memory_file(extension: ".png")
60
41
  end
61
42
 
62
43
  private
63
44
 
64
45
  def barcode
65
- @barcode ||= PDF417.new(data).tap(&:generate)
66
- end
67
-
68
- def bar
69
- barcode.instance_variable_get(:@bar)
46
+ @barcode ||= Zint::Barcode.new(**options, value: data, symbology: Zint::BARCODE_PDF417)
70
47
  end
71
48
  end
@@ -4,7 +4,7 @@ class Croatia::QRCode
4
4
  def self.ensure_supported!
5
5
  return if supported?
6
6
 
7
- raise LoadError, "RQRCode library is not loaded. Please ensure you have the rqrcode gem installed."
7
+ raise LoadError, "RQRCode library is not loaded. Please ensure you have the rqrcode gem installed and required."
8
8
  end
9
9
 
10
10
  def self.supported?
data/lib/croatia/umcn.rb CHANGED
@@ -2,29 +2,190 @@
2
2
 
3
3
  # JMBG - Jedinstveni Matični Broj Građana
4
4
  # UMCN - Unique Master Citizen Number
5
- module Croatia::UMCN
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
+
6
109
  def self.valid?(umcn)
7
- return false unless umcn =~ /\A\d{13}\Z/
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
8
117
 
118
+ def self.parse(umcn)
9
119
  digits = umcn.chars.map(&:to_i)
10
120
 
11
121
  day = digits[0..1].join.to_i
12
122
  month = digits[2..3].join.to_i
13
123
  year = digits[4..6].join.to_i
14
- century = case digits[4]
124
+ millenium = case digits[4]
15
125
  when 0 then 2000
16
- when 9 then 1800
17
- else 1900
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}"
18
150
  end
19
- full_year = century + year
151
+ end
20
152
 
21
- return false unless Date.valid_date?(full_year, month, day)
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
22
162
 
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] }
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] }
25
180
  mod = sum % 11
26
- checksum = mod == 0 || mod == 1 ? 0 : 11 - mod
27
181
 
28
- digits[12] == checksum
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
29
190
  end
30
191
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Croatia
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: croatia
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.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-06-03 00:00:00.000000000 Z
10
+ date: 2025-06-04 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: openssl