invoicehn 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +59 -0
- data/LICENSE.txt +21 -0
- data/README.md +430 -0
- data/config/locales/en.yml +89 -0
- data/config/locales/es.yml +87 -0
- data/exe/invoicehn +7 -0
- data/lib/invoicehn/authorization.rb +144 -0
- data/lib/invoicehn/cli/builder.rb +88 -0
- data/lib/invoicehn/cli/main.rb +233 -0
- data/lib/invoicehn/cli/reporter.rb +122 -0
- data/lib/invoicehn/cli/wizard.rb +234 -0
- data/lib/invoicehn/cli.rb +16 -0
- data/lib/invoicehn/compliance/validator.rb +222 -0
- data/lib/invoicehn/compliance/violation.rb +42 -0
- data/lib/invoicehn/config.rb +50 -0
- data/lib/invoicehn/correlative.rb +144 -0
- data/lib/invoicehn/customer.rb +188 -0
- data/lib/invoicehn/errors.rb +39 -0
- data/lib/invoicehn/exchange_rate.rb +108 -0
- data/lib/invoicehn/invoice.rb +165 -0
- data/lib/invoicehn/issuance.rb +124 -0
- data/lib/invoicehn/issuer.rb +95 -0
- data/lib/invoicehn/ledger.rb +107 -0
- data/lib/invoicehn/line_item.rb +120 -0
- data/lib/invoicehn/locale.rb +77 -0
- data/lib/invoicehn/money.rb +177 -0
- data/lib/invoicehn/renderers/json.rb +77 -0
- data/lib/invoicehn/renderers/pdf.rb +226 -0
- data/lib/invoicehn/renderers/text.rb +251 -0
- data/lib/invoicehn/rtn.rb +70 -0
- data/lib/invoicehn/sequence.rb +126 -0
- data/lib/invoicehn/spanish_numerals.rb +123 -0
- data/lib/invoicehn/storage/json_store.rb +154 -0
- data/lib/invoicehn/tax_summary.rb +129 -0
- data/lib/invoicehn/tax_treatment.rb +109 -0
- data/lib/invoicehn/version.rb +5 -0
- data/lib/invoicehn.rb +40 -0
- metadata +161 -0
|
@@ -0,0 +1,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
|