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,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,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
|