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,87 @@
1
+ # Textos de la interfaz de línea de comandos.
2
+ #
3
+ # IMPORTANTE: este catálogo NO contiene el texto del documento fiscal. Las
4
+ # leyendas de la Factura ("FACTURA", "Original: Cliente", "CONSUMIDOR FINAL",
5
+ # "ANULADA") son texto legal fijado por el Reglamento y viven como constantes en
6
+ # Invoicehn::Renderers::Text. Traducirlas produciría un documento inválido.
7
+ es:
8
+ setup:
9
+ title: "Configuración del emisor (Art. 10 num. 1)"
10
+ intro: "Estos datos se imprimen en cada factura. Todos son obligatorios."
11
+ rtn: "RTN del emisor (14 dígitos)"
12
+ legal_name: "Nombre o razón social"
13
+ trade_name: "Nombre comercial"
14
+ headquarters: "Dirección de la casa matriz"
15
+ branch: "Dirección del establecimiento (dejar vacío si es la casa matriz)"
16
+ phone: "Número telefónico"
17
+ email: "Correo electrónico"
18
+ saved: "Emisor guardado en %{path}"
19
+
20
+ auth:
21
+ title: "Registrar autorización del SAR"
22
+ intro: |
23
+ El CAI, el rango autorizado y la fecha límite de emisión los otorga el SAR
24
+ (Arts. 59-61). Cópielos del documento de autorización; este programa no los genera.
25
+ cai: "CAI (Clave de Autorización de Impresión)"
26
+ establishment: "Establecimiento (3 dígitos; casa matriz = 000)"
27
+ emission_point: "Punto de emisión (3 dígitos)"
28
+ range_start: "Correlativo inicial autorizado (8 dígitos)"
29
+ range_end: "Correlativo final autorizado (8 dígitos)"
30
+ limit_date: "Fecha límite de emisión (AAAA-MM-DD)"
31
+ saved: "Autorización registrada: %{range}"
32
+ none: "No hay autorizaciones registradas. Use «invoicehn auth add»."
33
+ list_header: "Autorizaciones registradas"
34
+
35
+ invoice:
36
+ customer_kind: "Tipo de cliente"
37
+ kind_consumidor_final: "Consumidor final"
38
+ kind_taxpayer: "Obligado tributario (sustenta crédito fiscal)"
39
+ kind_exonerado: "Adquirente exonerado"
40
+ customer_name: "Nombre o razón social del cliente"
41
+ customer_rtn: "RTN del cliente"
42
+ id_type: "Tipo de documento de identificación"
43
+ id_number: "Número de documento de identificación"
44
+ purchase_order: "Número de Orden de Compra Exenta"
45
+ exoneration_registry: "Número de Constancia del Registro de Exonerados"
46
+ sag_registry: "Número del Registro SAG"
47
+ add_line: "¿Agregar una línea de detalle?"
48
+ description: "Descripción detallada del bien o servicio"
49
+ quantity: "Cantidad de unidades"
50
+ unit_price: "Valor unitario"
51
+ discount: "Descuento otorgado (0 si no aplica)"
52
+ treatment: "Tratamiento del ISV"
53
+ currency: "Moneda"
54
+ exchange_rate: "Tasa de cambio vigente a la fecha de emisión"
55
+ exchange_source: "Fuente de la tasa de cambio"
56
+ notes: "Notas adicionales (opcional)"
57
+ confirm: "¿Emitir esta factura?"
58
+ cancelled: "Emisión cancelada. No se consumió ningún correlativo."
59
+ issued: "Factura emitida: %{correlative}"
60
+
61
+ check:
62
+ title: "Estado del sistema de facturación"
63
+ issuer_ok: "Emisor configurado y completo"
64
+ issuer_missing: "Faltan datos del emisor: %{fields}"
65
+ issuer_absent: "No hay emisor configurado. Ejecute «invoicehn setup»."
66
+ auth_active: "Autorización vigente: %{cai}"
67
+ auth_absent: "No hay autorización vigente para %{identifier}"
68
+ next: "Próximo correlativo: %{correlative}"
69
+ remaining: "Documentos disponibles: %{count}"
70
+ days: "Días hasta la fecha límite: %{days}"
71
+ lapsed: |
72
+ Atención: %{count} autorización(es) vencieron con documentos sin utilizar.
73
+ El Art. 42 obliga a comunicarlo al SAR dentro de los primeros 10 días
74
+ hábiles del mes siguiente.
75
+ ready: "Listo para emitir."
76
+ not_ready: "No se puede emitir todavía."
77
+
78
+ errors:
79
+ not_initialized: "No se ha configurado el emisor. Ejecute «invoicehn setup» primero."
80
+ not_found: "No existe la factura %{correlative}"
81
+ compliance: "La factura no cumple los requisitos legales:"
82
+
83
+ common:
84
+ yes: "Sí"
85
+ no: "No"
86
+ total: "Total"
87
+ cancel: "Cancelar"
data/exe/invoicehn ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "invoicehn"
5
+ require "invoicehn/cli"
6
+
7
+ Invoicehn::CLI::Main.start(ARGV)
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module Invoicehn
6
+ # A SAR authorization: the CAI, the authorized range, and the fecha límite de
7
+ # emisión, granted per emission point and document type (Art. 59).
8
+ #
9
+ # Art. 10 requires the invoice to show num. 3 the CAI, num. 4 the fecha límite
10
+ # de emisión vigente, and num. 5 the rango autorizado vigente.
11
+ #
12
+ # This library consumes these values; it never generates them. Obtaining them
13
+ # is the operator's obligation under Arts. 59-61.
14
+ class Authorization
15
+ attr_reader :cai, :range_start, :range_end, :limit_date
16
+
17
+ # @param cai [String] the Clave de Autorización de Impresión, as SAR issued
18
+ # it. Art. 4 num. 7 defines it only as "una serie alfanumérica generada
19
+ # electrónicamente" — no length or grouping is fixed anywhere in the
20
+ # decree and no authoritative layout is published, so it is stored and
21
+ # printed verbatim. Validating it against a guessed format would reject
22
+ # real CAIs.
23
+ # @param range_start [String, Correlative] first authorized number
24
+ # @param range_end [String, Correlative] last authorized number
25
+ # @param limit_date [Date, String] fecha límite de emisión
26
+ def initialize(cai:, range_start:, range_end:, limit_date:)
27
+ @cai = cai.to_s.strip
28
+ raise ValidationError, "el CAI es obligatorio (Art. 10 num. 3)" if @cai.empty?
29
+
30
+ @range_start = coerce_correlative(range_start, "inicio del rango")
31
+ @range_end = coerce_correlative(range_end, "fin del rango")
32
+ @limit_date = coerce_date(limit_date)
33
+
34
+ validate_range!
35
+ freeze
36
+ end
37
+
38
+ # The (establecimiento, punto de emisión, tipo de documento) triple this
39
+ # authorization covers.
40
+ def identifier = @range_start.identifier
41
+
42
+ def document_type = @range_start.document_type
43
+ def establishment = @range_start.establishment
44
+ def emission_point = @range_start.emission_point
45
+
46
+ # Art. 62: "Los Comprobantes Fiscales y/o Documentos Complementarios
47
+ # perderán su validez y no podrán ser utilizados cuando se haya vencido el
48
+ # plazo de tiempo autorizado." The fecha límite is the last day on which a
49
+ # document may be issued, so it is still usable on that date itself.
50
+ def expired?(on = Date.today) = on > @limit_date
51
+
52
+ def days_remaining(from = Date.today) = (@limit_date - from).to_i
53
+
54
+ def covers?(correlative)
55
+ correlative = Correlative.parse(correlative.to_s)
56
+ correlative.identifier == identifier &&
57
+ correlative.sequence >= @range_start.sequence &&
58
+ correlative.sequence <= @range_end.sequence
59
+ end
60
+
61
+ # Total documents SAR authorized under this grant.
62
+ def capacity = @range_end.sequence - @range_start.sequence + 1
63
+
64
+ # Whether this authorization can still be used to issue the given number.
65
+ def usable?(correlative, on: Date.today)
66
+ !expired?(on) && covers?(correlative)
67
+ end
68
+
69
+ # Raises with the specific reason, so the caller can report which of the two
70
+ # conditions failed rather than a generic refusal.
71
+ def assert_usable!(correlative, on: Date.today)
72
+ if expired?(on)
73
+ raise AuthorizationExpired,
74
+ "la fecha límite de emisión (#{@limit_date}) ya venció; " \
75
+ "los documentos pierden validez (Art. 62)"
76
+ end
77
+
78
+ unless covers?(correlative)
79
+ raise RangeExhausted,
80
+ "el correlativo #{correlative} está fuera del rango autorizado " \
81
+ "#{@range_start}–#{@range_end} (Art. 10 num. 5)"
82
+ end
83
+
84
+ self
85
+ end
86
+
87
+ # "000-001-01-00000001 al 000-001-01-00000500" as printed on the document.
88
+ def range_label = "#{@range_start} al #{@range_end}"
89
+
90
+ def to_h
91
+ {
92
+ "cai" => @cai,
93
+ "range_start" => @range_start.to_s,
94
+ "range_end" => @range_end.to_s,
95
+ "limit_date" => @limit_date.iso8601
96
+ }
97
+ end
98
+
99
+ def self.from_h(hash)
100
+ new(
101
+ cai: hash["cai"],
102
+ range_start: hash["range_start"],
103
+ range_end: hash["range_end"],
104
+ limit_date: hash["limit_date"]
105
+ )
106
+ end
107
+
108
+ def to_s = "CAI #{@cai} · #{range_label} · vence #{@limit_date}"
109
+
110
+ private
111
+
112
+ def coerce_correlative(value, label)
113
+ return value if value.is_a?(Correlative)
114
+
115
+ Correlative.parse(value)
116
+ rescue ValidationError => e
117
+ raise ValidationError, "#{label}: #{e.message}"
118
+ end
119
+
120
+ def coerce_date(value)
121
+ case value
122
+ when Date then value
123
+ when String then Date.parse(value)
124
+ else
125
+ raise ValidationError, "fecha límite de emisión inválida: #{value.inspect}"
126
+ end
127
+ rescue ArgumentError, TypeError
128
+ raise ValidationError, "fecha límite de emisión inválida: #{value.inspect}"
129
+ end
130
+
131
+ def validate_range!
132
+ unless @range_start.identifier == @range_end.identifier
133
+ raise ValidationError,
134
+ "el rango abarca identificadores distintos: " \
135
+ "#{@range_start.identifier} y #{@range_end.identifier}"
136
+ end
137
+
138
+ return unless @range_end.sequence < @range_start.sequence
139
+
140
+ raise ValidationError,
141
+ "el rango termina (#{@range_end}) antes de comenzar (#{@range_start})"
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Invoicehn
4
+ module CLI
5
+ # Turns a plain hash — from a JSON file or the wizard — into an issued
6
+ # invoice. Shared by the scriptable and interactive paths so both go through
7
+ # exactly the same construction and validation.
8
+ class Builder
9
+ attr_reader :issuance
10
+
11
+ def initialize(issuance)
12
+ @issuance = issuance
13
+ end
14
+
15
+ def from_hash(data, identifier: "000-001-01")
16
+ currency = (data["currency"] || "HNL").to_s.upcase
17
+
18
+ @issuance.issue(
19
+ identifier: identifier,
20
+ customer: build_customer(data["customer"] || {}),
21
+ line_items: Array(data["items"] || data["line_items"]).map { |i| build_line(i, currency) },
22
+ currency: currency,
23
+ exchange_rate: build_rate(data["exchange_rate"], currency),
24
+ notes: data["notes"]
25
+ )
26
+ end
27
+
28
+ def build_customer(data)
29
+ kind = (data["kind"] || data["tipo"] || "consumidor_final").to_s
30
+
31
+ case kind
32
+ when "taxpayer", "obligado_tributario"
33
+ Customer::Taxpayer.new(name: data["name"], rtn: data["rtn"])
34
+ when "exonerado"
35
+ Customer::Exonerado.new(
36
+ name: data["name"], rtn: data["rtn"],
37
+ purchase_order: data["purchase_order"],
38
+ exoneration_registry: data["exoneration_registry"],
39
+ sag_registry: data["sag_registry"]
40
+ )
41
+ else
42
+ Customer::ConsumidorFinal.new(
43
+ name: data["name"],
44
+ identification_type: data["identification_type"],
45
+ identification_number: data["identification_number"]
46
+ )
47
+ end
48
+ end
49
+
50
+ def build_line(data, currency)
51
+ discount = data["discount"] || data["descuento"]
52
+
53
+ LineItem.new(
54
+ description: data["description"] || data["descripcion"],
55
+ quantity: to_decimal(data["quantity"] || data["cantidad"] || 1),
56
+ unit_price: Money.new(to_decimal(data["unit_price"] || data["precio"]), currency),
57
+ discount: discount && Money.new(to_decimal(discount), currency),
58
+ treatment: data["treatment"] || data["tratamiento"] || :gravado_15
59
+ )
60
+ end
61
+
62
+ def build_rate(data, currency)
63
+ return nil if data.nil?
64
+ return ExchangeRate.from_h(data) if data.is_a?(Hash)
65
+
66
+ ExchangeRate.new(rate: to_decimal(data), date: Date.today, currency: currency)
67
+ end
68
+
69
+ private
70
+
71
+ # JSON numbers arrive as Float, which must never reach a fiscal figure —
72
+ # so they are converted through their decimal text form, not their binary
73
+ # value.
74
+ def to_decimal(value)
75
+ case value
76
+ when BigDecimal then value
77
+ when Integer then BigDecimal(value)
78
+ when Float then BigDecimal(value.to_s)
79
+ when String then BigDecimal(value)
80
+ when nil then raise ValidationError, "falta un importe o cantidad en el archivo"
81
+ else raise ValidationError, "valor no numérico: #{value.inspect}"
82
+ end
83
+ rescue ArgumentError
84
+ raise ValidationError, "valor no numérico: #{value.inspect}"
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,233 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require "json"
5
+
6
+ module Invoicehn
7
+ module CLI
8
+ # `invoicehn <command>`.
9
+ #
10
+ # Subcommands are scriptable; `invoicehn new` opens the interactive wizard.
11
+ # Both drive the same Invoicehn::Issuance service, so neither can produce a
12
+ # document the other could not.
13
+ class Main < Thor
14
+ class_option :lang, type: :string, aliases: "-l",
15
+ desc: "Idioma de la interfaz / interface language (es, en)"
16
+ class_option :"data-dir", type: :string,
17
+ desc: "Directorio de datos (por defecto ~/.invoicehn)"
18
+
19
+ def self.exit_on_failure? = true
20
+
21
+ desc "setup", "Configura los datos del emisor (Art. 10 num. 1)"
22
+ method_option :rtn, type: :string
23
+ method_option :"legal-name", type: :string
24
+ method_option :"trade-name", type: :string
25
+ method_option :address, type: :string, desc: "Dirección de la casa matriz"
26
+ method_option :branch, type: :string, desc: "Dirección del establecimiento"
27
+ method_option :phone, type: :string
28
+ method_option :email, type: :string
29
+ def setup
30
+ prepare
31
+
32
+ # Every Art. 10 num. 1 field supplied on the command line means the
33
+ # profile can be set up unattended; otherwise the wizard asks.
34
+ required = [options[:rtn], options[:"legal-name"], options[:"trade-name"],
35
+ options[:address], options[:phone], options[:email]]
36
+
37
+ if required.all?
38
+ issuer = Issuer.new(
39
+ rtn: options[:rtn], legal_name: options[:"legal-name"],
40
+ trade_name: options[:"trade-name"], headquarters_address: options[:address],
41
+ branch_address: options[:branch], phone: options[:phone], email: options[:email]
42
+ ).validate!
43
+
44
+ issuance.store.save_issuer(issuer)
45
+ say Locale.t("setup.saved", path: config.issuer_path), :green
46
+ else
47
+ Wizard.new(issuance, shell).setup
48
+ end
49
+ rescue Invoicehn::Error => e
50
+ abort_with(e)
51
+ end
52
+
53
+ desc "auth SUBCOMANDO", "Gestiona las autorizaciones del SAR (CAI, rango, fecha límite)"
54
+ subcommand "auth", Class.new(Thor) {
55
+ class_option :"data-dir", type: :string
56
+ def self.exit_on_failure? = true
57
+
58
+ desc "add", "Registra un CAI, su rango autorizado y su fecha límite"
59
+ method_option :cai, type: :string
60
+ method_option :from, type: :string, desc: "Correlativo inicial (NNN-NNN-NN-NNNNNNNN)"
61
+ method_option :to, type: :string, desc: "Correlativo final"
62
+ method_option :limit, type: :string, desc: "Fecha límite de emisión (AAAA-MM-DD)"
63
+ def add
64
+ cli = Main.new([], options)
65
+ if options[:cai] && options[:from] && options[:to] && options[:limit]
66
+ auth = Authorization.new(cai: options[:cai], range_start: options[:from],
67
+ range_end: options[:to], limit_date: options[:limit])
68
+ cli.send(:issuance).store.add_authorization(auth)
69
+ cli.send(:issuance).sequence.align_to(auth)
70
+ say Locale.t("auth.saved", range: auth.range_label)
71
+ else
72
+ Wizard.new(cli.send(:issuance), shell).add_authorization
73
+ end
74
+ rescue Invoicehn::Error => e
75
+ warn "Error: #{e.message}"
76
+ exit 1
77
+ end
78
+
79
+ desc "list", "Muestra las autorizaciones registradas y su vigencia"
80
+ def list
81
+ cli = Main.new([], options)
82
+ Reporter.new(cli.send(:issuance), shell).authorizations
83
+ end
84
+ }
85
+
86
+ desc "new", "Emite una factura con el asistente interactivo"
87
+ def new
88
+ prepare
89
+ Wizard.new(issuance, shell).issue
90
+ rescue Invoicehn::Error => e
91
+ abort_with(e)
92
+ end
93
+
94
+ desc "issue", "Emite una factura desde un archivo JSON"
95
+ method_option :file, type: :string, required: true, aliases: "-f",
96
+ desc: "Archivo JSON con el cliente y las líneas de detalle"
97
+ method_option :identifier, type: :string, default: "000-001-01"
98
+ method_option :print, type: :boolean, default: true, desc: "Muestra la factura al emitirla"
99
+ def issue
100
+ prepare
101
+ data = JSON.parse(File.read(options[:file]))
102
+ invoice = Builder.new(issuance).from_hash(data, identifier: options[:identifier])
103
+
104
+ say Locale.t("invoice.issued", correlative: invoice.correlative), :green
105
+ say "\n#{Renderers::Text.new(invoice).render}" if options[:print]
106
+ rescue JSON::ParserError => e
107
+ warn "Archivo JSON inválido: #{e.message}"
108
+ exit 1
109
+ rescue Invoicehn::Error => e
110
+ abort_with(e)
111
+ end
112
+
113
+ desc "show CORRELATIVO", "Muestra una factura emitida"
114
+ method_option :format, type: :string, default: "text", enum: %w[text json]
115
+ method_option :copy, type: :string, default: "original", enum: %w[original copia]
116
+ def show(correlative)
117
+ invoice = issuance.store.find(correlative)
118
+
119
+ case options[:format]
120
+ when "json" then say Renderers::Json.new(invoice).render
121
+ else say Renderers::Text.new(invoice, copy: options[:copy].to_sym).render
122
+ end
123
+ rescue Invoicehn::Error => e
124
+ abort_with(e)
125
+ end
126
+
127
+ desc "list", "Lista las facturas emitidas en orden cronológico"
128
+ method_option :from, type: :string, desc: "Desde (AAAA-MM-DD)"
129
+ method_option :to, type: :string, desc: "Hasta (AAAA-MM-DD)"
130
+ def list
131
+ Reporter.new(issuance, shell).documents(from: parse_date(options[:from]),
132
+ to: parse_date(options[:to]))
133
+ rescue Invoicehn::Error => e
134
+ abort_with(e)
135
+ end
136
+
137
+ desc "annul CORRELATIVO", "Anula una factura (Art. 41)"
138
+ method_option :reason, type: :string, required: true, aliases: "-r",
139
+ desc: "Motivo de la anulación"
140
+ def annul(correlative)
141
+ invoice = issuance.annul(correlative, reason: options[:reason])
142
+
143
+ say "Factura #{invoice.correlative} anulada.", :yellow
144
+ say "El correlativo queda consumido y no se reutilizará."
145
+ rescue Invoicehn::Error => e
146
+ abort_with(e)
147
+ end
148
+
149
+ desc "pdf CORRELATIVO", "Genera el PDF de una factura"
150
+ method_option :output, type: :string, aliases: "-o"
151
+ method_option :copy, type: :string, default: "original", enum: %w[original copia]
152
+ def pdf(correlative)
153
+ invoice = issuance.store.find(correlative)
154
+ path = options[:output] || "#{invoice.correlative}.pdf"
155
+ Renderers::Pdf.new(invoice, copy: options[:copy].to_sym).render_file(path)
156
+
157
+ say "PDF generado: #{path}", :green
158
+ rescue Invoicehn::Error => e
159
+ abort_with(e)
160
+ end
161
+
162
+ desc "export", "Exporta las facturas en texto para el SAR (Art. 53 num. 5)"
163
+ method_option :from, type: :string
164
+ method_option :to, type: :string
165
+ method_option :format, type: :string, default: "json", enum: %w[json csv]
166
+ method_option :output, type: :string, aliases: "-o"
167
+ def export
168
+ invoices = issuance.store.all(from: parse_date(options[:from]), to: parse_date(options[:to]))
169
+
170
+ content = case options[:format]
171
+ when "csv" then Renderers::Json.export_csv(invoices)
172
+ else Renderers::Json.export(invoices)
173
+ end
174
+
175
+ if options[:output]
176
+ File.write(options[:output], "#{content}\n")
177
+ say "#{invoices.size} documento(s) exportado(s) a #{options[:output]}", :green
178
+ else
179
+ say content
180
+ end
181
+ rescue Invoicehn::Error => e
182
+ abort_with(e)
183
+ end
184
+
185
+ desc "check", "Verifica que se pueda emitir: emisor, autorización y correlativo"
186
+ method_option :identifier, type: :string, default: "000-001-01"
187
+ def check
188
+ Reporter.new(issuance, shell).health(identifier: options[:identifier])
189
+ rescue Invoicehn::Error => e
190
+ abort_with(e)
191
+ end
192
+
193
+ desc "version", "Muestra la versión"
194
+ def version
195
+ say "invoicehn #{Invoicehn::VERSION}"
196
+ end
197
+
198
+ private
199
+
200
+ def config
201
+ @config ||= Config.new(home: options[:"data-dir"])
202
+ end
203
+
204
+ def issuance
205
+ Locale.current = options[:lang] if options[:lang]
206
+ @issuance ||= Issuance.new(config: config)
207
+ end
208
+
209
+ def prepare
210
+ Locale.current = options[:lang] if options[:lang]
211
+ config.ensure_home!
212
+ end
213
+
214
+ def parse_date(value)
215
+ value && Date.parse(value)
216
+ rescue ArgumentError
217
+ raise ValidationError, "fecha inválida: #{value}"
218
+ end
219
+
220
+ # Compliance failures list every problem at once, which is more useful
221
+ # than the first one.
222
+ def abort_with(error)
223
+ if error.is_a?(ComplianceError)
224
+ warn Locale.t("errors.compliance")
225
+ error.violations.each { |v| warn " · #{v}" }
226
+ else
227
+ warn "Error: #{error.message}"
228
+ end
229
+ exit 1
230
+ end
231
+ end
232
+ end
233
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Invoicehn
4
+ module CLI
5
+ # Terminal reports: authorization status, the document list, and the
6
+ # pre-flight check.
7
+ class Reporter
8
+ def initialize(issuance, shell = Thor::Base.shell.new)
9
+ @issuance = issuance
10
+ @shell = shell
11
+ end
12
+
13
+ def authorizations
14
+ list = @issuance.store.authorizations
15
+
16
+ return say(Locale.t("auth.none"), :yellow) if list.empty?
17
+
18
+ say Locale.t("auth.list_header"), :bold
19
+ rows = list.map do |auth|
20
+ issued = @issuance.sequence.issued_count(auth.identifier)
21
+ remaining = [auth.range_end.sequence - [issued, auth.range_start.sequence - 1].max, 0].max
22
+ status = auth.expired? ? "VENCIDA" : "vigente"
23
+
24
+ [auth.cai, auth.range_label, auth.limit_date.to_s, status, remaining.to_s]
25
+ end
26
+
27
+ table(%w[CAI Rango Vence Estado Disponibles], rows)
28
+ end
29
+
30
+ def documents(from: nil, to: nil)
31
+ invoices = @issuance.store.all(from: from, to: to)
32
+
33
+ return say("No hay facturas emitidas en ese período.", :yellow) if invoices.empty?
34
+
35
+ rows = invoices.map do |inv|
36
+ [
37
+ inv.correlative.to_s,
38
+ inv.issue_date.to_s,
39
+ truncate(inv.customer.to_s, 28),
40
+ inv.total.to_s,
41
+ inv.annulled? ? "ANULADA" : ""
42
+ ]
43
+ end
44
+
45
+ table(%w[Correlativo Fecha Cliente Total Estado], rows)
46
+ say "\n#{invoices.size} documento(s). " \
47
+ "Total emitido: #{sum_of(invoices.reject(&:annulled?))}"
48
+ end
49
+
50
+ def health(identifier: "000-001-01")
51
+ status = @issuance.health(identifier: identifier)
52
+
53
+ say Locale.t("check.title"), :bold
54
+ say ""
55
+
56
+ if !status[:issuer_configured]
57
+ say " ✗ #{Locale.t("check.issuer_absent")}", :red
58
+ elsif !status[:issuer_complete]
59
+ say " ✗ #{Locale.t("check.issuer_missing", fields: status[:issuer_missing].join("; "))}", :red
60
+ else
61
+ say " ✓ #{Locale.t("check.issuer_ok")}", :green
62
+ end
63
+
64
+ if status[:active_authorization]
65
+ auth = status[:active_authorization]
66
+ say " ✓ #{Locale.t("check.auth_active", cai: auth.cai)}", :green
67
+ say " #{Locale.t("check.next", correlative: status[:next_correlative])}"
68
+ say " #{Locale.t("check.remaining", count: status[:remaining])}",
69
+ status[:remaining] < 25 ? :yellow : nil
70
+ say " #{Locale.t("check.days", days: status[:days_remaining])}",
71
+ status[:days_remaining] < 30 ? :yellow : nil
72
+ else
73
+ say " ✗ #{Locale.t("check.auth_absent", identifier: identifier)}", :red
74
+ end
75
+
76
+ # Art. 42 — expired authorizations holding unused documents must be
77
+ # reported to SAR within the first 10 business days of the next month.
78
+ if status[:lapsed_with_unused].any?
79
+ say ""
80
+ say Locale.t("check.lapsed", count: status[:lapsed_with_unused].size), :yellow
81
+ end
82
+
83
+ say ""
84
+ if status[:ready]
85
+ say Locale.t("check.ready"), :green
86
+ else
87
+ say Locale.t("check.not_ready"), :red
88
+ end
89
+ end
90
+
91
+ private
92
+
93
+ def say(message, color = nil) = @shell.say(message, color)
94
+
95
+ def sum_of(invoices)
96
+ return Money.zero if invoices.empty?
97
+
98
+ Money.sum(invoices.map(&:total), currency: invoices.first.currency)
99
+ end
100
+
101
+ def truncate(text, limit)
102
+ text.length > limit ? "#{text[0, limit - 1]}…" : text
103
+ end
104
+
105
+ # Rows are right-stripped because Thor's #say omits the trailing newline
106
+ # when a line ends in whitespace, which would run padded rows together.
107
+ def table(headers, rows)
108
+ widths = headers.each_with_index.map do |header, i|
109
+ [header.length, *rows.map { |r| r[i].to_s.length }].max
110
+ end
111
+
112
+ row_line = lambda do |cells|
113
+ widths.each_with_index.map { |w, i| cells[i].to_s.ljust(w) }.join(" ").rstrip
114
+ end
115
+
116
+ say row_line.call(headers), :bold
117
+ say widths.map { |w| "─" * w }.join(" ")
118
+ rows.each { |row| say row_line.call(row) }
119
+ end
120
+ end
121
+ end
122
+ end