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.
@@ -3,38 +3,64 @@
3
3
  require "date"
4
4
 
5
5
  module Invoicehn
6
- # Issues a Factura: picks the authorization, allocates the correlativo,
7
- # validates against the Reglamento, persists, and posts to the ledger.
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 lock, so a document is
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
- def initialize(config: Config.new, store: nil, sequence: nil, ledger: nil)
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(config)
18
- @sequence = sequence || Sequence.new(config)
19
- @ledger = ledger || Ledger::JsonlLedger.new(config)
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. Defaults to the casa matriz's first
24
- # emission point issuing a Factura.
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
- def issue(customer:, line_items:, identifier: "000-001-01", currency: "HNL",
30
- exchange_rate: nil, notes: nil, issue_date: Date.today)
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
- invoice = Invoice.new(
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
- invoice.validate!(on: issue_date)
50
- @store.save_document(invoice)
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
- def annul(correlative, reason:, on: Date.today)
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
- @ledger.record(annulled, event: :anulacion)
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: "000-001-01", on: Date.today)
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 ? (active.range_end.sequence - next_correlative.sequence + 1) : 0,
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
 
@@ -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 the Factura as a PDF.
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::DOCUMENT_NAME), size: TITLE_SIZE, align: :center, style: :bold
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::COPIES.fetch(@copy, Text::COPY_ORIGINAL)), align: :center, style: :bold
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 the Factura as plain text for the terminal or a printer.
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(DOCUMENT_NAME)
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. 10 num. 6.
245
- lines << centre(COPIES.fetch(@copy, COPY_ORIGINAL))
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
- def active_authorization(identifier, next_sequence: nil, on: Date.today)
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
- covering = candidates.select { |a| a.covers?(sequence_to_correlative(identifier, next_sequence)) }
67
- return covering.min_by { |a| a.range_start.sequence } if covering.any?
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
- "la factura #{invoice.correlative} ya está registrada y no puede modificarse (Art. 41)"
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 la factura #{correlative}" unless path
106
+ raise DocumentNotFound, "no existe el documento #{correlative}" unless path
92
107
 
93
108
  Invoice.from_h(read_json(path))
94
109
  end