invoicehn 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +114 -1
- data/README.md +263 -6
- data/lib/invoicehn/authorization.rb +4 -4
- data/lib/invoicehn/cli/builder.rb +15 -2
- data/lib/invoicehn/cli/wizard.rb +1 -1
- data/lib/invoicehn/compliance/validator.rb +136 -14
- data/lib/invoicehn/correlative.rb +46 -10
- data/lib/invoicehn/document_reference.rb +83 -0
- data/lib/invoicehn/invoice.rb +59 -15
- data/lib/invoicehn/issuance.rb +155 -22
- data/lib/invoicehn/ledger.rb +9 -1
- data/lib/invoicehn/renderers/json.rb +5 -2
- data/lib/invoicehn/renderers/pdf.rb +25 -5
- data/lib/invoicehn/renderers/text.rb +64 -5
- data/lib/invoicehn/settings.rb +44 -0
- data/lib/invoicehn/storage/json_store.rb +22 -7
- data/lib/invoicehn/test_support/contracts.rb +253 -0
- data/lib/invoicehn/test_support/memory.rb +112 -0
- data/lib/invoicehn/testing.rb +140 -0
- data/lib/invoicehn/version.rb +1 -1
- data/lib/invoicehn.rb +28 -0
- metadata +12 -9
data/lib/invoicehn/issuance.rb
CHANGED
|
@@ -3,38 +3,64 @@
|
|
|
3
3
|
require "date"
|
|
4
4
|
|
|
5
5
|
module Invoicehn
|
|
6
|
-
# Issues a Factura
|
|
7
|
-
#
|
|
6
|
+
# Issues a Factura, Nota de Crédito or Nota de Débito: picks the
|
|
7
|
+
# authorization, allocates the correlativo, validates against the
|
|
8
|
+
# Reglamento, persists, and posts to the ledger.
|
|
8
9
|
#
|
|
9
|
-
# The whole sequence happens inside the allocator's
|
|
10
|
+
# The whole sequence happens inside the allocator's block, so a document is
|
|
10
11
|
# either fully recorded or its number was never consumed. Nothing here can
|
|
11
|
-
# produce a number without a document behind it
|
|
12
|
+
# produce a number without a document behind it, and a failure part-way
|
|
13
|
+
# leaves nothing behind: if the ledger refuses the entry, the document just
|
|
14
|
+
# saved is deleted again before the counter would have advanced.
|
|
15
|
+
#
|
|
16
|
+
# The store, sequence and ledger are duck-typed collaborators; the README
|
|
17
|
+
# lists the exact methods each must answer, and Invoicehn::Testing checks
|
|
18
|
+
# an implementation against that list.
|
|
12
19
|
class Issuance
|
|
20
|
+
DEFAULT_IDENTIFIER = "000-001-01"
|
|
21
|
+
CREDIT_NOTE_IDENTIFIER = "000-001-06"
|
|
22
|
+
DEBIT_NOTE_IDENTIFIER = "000-001-07"
|
|
23
|
+
|
|
13
24
|
attr_reader :store, :sequence, :ledger
|
|
14
25
|
|
|
15
|
-
|
|
26
|
+
# The bundled JSON implementations are built only for the collaborators
|
|
27
|
+
# not injected. A host that injects all three never touches the file
|
|
28
|
+
# system: no Config is built, ~/.invoicehn is never read or created.
|
|
29
|
+
def initialize(config: nil, store: nil, sequence: nil, ledger: nil)
|
|
16
30
|
@config = config
|
|
17
|
-
@store = store || Storage::JsonStore.new(
|
|
18
|
-
@sequence = sequence || Sequence.new(
|
|
19
|
-
@ledger = ledger || Ledger::JsonlLedger.new(
|
|
31
|
+
@store = store || Storage::JsonStore.new(default_config)
|
|
32
|
+
@sequence = sequence || Sequence.new(default_config)
|
|
33
|
+
@ledger = ledger || Ledger::JsonlLedger.new(default_config)
|
|
20
34
|
end
|
|
21
35
|
|
|
22
36
|
# @param identifier [String] "NNN-NNN-NN", the establecimiento / punto de
|
|
23
|
-
# emisión / tipo de documento triple.
|
|
24
|
-
#
|
|
37
|
+
# emisión / tipo de documento triple. Its last group decides the kind of
|
|
38
|
+
# document: 01 Factura, 06 Nota de Crédito, 07 Nota de Débito. Each
|
|
39
|
+
# identifier has its own counter and its own SAR authorization (Art. 59:
|
|
40
|
+
# "La autorización será por punto de emisión y por tipo de documento").
|
|
25
41
|
# @param issue_date [Date] pinned to today by default. Art. 43 obliges
|
|
26
42
|
# chronological custody, and a ledger whose numbering disagrees with its
|
|
27
43
|
# dates is the first thing an audit questions — so backdating is not
|
|
28
44
|
# offered through the CLI.
|
|
29
|
-
|
|
30
|
-
|
|
45
|
+
# @param reference [DocumentReference, String, Correlative, nil] notes
|
|
46
|
+
# only: the document the note applies to. A correlative is looked up in
|
|
47
|
+
# the store and its CAI and date copied; a DocumentReference is used as
|
|
48
|
+
# given (for documents issued outside this store) and, if the store does
|
|
49
|
+
# hold that document, checked against it.
|
|
50
|
+
# @param motivo [String, nil] notes only: "Motivo de la emisión del
|
|
51
|
+
# documento" (Arts. 26 and 28 num. 4).
|
|
52
|
+
def issue(customer:, line_items:, identifier: DEFAULT_IDENTIFIER, currency: "HNL",
|
|
53
|
+
exchange_rate: nil, notes: nil, issue_date: Invoicehn.today,
|
|
54
|
+
reference: nil, motivo: nil)
|
|
31
55
|
issuer = @store.issuer
|
|
32
56
|
raise NoAuthorization, "no se ha configurado el emisor; ejecute «invoicehn setup»" if issuer.nil?
|
|
33
57
|
|
|
58
|
+
reference = resolve_reference(reference, customer)
|
|
59
|
+
|
|
34
60
|
@sequence.allocate(identifier) do |correlative|
|
|
35
61
|
authorization = pick_authorization(identifier, correlative, issue_date)
|
|
36
62
|
|
|
37
|
-
|
|
63
|
+
document = Invoice.new(
|
|
38
64
|
correlative: correlative,
|
|
39
65
|
issuer: issuer,
|
|
40
66
|
customer: customer,
|
|
@@ -43,30 +69,60 @@ module Invoicehn
|
|
|
43
69
|
issue_date: issue_date,
|
|
44
70
|
currency: currency,
|
|
45
71
|
exchange_rate: exchange_rate,
|
|
46
|
-
notes: notes
|
|
72
|
+
notes: notes,
|
|
73
|
+
reference: reference,
|
|
74
|
+
motivo: motivo
|
|
47
75
|
)
|
|
48
76
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
@ledger.record(invoice, event: :emision)
|
|
52
|
-
invoice
|
|
77
|
+
document.validate!(on: issue_date)
|
|
78
|
+
persist(document)
|
|
53
79
|
end
|
|
54
80
|
end
|
|
55
81
|
|
|
82
|
+
# A Nota de Crédito (tipo 06, Arts. 25-26): returns, discounts granted after
|
|
83
|
+
# the sale, or the reversal of an operation (Art. 4 num. 32).
|
|
84
|
+
def issue_credit_note(reference:, motivo:, customer:, line_items:,
|
|
85
|
+
identifier: CREDIT_NOTE_IDENTIFIER, **options)
|
|
86
|
+
assert_type!(identifier, Correlative::NOTA_DE_CREDITO)
|
|
87
|
+
issue(customer: customer, line_items: line_items, identifier: identifier,
|
|
88
|
+
reference: reference, motivo: motivo, **options)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# A Nota de Débito (tipo 07, Arts. 27-28): an adjustment charged on top of
|
|
92
|
+
# a Comprobante Fiscal already issued (Art. 4 num. 31).
|
|
93
|
+
def issue_debit_note(reference:, motivo:, customer:, line_items:,
|
|
94
|
+
identifier: DEBIT_NOTE_IDENTIFIER, **options)
|
|
95
|
+
assert_type!(identifier, Correlative::NOTA_DE_DEBITO)
|
|
96
|
+
issue(customer: customer, line_items: line_items, identifier: identifier,
|
|
97
|
+
reference: reference, motivo: motivo, **options)
|
|
98
|
+
end
|
|
99
|
+
|
|
56
100
|
# Art. 41 — annulment. The correlativo stays consumed; the sequence never
|
|
57
101
|
# goes backwards.
|
|
58
|
-
|
|
102
|
+
# Works the same for a Factura and for a note.
|
|
103
|
+
#
|
|
104
|
+
# If the ledger refuses the entry, the issued form is put back. Otherwise
|
|
105
|
+
# the store would hold the ANULADA form with no :anulacion entry in the
|
|
106
|
+
# ledger, and a retry would fail with ImmutableDocument ("ya está
|
|
107
|
+
# anulada"), leaving no way to post the entry through this API.
|
|
108
|
+
def annul(correlative, reason:, on: Invoicehn.today)
|
|
59
109
|
invoice = @store.find(correlative)
|
|
60
110
|
annulled = invoice.annul(reason: reason, on: on)
|
|
61
111
|
|
|
62
112
|
@store.save_document(annulled)
|
|
63
|
-
|
|
113
|
+
recorded = false
|
|
114
|
+
begin
|
|
115
|
+
@ledger.record(annulled, event: :anulacion)
|
|
116
|
+
recorded = true
|
|
117
|
+
ensure
|
|
118
|
+
restore(invoice, annulled) unless recorded
|
|
119
|
+
end
|
|
64
120
|
annulled
|
|
65
121
|
end
|
|
66
122
|
|
|
67
123
|
# What `invoicehn check` reports: whether issuing is possible right now, and
|
|
68
124
|
# what is about to go wrong.
|
|
69
|
-
def health(identifier:
|
|
125
|
+
def health(identifier: DEFAULT_IDENTIFIER, on: Invoicehn.today)
|
|
70
126
|
issuer = @store.issuer
|
|
71
127
|
next_correlative = @sequence.peek(identifier)
|
|
72
128
|
authorizations = @store.authorizations_for(identifier)
|
|
@@ -81,7 +137,7 @@ module Invoicehn
|
|
|
81
137
|
active_authorization: active,
|
|
82
138
|
next_correlative: next_correlative,
|
|
83
139
|
issued: @sequence.issued_count(identifier),
|
|
84
|
-
remaining: active ?
|
|
140
|
+
remaining: active ? [active.range_end.sequence - next_correlative.sequence + 1, 0].max : 0,
|
|
85
141
|
days_remaining: active&.days_remaining(on),
|
|
86
142
|
# Art. 42 — expired authorizations holding unused documents must be
|
|
87
143
|
# reported to SAR within the first 10 business days of the next month.
|
|
@@ -92,6 +148,83 @@ module Invoicehn
|
|
|
92
148
|
|
|
93
149
|
private
|
|
94
150
|
|
|
151
|
+
def default_config = (@config ||= Config.new) # rubocop:disable Naming/MemoizedInstanceVariableName
|
|
152
|
+
|
|
153
|
+
# Saves, then posts to the ledger. If posting fails — for any reason,
|
|
154
|
+
# including an interrupt — the document is deleted again, so the store
|
|
155
|
+
# never holds a number the counter has not consumed. Without this a
|
|
156
|
+
# failing ledger left the document on disk and the counter behind it, and
|
|
157
|
+
# every later issue for that identifier failed with ImmutableDocument.
|
|
158
|
+
def persist(document)
|
|
159
|
+
@store.save_document(document)
|
|
160
|
+
recorded = false
|
|
161
|
+
begin
|
|
162
|
+
@ledger.record(document, event: :emision)
|
|
163
|
+
recorded = true
|
|
164
|
+
ensure
|
|
165
|
+
@store.delete_document(document) unless recorded
|
|
166
|
+
end
|
|
167
|
+
document
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Puts the issued form back after a failed annulment. save_document
|
|
171
|
+
# refuses to overwrite with a form that is not annulled, so the annulled
|
|
172
|
+
# one is deleted first.
|
|
173
|
+
def restore(issued, annulled)
|
|
174
|
+
@store.delete_document(annulled)
|
|
175
|
+
@store.save_document(issued)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def assert_type!(identifier, type)
|
|
179
|
+
return if identifier.to_s.split("-")[2] == type
|
|
180
|
+
|
|
181
|
+
raise ValidationError,
|
|
182
|
+
"el identificador #{identifier} no corresponde a #{Correlative::DOCUMENT_TYPES.fetch(type)} " \
|
|
183
|
+
"(tipo #{type})"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Turns what the caller passed into a DocumentReference, cross-checking it
|
|
187
|
+
# against the original when the store holds it.
|
|
188
|
+
def resolve_reference(reference, customer)
|
|
189
|
+
return nil if reference.nil?
|
|
190
|
+
|
|
191
|
+
if reference.is_a?(DocumentReference)
|
|
192
|
+
original = @store.exists?(reference.correlative) ? @store.find(reference.correlative) : nil
|
|
193
|
+
check_reference_matches!(reference, original) if original
|
|
194
|
+
else
|
|
195
|
+
original = @store.find(reference)
|
|
196
|
+
reference = DocumentReference.from_document(original)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
check_original!(original, customer) if original
|
|
200
|
+
reference
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def check_reference_matches!(reference, original)
|
|
204
|
+
stored = DocumentReference.from_document(original)
|
|
205
|
+
return if stored == reference
|
|
206
|
+
|
|
207
|
+
raise ValidationError,
|
|
208
|
+
"los datos del documento de referencia no coinciden con #{original.correlative} " \
|
|
209
|
+
"registrado (CAI #{stored.cai}, fecha #{stored.issue_date})"
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Not article text but consistency: a note adjusts the operation of the
|
|
213
|
+
# document it points at, so that document must still stand and belong to
|
|
214
|
+
# the same acquirer.
|
|
215
|
+
def check_original!(original, customer)
|
|
216
|
+
if original.annulled?
|
|
217
|
+
raise ValidationError, "#{original.correlative} está ANULADA (Art. 41); no se le puede aplicar una nota"
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
return unless original.customer.rtn && customer.rtn
|
|
221
|
+
return if original.customer.rtn == customer.rtn
|
|
222
|
+
|
|
223
|
+
raise ValidationError,
|
|
224
|
+
"el adquirente de la nota (RTN #{customer.rtn.formatted}) no es el de " \
|
|
225
|
+
"#{original.correlative} (RTN #{original.customer.rtn.formatted})"
|
|
226
|
+
end
|
|
227
|
+
|
|
95
228
|
def pick_authorization(identifier, correlative, on)
|
|
96
229
|
authorization = @store.active_authorization(identifier, next_sequence: correlative.sequence, on: on)
|
|
97
230
|
|
data/lib/invoicehn/ledger.rb
CHANGED
|
@@ -18,7 +18,13 @@ module Invoicehn
|
|
|
18
18
|
# software. The default implementation writes an append-only JSONL book, which
|
|
19
19
|
# satisfies the requirement standalone and feeds the Art. 53 num. 5 export.
|
|
20
20
|
class Ledger
|
|
21
|
-
# Called once per issued or annulled document
|
|
21
|
+
# Called once per issued or annulled document — a Factura or a Nota de
|
|
22
|
+
# Crédito or de Débito (see #document_type on the document).
|
|
23
|
+
#
|
|
24
|
+
# If this raises during an issue, Issuance deletes the document it just
|
|
25
|
+
# saved and the correlative is not consumed, so an implementation may
|
|
26
|
+
# refuse an entry safely. During an annulment, Issuance puts the issued
|
|
27
|
+
# form back. It must not swallow its own failures.
|
|
22
28
|
#
|
|
23
29
|
# @param invoice [Invoice]
|
|
24
30
|
# @param event [Symbol] :emision or :anulacion
|
|
@@ -63,6 +69,8 @@ module Invoicehn
|
|
|
63
69
|
"recorded_at" => Time.now.utc.iso8601,
|
|
64
70
|
"event" => event.to_s,
|
|
65
71
|
"correlative" => invoice.correlative.to_s,
|
|
72
|
+
"document_type" => invoice.document_type,
|
|
73
|
+
"reference" => invoice.reference&.correlative&.to_s,
|
|
66
74
|
"issue_date" => invoice.issue_date.iso8601,
|
|
67
75
|
"cai" => invoice.authorization.cai,
|
|
68
76
|
"customer" => invoice.customer.to_s,
|
|
@@ -46,7 +46,7 @@ module Invoicehn
|
|
|
46
46
|
def self.export_csv(invoices)
|
|
47
47
|
headers = %w[
|
|
48
48
|
correlativo fecha estado cai cliente rtn_cliente moneda
|
|
49
|
-
subtotal descuentos isv total
|
|
49
|
+
subtotal descuentos isv total tipo documento_referencia motivo
|
|
50
50
|
]
|
|
51
51
|
|
|
52
52
|
rows = invoices.map do |inv|
|
|
@@ -61,7 +61,10 @@ module Invoicehn
|
|
|
61
61
|
inv.subtotal.to_fixed,
|
|
62
62
|
inv.discount.to_fixed,
|
|
63
63
|
inv.isv_total.to_fixed,
|
|
64
|
-
inv.total.to_fixed
|
|
64
|
+
inv.total.to_fixed,
|
|
65
|
+
inv.document_type,
|
|
66
|
+
inv.reference&.correlative&.to_s,
|
|
67
|
+
inv.motivo
|
|
65
68
|
]
|
|
66
69
|
end
|
|
67
70
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module Invoicehn
|
|
4
4
|
module Renderers
|
|
5
|
-
# Renders
|
|
5
|
+
# Renders a Factura, Nota de Crédito or Nota de Débito as a PDF.
|
|
6
6
|
#
|
|
7
7
|
# The legends are the same constants the text renderer uses, so the two
|
|
8
8
|
# outputs cannot drift apart on the wording the decree fixes.
|
|
@@ -70,6 +70,7 @@ module Invoicehn
|
|
|
70
70
|
issuer_section(pdf)
|
|
71
71
|
document_section(pdf)
|
|
72
72
|
customer_section(pdf)
|
|
73
|
+
reference_section(pdf)
|
|
73
74
|
line_items_section(pdf)
|
|
74
75
|
totals_section(pdf)
|
|
75
76
|
footer_section(pdf)
|
|
@@ -94,7 +95,7 @@ module Invoicehn
|
|
|
94
95
|
# Art. 10 num. 2, 3, 4, 5 and 7.
|
|
95
96
|
def document_section(pdf)
|
|
96
97
|
auth = @invoice.authorization
|
|
97
|
-
pdf.text e(Text
|
|
98
|
+
pdf.text e(Text.title_for(@invoice)), size: TITLE_SIZE, align: :center, style: :bold
|
|
98
99
|
|
|
99
100
|
pdf.text e(Text::ANNULLED_LEGEND), size: TITLE_SIZE, align: :center, style: :bold if @invoice.annulled?
|
|
100
101
|
|
|
@@ -129,6 +130,16 @@ module Invoicehn
|
|
|
129
130
|
pdf.move_down 8
|
|
130
131
|
end
|
|
131
132
|
|
|
133
|
+
# Art. 26 num. 3 and 4; Art. 28 num. 3 and 4.
|
|
134
|
+
def reference_section(pdf)
|
|
135
|
+
return unless @invoice.note?
|
|
136
|
+
|
|
137
|
+
pdf.text e(Text::REFERENCE_HEADING), style: :bold
|
|
138
|
+
Text.reference_lines(@invoice).each { |label, value| pdf.text e("#{label}: #{value}") }
|
|
139
|
+
pdf.text e("Motivo: #{@invoice.motivo}")
|
|
140
|
+
pdf.move_down 8
|
|
141
|
+
end
|
|
142
|
+
|
|
132
143
|
# Art. 11 num. 1 lit. d) e) f) and l).
|
|
133
144
|
def line_items_section(pdf)
|
|
134
145
|
header = ["Descripción", "Cant.", "V. unitario", "Descuento", "Tratamiento", "Valor"].map { |h| e(h) }
|
|
@@ -201,7 +212,7 @@ module Invoicehn
|
|
|
201
212
|
end
|
|
202
213
|
|
|
203
214
|
def footer_section(pdf)
|
|
204
|
-
if @invoice.summary.mixed_supply?
|
|
215
|
+
if @invoice.factura? && @invoice.summary.mixed_supply?
|
|
205
216
|
pdf.text e("Esta factura sustenta crédito fiscal únicamente por las ventas gravadas: " \
|
|
206
217
|
"#{@invoice.summary.credito_fiscal_base} (Art. 11 num. 3)."), size: 8
|
|
207
218
|
pdf.move_down 4
|
|
@@ -215,11 +226,20 @@ module Invoicehn
|
|
|
215
226
|
|
|
216
227
|
pdf.text e(@invoice.notes), size: 8 unless @invoice.notes.empty?
|
|
217
228
|
|
|
229
|
+
# Art. 26 num. 7 — completed by hand by whoever receives the note.
|
|
230
|
+
if @invoice.credit_note?
|
|
231
|
+
pdf.move_down 10
|
|
232
|
+
Text::RECEIVER_FIELDS.each do |label|
|
|
233
|
+
pdf.text e("#{label}: #{"_" * 50}")
|
|
234
|
+
pdf.move_down 6
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
218
238
|
pdf.move_down 6
|
|
219
239
|
pdf.stroke_horizontal_rule
|
|
220
240
|
pdf.move_down 4
|
|
221
|
-
# Art. 10 num. 6.
|
|
222
|
-
pdf.text e(Text
|
|
241
|
+
# Art. 10 num. 6; Arts. 25 and 27 num. 7.
|
|
242
|
+
pdf.text e(Text.copy_legend(@invoice, @copy)), align: :center, style: :bold
|
|
223
243
|
end
|
|
224
244
|
end
|
|
225
245
|
end
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module Invoicehn
|
|
4
4
|
module Renderers
|
|
5
|
-
# Renders
|
|
5
|
+
# Renders a Factura, Nota de Crédito or Nota de Débito as plain text for
|
|
6
|
+
# the terminal or a printer.
|
|
6
7
|
#
|
|
7
8
|
# Every legend here is legal text fixed by the Reglamento, not interface
|
|
8
9
|
# copy, so it lives as a constant rather than in the i18n catalog: an
|
|
@@ -13,10 +14,50 @@ module Invoicehn
|
|
|
13
14
|
# Art. 10 num. 2 — "Denominación del documento: 'Factura'".
|
|
14
15
|
DOCUMENT_NAME = "FACTURA"
|
|
15
16
|
|
|
17
|
+
# Art. 25 num. 2 — "Nota de Crédito"; Art. 27 num. 2 — "Nota de Débito".
|
|
18
|
+
TITLES = {
|
|
19
|
+
Correlative::FACTURA => DOCUMENT_NAME,
|
|
20
|
+
Correlative::NOTA_DE_CREDITO => "NOTA DE CRÉDITO",
|
|
21
|
+
Correlative::NOTA_DE_DEBITO => "NOTA DE DÉBITO"
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
16
24
|
# Art. 10 num. 6 — "Destino de los ejemplares de la factura".
|
|
17
25
|
COPY_ORIGINAL = "Original: Cliente"
|
|
18
26
|
COPY_ISSUER = "Copia: Obligado tributario emisor"
|
|
19
27
|
|
|
28
|
+
# Art. 25 num. 7 and Art. 27 num. 7 word the issuer's copy differently:
|
|
29
|
+
# "Copia: Obligado Tributario".
|
|
30
|
+
COPY_ISSUER_NOTE = "Copia: Obligado Tributario"
|
|
31
|
+
|
|
32
|
+
# Art. 26 num. 3 — the block that identifies the original document.
|
|
33
|
+
REFERENCE_HEADING = "Documento al que se aplica la nota"
|
|
34
|
+
|
|
35
|
+
# Art. 26 num. 7 — "Apellidos, Nombres, Tarjeta de Identidad o documento
|
|
36
|
+
# de identificación, y firma de la persona que recibe la Nota de
|
|
37
|
+
# Crédito." Filled in by hand when the note is delivered.
|
|
38
|
+
RECEIVER_FIELDS = ["Recibido por (nombres y apellidos)", "Identidad o documento de identificación",
|
|
39
|
+
"Firma"].freeze
|
|
40
|
+
|
|
41
|
+
def self.title_for(invoice) = TITLES.fetch(invoice.document_type, invoice.document_name.upcase)
|
|
42
|
+
|
|
43
|
+
def self.copy_legend(invoice, copy)
|
|
44
|
+
return COPY_ORIGINAL unless copy == :copia
|
|
45
|
+
|
|
46
|
+
invoice.factura? ? COPY_ISSUER : COPY_ISSUER_NOTE
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# [label, value] pairs printed for a note's reference and motivo.
|
|
50
|
+
def self.reference_lines(invoice)
|
|
51
|
+
reference = invoice.reference
|
|
52
|
+
return [] unless invoice.note? && reference
|
|
53
|
+
|
|
54
|
+
[
|
|
55
|
+
["#{reference.document_type_name} No.", reference.correlative.to_s],
|
|
56
|
+
["CAI", reference.cai],
|
|
57
|
+
["Fecha de emisión", reference.issue_date.to_s]
|
|
58
|
+
]
|
|
59
|
+
end
|
|
60
|
+
|
|
20
61
|
# Art. 41 — "consignando en los mismos la leyenda 'ANULADA'".
|
|
21
62
|
ANNULLED_LEGEND = "ANULADA"
|
|
22
63
|
|
|
@@ -40,6 +81,7 @@ module Invoicehn
|
|
|
40
81
|
issuer_block,
|
|
41
82
|
document_block,
|
|
42
83
|
customer_block,
|
|
84
|
+
reference_block,
|
|
43
85
|
line_items_block,
|
|
44
86
|
totals_block,
|
|
45
87
|
footer_block
|
|
@@ -86,7 +128,7 @@ module Invoicehn
|
|
|
86
128
|
def document_block
|
|
87
129
|
auth = @invoice.authorization
|
|
88
130
|
lines = []
|
|
89
|
-
lines << centre(
|
|
131
|
+
lines << centre(self.class.title_for(@invoice))
|
|
90
132
|
lines << centre(ANNULLED_LEGEND) if @invoice.annulled?
|
|
91
133
|
lines << ""
|
|
92
134
|
lines << pair("No.: #{@invoice.correlative}", "Fecha de emisión: #{@invoice.issue_date}")
|
|
@@ -119,6 +161,17 @@ module Invoicehn
|
|
|
119
161
|
lines.join("\n")
|
|
120
162
|
end
|
|
121
163
|
|
|
164
|
+
# Art. 26 num. 3 and 4; Art. 28 num. 3 and 4.
|
|
165
|
+
def reference_block
|
|
166
|
+
return nil unless @invoice.note?
|
|
167
|
+
|
|
168
|
+
lines = [REFERENCE_HEADING]
|
|
169
|
+
lines += self.class.reference_lines(@invoice).map { |label, value| " #{label}: #{value}" }
|
|
170
|
+
lines += wrap("Motivo: #{@invoice.motivo}")
|
|
171
|
+
lines << rule
|
|
172
|
+
lines.join("\n")
|
|
173
|
+
end
|
|
174
|
+
|
|
122
175
|
# Art. 11 num. 1 lit. d), e), f) and l).
|
|
123
176
|
def line_items_block
|
|
124
177
|
lines = [header_row, rule("·")]
|
|
@@ -228,7 +281,7 @@ module Invoicehn
|
|
|
228
281
|
# Art. 11 num. 3 — where the invoice mixes exempt and taxed sales, only
|
|
229
282
|
# the taxed sales support crédito fiscal. Saying so on the document
|
|
230
283
|
# keeps the buyer from over-claiming.
|
|
231
|
-
if @invoice.summary.mixed_supply?
|
|
284
|
+
if @invoice.factura? && @invoice.summary.mixed_supply?
|
|
232
285
|
lines += wrap("Esta factura sustenta crédito fiscal únicamente por las ventas gravadas: " \
|
|
233
286
|
"#{@invoice.summary.credito_fiscal_base} (Art. 11 num. 3).")
|
|
234
287
|
lines << ""
|
|
@@ -241,8 +294,14 @@ module Invoicehn
|
|
|
241
294
|
|
|
242
295
|
lines += wrap(@invoice.notes) unless @invoice.notes.empty?
|
|
243
296
|
|
|
244
|
-
# Art.
|
|
245
|
-
|
|
297
|
+
# Art. 26 num. 7.
|
|
298
|
+
if @invoice.credit_note?
|
|
299
|
+
RECEIVER_FIELDS.each { |label| lines << "#{label}: #{"_" * [@width - label.length - 2, 10].max}" }
|
|
300
|
+
lines << ""
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Art. 10 num. 6; Arts. 25 and 27 num. 7.
|
|
304
|
+
lines << centre(self.class.copy_legend(@invoice, @copy))
|
|
246
305
|
lines << rule("═")
|
|
247
306
|
lines.join("\n")
|
|
248
307
|
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module Invoicehn
|
|
6
|
+
# Process-wide settings, set once at boot with Invoicehn.configure.
|
|
7
|
+
#
|
|
8
|
+
# Not to be confused with Invoicehn::Config, which is where the bundled JSON
|
|
9
|
+
# store keeps its files.
|
|
10
|
+
#
|
|
11
|
+
# == The clock
|
|
12
|
+
#
|
|
13
|
+
# Every date this library defaults — the fecha de emisión (Art. 10 num. 7 /
|
|
14
|
+
# Art. 11 num. 1 lit. c), the day an authorization is checked against its
|
|
15
|
+
# fecha límite (Art. 62), the day of an annulment (Art. 41) — comes from
|
|
16
|
+
# Invoicehn.today. The default is Date.today, which reads the *process* time
|
|
17
|
+
# zone. A server running in UTC is six hours ahead of Honduras (UTC-6, no
|
|
18
|
+
# daylight saving), so between 18:00 and midnight in Tegucigalpa it would
|
|
19
|
+
# stamp tomorrow's date on the document and could even treat an
|
|
20
|
+
# authorization as expired a few hours early.
|
|
21
|
+
#
|
|
22
|
+
# A Rails host should point the clock at its configured zone:
|
|
23
|
+
#
|
|
24
|
+
# # config/application.rb: config.time_zone = "America/Tegucigalpa"
|
|
25
|
+
# Invoicehn.configure { |c| c.today = -> { Date.current } }
|
|
26
|
+
class Settings
|
|
27
|
+
DEFAULT_TODAY = -> { Date.today }
|
|
28
|
+
|
|
29
|
+
attr_reader :today
|
|
30
|
+
|
|
31
|
+
def initialize
|
|
32
|
+
@today = DEFAULT_TODAY
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @param provider [#call] returns the current Date in Honduras.
|
|
36
|
+
def today=(provider)
|
|
37
|
+
unless provider.respond_to?(:call)
|
|
38
|
+
raise ArgumentError, "today debe responder a #call (p. ej. -> { Date.current })"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
@today = provider
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -58,13 +58,16 @@ module Invoicehn
|
|
|
58
58
|
|
|
59
59
|
# The authorization that should be used next for an identifier: still
|
|
60
60
|
# current, and with room left in its range.
|
|
61
|
-
|
|
61
|
+
#
|
|
62
|
+
# With next_sequence, only an authorization whose range covers that
|
|
63
|
+
# number qualifies. An exhausted range is not "active" just because its
|
|
64
|
+
# fecha límite has not passed — reporting it as such made «check» say
|
|
65
|
+
# the point was ready with a negative count of documents left.
|
|
66
|
+
def active_authorization(identifier, next_sequence: nil, on: Invoicehn.today)
|
|
62
67
|
candidates = authorizations_for(identifier).reject { |a| a.expired?(on) }
|
|
63
|
-
return nil if candidates.empty?
|
|
64
|
-
|
|
65
68
|
if next_sequence
|
|
66
|
-
|
|
67
|
-
|
|
69
|
+
correlative = sequence_to_correlative(identifier, next_sequence)
|
|
70
|
+
candidates = candidates.select { |a| a.covers?(correlative) }
|
|
68
71
|
end
|
|
69
72
|
|
|
70
73
|
candidates.min_by { |a| a.range_start.sequence }
|
|
@@ -78,17 +81,29 @@ module Invoicehn
|
|
|
78
81
|
|
|
79
82
|
if File.exist?(path) && !invoice.annulled?
|
|
80
83
|
raise ImmutableDocument,
|
|
81
|
-
"
|
|
84
|
+
"el documento #{invoice.correlative} ya está registrado y no puede modificarse (Art. 41)"
|
|
82
85
|
end
|
|
83
86
|
|
|
84
87
|
write_json(path, invoice.to_h)
|
|
85
88
|
invoice
|
|
86
89
|
end
|
|
87
90
|
|
|
91
|
+
# Removes a document whose issuance failed before it was complete — the
|
|
92
|
+
# ledger refused it, inside the allocator's block, so the counter never
|
|
93
|
+
# advanced past it — or the ANULADA form of a document whose annulment the
|
|
94
|
+
# ledger refused, before the issued form is saved back. Issuance calls
|
|
95
|
+
# this and nothing else should: an issued document is corrected by
|
|
96
|
+
# annulment (Art. 41), never deleted.
|
|
97
|
+
def delete_document(invoice)
|
|
98
|
+
path = @config.document_path(invoice.correlative, invoice.issue_date)
|
|
99
|
+
FileUtils.rm_f(path)
|
|
100
|
+
invoice
|
|
101
|
+
end
|
|
102
|
+
|
|
88
103
|
def find(correlative)
|
|
89
104
|
correlative = Correlative.parse(correlative.to_s)
|
|
90
105
|
path = Dir.glob(File.join(@config.documents_dir, "*", "*", "#{correlative}.json")).first
|
|
91
|
-
raise DocumentNotFound, "no existe
|
|
106
|
+
raise DocumentNotFound, "no existe el documento #{correlative}" unless path
|
|
92
107
|
|
|
93
108
|
Invoice.from_h(read_json(path))
|
|
94
109
|
end
|