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.
@@ -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