invoicehn 0.1.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 +7 -0
- data/CHANGELOG.md +59 -0
- data/LICENSE.txt +21 -0
- data/README.md +430 -0
- data/config/locales/en.yml +89 -0
- data/config/locales/es.yml +87 -0
- data/exe/invoicehn +7 -0
- data/lib/invoicehn/authorization.rb +144 -0
- data/lib/invoicehn/cli/builder.rb +88 -0
- data/lib/invoicehn/cli/main.rb +233 -0
- data/lib/invoicehn/cli/reporter.rb +122 -0
- data/lib/invoicehn/cli/wizard.rb +234 -0
- data/lib/invoicehn/cli.rb +16 -0
- data/lib/invoicehn/compliance/validator.rb +222 -0
- data/lib/invoicehn/compliance/violation.rb +42 -0
- data/lib/invoicehn/config.rb +50 -0
- data/lib/invoicehn/correlative.rb +144 -0
- data/lib/invoicehn/customer.rb +188 -0
- data/lib/invoicehn/errors.rb +39 -0
- data/lib/invoicehn/exchange_rate.rb +108 -0
- data/lib/invoicehn/invoice.rb +165 -0
- data/lib/invoicehn/issuance.rb +124 -0
- data/lib/invoicehn/issuer.rb +95 -0
- data/lib/invoicehn/ledger.rb +107 -0
- data/lib/invoicehn/line_item.rb +120 -0
- data/lib/invoicehn/locale.rb +77 -0
- data/lib/invoicehn/money.rb +177 -0
- data/lib/invoicehn/renderers/json.rb +77 -0
- data/lib/invoicehn/renderers/pdf.rb +226 -0
- data/lib/invoicehn/renderers/text.rb +251 -0
- data/lib/invoicehn/rtn.rb +70 -0
- data/lib/invoicehn/sequence.rb +126 -0
- data/lib/invoicehn/spanish_numerals.rb +123 -0
- data/lib/invoicehn/storage/json_store.rb +154 -0
- data/lib/invoicehn/tax_summary.rb +129 -0
- data/lib/invoicehn/tax_treatment.rb +109 -0
- data/lib/invoicehn/version.rb +5 -0
- data/lib/invoicehn.rb +40 -0
- metadata +161 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Invoicehn
|
|
4
|
+
# The 16-digit document number of Art. 10 num. 7: NNN-NNN-NN-NNNNNNNN.
|
|
5
|
+
#
|
|
6
|
+
# a) first 3 digits — establecimiento; the casa matriz is assigned 000
|
|
7
|
+
# b) next 3 digits — punto de emisión
|
|
8
|
+
# c) next 2 digits — tipo de documento (01 = Factura)
|
|
9
|
+
# d) last 8 digits — sequence, starting at 00000001 and restarting after
|
|
10
|
+
# 99999999
|
|
11
|
+
#
|
|
12
|
+
# The first three groups together are the "identificador del documento".
|
|
13
|
+
class Correlative
|
|
14
|
+
include Comparable
|
|
15
|
+
|
|
16
|
+
SEQUENCE_MIN = 1
|
|
17
|
+
SEQUENCE_MAX = 99_999_999
|
|
18
|
+
PATTERN = /\A(\d{3})-(\d{3})-(\d{2})-(\d{8})\z/
|
|
19
|
+
|
|
20
|
+
# Codes verified in the decree. 09 is deliberately absent: it was not
|
|
21
|
+
# confirmed in the text, and guessing a fiscal document code is not worth
|
|
22
|
+
# the risk.
|
|
23
|
+
DOCUMENT_TYPES = {
|
|
24
|
+
"01" => "Factura",
|
|
25
|
+
"02" => "Factura Prevalorada",
|
|
26
|
+
"03" => "Ticket",
|
|
27
|
+
"04" => "Recibo por Honorarios Profesionales",
|
|
28
|
+
"05" => "Boleta de Compra",
|
|
29
|
+
"06" => "Constancia de Donación",
|
|
30
|
+
"07" => "Nota de Crédito",
|
|
31
|
+
"08" => "Nota de Débito",
|
|
32
|
+
"10" => "Comprobante de Retención"
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
FACTURA = "01"
|
|
36
|
+
|
|
37
|
+
attr_reader :establishment, :emission_point, :document_type, :sequence
|
|
38
|
+
|
|
39
|
+
class << self
|
|
40
|
+
def parse(value)
|
|
41
|
+
match = PATTERN.match(value.to_s.strip)
|
|
42
|
+
raise ValidationError, "correlativo inválido: #{value.inspect} (se espera NNN-NNN-NN-NNNNNNNN)" unless match
|
|
43
|
+
|
|
44
|
+
new(
|
|
45
|
+
establishment: match[1],
|
|
46
|
+
emission_point: match[2],
|
|
47
|
+
document_type: match[3],
|
|
48
|
+
sequence: match[4].to_i
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def valid?(value)
|
|
53
|
+
parse(value)
|
|
54
|
+
true
|
|
55
|
+
rescue ValidationError
|
|
56
|
+
false
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def initialize(establishment:, emission_point:, document_type:, sequence:)
|
|
61
|
+
@establishment = pad(establishment, 3, "establecimiento")
|
|
62
|
+
@emission_point = pad(emission_point, 3, "punto de emisión")
|
|
63
|
+
@document_type = pad(document_type, 2, "tipo de documento")
|
|
64
|
+
@sequence = Integer(sequence)
|
|
65
|
+
|
|
66
|
+
unless DOCUMENT_TYPES.key?(@document_type)
|
|
67
|
+
raise ValidationError,
|
|
68
|
+
"tipo de documento no reconocido: #{@document_type} " \
|
|
69
|
+
"(válidos: #{DOCUMENT_TYPES.keys.join(", ")})"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
unless @sequence.between?(SEQUENCE_MIN, SEQUENCE_MAX)
|
|
73
|
+
raise ValidationError,
|
|
74
|
+
"correlativo fuera de rango: #{@sequence} " \
|
|
75
|
+
"(debe estar entre #{SEQUENCE_MIN} y #{SEQUENCE_MAX})"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
freeze
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The (establecimiento, punto de emisión, tipo de documento) triple. SAR
|
|
82
|
+
# authorizes a CAI and range per emission point and document type, so this
|
|
83
|
+
# is the key a sequence is allocated against.
|
|
84
|
+
def identifier
|
|
85
|
+
"#{@establishment}-#{@emission_point}-#{@document_type}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def document_type_name = DOCUMENT_TYPES.fetch(@document_type)
|
|
89
|
+
def factura? = @document_type == FACTURA
|
|
90
|
+
|
|
91
|
+
def succ
|
|
92
|
+
raise RangeExhausted, "el correlativo #{self} es el último de la serie" if last?
|
|
93
|
+
|
|
94
|
+
with_sequence(@sequence + 1)
|
|
95
|
+
end
|
|
96
|
+
alias next succ
|
|
97
|
+
|
|
98
|
+
def last? = @sequence == SEQUENCE_MAX
|
|
99
|
+
|
|
100
|
+
def with_sequence(value)
|
|
101
|
+
self.class.new(
|
|
102
|
+
establishment: @establishment,
|
|
103
|
+
emission_point: @emission_point,
|
|
104
|
+
document_type: @document_type,
|
|
105
|
+
sequence: value
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def to_s
|
|
110
|
+
"#{@establishment}-#{@emission_point}-#{@document_type}-#{format("%08d", @sequence)}"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def inspect = "#<Invoicehn::Correlative #{self}>"
|
|
114
|
+
|
|
115
|
+
# Ordering is only meaningful inside one identifier — two different emission
|
|
116
|
+
# points run independent sequences.
|
|
117
|
+
def <=>(other)
|
|
118
|
+
return nil unless other.is_a?(self.class)
|
|
119
|
+
return nil unless identifier == other.identifier
|
|
120
|
+
|
|
121
|
+
@sequence <=> other.sequence
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def ==(other)
|
|
125
|
+
other.is_a?(self.class) && other.to_s == to_s
|
|
126
|
+
end
|
|
127
|
+
alias eql? ==
|
|
128
|
+
|
|
129
|
+
def hash = to_s.hash
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
def pad(value, width, label)
|
|
134
|
+
digits = value.to_s.strip
|
|
135
|
+
digits = format("%0#{width}d", digits.to_i) if digits.match?(/\A\d+\z/) && digits.length < width
|
|
136
|
+
|
|
137
|
+
unless digits.match?(/\A\d{#{width}}\z/)
|
|
138
|
+
raise ValidationError, "#{label} inválido: #{value.inspect} (se esperan #{width} dígitos)"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
digits
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Invoicehn
|
|
4
|
+
# The purchaser. Art. 11 distinguishes three situations, and which fields are
|
|
5
|
+
# mandatory depends on which one applies — so each is a separate class rather
|
|
6
|
+
# than a flag on one.
|
|
7
|
+
#
|
|
8
|
+
# Taxpayer — num. 1: a buyer who needs the invoice to support crédito
|
|
9
|
+
# fiscal. Name and RTN are both required.
|
|
10
|
+
# ConsumidorFinal— num. 2: name and identification, or the legend
|
|
11
|
+
# "CONSUMIDOR FINAL". Above L 10,000.00 the client's data
|
|
12
|
+
# becomes mandatory.
|
|
13
|
+
# Exonerado — num. 4 with Art. 10 num. 8: a buyer holding an
|
|
14
|
+
# exoneration, which brings its own supporting numbers.
|
|
15
|
+
class Customer
|
|
16
|
+
CONSUMIDOR_FINAL_LEGEND = "CONSUMIDOR FINAL"
|
|
17
|
+
|
|
18
|
+
# Art. 11 num. 2: "Cuando la venta de bienes o prestación de servicios se
|
|
19
|
+
# realice al Consumidor Final y excediera la suma de diez mil Lempiras
|
|
20
|
+
# (L 10,000.00), debe consignarse obligatoriamente los datos del cliente".
|
|
21
|
+
#
|
|
22
|
+
# The reforms moved the power to change this figure to the Secretaría de
|
|
23
|
+
# Finanzas, so it is a constant here and not a guess at a future value.
|
|
24
|
+
IDENTIFICATION_THRESHOLD = Money.new("10000.00", "HNL")
|
|
25
|
+
|
|
26
|
+
attr_reader :name
|
|
27
|
+
|
|
28
|
+
def initialize(name:)
|
|
29
|
+
@name = name.to_s.strip
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def consumidor_final? = false
|
|
33
|
+
def exonerado? = false
|
|
34
|
+
def taxpayer? = false
|
|
35
|
+
def rtn = nil
|
|
36
|
+
|
|
37
|
+
# What goes in the space reserved for the RTN on the printed document.
|
|
38
|
+
def identification_line = rtn&.formatted.to_s
|
|
39
|
+
|
|
40
|
+
def to_h = { "kind" => kind, "name" => @name }
|
|
41
|
+
|
|
42
|
+
def self.from_h(hash)
|
|
43
|
+
case hash["kind"]
|
|
44
|
+
when "taxpayer" then Taxpayer.from_h(hash)
|
|
45
|
+
when "consumidor_final" then ConsumidorFinal.from_h(hash)
|
|
46
|
+
when "exonerado" then Exonerado.from_h(hash)
|
|
47
|
+
else
|
|
48
|
+
raise ValidationError, "tipo de cliente desconocido: #{hash["kind"].inspect}"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# A buyer supporting crédito fiscal — Art. 11 num. 1 lit. a) and b).
|
|
53
|
+
class Taxpayer < Customer
|
|
54
|
+
attr_reader :rtn
|
|
55
|
+
|
|
56
|
+
def initialize(name:, rtn:)
|
|
57
|
+
super(name: name)
|
|
58
|
+
@rtn = rtn.is_a?(Rtn) ? rtn : Rtn.new(rtn)
|
|
59
|
+
freeze
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def kind = "taxpayer"
|
|
63
|
+
def taxpayer? = true
|
|
64
|
+
|
|
65
|
+
def to_h = super.merge("rtn" => @rtn.to_s)
|
|
66
|
+
|
|
67
|
+
def self.from_h(hash) = new(name: hash["name"], rtn: hash["rtn"])
|
|
68
|
+
|
|
69
|
+
def to_s = "#{@name} (RTN #{@rtn.formatted})"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Art. 11 num. 2 lit. a) — "Nombres y Apellidos, Número de identificación o
|
|
73
|
+
# consignar la leyenda 'CONSUMIDOR FINAL'".
|
|
74
|
+
class ConsumidorFinal < Customer
|
|
75
|
+
attr_reader :identification_type, :identification_number
|
|
76
|
+
|
|
77
|
+
# @param name [String, nil] omit for an anonymous sale, which prints the
|
|
78
|
+
# legend instead.
|
|
79
|
+
# @param identification_type [String, nil] e.g. "DNI", "Pasaporte".
|
|
80
|
+
def initialize(name: nil, identification_type: nil, identification_number: nil)
|
|
81
|
+
super(name: name)
|
|
82
|
+
@identification_type = identification_type.to_s.strip
|
|
83
|
+
@identification_number = identification_number.to_s.strip
|
|
84
|
+
freeze
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def kind = "consumidor_final"
|
|
88
|
+
def consumidor_final? = true
|
|
89
|
+
|
|
90
|
+
def identified? = !@name.empty? && !@identification_number.empty?
|
|
91
|
+
|
|
92
|
+
# Above the threshold Art. 11 requires "nombres y apellidos, el tipo y
|
|
93
|
+
# número de documento de identificación en el espacio destinado al RTN".
|
|
94
|
+
#
|
|
95
|
+
# @param total [Money] must be in lempiras: the article states the
|
|
96
|
+
# threshold as a sum in lempiras, so a foreign-currency invoice is
|
|
97
|
+
# measured by its converted equivalent. The caller does the conversion.
|
|
98
|
+
def identification_required?(total)
|
|
99
|
+
unless total.currency == "HNL"
|
|
100
|
+
raise CurrencyMismatch,
|
|
101
|
+
"el umbral del Art. 11 num. 2 se mide en lempiras; se recibió #{total.currency}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
total > IDENTIFICATION_THRESHOLD
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def display_name = @name.empty? ? CONSUMIDOR_FINAL_LEGEND : @name
|
|
108
|
+
|
|
109
|
+
def identification_line
|
|
110
|
+
return CONSUMIDOR_FINAL_LEGEND unless identified?
|
|
111
|
+
|
|
112
|
+
[@identification_type, @identification_number].reject(&:empty?).join(" ")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def to_h
|
|
116
|
+
super.merge(
|
|
117
|
+
"identification_type" => @identification_type,
|
|
118
|
+
"identification_number" => @identification_number
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def self.from_h(hash)
|
|
123
|
+
new(
|
|
124
|
+
name: hash["name"],
|
|
125
|
+
identification_type: hash["identification_type"],
|
|
126
|
+
identification_number: hash["identification_number"]
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def to_s = identified? ? "#{@name} (#{identification_line})" : CONSUMIDOR_FINAL_LEGEND
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Art. 11 num. 4 with Art. 10 num. 8 — an exonerated purchaser. At least one
|
|
134
|
+
# of the three supporting numbers must be present:
|
|
135
|
+
# a) Número correlativo de la Orden de Compra Exenta
|
|
136
|
+
# b) Número correlativo de la Constancia del Registro de Exonerados
|
|
137
|
+
# c) Número identificativo del Registro de la Secretaría de Estado en el
|
|
138
|
+
# Despacho de Agricultura y Ganadería
|
|
139
|
+
class Exonerado < Customer
|
|
140
|
+
attr_reader :rtn, :purchase_order, :exoneration_registry, :sag_registry
|
|
141
|
+
|
|
142
|
+
def initialize(name:, rtn:, purchase_order: nil, exoneration_registry: nil, sag_registry: nil)
|
|
143
|
+
super(name: name)
|
|
144
|
+
@rtn = rtn.is_a?(Rtn) ? rtn : Rtn.new(rtn)
|
|
145
|
+
@purchase_order = purchase_order.to_s.strip
|
|
146
|
+
@exoneration_registry = exoneration_registry.to_s.strip
|
|
147
|
+
@sag_registry = sag_registry.to_s.strip
|
|
148
|
+
freeze
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def kind = "exonerado"
|
|
152
|
+
def exonerado? = true
|
|
153
|
+
|
|
154
|
+
def supporting_documents
|
|
155
|
+
{
|
|
156
|
+
"Orden de Compra Exenta" => @purchase_order,
|
|
157
|
+
"Constancia del Registro de Exonerados" => @exoneration_registry,
|
|
158
|
+
"Registro SAG" => @sag_registry
|
|
159
|
+
}.reject { |_label, value| value.empty? }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# "según corresponda" — the article accepts whichever of the three applies
|
|
163
|
+
# to the buyer, so one is enough, but none is not.
|
|
164
|
+
def supported? = supporting_documents.any?
|
|
165
|
+
|
|
166
|
+
def to_h
|
|
167
|
+
super.merge(
|
|
168
|
+
"rtn" => @rtn.to_s,
|
|
169
|
+
"purchase_order" => @purchase_order,
|
|
170
|
+
"exoneration_registry" => @exoneration_registry,
|
|
171
|
+
"sag_registry" => @sag_registry
|
|
172
|
+
)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def self.from_h(hash)
|
|
176
|
+
new(
|
|
177
|
+
name: hash["name"],
|
|
178
|
+
rtn: hash["rtn"],
|
|
179
|
+
purchase_order: hash["purchase_order"],
|
|
180
|
+
exoneration_registry: hash["exoneration_registry"],
|
|
181
|
+
sag_registry: hash["sag_registry"]
|
|
182
|
+
)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def to_s = "#{@name} (RTN #{@rtn.formatted}, exonerado)"
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Invoicehn
|
|
4
|
+
# Base for every error this library raises.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# A value does not satisfy the format the law or this library requires.
|
|
8
|
+
class ValidationError < Error; end
|
|
9
|
+
|
|
10
|
+
# Arithmetic across mismatched currencies.
|
|
11
|
+
class CurrencyMismatch < Error; end
|
|
12
|
+
|
|
13
|
+
# Raised when a document fails one or more compliance rules. Carries the
|
|
14
|
+
# violations so a caller can report every problem at once rather than
|
|
15
|
+
# discovering them one at a time.
|
|
16
|
+
class ComplianceError < Error
|
|
17
|
+
attr_reader :violations
|
|
18
|
+
|
|
19
|
+
def initialize(violations)
|
|
20
|
+
@violations = Array(violations)
|
|
21
|
+
super(@violations.join("\n"))
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# The authorization's fecha límite de emisión has passed (Art. 62).
|
|
26
|
+
class AuthorizationExpired < Error; end
|
|
27
|
+
|
|
28
|
+
# The next correlative would fall outside the authorized range (Art. 10 num. 5).
|
|
29
|
+
class RangeExhausted < Error; end
|
|
30
|
+
|
|
31
|
+
# No authorization on file covers the document being issued.
|
|
32
|
+
class NoAuthorization < Error; end
|
|
33
|
+
|
|
34
|
+
# The requested document was not found in the store.
|
|
35
|
+
class DocumentNotFound < Error; end
|
|
36
|
+
|
|
37
|
+
# An issued document may not be altered (Art. 41 — correct by annulment).
|
|
38
|
+
class ImmutableDocument < Error; end
|
|
39
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
require "date"
|
|
5
|
+
|
|
6
|
+
module Invoicehn
|
|
7
|
+
# The exchange rate shown on an invoice denominated in a currency other than
|
|
8
|
+
# the Lempira.
|
|
9
|
+
#
|
|
10
|
+
# Art. 11 closes with a paragraph that governs both invoice types: "En ambos
|
|
11
|
+
# casos, cuando el Obligado Tributario emita facturas con otra denominación
|
|
12
|
+
# monetaria, debe indicar la tasa de cambio vigente a la fecha de emisión."
|
|
13
|
+
#
|
|
14
|
+
# Note what the norm does *not* say. It does not require a computed Lempira
|
|
15
|
+
# equivalent, does not name a rate source, and does not fix the rate's
|
|
16
|
+
# precision — and no further SAR guidance on the point exists. The Banco
|
|
17
|
+
# Central de Honduras daily reference rate is the market default and is what
|
|
18
|
+
# this library names when no source is given, but a bank or negotiated rate is
|
|
19
|
+
# not visibly prohibited, so the source is a free-text field.
|
|
20
|
+
class ExchangeRate
|
|
21
|
+
DEFAULT_SOURCE = "Banco Central de Honduras"
|
|
22
|
+
|
|
23
|
+
attr_reader :rate, :date, :source, :currency
|
|
24
|
+
|
|
25
|
+
# @param rate [BigDecimal, String, Integer] lempiras per one unit of
|
|
26
|
+
# +currency+.
|
|
27
|
+
# @param date [Date, String] must equal the invoice's issue date — the
|
|
28
|
+
# article requires the rate "vigente a la fecha de emisión".
|
|
29
|
+
def initialize(rate:, date:, currency: "USD", source: DEFAULT_SOURCE)
|
|
30
|
+
@rate = coerce_rate(rate)
|
|
31
|
+
@date = coerce_date(date)
|
|
32
|
+
@currency = currency.to_s.upcase
|
|
33
|
+
@source = source.to_s.strip
|
|
34
|
+
@source = DEFAULT_SOURCE if @source.empty?
|
|
35
|
+
|
|
36
|
+
raise ValidationError, "la tasa de cambio debe ser mayor que cero" unless @rate.positive?
|
|
37
|
+
raise ValidationError, "moneda no soportada: #{currency}" unless Money.currency?(@currency)
|
|
38
|
+
|
|
39
|
+
freeze
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Whether this rate is the one in effect on the given issue date.
|
|
43
|
+
def current_on?(issue_date) = @date == issue_date
|
|
44
|
+
|
|
45
|
+
# The Lempira equivalent of a foreign-currency amount. Not required by the
|
|
46
|
+
# norm, but useful on the face of the document and for the ledger.
|
|
47
|
+
def to_lempiras(money)
|
|
48
|
+
unless money.currency == @currency
|
|
49
|
+
raise CurrencyMismatch,
|
|
50
|
+
"la tasa convierte desde #{@currency}, no desde #{money.currency}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
Money.new(money.amount * @rate, "HNL").round_statutory
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# "1 USD = L 24.6543 (Banco Central de Honduras, 2026-08-28)"
|
|
57
|
+
def to_s
|
|
58
|
+
"1 #{@currency} = L #{@rate.to_s("F")} (#{@source}, #{@date.iso8601})"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def to_h
|
|
62
|
+
{
|
|
63
|
+
"rate" => @rate.to_s("F"),
|
|
64
|
+
"date" => @date.iso8601,
|
|
65
|
+
"currency" => @currency,
|
|
66
|
+
"source" => @source
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.from_h(hash)
|
|
71
|
+
return nil if hash.nil?
|
|
72
|
+
|
|
73
|
+
new(
|
|
74
|
+
rate: hash["rate"],
|
|
75
|
+
date: hash["date"],
|
|
76
|
+
currency: hash["currency"] || "USD",
|
|
77
|
+
source: hash["source"]
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def coerce_rate(value)
|
|
84
|
+
case value
|
|
85
|
+
when BigDecimal then value
|
|
86
|
+
when Integer then BigDecimal(value)
|
|
87
|
+
when String then BigDecimal(value)
|
|
88
|
+
when Float
|
|
89
|
+
raise ValidationError, "Float no está permitido en la tasa de cambio; use BigDecimal o String"
|
|
90
|
+
else
|
|
91
|
+
raise ValidationError, "tasa de cambio no numérica: #{value.inspect}"
|
|
92
|
+
end
|
|
93
|
+
rescue ArgumentError
|
|
94
|
+
raise ValidationError, "tasa de cambio no numérica: #{value.inspect}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def coerce_date(value)
|
|
98
|
+
case value
|
|
99
|
+
when Date then value
|
|
100
|
+
when String then Date.parse(value)
|
|
101
|
+
else
|
|
102
|
+
raise ValidationError, "fecha de la tasa de cambio inválida: #{value.inspect}"
|
|
103
|
+
end
|
|
104
|
+
rescue ArgumentError, TypeError
|
|
105
|
+
raise ValidationError, "fecha de la tasa de cambio inválida: #{value.inspect}"
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module Invoicehn
|
|
6
|
+
# A Factura (Comprobante Fiscal, tipo de documento 01).
|
|
7
|
+
#
|
|
8
|
+
# An issued invoice is immutable. Art. 41 provides the only correction: "En el
|
|
9
|
+
# caso de emisión de Comprobantes Fiscales y/o Documentos Complementarios con
|
|
10
|
+
# errores, estos deben ser anulados, consignando en los mismos la leyenda
|
|
11
|
+
# 'ANULADA'." Annulment returns a new object; it does not mutate the record,
|
|
12
|
+
# and the correlative it consumed is never reused.
|
|
13
|
+
class Invoice
|
|
14
|
+
DOCUMENT_NAME = "Factura" # Art. 10 num. 2
|
|
15
|
+
|
|
16
|
+
STATUS_ISSUED = "emitida"
|
|
17
|
+
STATUS_ANNULLED = "anulada"
|
|
18
|
+
|
|
19
|
+
attr_reader :correlative, :issuer, :customer, :authorization, :line_items,
|
|
20
|
+
:issue_date, :currency, :exchange_rate, :status,
|
|
21
|
+
:annulment_reason, :annulled_at, :notes
|
|
22
|
+
|
|
23
|
+
def initialize(correlative:, issuer:, customer:, authorization:, line_items:,
|
|
24
|
+
issue_date: nil, currency: "HNL", exchange_rate: nil,
|
|
25
|
+
status: STATUS_ISSUED, annulment_reason: nil, annulled_at: nil,
|
|
26
|
+
notes: nil)
|
|
27
|
+
@correlative = correlative.is_a?(Correlative) ? correlative : Correlative.parse(correlative)
|
|
28
|
+
@issuer = issuer
|
|
29
|
+
@customer = customer
|
|
30
|
+
@authorization = authorization
|
|
31
|
+
@line_items = Array(line_items).freeze
|
|
32
|
+
@issue_date = coerce_date(issue_date || Date.today)
|
|
33
|
+
@currency = currency.to_s.upcase
|
|
34
|
+
@exchange_rate = exchange_rate
|
|
35
|
+
@status = status
|
|
36
|
+
@annulment_reason = annulment_reason
|
|
37
|
+
@annulled_at = annulled_at && coerce_date(annulled_at)
|
|
38
|
+
@notes = notes.to_s.strip
|
|
39
|
+
|
|
40
|
+
@summary = TaxSummary.new(@line_items, currency: @currency)
|
|
41
|
+
freeze
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The per-rate breakdown required by Art. 11 num. 1 lit. g) h) i) and l).
|
|
45
|
+
attr_reader :summary
|
|
46
|
+
|
|
47
|
+
def subtotal = @summary.subtotal
|
|
48
|
+
def discount = @summary.discount
|
|
49
|
+
def isv_total = @summary.isv_total
|
|
50
|
+
def total = @summary.total
|
|
51
|
+
|
|
52
|
+
# Art. 11 num. 1 lit. k) — "Importe total en números y letras". Always
|
|
53
|
+
# rendered: required for a crédito-fiscal invoice and permitted for a
|
|
54
|
+
# consumidor final, so there is one path rather than a conditional.
|
|
55
|
+
def total_in_words = SpanishNumerals.money_to_words(total)
|
|
56
|
+
|
|
57
|
+
def foreign_currency? = @currency != "HNL"
|
|
58
|
+
|
|
59
|
+
# The Lempira equivalent of the total. Not required by the norm, which asks
|
|
60
|
+
# only for the rate, but shown when a rate is present and used to measure
|
|
61
|
+
# the Art. 11 num. 2 threshold, which the article states in lempiras.
|
|
62
|
+
#
|
|
63
|
+
# Returns nil rather than raising when no usable rate is on file: a missing
|
|
64
|
+
# or mismatched rate is already reported by the Art. 11 closing-paragraph
|
|
65
|
+
# rule, and a query used during validation must not blow up the validator.
|
|
66
|
+
def total_in_lempiras
|
|
67
|
+
return total unless foreign_currency?
|
|
68
|
+
return nil if @exchange_rate.nil? || @exchange_rate.currency != @currency
|
|
69
|
+
|
|
70
|
+
@exchange_rate.to_lempiras(total)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def annulled? = @status == STATUS_ANNULLED
|
|
74
|
+
def issued? = @status == STATUS_ISSUED
|
|
75
|
+
|
|
76
|
+
# Art. 41. Returns a new invoice carrying the ANULADA legend; the original
|
|
77
|
+
# object is untouched and the correlative stays consumed.
|
|
78
|
+
def annul(reason:, on: Date.today)
|
|
79
|
+
raise ImmutableDocument, "la factura #{@correlative} ya está anulada" if annulled?
|
|
80
|
+
|
|
81
|
+
reason = reason.to_s.strip
|
|
82
|
+
raise ValidationError, "debe indicarse el motivo de la anulación" if reason.empty?
|
|
83
|
+
|
|
84
|
+
with(status: STATUS_ANNULLED, annulment_reason: reason, annulled_at: coerce_date(on))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Raises on errors only. Warnings (Art. 11 num. 3's mixed-supply notice) are
|
|
88
|
+
# information the document should carry, not grounds to refuse it.
|
|
89
|
+
def validate!(on: Date.today)
|
|
90
|
+
errors = Compliance::Validator.new(self, on: on).errors
|
|
91
|
+
raise ComplianceError, errors if errors.any?
|
|
92
|
+
|
|
93
|
+
self
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def compliant?(on: Date.today) = Compliance::Validator.new(self, on: on).valid?
|
|
97
|
+
|
|
98
|
+
def violations(on: Date.today) = Compliance::Validator.new(self, on: on).violations
|
|
99
|
+
def warnings(on: Date.today) = Compliance::Validator.new(self, on: on).warnings
|
|
100
|
+
|
|
101
|
+
def to_h
|
|
102
|
+
{
|
|
103
|
+
"correlative" => @correlative.to_s,
|
|
104
|
+
"document_name" => DOCUMENT_NAME,
|
|
105
|
+
"status" => @status,
|
|
106
|
+
"issue_date" => @issue_date.iso8601,
|
|
107
|
+
"currency" => @currency,
|
|
108
|
+
"issuer" => @issuer.to_h,
|
|
109
|
+
"customer" => @customer.to_h,
|
|
110
|
+
"authorization" => @authorization.to_h,
|
|
111
|
+
"line_items" => @line_items.map(&:to_h),
|
|
112
|
+
"exchange_rate" => @exchange_rate&.to_h,
|
|
113
|
+
"totals" => @summary.to_h,
|
|
114
|
+
"total_in_words" => total_in_words,
|
|
115
|
+
"annulment_reason" => @annulment_reason,
|
|
116
|
+
"annulled_at" => @annulled_at&.iso8601,
|
|
117
|
+
"notes" => @notes
|
|
118
|
+
}.compact
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def self.from_h(hash)
|
|
122
|
+
new(
|
|
123
|
+
correlative: hash["correlative"],
|
|
124
|
+
issuer: Issuer.from_h(hash["issuer"]),
|
|
125
|
+
customer: Customer.from_h(hash["customer"]),
|
|
126
|
+
authorization: Authorization.from_h(hash["authorization"]),
|
|
127
|
+
line_items: Array(hash["line_items"]).map { |h| LineItem.from_h(h) },
|
|
128
|
+
issue_date: hash["issue_date"],
|
|
129
|
+
currency: hash["currency"],
|
|
130
|
+
exchange_rate: ExchangeRate.from_h(hash["exchange_rate"]),
|
|
131
|
+
status: hash["status"] || STATUS_ISSUED,
|
|
132
|
+
annulment_reason: hash["annulment_reason"],
|
|
133
|
+
annulled_at: hash["annulled_at"],
|
|
134
|
+
notes: hash["notes"]
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def to_s
|
|
139
|
+
"#{DOCUMENT_NAME} #{@correlative} · #{@issue_date} · #{total}#{" · ANULADA" if annulled?}"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
def with(**changes)
|
|
145
|
+
self.class.new(
|
|
146
|
+
correlative: @correlative, issuer: @issuer, customer: @customer,
|
|
147
|
+
authorization: @authorization, line_items: @line_items,
|
|
148
|
+
issue_date: @issue_date, currency: @currency, exchange_rate: @exchange_rate,
|
|
149
|
+
status: @status, annulment_reason: @annulment_reason,
|
|
150
|
+
annulled_at: @annulled_at, notes: @notes, **changes
|
|
151
|
+
)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def coerce_date(value)
|
|
155
|
+
case value
|
|
156
|
+
when Date then value
|
|
157
|
+
when String then Date.parse(value)
|
|
158
|
+
else
|
|
159
|
+
raise ValidationError, "fecha inválida: #{value.inspect}"
|
|
160
|
+
end
|
|
161
|
+
rescue ArgumentError, TypeError
|
|
162
|
+
raise ValidationError, "fecha inválida: #{value.inspect}"
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|