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 +4 -4
- data/lib/croatia/config.rb +30 -0
- data/lib/croatia/fiscalizer/xml_builder.rb +30 -0
- data/lib/croatia/fiscalizer.rb +62 -0
- data/lib/croatia/invoice/e_invoicable.rb +4 -0
- data/lib/croatia/invoice/fiscalizable.rb +59 -0
- data/lib/croatia/invoice/line_item.rb +103 -0
- data/lib/croatia/invoice/party.rb +20 -0
- data/lib/croatia/invoice/payable.rb +37 -0
- data/lib/croatia/invoice/tax.rb +27 -0
- data/lib/croatia/invoice.rb +121 -0
- data/lib/croatia/payment_barcode.rb +95 -0
- data/lib/croatia/pdf417.rb +71 -0
- data/lib/croatia/pin.rb +2 -0
- data/lib/croatia/qr_code.rb +34 -0
- data/lib/croatia/umcn.rb +30 -0
- data/lib/croatia/utils/enum.rb +57 -0
- data/lib/croatia/version.rb +1 -1
- data/lib/croatia.rb +35 -0
- metadata +63 -8
- data/.rubocop.yml +0 -5
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 2e22a48f786d2268182526ab25ca8ef664847694e843b164afdbf38214f937bc
|
4
|
+
data.tar.gz: 8c36543cca8688fdccda47197ebc5b66f5554b3342629e04e03cafe528aaa88e
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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,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
@@ -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
|
data/lib/croatia/umcn.rb
ADDED
@@ -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
|
data/lib/croatia/version.rb
CHANGED
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.
|
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-
|
11
|
-
dependencies:
|
12
|
-
|
13
|
-
|
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
|