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,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Invoicehn
6
+ module Renderers
7
+ # Machine-readable form of the document.
8
+ #
9
+ # This serves Art. 53 num. 5 — "El Sistema debe tener la capacidad de
10
+ # generación de archivos tipo texto para su almacenamiento y traslado hacia
11
+ # la Administración Tributaria a través de servicios web o intercambio de
12
+ # protocolo" — and is the shape a future electronic-emission module (Art. 54,
13
+ # CAEE) would build its payload from.
14
+ #
15
+ # Every monetary figure is a string. A JSON number would invite the reader
16
+ # to parse it as a float, and Ley del ISV Art. 9 makes a mis-rounded charge
17
+ # an offence.
18
+ class Json
19
+ attr_reader :invoice
20
+
21
+ def initialize(invoice, pretty: true)
22
+ @invoice = invoice
23
+ @pretty = pretty
24
+ end
25
+
26
+ def render
27
+ @pretty ? JSON.pretty_generate(payload) : JSON.generate(payload)
28
+ end
29
+ alias to_s render
30
+
31
+ def payload = @invoice.to_h
32
+
33
+ # A batch export, for handing a period's documents to SAR or to an
34
+ # accounting system.
35
+ def self.export(invoices, pretty: true)
36
+ payload = {
37
+ "generated_at" => Time.now.utc.iso8601,
38
+ "count" => invoices.size,
39
+ "documents" => invoices.map(&:to_h)
40
+ }
41
+ pretty ? JSON.pretty_generate(payload) : JSON.generate(payload)
42
+ end
43
+
44
+ # Flat, one row per document — the shape a spreadsheet or an accounting
45
+ # import expects.
46
+ def self.export_csv(invoices)
47
+ headers = %w[
48
+ correlativo fecha estado cai cliente rtn_cliente moneda
49
+ subtotal descuentos isv total
50
+ ]
51
+
52
+ rows = invoices.map do |inv|
53
+ [
54
+ inv.correlative.to_s,
55
+ inv.issue_date.iso8601,
56
+ inv.status,
57
+ inv.authorization.cai,
58
+ inv.customer.to_s,
59
+ inv.customer.rtn&.to_s,
60
+ inv.currency,
61
+ inv.subtotal.to_fixed,
62
+ inv.discount.to_fixed,
63
+ inv.isv_total.to_fixed,
64
+ inv.total.to_fixed
65
+ ]
66
+ end
67
+
68
+ ([headers] + rows).map { |row| row.map { |cell| quote_csv(cell) }.join(",") }.join("\n")
69
+ end
70
+
71
+ def self.quote_csv(value)
72
+ value = value.to_s
73
+ value.match?(/[",\n]/) ? "\"#{value.gsub('"', '""')}\"" : value
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Invoicehn
4
+ module Renderers
5
+ # Renders the Factura as a PDF.
6
+ #
7
+ # The legends are the same constants the text renderer uses, so the two
8
+ # outputs cannot drift apart on the wording the decree fixes.
9
+ #
10
+ # Art. 38 forbids issuing fiscal documents on thermal paper and requires the
11
+ # information to be legible and permanent — a PDF printed on ordinary paper
12
+ # satisfies that; the operator remains responsible for the medium.
13
+ class Pdf
14
+ # Loaded lazily so the rest of the library works without prawn installed.
15
+ #
16
+ # LoadError also covers Gem::ConflictError, which is what actually surfaces
17
+ # when prawn's ttfunk dependency pins bigdecimal and a newer bigdecimal has
18
+ # already been activated — a conflict rather than a missing file.
19
+ def self.available?
20
+ require "prawn"
21
+ require "prawn/table"
22
+ true
23
+ rescue LoadError
24
+ false
25
+ end
26
+
27
+ # Prawn's built-in AFM fonts encode as Windows-1252. That covers every
28
+ # accented character Spanish needs, but a name or description carrying
29
+ # anything outside it would raise mid-render — so text is transcoded with
30
+ # a replacement rather than allowed to abort a fiscal document.
31
+ def self.encode(text)
32
+ text.to_s.encode("Windows-1252", invalid: :replace, undef: :replace, replace: "?")
33
+ .encode("UTF-8")
34
+ end
35
+
36
+ MARGIN = 36
37
+ TITLE_SIZE = 16
38
+ BODY_SIZE = 9
39
+
40
+ attr_reader :invoice, :copy
41
+
42
+ def initialize(invoice, copy: :original)
43
+ @invoice = invoice
44
+ @copy = copy
45
+ end
46
+
47
+ def render_file(path)
48
+ document.render_file(path)
49
+ path
50
+ end
51
+
52
+ def render = document.render
53
+
54
+ private
55
+
56
+ # Shorthand for the Windows-1252 transcode every string on the page goes
57
+ # through.
58
+ def e(text) = self.class.encode(text)
59
+
60
+ def document
61
+ unless self.class.available?
62
+ raise Error, "se requiere la gema «prawn» para generar PDF: gem install prawn prawn-table"
63
+ end
64
+
65
+ Prawn::Fonts::AFM.hide_m17n_warning = true if defined?(Prawn::Fonts::AFM)
66
+
67
+ pdf = Prawn::Document.new(page_size: "LETTER", margin: MARGIN)
68
+ pdf.font_size BODY_SIZE
69
+
70
+ issuer_section(pdf)
71
+ document_section(pdf)
72
+ customer_section(pdf)
73
+ line_items_section(pdf)
74
+ totals_section(pdf)
75
+ footer_section(pdf)
76
+
77
+ pdf
78
+ end
79
+
80
+ # Art. 10 num. 1.
81
+ def issuer_section(pdf)
82
+ issuer = @invoice.issuer
83
+ pdf.text e(issuer.trade_name.upcase), size: 14, align: :center, style: :bold
84
+ pdf.text e(issuer.legal_name), align: :center
85
+ pdf.text e("RTN: #{issuer.rtn.formatted}"), align: :center
86
+ pdf.text e("Casa matriz: #{issuer.headquarters_address}"), align: :center
87
+ pdf.text e("Establecimiento: #{issuer.branch_address}"), align: :center if issuer.branch?
88
+ pdf.text e("Tel.: #{issuer.phone} Correo: #{issuer.email}"), align: :center
89
+ pdf.move_down 8
90
+ pdf.stroke_horizontal_rule
91
+ pdf.move_down 8
92
+ end
93
+
94
+ # Art. 10 num. 2, 3, 4, 5 and 7.
95
+ def document_section(pdf)
96
+ auth = @invoice.authorization
97
+ pdf.text e(Text::DOCUMENT_NAME), size: TITLE_SIZE, align: :center, style: :bold
98
+
99
+ pdf.text e(Text::ANNULLED_LEGEND), size: TITLE_SIZE, align: :center, style: :bold if @invoice.annulled?
100
+
101
+ pdf.move_down 6
102
+ pdf.text e("No.: <b>#{@invoice.correlative}</b>"), inline_format: true
103
+ pdf.text e("Fecha de emisión: #{@invoice.issue_date}")
104
+ pdf.move_down 4
105
+ pdf.text e("CAI: #{auth.cai}")
106
+ pdf.text e("Rango autorizado: #{auth.range_label}")
107
+ pdf.text e("Fecha límite de emisión: #{auth.limit_date}")
108
+ pdf.move_down 8
109
+ pdf.stroke_horizontal_rule
110
+ pdf.move_down 8
111
+ end
112
+
113
+ # Art. 11 num. 1 lit. a) b), num. 2 lit. a), Art. 10 num. 8.
114
+ def customer_section(pdf)
115
+ customer = @invoice.customer
116
+
117
+ if customer.is_a?(Customer::ConsumidorFinal)
118
+ pdf.text e("Cliente: #{customer.display_name}")
119
+ pdf.text e("Identificación: #{customer.identification_line}")
120
+ else
121
+ pdf.text e("Cliente: #{customer.name}")
122
+ pdf.text e("RTN: #{customer.rtn.formatted}")
123
+ end
124
+
125
+ if customer.is_a?(Customer::Exonerado)
126
+ customer.supporting_documents.each { |label, value| pdf.text e("#{label}: #{value}") }
127
+ end
128
+
129
+ pdf.move_down 8
130
+ end
131
+
132
+ # Art. 11 num. 1 lit. d) e) f) and l).
133
+ def line_items_section(pdf)
134
+ header = ["Descripción", "Cant.", "V. unitario", "Descuento", "Tratamiento", "Valor"].map { |h| e(h) }
135
+ rows = @invoice.line_items.map do |item|
136
+ [
137
+ e(item.description),
138
+ item.quantity.frac.zero? ? item.quantity.to_i.to_s : item.quantity.to_s("F"),
139
+ item.unit_price.to_fixed,
140
+ item.discount.to_fixed,
141
+ e(item.treatment.label),
142
+ item.taxable_base.to_fixed
143
+ ]
144
+ end
145
+
146
+ pdf.table([header] + rows, width: pdf.bounds.width, header: true) do |t|
147
+ t.cells.size = BODY_SIZE
148
+ t.cells.padding = [3, 4, 3, 4]
149
+ t.row(0).font_style = :bold
150
+ t.row(0).background_color = "EEEEEE"
151
+ t.columns(1..3).align = :right
152
+ t.column(5).align = :right
153
+ end
154
+
155
+ pdf.move_down 8
156
+ end
157
+
158
+ # Art. 11 num. 1 lit. g) h) i) j) k) l); Art. 10 num. 10.
159
+ def totals_section(pdf)
160
+ summary = @invoice.summary
161
+ rows = []
162
+
163
+ rows += summary.untaxed_buckets.map do |b|
164
+ [e("Importe #{b.treatment.label.downcase}:"), e(b.base.to_s)]
165
+ end
166
+ rows += summary.taxed_buckets.map do |b|
167
+ [e("Importe gravado #{b.treatment.rate_percent}%:"), e(b.base.to_s)]
168
+ end
169
+
170
+ rows << [e("Subtotal:"), e(summary.gross.to_s)]
171
+ # Art. 10 num. 10 — part of the required format, printed whether or not
172
+ # a discount was granted.
173
+ rows << [e("Descuentos y rebajas otorgados:"), e(summary.discount.to_s)]
174
+
175
+ rows += summary.taxed_buckets.map do |b|
176
+ [e("ISV #{b.treatment.rate_percent}%:"), e(b.isv.to_s)]
177
+ end
178
+
179
+ rows << [e("TOTAL:"), e(@invoice.total.to_s)]
180
+
181
+ pdf.table(rows, position: :right, width: pdf.bounds.width * 0.55) do |t|
182
+ t.cells.size = BODY_SIZE
183
+ t.cells.borders = []
184
+ t.cells.padding = [2, 4, 2, 4]
185
+ t.column(1).align = :right
186
+ t.row(-1).font_style = :bold
187
+ t.row(-1).borders = [:top]
188
+ end
189
+
190
+ pdf.move_down 8
191
+ pdf.text e(@invoice.total_in_words), style: :italic
192
+
193
+ if @invoice.foreign_currency?
194
+ pdf.move_down 4
195
+ pdf.text e("Tasa de cambio a la fecha de emisión: #{@invoice.exchange_rate}")
196
+ equivalent = @invoice.total_in_lempiras
197
+ pdf.text e("Equivalente en Lempiras: #{equivalent}") if equivalent
198
+ end
199
+
200
+ pdf.move_down 8
201
+ end
202
+
203
+ def footer_section(pdf)
204
+ if @invoice.summary.mixed_supply?
205
+ pdf.text e("Esta factura sustenta crédito fiscal únicamente por las ventas gravadas: " \
206
+ "#{@invoice.summary.credito_fiscal_base} (Art. 11 num. 3)."), size: 8
207
+ pdf.move_down 4
208
+ end
209
+
210
+ if @invoice.annulled?
211
+ pdf.text e("#{Text::ANNULLED_LEGEND} el #{@invoice.annulled_at}: #{@invoice.annulment_reason}"),
212
+ style: :bold
213
+ pdf.move_down 4
214
+ end
215
+
216
+ pdf.text e(@invoice.notes), size: 8 unless @invoice.notes.empty?
217
+
218
+ pdf.move_down 6
219
+ pdf.stroke_horizontal_rule
220
+ pdf.move_down 4
221
+ # Art. 10 num. 6.
222
+ pdf.text e(Text::COPIES.fetch(@copy, Text::COPY_ORIGINAL)), align: :center, style: :bold
223
+ end
224
+ end
225
+ end
226
+ end
@@ -0,0 +1,251 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Invoicehn
4
+ module Renderers
5
+ # Renders the Factura as plain text for the terminal or a printer.
6
+ #
7
+ # Every legend here is legal text fixed by the Reglamento, not interface
8
+ # copy, so it lives as a constant rather than in the i18n catalog: an
9
+ # English-locale CLI must never leak English onto a fiscal document.
10
+ class Text
11
+ WIDTH = 78
12
+
13
+ # Art. 10 num. 2 — "Denominación del documento: 'Factura'".
14
+ DOCUMENT_NAME = "FACTURA"
15
+
16
+ # Art. 10 num. 6 — "Destino de los ejemplares de la factura".
17
+ COPY_ORIGINAL = "Original: Cliente"
18
+ COPY_ISSUER = "Copia: Obligado tributario emisor"
19
+
20
+ # Art. 41 — "consignando en los mismos la leyenda 'ANULADA'".
21
+ ANNULLED_LEGEND = "ANULADA"
22
+
23
+ COPIES = { original: COPY_ORIGINAL, copia: COPY_ISSUER }.freeze
24
+
25
+ DESCRIPTION_WIDTH = 30
26
+ ROW_TEMPLATE = "%-#{DESCRIPTION_WIDTH}s %8s %12s %11s %13s".freeze
27
+
28
+ attr_reader :invoice, :copy
29
+
30
+ # @param copy [Symbol] :original or :copia — Art. 5 requires documents to
31
+ # be generated "en original y copia".
32
+ def initialize(invoice, copy: :original, width: WIDTH)
33
+ @invoice = invoice
34
+ @copy = copy
35
+ @width = width
36
+ end
37
+
38
+ def render
39
+ sections = [
40
+ issuer_block,
41
+ document_block,
42
+ customer_block,
43
+ line_items_block,
44
+ totals_block,
45
+ footer_block
46
+ ].compact
47
+
48
+ "#{sections.join("\n")}\n"
49
+ end
50
+ alias to_s render
51
+
52
+ private
53
+
54
+ def rule(char = "─") = char * @width
55
+
56
+ def centre(text) = text.to_s.center(@width).rstrip
57
+
58
+ # Label on the left, value on the right, dot-filled between.
59
+ def pair(label, value)
60
+ value = value.to_s
61
+ space = @width - label.length - value.length
62
+ space = 1 if space < 1
63
+ "#{label}#{" " * space}#{value}"
64
+ end
65
+
66
+ def wrap(text, indent: 0)
67
+ limit = @width - indent
68
+ text.to_s.scan(/\S.{0,#{limit - 1}}(?:\s|$)/).map { |l| "#{" " * indent}#{l.strip}" }
69
+ end
70
+
71
+ # Art. 10 num. 1 — datos de identificación del emisor.
72
+ def issuer_block
73
+ issuer = @invoice.issuer
74
+ lines = [rule("═")]
75
+ lines << centre(issuer.trade_name.upcase)
76
+ lines << centre(issuer.legal_name)
77
+ lines << centre("RTN: #{issuer.rtn.formatted}")
78
+ lines += wrap("Casa matriz: #{issuer.headquarters_address}").map { |l| centre(l) }
79
+ lines += wrap("Establecimiento: #{issuer.branch_address}").map { |l| centre(l) } if issuer.branch?
80
+ lines << centre("Tel.: #{issuer.phone} Correo: #{issuer.email}")
81
+ lines << rule("═")
82
+ lines.join("\n")
83
+ end
84
+
85
+ # Art. 10 num. 2, 3, 4, 5, 6 and 7.
86
+ def document_block
87
+ auth = @invoice.authorization
88
+ lines = []
89
+ lines << centre(DOCUMENT_NAME)
90
+ lines << centre(ANNULLED_LEGEND) if @invoice.annulled?
91
+ lines << ""
92
+ lines << pair("No.: #{@invoice.correlative}", "Fecha de emisión: #{@invoice.issue_date}")
93
+ lines << ""
94
+ lines += wrap("CAI: #{auth.cai}")
95
+ lines << "Rango autorizado: #{auth.range_label}"
96
+ lines << "Fecha límite de emisión: #{auth.limit_date}"
97
+ lines << rule
98
+ lines.join("\n")
99
+ end
100
+
101
+ # Art. 11 num. 1 lit. a) and b), or num. 2 lit. a).
102
+ def customer_block
103
+ customer = @invoice.customer
104
+ lines = []
105
+
106
+ if customer.is_a?(Customer::ConsumidorFinal)
107
+ lines << pair("Cliente: #{customer.display_name}", customer.identification_line)
108
+ else
109
+ lines << "Cliente: #{customer.name}"
110
+ lines << "RTN: #{customer.rtn.formatted}"
111
+ end
112
+
113
+ # Art. 10 num. 8 / Art. 11 num. 4 — datos del adquirente exonerado.
114
+ if customer.is_a?(Customer::Exonerado)
115
+ customer.supporting_documents.each { |label, value| lines << "#{label}: #{value}" }
116
+ end
117
+
118
+ lines << rule
119
+ lines.join("\n")
120
+ end
121
+
122
+ # Art. 11 num. 1 lit. d), e), f) and l).
123
+ def line_items_block
124
+ lines = [header_row, rule("·")]
125
+
126
+ @invoice.line_items.each do |item|
127
+ lines += item_rows(item)
128
+ end
129
+
130
+ lines << rule
131
+ lines.join("\n")
132
+ end
133
+
134
+ # Header and detail rows share one template, so the columns cannot drift
135
+ # apart.
136
+ def header_row
137
+ row("Descripción", "Cant.", "V. unitario", "Descuento", "Valor")
138
+ end
139
+
140
+ def row(*cells) = format(ROW_TEMPLATE, *cells)
141
+
142
+ def item_rows(item)
143
+ # Art. 11 requires the description to be "detallada", so a long one
144
+ # continues on its own lines instead of being truncated — and it breaks
145
+ # between words, not mid-word.
146
+ head, *rest = wrap_words(item.description, DESCRIPTION_WIDTH)
147
+
148
+ rows = [row(head,
149
+ trim_quantity(item.quantity),
150
+ item.unit_price.to_fixed,
151
+ item.discount.to_fixed,
152
+ item.taxable_base.to_fixed)]
153
+
154
+ rows += rest.map { |line| " #{line}" }
155
+ rows << " (#{item.treatment.label})"
156
+ rows
157
+ end
158
+
159
+ def wrap_words(text, limit)
160
+ words = text.to_s.split
161
+ return [""] if words.empty?
162
+
163
+ words.each_with_object([+""]) do |word, lines|
164
+ if lines.last.empty?
165
+ lines[-1] = word.length > limit ? word[0, limit] : word
166
+ elsif lines.last.length + 1 + word.length <= limit
167
+ lines[-1] = "#{lines.last} #{word}"
168
+ else
169
+ lines << word
170
+ end
171
+ end
172
+ end
173
+
174
+ def trim_quantity(quantity)
175
+ whole = quantity.frac.zero?
176
+ whole ? quantity.to_i.to_s : quantity.to_s("F")
177
+ end
178
+
179
+ # Art. 11 num. 1 lit. g), h), i), j), k) and l); Art. 10 num. 10.
180
+ def totals_block
181
+ summary = @invoice.summary
182
+ lines = []
183
+
184
+ # lit. g) — the untaxed categories, each shown separately.
185
+ lines += summary.untaxed_buckets.map do |bucket|
186
+ pair("Importe #{bucket.treatment.label.downcase}:", bucket.base.to_s)
187
+ end
188
+
189
+ # lit. h) — subtotals by rate.
190
+ lines += summary.taxed_buckets.map do |bucket|
191
+ pair("Importe gravado #{bucket.treatment.rate_percent}%:", bucket.base.to_s)
192
+ end
193
+
194
+ lines << pair("Subtotal:", summary.gross.to_s)
195
+
196
+ # Art. 10 num. 10 and Art. 11 num. 1 lit. l) — the discounts line is
197
+ # part of the required format, so it is printed whether or not a
198
+ # discount was granted.
199
+ lines << pair("Descuentos y rebajas otorgados:", summary.discount.to_s)
200
+
201
+ # lit. i) — taxes by rate.
202
+ lines += summary.taxed_buckets.map do |bucket|
203
+ pair("ISV #{bucket.treatment.rate_percent}%:", bucket.isv.to_s)
204
+ end
205
+
206
+ lines << rule("·")
207
+ lines << pair("TOTAL:", @invoice.total.to_s)
208
+ lines << ""
209
+
210
+ # lit. k) — the total in words.
211
+ lines += wrap(@invoice.total_in_words)
212
+
213
+ # Art. 11, closing paragraph — the rate in force on the issue date.
214
+ if @invoice.foreign_currency?
215
+ lines << ""
216
+ lines << "Tasa de cambio a la fecha de emisión: #{@invoice.exchange_rate}"
217
+ equivalent = @invoice.total_in_lempiras
218
+ lines << pair("Equivalente en Lempiras:", equivalent.to_s) if equivalent
219
+ end
220
+
221
+ lines << rule
222
+ lines.join("\n")
223
+ end
224
+
225
+ def footer_block
226
+ lines = []
227
+
228
+ # Art. 11 num. 3 — where the invoice mixes exempt and taxed sales, only
229
+ # the taxed sales support crédito fiscal. Saying so on the document
230
+ # keeps the buyer from over-claiming.
231
+ if @invoice.summary.mixed_supply?
232
+ lines += wrap("Esta factura sustenta crédito fiscal únicamente por las ventas gravadas: " \
233
+ "#{@invoice.summary.credito_fiscal_base} (Art. 11 num. 3).")
234
+ lines << ""
235
+ end
236
+
237
+ if @invoice.annulled?
238
+ lines += wrap("#{ANNULLED_LEGEND} el #{@invoice.annulled_at}: #{@invoice.annulment_reason}")
239
+ lines << ""
240
+ end
241
+
242
+ lines += wrap(@invoice.notes) unless @invoice.notes.empty?
243
+
244
+ # Art. 10 num. 6.
245
+ lines << centre(COPIES.fetch(@copy, COPY_ORIGINAL))
246
+ lines << rule("═")
247
+ lines.join("\n")
248
+ end
249
+ end
250
+ end
251
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Invoicehn
4
+ # Registro Tributario Nacional — 14 digits, for both personas naturales and
5
+ # personas jurídicas.
6
+ #
7
+ # Validation is deliberately limited to length and digits. There is no
8
+ # publicly documented check digit for the Honduran RTN: the word "dígito"
9
+ # appears nowhere in the Código Tributario (Decreto 170-2016), Acuerdo
10
+ # 481-2017 requires the RTN on invoices without ever specifying its structure,
11
+ # and SAR publishes no algorithm. Porting a mod-11 scheme by analogy from the
12
+ # Chilean RUT or Mexican RFC would reject valid Honduran RTNs, so this class
13
+ # does not attempt it. Verify a real RTN against SAR's consulta, not
14
+ # arithmetic.
15
+ #
16
+ # Note also that primary sources disagree on how a natural person's RTN is
17
+ # derived — Código Tributario Art. 66 num. 3 says it *is* the RNP number,
18
+ # while SAR states it is the 13-digit DNI plus one digit. Nothing here assumes
19
+ # either, and nothing here assumes RTN and DNI are interchangeable.
20
+ class Rtn
21
+ LENGTH = 14
22
+ PATTERN = /\A\d{#{LENGTH}}\z/
23
+
24
+ attr_reader :digits
25
+
26
+ class << self
27
+ def parse(value)
28
+ new(value)
29
+ rescue ValidationError
30
+ nil
31
+ end
32
+
33
+ def valid?(value)
34
+ normalize(value).match?(PATTERN)
35
+ end
36
+
37
+ # Dashes and spaces are a display convention with no basis in any norm,
38
+ # so they are accepted on input and discarded.
39
+ def normalize(value)
40
+ value.to_s.gsub(/[\s-]/, "")
41
+ end
42
+ end
43
+
44
+ def initialize(value)
45
+ @digits = self.class.normalize(value)
46
+
47
+ unless @digits.match?(PATTERN)
48
+ raise ValidationError,
49
+ "RTN inválido: se esperan #{LENGTH} dígitos, se recibió #{@digits.inspect}"
50
+ end
51
+
52
+ freeze
53
+ end
54
+
55
+ # "0801-1990-123456" — the 4-4-6 grouping is customary, not prescribed.
56
+ def formatted
57
+ "#{@digits[0, 4]}-#{@digits[4, 4]}-#{@digits[8, 6]}"
58
+ end
59
+
60
+ def to_s = @digits
61
+ def inspect = "#<Invoicehn::Rtn #{formatted}>"
62
+
63
+ def ==(other)
64
+ other.is_a?(self.class) && other.digits == @digits
65
+ end
66
+ alias eql? ==
67
+
68
+ def hash = @digits.hash
69
+ end
70
+ end