invoicehn 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/CHANGELOG.md +114 -1
- data/README.md +263 -6
- data/lib/invoicehn/authorization.rb +4 -4
- data/lib/invoicehn/cli/builder.rb +15 -2
- data/lib/invoicehn/cli/wizard.rb +1 -1
- data/lib/invoicehn/compliance/validator.rb +136 -14
- data/lib/invoicehn/correlative.rb +46 -10
- data/lib/invoicehn/document_reference.rb +83 -0
- data/lib/invoicehn/invoice.rb +59 -15
- data/lib/invoicehn/issuance.rb +155 -22
- data/lib/invoicehn/ledger.rb +9 -1
- data/lib/invoicehn/renderers/json.rb +5 -2
- data/lib/invoicehn/renderers/pdf.rb +25 -5
- data/lib/invoicehn/renderers/text.rb +64 -5
- data/lib/invoicehn/settings.rb +44 -0
- data/lib/invoicehn/storage/json_store.rb +22 -7
- data/lib/invoicehn/test_support/contracts.rb +253 -0
- data/lib/invoicehn/test_support/memory.rb +112 -0
- data/lib/invoicehn/testing.rb +140 -0
- data/lib/invoicehn/version.rb +1 -1
- data/lib/invoicehn.rb +28 -0
- metadata +12 -9
|
@@ -4,7 +4,12 @@ require "date"
|
|
|
4
4
|
|
|
5
5
|
module Invoicehn
|
|
6
6
|
module Compliance
|
|
7
|
-
# Checks
|
|
7
|
+
# Checks a Factura, Nota de Crédito or Nota de Débito against the
|
|
8
|
+
# Reglamento del Régimen de Facturación.
|
|
9
|
+
#
|
|
10
|
+
# The Factura's content is Arts. 10 and 11; the Nota de Crédito's is
|
|
11
|
+
# Arts. 25 and 26; the Nota de Débito's, Arts. 27 and 28. Rules specific to
|
|
12
|
+
# one kind say so and return nothing for the others.
|
|
8
13
|
#
|
|
9
14
|
# Every rule cites the article it enforces. The validator reports all
|
|
10
15
|
# failures at once rather than stopping at the first, so an operator fixing
|
|
@@ -20,18 +25,24 @@ module Invoicehn
|
|
|
20
25
|
:authorization_present, # Art. 10 num. 3, 4, 5
|
|
21
26
|
:authorization_current, # Art. 62
|
|
22
27
|
:correlative_in_range, # Art. 10 num. 5, 7
|
|
23
|
-
:
|
|
24
|
-
:line_items_present, # Art. 11 num. 1 lit. d, e, f
|
|
25
|
-
:customer_identification, # Art. 11 num. 1 lit. a, b / num. 2 lit. a
|
|
28
|
+
:document_type_supported, # Art. 10 num. 7 lit. c; Arts. 25, 27 num. 5 lit. c
|
|
29
|
+
:line_items_present, # Art. 11 num. 1 lit. d, e, f; Art. 26 num. 10; Art. 28 num. 7, 9
|
|
30
|
+
:customer_identification, # Art. 11 num. 1 lit. a, b / num. 2 lit. a; Arts. 26, 28 num. 1, 2
|
|
26
31
|
:consumidor_final_threshold, # Art. 11 num. 2
|
|
27
32
|
:exonerado_documents, # Art. 10 num. 8 / Art. 11 num. 4
|
|
28
33
|
:foreign_currency_rate, # Art. 11 closing paragraph
|
|
34
|
+
:note_currency, # Art. 26 num. 9; Art. 28 num. 8
|
|
35
|
+
:note_reference, # Arts. 26, 28 num. 3; Art. 4 num. 31, 32
|
|
36
|
+
:note_motivo, # Arts. 26, 28 num. 4
|
|
29
37
|
:mixed_supply_notice # Art. 11 num. 3
|
|
30
38
|
].freeze
|
|
31
39
|
|
|
40
|
+
# The document types this library issues.
|
|
41
|
+
SUPPORTED_TYPES = [Correlative::FACTURA, Correlative::NOTA_DE_CREDITO, Correlative::NOTA_DE_DEBITO].freeze
|
|
42
|
+
|
|
32
43
|
attr_reader :invoice, :on
|
|
33
44
|
|
|
34
|
-
def initialize(invoice, on:
|
|
45
|
+
def initialize(invoice, on: Invoicehn.today)
|
|
35
46
|
@invoice = invoice
|
|
36
47
|
@on = on
|
|
37
48
|
end
|
|
@@ -93,30 +104,44 @@ module Invoicehn
|
|
|
93
104
|
"el correlativo #{invoice.correlative} está fuera del rango #{auth.range_label}")
|
|
94
105
|
end
|
|
95
106
|
|
|
96
|
-
# Art. 10 num. 7 lit. c — "01 = Factura".
|
|
97
|
-
|
|
98
|
-
|
|
107
|
+
# Art. 10 num. 7 lit. c — "01 = Factura"; Art. 25 num. 5 lit. c — "06 =
|
|
108
|
+
# Nota de Crédito"; Art. 27 num. 5 lit. c — "07 = Nota de Débito". The
|
|
109
|
+
# other types have contents of their own that this library does not
|
|
110
|
+
# model.
|
|
111
|
+
def document_type_supported
|
|
112
|
+
return if SUPPORTED_TYPES.include?(invoice.correlative.document_type)
|
|
99
113
|
|
|
100
|
-
violation("Art. 10 num. 7 lit. c", "Código de tipo de documento 01
|
|
114
|
+
violation("Art. 10 num. 7 lit. c", "Código de tipo de documento 01, 06 o 07",
|
|
101
115
|
"el correlativo declara el tipo #{invoice.correlative.document_type} " \
|
|
102
|
-
"(#{invoice.correlative.document_type_name})")
|
|
116
|
+
"(#{invoice.correlative.document_type_name}), que esta biblioteca no emite")
|
|
103
117
|
end
|
|
104
118
|
|
|
105
119
|
# Art. 11 num. 1 lit. d, e, f — a document with nothing on it describes no
|
|
106
120
|
# transferencia de bienes ni prestación de servicios.
|
|
121
|
+
#
|
|
122
|
+
# A Nota de Crédito must state the "Unidades del bien vendido o servicio
|
|
123
|
+
# prestado" (Art. 26 num. 10); a Nota de Débito, the "Descripción
|
|
124
|
+
# detallada" and the units (Art. 28 num. 7 and 9).
|
|
107
125
|
def line_items_present
|
|
108
126
|
return if invoice.line_items.any?
|
|
109
127
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
"
|
|
128
|
+
article = by_type("Art. 11 num. 1 lit. d, e y f", "Art. 26 num. 10", "Art. 28 num. 7 y 9")
|
|
129
|
+
violation(article, "Descripción detallada, cantidad de unidades y valor unitario",
|
|
130
|
+
"el documento no tiene líneas de detalle")
|
|
113
131
|
end
|
|
114
132
|
|
|
115
133
|
# Art. 11 num. 1 lit. a and b for taxpayers; num. 2 lit. a for final
|
|
116
134
|
# consumers.
|
|
135
|
+
#
|
|
136
|
+
# For the notes, Art. 26 num. 1-2 and Art. 28 num. 1-2 require
|
|
137
|
+
# "Nombres y Apellidos, Razón o Denominación Social del adquirente" and
|
|
138
|
+
# "Número de Registro Tributario Nacional (RTN) del adquirente", with no
|
|
139
|
+
# CONSUMIDOR FINAL alternative like the one Art. 11 num. 2 gives the
|
|
140
|
+
# Factura. A note to an acquirer without an RTN is therefore refused.
|
|
117
141
|
def customer_identification
|
|
118
142
|
customer = invoice.customer
|
|
119
143
|
return violation("Art. 11", "Datos del cliente", "no se configuró el cliente") if customer.nil?
|
|
144
|
+
return note_customer_identification(customer) if invoice.note?
|
|
120
145
|
|
|
121
146
|
if customer.taxpayer? || customer.exonerado?
|
|
122
147
|
return if !customer.name.empty? && customer.rtn
|
|
@@ -134,6 +159,7 @@ module Invoicehn
|
|
|
134
159
|
# identificación en el espacio destinado al RTN".
|
|
135
160
|
def consumidor_final_threshold
|
|
136
161
|
customer = invoice.customer
|
|
162
|
+
return unless invoice.factura?
|
|
137
163
|
return unless customer.is_a?(Customer::ConsumidorFinal)
|
|
138
164
|
|
|
139
165
|
# The threshold is a sum *in lempiras*, so a foreign-currency invoice is
|
|
@@ -159,7 +185,9 @@ module Invoicehn
|
|
|
159
185
|
customer = invoice.customer
|
|
160
186
|
results = []
|
|
161
187
|
|
|
162
|
-
|
|
188
|
+
# Art. 10 num. 8 is a requirement of the Factura's format; Arts. 25 and
|
|
189
|
+
# 27 have no counterpart.
|
|
190
|
+
if invoice.factura? && customer.is_a?(Customer::Exonerado) && !customer.supported?
|
|
163
191
|
results << violation("Art. 10 num. 8 y Art. 11 num. 4",
|
|
164
192
|
"Orden de Compra Exenta, Constancia del Registro de Exonerados o Registro SAG",
|
|
165
193
|
"el adquirente exonerado no tiene ninguno de los tres documentos de respaldo")
|
|
@@ -181,6 +209,7 @@ module Invoicehn
|
|
|
181
209
|
# Tributario emita facturas con otra denominación monetaria, debe indicar
|
|
182
210
|
# la tasa de cambio vigente a la fecha de emisión."
|
|
183
211
|
def foreign_currency_rate
|
|
212
|
+
return unless invoice.factura?
|
|
184
213
|
return unless invoice.foreign_currency?
|
|
185
214
|
|
|
186
215
|
rate = invoice.exchange_rate
|
|
@@ -208,6 +237,7 @@ module Invoicehn
|
|
|
208
237
|
# las ventas gravadas." Not a defect: a notice, so the document can say so
|
|
209
238
|
# and the buyer is not misled about what it supports.
|
|
210
239
|
def mixed_supply_notice
|
|
240
|
+
return unless invoice.factura?
|
|
211
241
|
return unless invoice.summary.mixed_supply?
|
|
212
242
|
return unless invoice.customer.taxpayer?
|
|
213
243
|
|
|
@@ -217,6 +247,98 @@ module Invoicehn
|
|
|
217
247
|
"sustenta crédito fiscal",
|
|
218
248
|
severity: :warning)
|
|
219
249
|
end
|
|
250
|
+
|
|
251
|
+
# Art. 26 num. 9 and Art. 28 num. 8 — "Denominación literal de la Moneda
|
|
252
|
+
# Nacional Lempira o símbolo (L)". The permission to use "otra
|
|
253
|
+
# denominación monetaria" with the exchange rate is in Art. 11's closing
|
|
254
|
+
# paragraph, and it speaks of "facturas" only; the notes' articles have
|
|
255
|
+
# no such paragraph. A note is therefore issued in lempiras.
|
|
256
|
+
def note_currency
|
|
257
|
+
return unless invoice.note?
|
|
258
|
+
return unless invoice.foreign_currency?
|
|
259
|
+
|
|
260
|
+
violation(by_type(nil, "Art. 26 num. 9", "Art. 28 num. 8"),
|
|
261
|
+
"Denominación literal de la Moneda Nacional Lempira o símbolo (L)",
|
|
262
|
+
"la nota está en #{invoice.currency}; las notas se emiten en lempiras")
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Art. 26 num. 3 and Art. 28 num. 3 — "Datos del Comprobante Fiscal que
|
|
266
|
+
# da lugar a la Nota": CAI, número correlativo and fecha de emisión.
|
|
267
|
+
def note_reference
|
|
268
|
+
return unless invoice.note?
|
|
269
|
+
|
|
270
|
+
reference = invoice.reference
|
|
271
|
+
article = by_type(nil, "Art. 26 num. 3", "Art. 28 num. 3")
|
|
272
|
+
requirement = "Datos del Comprobante Fiscal que da lugar a la Nota (CAI, correlativo y fecha de emisión)"
|
|
273
|
+
return violation(article, requirement, "la nota no indica el documento al que se aplica") if reference.nil?
|
|
274
|
+
|
|
275
|
+
[
|
|
276
|
+
reference_type_violation(reference, article, requirement),
|
|
277
|
+
reference_self_violation(reference, article, requirement),
|
|
278
|
+
reference_date_violation(reference)
|
|
279
|
+
].compact
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Art. 26 num. 4 and Art. 28 num. 4 — "Motivo de la emisión del
|
|
283
|
+
# documento".
|
|
284
|
+
def note_motivo
|
|
285
|
+
return unless invoice.note?
|
|
286
|
+
return unless invoice.motivo.empty?
|
|
287
|
+
|
|
288
|
+
violation(by_type(nil, "Art. 26 num. 4", "Art. 28 num. 4"),
|
|
289
|
+
"Motivo de la emisión del documento", "la nota no indica el motivo")
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def note_customer_identification(customer)
|
|
293
|
+
return if !customer.name.empty? && customer.rtn
|
|
294
|
+
|
|
295
|
+
violation(by_type(nil, "Art. 26 num. 1 y 2", "Art. 28 num. 1 y 2"),
|
|
296
|
+
"Nombres y Apellidos o Razón Social, y RTN del adquirente",
|
|
297
|
+
"la nota debe identificar al adquirente con nombre y RTN; " \
|
|
298
|
+
"el Reglamento no admite CONSUMIDOR FINAL en las notas")
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Art. 4 num. 32 defines the Nota de Crédito as "vinculado a un
|
|
302
|
+
# Comprobante Fiscal", and Art. 26 num. 3 lit. b) asks for the
|
|
303
|
+
# "Comprobante Fiscal al que le está aplicando la Nota" — so it must
|
|
304
|
+
# point at one of the Art. 6 types. Art. 28 num. 3 lit. b) widens the
|
|
305
|
+
# Nota de Débito to "el Comprobante Fiscal o el documento al que le está
|
|
306
|
+
# aplicando la Nota", so any recognised document type is accepted there.
|
|
307
|
+
def reference_type_violation(reference, article, requirement)
|
|
308
|
+
return unless invoice.credit_note?
|
|
309
|
+
return if reference.correlative.comprobante_fiscal?
|
|
310
|
+
|
|
311
|
+
violation(article, requirement,
|
|
312
|
+
"#{reference.correlative} es #{reference.document_type_name}, " \
|
|
313
|
+
"que no es un Comprobante Fiscal (Art. 6)")
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def reference_self_violation(reference, article, requirement)
|
|
317
|
+
return unless reference.correlative == invoice.correlative
|
|
318
|
+
|
|
319
|
+
violation(article, requirement, "la nota no puede aplicarse a sí misma")
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# Art. 4 num. 32: the Nota de Crédito records adjustments "efectuados con
|
|
323
|
+
# posterioridad a la emisión del correspondiente Comprobante Fiscal"; a
|
|
324
|
+
# Nota de Débito (num. 31) is likewise "vinculado a un Comprobante Fiscal"
|
|
325
|
+
# that must already exist. A reference dated after the note is a typo or
|
|
326
|
+
# a forgery.
|
|
327
|
+
def reference_date_violation(reference)
|
|
328
|
+
return unless reference.issue_date > invoice.issue_date
|
|
329
|
+
|
|
330
|
+
violation(by_type(nil, "Art. 4 num. 32", "Art. 4 num. 31"),
|
|
331
|
+
"La nota es posterior al documento que la origina",
|
|
332
|
+
"el documento de referencia es del #{reference.issue_date} " \
|
|
333
|
+
"y la nota se emitió el #{invoice.issue_date}")
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def by_type(factura, credit, debit)
|
|
337
|
+
return credit if invoice.credit_note?
|
|
338
|
+
return debit if invoice.debit_note?
|
|
339
|
+
|
|
340
|
+
factura
|
|
341
|
+
end
|
|
220
342
|
end
|
|
221
343
|
end
|
|
222
344
|
end
|
|
@@ -17,22 +17,54 @@ module Invoicehn
|
|
|
17
17
|
SEQUENCE_MAX = 99_999_999
|
|
18
18
|
PATTERN = /\A(\d{3})-(\d{3})-(\d{2})-(\d{8})\z/
|
|
19
19
|
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
20
|
+
# Art. 10 num. 7 lit. c) and its counterparts in every other document type.
|
|
21
|
+
# The codes are the ones in force after Acuerdo 609-2017 (La Gaceta 34,457,
|
|
22
|
+
# 2 Oct 2017), whose Art. 1 rewrote lit. c) of Arts. 15, 17, 20, 23, 25,
|
|
23
|
+
# 27, 29 and 33. Line numbers refer to doc/fuentes/acuerdo_481_2017_consolidado.txt:
|
|
24
|
+
#
|
|
25
|
+
# 01 Factura Art. 10 num. 7 c) (l. 626)
|
|
26
|
+
# 04 Recibo por Honorarios Profesionales Art. 18 num. 7 c) (l. 1039) — not reformed
|
|
27
|
+
# 05 Comprobante de Retención Art. 33 num. 5 c) (l. 1750)
|
|
28
|
+
# 06 Nota de Crédito Art. 25 num. 5 c) (l. 1345)
|
|
29
|
+
# 07 Nota de Débito Art. 27 num. 5 c) (l. 1438)
|
|
30
|
+
# 08 Guía de Remisión Art. 29 num. 4 c) (l. 1535)
|
|
31
|
+
# 09 Ticket Art. 17 num. 4 a) (l. 969) *
|
|
32
|
+
# 10 Factura Prevalorada Art. 15 num. 6 c) (l. 889)
|
|
33
|
+
# 11 Boleta de Compra Art. 20 num. 5 c) (l. 1122)
|
|
34
|
+
# 12 Constancia de Donación Art. 23 num. 7 c) (l. 1259)
|
|
35
|
+
#
|
|
36
|
+
# * Art. 17's closing sentence still reads "El Ticket tendrá como código 03"
|
|
37
|
+
# (l. 988); 609-2017 changed num. 4 only. The table follows the reformed
|
|
38
|
+
# numeral. A Ticket's own number is ten digits, not this 16-digit shape.
|
|
39
|
+
#
|
|
40
|
+
# 02 and 03 belonged to the 2017 original (Prevalorada and Ticket) and are
|
|
41
|
+
# assigned to nothing after the reform, so they are rejected. Version 0.1.0
|
|
42
|
+
# shipped the original table; see the CHANGELOG for the migration.
|
|
23
43
|
DOCUMENT_TYPES = {
|
|
24
44
|
"01" => "Factura",
|
|
25
|
-
"02" => "Factura Prevalorada",
|
|
26
|
-
"03" => "Ticket",
|
|
27
45
|
"04" => "Recibo por Honorarios Profesionales",
|
|
28
|
-
"05" => "
|
|
29
|
-
"06" => "
|
|
30
|
-
"07" => "Nota de
|
|
31
|
-
"08" => "
|
|
32
|
-
"
|
|
46
|
+
"05" => "Comprobante de Retención",
|
|
47
|
+
"06" => "Nota de Crédito",
|
|
48
|
+
"07" => "Nota de Débito",
|
|
49
|
+
"08" => "Guía de Remisión",
|
|
50
|
+
"09" => "Ticket",
|
|
51
|
+
"10" => "Factura Prevalorada",
|
|
52
|
+
"11" => "Boleta de Compra",
|
|
53
|
+
"12" => "Constancia de Donación"
|
|
33
54
|
}.freeze
|
|
34
55
|
|
|
35
56
|
FACTURA = "01"
|
|
57
|
+
NOTA_DE_CREDITO = "06"
|
|
58
|
+
NOTA_DE_DEBITO = "07"
|
|
59
|
+
|
|
60
|
+
# Art. 6 — "Son Comprobantes Fiscales": Factura, Factura Prevalorada,
|
|
61
|
+
# Ticket, Recibo por Honorarios Profesionales, Boleta de Compra and
|
|
62
|
+
# Constancia de Donación.
|
|
63
|
+
COMPROBANTES_FISCALES = %w[01 10 09 04 11 12].freeze
|
|
64
|
+
|
|
65
|
+
# Art. 7 — "Son Documentos Complementarios": Notas de Crédito, Notas de
|
|
66
|
+
# Débito, Guías de Remisión and Comprobantes de Retención.
|
|
67
|
+
DOCUMENTOS_COMPLEMENTARIOS = %w[06 07 08 05].freeze
|
|
36
68
|
|
|
37
69
|
attr_reader :establishment, :emission_point, :document_type, :sequence
|
|
38
70
|
|
|
@@ -87,6 +119,10 @@ module Invoicehn
|
|
|
87
119
|
|
|
88
120
|
def document_type_name = DOCUMENT_TYPES.fetch(@document_type)
|
|
89
121
|
def factura? = @document_type == FACTURA
|
|
122
|
+
def credit_note? = @document_type == NOTA_DE_CREDITO
|
|
123
|
+
def debit_note? = @document_type == NOTA_DE_DEBITO
|
|
124
|
+
def note? = credit_note? || debit_note?
|
|
125
|
+
def comprobante_fiscal? = COMPROBANTES_FISCALES.include?(@document_type)
|
|
90
126
|
|
|
91
127
|
def succ
|
|
92
128
|
raise RangeExhausted, "el correlativo #{self} es el último de la serie" if last?
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module Invoicehn
|
|
6
|
+
# The document a Nota de Crédito or Nota de Débito applies to.
|
|
7
|
+
#
|
|
8
|
+
# Art. 26 num. 3 (Nota de Crédito) and Art. 28 num. 3 (Nota de Débito)
|
|
9
|
+
# require, at the moment of issue, the "Datos del Comprobante Fiscal que da
|
|
10
|
+
# lugar a la Nota":
|
|
11
|
+
#
|
|
12
|
+
# a) Clave de Autorización de Impresión (CAI)
|
|
13
|
+
# b) Número correlativo del Comprobante Fiscal al que le está aplicando la
|
|
14
|
+
# Nota
|
|
15
|
+
# c) Fecha de emisión del Comprobante Fiscal al que le está aplicando la
|
|
16
|
+
# Nota
|
|
17
|
+
#
|
|
18
|
+
# These are exactly the three fields here, all mandatory. The CAI is the one
|
|
19
|
+
# printed on the original document — not the note's own CAI.
|
|
20
|
+
class DocumentReference
|
|
21
|
+
attr_reader :correlative, :cai, :issue_date
|
|
22
|
+
|
|
23
|
+
# @param correlative [String, Correlative] the original document's number
|
|
24
|
+
# @param cai [String] the CAI printed on the original document
|
|
25
|
+
# @param issue_date [Date, String] the original document's fecha de emisión
|
|
26
|
+
def initialize(correlative:, cai:, issue_date:)
|
|
27
|
+
@correlative = correlative.is_a?(Correlative) ? correlative : Correlative.parse(correlative)
|
|
28
|
+
@cai = cai.to_s.strip
|
|
29
|
+
if @cai.empty?
|
|
30
|
+
raise ValidationError, "el CAI del documento de referencia es obligatorio (Arts. 26 y 28 num. 3 lit. a)"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
@issue_date = coerce_date(issue_date)
|
|
34
|
+
freeze
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Builds the reference from a document this library issued, so the three
|
|
38
|
+
# fields are copied rather than retyped.
|
|
39
|
+
def self.from_document(document)
|
|
40
|
+
new(correlative: document.correlative, cai: document.authorization.cai, issue_date: document.issue_date)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.from_h(hash)
|
|
44
|
+
return nil if hash.nil?
|
|
45
|
+
|
|
46
|
+
new(correlative: hash["correlative"], cai: hash["cai"], issue_date: hash["issue_date"])
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def document_type = @correlative.document_type
|
|
50
|
+
def document_type_name = @correlative.document_type_name
|
|
51
|
+
|
|
52
|
+
def to_h
|
|
53
|
+
{
|
|
54
|
+
"correlative" => @correlative.to_s,
|
|
55
|
+
"document_type" => document_type,
|
|
56
|
+
"cai" => @cai,
|
|
57
|
+
"issue_date" => @issue_date.iso8601
|
|
58
|
+
}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def ==(other)
|
|
62
|
+
other.is_a?(self.class) && other.to_h == to_h
|
|
63
|
+
end
|
|
64
|
+
alias eql? ==
|
|
65
|
+
|
|
66
|
+
def hash = to_h.hash
|
|
67
|
+
|
|
68
|
+
def to_s = "#{document_type_name} #{@correlative} · CAI #{@cai} · #{@issue_date}"
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def coerce_date(value)
|
|
73
|
+
case value
|
|
74
|
+
when Date then value
|
|
75
|
+
when String then Date.parse(value)
|
|
76
|
+
else
|
|
77
|
+
raise ValidationError, "fecha de emisión del documento de referencia inválida: #{value.inspect}"
|
|
78
|
+
end
|
|
79
|
+
rescue ArgumentError, TypeError
|
|
80
|
+
raise ValidationError, "fecha de emisión del documento de referencia inválida: #{value.inspect}"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
data/lib/invoicehn/invoice.rb
CHANGED
|
@@ -3,7 +3,16 @@
|
|
|
3
3
|
require "date"
|
|
4
4
|
|
|
5
5
|
module Invoicehn
|
|
6
|
-
# A Factura (Comprobante Fiscal, tipo
|
|
6
|
+
# A Factura (Comprobante Fiscal, tipo 01), or one of the two Documentos
|
|
7
|
+
# Complementarios that adjust it: the Nota de Crédito (tipo 06) and the Nota
|
|
8
|
+
# de Débito (tipo 07). The type is the one the correlative declares (Art. 10
|
|
9
|
+
# num. 7 lit. c; Arts. 25 and 27 num. 5 lit. c).
|
|
10
|
+
#
|
|
11
|
+
# The three share their content: issuer, CAI, rango, fecha límite,
|
|
12
|
+
# correlative, customer, detail lines, ISV by rate and total in words. A note
|
|
13
|
+
# adds what Arts. 26 and 28 require at the moment of issue: the data of the
|
|
14
|
+
# document it applies to (`reference`, a DocumentReference) and the reason
|
|
15
|
+
# for issuing it (`motivo`). A Factura carries neither.
|
|
7
16
|
#
|
|
8
17
|
# An issued invoice is immutable. Art. 41 provides the only correction: "En el
|
|
9
18
|
# caso de emisión de Comprobantes Fiscales y/o Documentos Complementarios con
|
|
@@ -18,24 +27,32 @@ module Invoicehn
|
|
|
18
27
|
|
|
19
28
|
attr_reader :correlative, :issuer, :customer, :authorization, :line_items,
|
|
20
29
|
:issue_date, :currency, :exchange_rate, :status,
|
|
21
|
-
:annulment_reason, :annulled_at, :notes
|
|
30
|
+
:annulment_reason, :annulled_at, :notes, :reference, :motivo
|
|
22
31
|
|
|
23
32
|
def initialize(correlative:, issuer:, customer:, authorization:, line_items:,
|
|
24
33
|
issue_date: nil, currency: "HNL", exchange_rate: nil,
|
|
25
34
|
status: STATUS_ISSUED, annulment_reason: nil, annulled_at: nil,
|
|
26
|
-
notes: nil)
|
|
35
|
+
notes: nil, reference: nil, motivo: nil)
|
|
27
36
|
@correlative = correlative.is_a?(Correlative) ? correlative : Correlative.parse(correlative)
|
|
28
37
|
@issuer = issuer
|
|
29
38
|
@customer = customer
|
|
30
39
|
@authorization = authorization
|
|
31
40
|
@line_items = Array(line_items).freeze
|
|
32
|
-
@issue_date = coerce_date(issue_date ||
|
|
41
|
+
@issue_date = coerce_date(issue_date || Invoicehn.today)
|
|
33
42
|
@currency = currency.to_s.upcase
|
|
34
43
|
@exchange_rate = exchange_rate
|
|
35
44
|
@status = status
|
|
36
45
|
@annulment_reason = annulment_reason
|
|
37
46
|
@annulled_at = annulled_at && coerce_date(annulled_at)
|
|
38
47
|
@notes = notes.to_s.strip
|
|
48
|
+
@reference = coerce_reference(reference)
|
|
49
|
+
@motivo = motivo.to_s.strip
|
|
50
|
+
|
|
51
|
+
if !note? && (@reference || !@motivo.empty?)
|
|
52
|
+
raise ValidationError,
|
|
53
|
+
"sólo las notas de crédito y de débito llevan documento de referencia y motivo " \
|
|
54
|
+
"(Arts. 26 y 28 num. 3 y 4); #{@correlative} es #{document_name}"
|
|
55
|
+
end
|
|
39
56
|
|
|
40
57
|
@summary = TaxSummary.new(@line_items, currency: @currency)
|
|
41
58
|
freeze
|
|
@@ -56,6 +73,17 @@ module Invoicehn
|
|
|
56
73
|
|
|
57
74
|
def foreign_currency? = @currency != "HNL"
|
|
58
75
|
|
|
76
|
+
def document_type = @correlative.document_type
|
|
77
|
+
|
|
78
|
+
# "Factura", "Nota de Crédito" or "Nota de Débito" — the denominación each
|
|
79
|
+
# article fixes (Art. 10 num. 2, Art. 25 num. 2, Art. 27 num. 2).
|
|
80
|
+
def document_name = @correlative.document_type_name
|
|
81
|
+
|
|
82
|
+
def factura? = @correlative.factura?
|
|
83
|
+
def credit_note? = @correlative.credit_note?
|
|
84
|
+
def debit_note? = @correlative.debit_note?
|
|
85
|
+
def note? = @correlative.note?
|
|
86
|
+
|
|
59
87
|
# The Lempira equivalent of the total. Not required by the norm, which asks
|
|
60
88
|
# only for the rate, but shown when a rate is present and used to measure
|
|
61
89
|
# the Art. 11 num. 2 threshold, which the article states in lempiras.
|
|
@@ -75,8 +103,8 @@ module Invoicehn
|
|
|
75
103
|
|
|
76
104
|
# Art. 41. Returns a new invoice carrying the ANULADA legend; the original
|
|
77
105
|
# object is untouched and the correlative stays consumed.
|
|
78
|
-
def annul(reason:, on:
|
|
79
|
-
raise ImmutableDocument, "
|
|
106
|
+
def annul(reason:, on: Invoicehn.today)
|
|
107
|
+
raise ImmutableDocument, "#{document_name} #{@correlative} ya está anulada" if annulled?
|
|
80
108
|
|
|
81
109
|
reason = reason.to_s.strip
|
|
82
110
|
raise ValidationError, "debe indicarse el motivo de la anulación" if reason.empty?
|
|
@@ -86,22 +114,23 @@ module Invoicehn
|
|
|
86
114
|
|
|
87
115
|
# Raises on errors only. Warnings (Art. 11 num. 3's mixed-supply notice) are
|
|
88
116
|
# information the document should carry, not grounds to refuse it.
|
|
89
|
-
def validate!(on:
|
|
117
|
+
def validate!(on: Invoicehn.today)
|
|
90
118
|
errors = Compliance::Validator.new(self, on: on).errors
|
|
91
119
|
raise ComplianceError, errors if errors.any?
|
|
92
120
|
|
|
93
121
|
self
|
|
94
122
|
end
|
|
95
123
|
|
|
96
|
-
def compliant?(on:
|
|
124
|
+
def compliant?(on: Invoicehn.today) = Compliance::Validator.new(self, on: on).valid?
|
|
97
125
|
|
|
98
|
-
def violations(on:
|
|
99
|
-
def warnings(on:
|
|
126
|
+
def violations(on: Invoicehn.today) = Compliance::Validator.new(self, on: on).violations
|
|
127
|
+
def warnings(on: Invoicehn.today) = Compliance::Validator.new(self, on: on).warnings
|
|
100
128
|
|
|
101
129
|
def to_h
|
|
102
130
|
{
|
|
103
131
|
"correlative" => @correlative.to_s,
|
|
104
|
-
"document_name" =>
|
|
132
|
+
"document_name" => document_name,
|
|
133
|
+
"document_type" => document_type,
|
|
105
134
|
"status" => @status,
|
|
106
135
|
"issue_date" => @issue_date.iso8601,
|
|
107
136
|
"currency" => @currency,
|
|
@@ -114,7 +143,9 @@ module Invoicehn
|
|
|
114
143
|
"total_in_words" => total_in_words,
|
|
115
144
|
"annulment_reason" => @annulment_reason,
|
|
116
145
|
"annulled_at" => @annulled_at&.iso8601,
|
|
117
|
-
"notes" => @notes
|
|
146
|
+
"notes" => @notes,
|
|
147
|
+
"reference" => @reference&.to_h,
|
|
148
|
+
"motivo" => (@motivo unless @motivo.empty?)
|
|
118
149
|
}.compact
|
|
119
150
|
end
|
|
120
151
|
|
|
@@ -131,12 +162,14 @@ module Invoicehn
|
|
|
131
162
|
status: hash["status"] || STATUS_ISSUED,
|
|
132
163
|
annulment_reason: hash["annulment_reason"],
|
|
133
164
|
annulled_at: hash["annulled_at"],
|
|
134
|
-
notes: hash["notes"]
|
|
165
|
+
notes: hash["notes"],
|
|
166
|
+
reference: DocumentReference.from_h(hash["reference"]),
|
|
167
|
+
motivo: hash["motivo"]
|
|
135
168
|
)
|
|
136
169
|
end
|
|
137
170
|
|
|
138
171
|
def to_s
|
|
139
|
-
"#{
|
|
172
|
+
"#{document_name} #{@correlative} · #{@issue_date} · #{total}#{" · ANULADA" if annulled?}"
|
|
140
173
|
end
|
|
141
174
|
|
|
142
175
|
private
|
|
@@ -147,10 +180,21 @@ module Invoicehn
|
|
|
147
180
|
authorization: @authorization, line_items: @line_items,
|
|
148
181
|
issue_date: @issue_date, currency: @currency, exchange_rate: @exchange_rate,
|
|
149
182
|
status: @status, annulment_reason: @annulment_reason,
|
|
150
|
-
annulled_at: @annulled_at, notes: @notes,
|
|
183
|
+
annulled_at: @annulled_at, notes: @notes, reference: @reference,
|
|
184
|
+
motivo: @motivo, **changes
|
|
151
185
|
)
|
|
152
186
|
end
|
|
153
187
|
|
|
188
|
+
def coerce_reference(value)
|
|
189
|
+
case value
|
|
190
|
+
when nil, DocumentReference then value
|
|
191
|
+
when Hash then DocumentReference.from_h(value.transform_keys(&:to_s))
|
|
192
|
+
else
|
|
193
|
+
raise ValidationError,
|
|
194
|
+
"el documento de referencia debe ser un Invoicehn::DocumentReference: #{value.inspect}"
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
154
198
|
def coerce_date(value)
|
|
155
199
|
case value
|
|
156
200
|
when Date then value
|