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.
@@ -4,7 +4,12 @@ require "date"
4
4
 
5
5
  module Invoicehn
6
6
  module Compliance
7
- # Checks an invoice against the Reglamento del Régimen de Facturación.
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
- :document_type_is_factura, # Art. 10 num. 7 lit. c
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: Date.today)
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
- def document_type_is_factura
98
- return if invoice.correlative.factura?
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 para la Factura",
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
- violation("Art. 11 num. 1 lit. d, e y f",
111
- "Descripción detallada, cantidad de unidades y valor unitario",
112
- "la factura no tiene líneas de detalle")
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
- if customer.is_a?(Customer::Exonerado) && !customer.supported?
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
- # 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.
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" => "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"
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
@@ -3,7 +3,16 @@
3
3
  require "date"
4
4
 
5
5
  module Invoicehn
6
- # A Factura (Comprobante Fiscal, tipo de documento 01).
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 || Date.today)
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: Date.today)
79
- raise ImmutableDocument, "la factura #{@correlative} ya está anulada" if annulled?
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: Date.today)
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: Date.today) = Compliance::Validator.new(self, on: on).valid?
124
+ def compliant?(on: Invoicehn.today) = Compliance::Validator.new(self, on: on).valid?
97
125
 
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
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" => 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
- "#{DOCUMENT_NAME} #{@correlative} · #{@issue_date} · #{total}#{" · ANULADA" if annulled?}"
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, **changes
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