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,234 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-prompt"
4
+
5
+ module Invoicehn
6
+ module CLI
7
+ # The interactive path: `invoicehn setup`, `invoicehn auth add` and
8
+ # `invoicehn new`.
9
+ #
10
+ # The wizard collects input and hands it to the same Issuance service the
11
+ # scriptable commands use, so it cannot produce a document the compliance
12
+ # rules would not accept. Nothing is written until the operator confirms —
13
+ # an abandoned wizard consumes no correlativo.
14
+ class Wizard
15
+ def initialize(issuance, shell = nil, prompt: nil)
16
+ @issuance = issuance
17
+ @shell = shell
18
+ @prompt = prompt || TTY::Prompt.new(interrupt: :exit)
19
+ end
20
+
21
+ # Art. 10 num. 1 — datos de identificación del emisor.
22
+ def setup
23
+ header Locale.t("setup.title")
24
+ say Locale.t("setup.intro")
25
+ say ""
26
+
27
+ existing = @issuance.store.issuer
28
+
29
+ issuer = Issuer.new(
30
+ rtn: ask_rtn(Locale.t("setup.rtn"), default: existing&.rtn&.to_s),
31
+ legal_name: ask(Locale.t("setup.legal_name"), default: existing&.legal_name),
32
+ trade_name: ask(Locale.t("setup.trade_name"), default: existing&.trade_name),
33
+ headquarters_address: ask(Locale.t("setup.headquarters"),
34
+ default: existing&.headquarters_address),
35
+ branch_address: @prompt.ask(Locale.t("setup.branch"),
36
+ default: existing&.branch? ? existing.branch_address : nil),
37
+ phone: ask(Locale.t("setup.phone"), default: existing&.phone),
38
+ email: ask(Locale.t("setup.email"), default: existing&.email)
39
+ )
40
+
41
+ issuer.validate!
42
+ @issuance.store.save_issuer(issuer)
43
+ say Locale.t("setup.saved", path: @issuance.store.config.issuer_path), :green
44
+ issuer
45
+ end
46
+
47
+ # Arts. 59-61 — the values SAR grants, transcribed from the authorization
48
+ # document. Nothing here is generated.
49
+ def add_authorization
50
+ header Locale.t("auth.title")
51
+ say Locale.t("auth.intro")
52
+ say ""
53
+
54
+ cai = ask(Locale.t("auth.cai"))
55
+ establishment = ask(Locale.t("auth.establishment"), default: "000")
56
+ emission_point = ask(Locale.t("auth.emission_point"), default: "001")
57
+
58
+ range_start = Correlative.new(establishment: establishment, emission_point: emission_point,
59
+ document_type: Correlative::FACTURA,
60
+ sequence: ask(Locale.t("auth.range_start"), default: "1").to_i)
61
+ range_end = range_start.with_sequence(ask(Locale.t("auth.range_end")).to_i)
62
+
63
+ authorization = Authorization.new(
64
+ cai: cai, range_start: range_start, range_end: range_end,
65
+ limit_date: ask(Locale.t("auth.limit_date"))
66
+ )
67
+
68
+ @issuance.store.add_authorization(authorization)
69
+ @issuance.sequence.align_to(authorization)
70
+
71
+ say Locale.t("auth.saved", range: authorization.range_label), :green
72
+ say "Documentos autorizados: #{authorization.capacity}"
73
+ authorization
74
+ end
75
+
76
+ def issue
77
+ customer = ask_customer
78
+ currency = @prompt.select(Locale.t("invoice.currency"), %w[HNL USD], default: "HNL")
79
+ line_items = ask_line_items(currency)
80
+
81
+ if line_items.empty?
82
+ say Locale.t("invoice.cancelled"), :yellow
83
+ return nil
84
+ end
85
+
86
+ exchange_rate = currency == "HNL" ? nil : ask_exchange_rate(currency)
87
+ notes = @prompt.ask(Locale.t("invoice.notes"))
88
+
89
+ preview = preview_invoice(customer, line_items, currency, exchange_rate, notes)
90
+ say ""
91
+ say preview
92
+
93
+ unless @prompt.yes?(Locale.t("invoice.confirm"))
94
+ say Locale.t("invoice.cancelled"), :yellow
95
+ return nil
96
+ end
97
+
98
+ invoice = @issuance.issue(customer: customer, line_items: line_items,
99
+ currency: currency, exchange_rate: exchange_rate, notes: notes)
100
+
101
+ say ""
102
+ say Locale.t("invoice.issued", correlative: invoice.correlative), :green
103
+ say ""
104
+ say Renderers::Text.new(invoice).render
105
+ invoice
106
+ end
107
+
108
+ private
109
+
110
+ def say(message, color = nil)
111
+ color ? @prompt.say(message, color: color) : @prompt.say(message)
112
+ end
113
+
114
+ def header(text)
115
+ say ""
116
+ say text
117
+ say "─" * text.length
118
+ end
119
+
120
+ def ask(question, default: nil)
121
+ options = { required: true }
122
+ options[:default] = default if default && !default.to_s.empty?
123
+ @prompt.ask(question, **options)
124
+ end
125
+
126
+ def ask_rtn(question, default: nil)
127
+ loop do
128
+ value = ask(question, default: default)
129
+ return value if Rtn.valid?(value)
130
+
131
+ say "RTN inválido: se esperan 14 dígitos.", :red
132
+ end
133
+ end
134
+
135
+ # Art. 11 distinguishes three cases, and which fields are mandatory
136
+ # depends on which one applies.
137
+ def ask_customer
138
+ kinds = {
139
+ Locale.t("invoice.kind_consumidor_final") => :consumidor_final,
140
+ Locale.t("invoice.kind_taxpayer") => :taxpayer,
141
+ Locale.t("invoice.kind_exonerado") => :exonerado
142
+ }
143
+
144
+ case @prompt.select(Locale.t("invoice.customer_kind"), kinds)
145
+ when :taxpayer
146
+ Customer::Taxpayer.new(
147
+ name: ask(Locale.t("invoice.customer_name")),
148
+ rtn: ask_rtn(Locale.t("invoice.customer_rtn"))
149
+ )
150
+ when :exonerado
151
+ Customer::Exonerado.new(
152
+ name: ask(Locale.t("invoice.customer_name")),
153
+ rtn: ask_rtn(Locale.t("invoice.customer_rtn")),
154
+ purchase_order: @prompt.ask(Locale.t("invoice.purchase_order")),
155
+ exoneration_registry: @prompt.ask(Locale.t("invoice.exoneration_registry")),
156
+ sag_registry: @prompt.ask(Locale.t("invoice.sag_registry"))
157
+ )
158
+ else
159
+ ask_consumidor_final
160
+ end
161
+ end
162
+
163
+ # Art. 11 num. 2 — the client's data is only mandatory above L 10,000.00,
164
+ # but it may always be recorded, so it is offered rather than forced.
165
+ def ask_consumidor_final
166
+ name = @prompt.ask(Locale.t("invoice.customer_name"))
167
+ return Customer::ConsumidorFinal.new if name.nil? || name.strip.empty?
168
+
169
+ Customer::ConsumidorFinal.new(
170
+ name: name,
171
+ identification_type: @prompt.ask(Locale.t("invoice.id_type"), default: "DNI"),
172
+ identification_number: @prompt.ask(Locale.t("invoice.id_number"))
173
+ )
174
+ end
175
+
176
+ def ask_line_items(currency)
177
+ items = []
178
+
179
+ loop do
180
+ items << ask_line(currency)
181
+ break unless @prompt.yes?(Locale.t("invoice.add_line"), default: false)
182
+ end
183
+
184
+ items
185
+ end
186
+
187
+ def ask_line(currency)
188
+ treatments = TaxTreatment.all.to_h { |t| [t.label, t.key] }
189
+
190
+ LineItem.new(
191
+ description: ask(Locale.t("invoice.description")),
192
+ quantity: BigDecimal(ask(Locale.t("invoice.quantity"), default: "1")),
193
+ unit_price: Money.new(ask(Locale.t("invoice.unit_price")), currency),
194
+ discount: Money.new(@prompt.ask(Locale.t("invoice.discount"), default: "0"), currency),
195
+ treatment: @prompt.select(Locale.t("invoice.treatment"), treatments, default: 4)
196
+ )
197
+ rescue ArgumentError, ValidationError => e
198
+ say "Dato inválido: #{e.message}", :red
199
+ retry
200
+ end
201
+
202
+ # Art. 11, closing paragraph — the rate in force on the issue date.
203
+ def ask_exchange_rate(currency)
204
+ ExchangeRate.new(
205
+ rate: ask(Locale.t("invoice.exchange_rate")),
206
+ date: Date.today,
207
+ currency: currency,
208
+ source: @prompt.ask(Locale.t("invoice.exchange_source"),
209
+ default: ExchangeRate::DEFAULT_SOURCE)
210
+ )
211
+ end
212
+
213
+ # Rendered against the number that *would* be allocated, without consuming
214
+ # it — so an operator who declines has not burned a correlativo.
215
+ def preview_invoice(customer, line_items, currency, exchange_rate, notes)
216
+ identifier = "000-001-01"
217
+ correlative = @issuance.sequence.peek(identifier)
218
+ authorization = @issuance.store.active_authorization(
219
+ identifier, next_sequence: correlative.sequence
220
+ )
221
+
222
+ raise NoAuthorization, "no hay autorización vigente para #{identifier}" if authorization.nil?
223
+
224
+ invoice = Invoice.new(
225
+ correlative: correlative, issuer: @issuance.store.issuer, customer: customer,
226
+ authorization: authorization, line_items: line_items, currency: currency,
227
+ exchange_rate: exchange_rate, notes: notes
228
+ )
229
+
230
+ Renderers::Text.new(invoice).render
231
+ end
232
+ end
233
+ end
234
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cli/builder"
4
+ require_relative "cli/reporter"
5
+ require_relative "cli/wizard"
6
+ require_relative "cli/main"
7
+
8
+ module Invoicehn
9
+ # The terminal interface.
10
+ #
11
+ # Interface strings are translatable (es/en); the fiscal document's own
12
+ # legends are not — they are legal text fixed by the Reglamento and live as
13
+ # constants in Renderers::Text.
14
+ module CLI
15
+ end
16
+ end
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module Invoicehn
6
+ module Compliance
7
+ # Checks an invoice against the Reglamento del Régimen de Facturación.
8
+ #
9
+ # Every rule cites the article it enforces. The validator reports all
10
+ # failures at once rather than stopping at the first, so an operator fixing
11
+ # a document sees the whole list.
12
+ #
13
+ # Scope: this validates *document content*. It cannot verify that the
14
+ # operator is registered in the Régimen de Facturación (Art. 45), enrolled
15
+ # as autoimpresor (Art. 47), or that the CAI it was handed is genuine —
16
+ # those are obligations the software cannot discharge.
17
+ class Validator
18
+ RULES = [
19
+ :issuer_identification, # Art. 10 num. 1
20
+ :authorization_present, # Art. 10 num. 3, 4, 5
21
+ :authorization_current, # Art. 62
22
+ :correlative_in_range, # Art. 10 num. 5, 7
23
+ :document_type_is_factura, # Art. 10 num. 7 lit. c
24
+ :line_items_present, # Art. 11 num. 1 lit. d, e, f
25
+ :customer_identification, # Art. 11 num. 1 lit. a, b / num. 2 lit. a
26
+ :consumidor_final_threshold, # Art. 11 num. 2
27
+ :exonerado_documents, # Art. 10 num. 8 / Art. 11 num. 4
28
+ :foreign_currency_rate, # Art. 11 closing paragraph
29
+ :mixed_supply_notice # Art. 11 num. 3
30
+ ].freeze
31
+
32
+ attr_reader :invoice, :on
33
+
34
+ def initialize(invoice, on: Date.today)
35
+ @invoice = invoice
36
+ @on = on
37
+ end
38
+
39
+ def violations
40
+ @violations ||= RULES.flat_map { |rule| Array(send(rule)) }.compact.freeze
41
+ end
42
+
43
+ def errors = violations.select(&:error?)
44
+ def warnings = violations.select(&:warning?)
45
+ def valid? = errors.empty?
46
+
47
+ private
48
+
49
+ def violation(article, requirement, detail = nil, severity: :error)
50
+ Violation.new(article: article, requirement: requirement, detail: detail, severity: severity)
51
+ end
52
+
53
+ # Art. 10 num. 1 — "Datos de identificación del emisor".
54
+ def issuer_identification
55
+ if invoice.issuer.nil?
56
+ return violation("Art. 10 num. 1", "Datos de identificación del emisor",
57
+ "no se configuró el emisor")
58
+ end
59
+
60
+ missing = invoice.issuer.missing_fields
61
+ return if missing.empty?
62
+
63
+ violation("Art. 10 num. 1", "Datos de identificación del emisor",
64
+ "faltan: #{missing.join("; ")}")
65
+ end
66
+
67
+ # Art. 10 num. 3, 4 and 5 — CAI, fecha límite and rango must appear.
68
+ def authorization_present
69
+ return if invoice.authorization
70
+
71
+ violation("Art. 10 num. 3, 4 y 5",
72
+ "Clave de Autorización de Impresión (CAI), fecha límite de emisión y rango autorizado",
73
+ "la factura no tiene autorización asociada")
74
+ end
75
+
76
+ # Art. 62 — documents lose validity once the authorized period expires.
77
+ def authorization_current
78
+ auth = invoice.authorization
79
+ return if auth.nil?
80
+ return unless auth.expired?(invoice.issue_date)
81
+
82
+ violation("Art. 62", "Fecha límite de emisión vigente",
83
+ "la autorización venció el #{auth.limit_date} y la factura se emitió el #{invoice.issue_date}")
84
+ end
85
+
86
+ # Art. 10 num. 5 and 7 — the number must fall inside the authorized range.
87
+ def correlative_in_range
88
+ auth = invoice.authorization
89
+ return if auth.nil?
90
+ return if auth.covers?(invoice.correlative)
91
+
92
+ violation("Art. 10 num. 5", "Rango autorizado vigente",
93
+ "el correlativo #{invoice.correlative} está fuera del rango #{auth.range_label}")
94
+ end
95
+
96
+ # Art. 10 num. 7 lit. c — "01 = Factura".
97
+ def document_type_is_factura
98
+ return if invoice.correlative.factura?
99
+
100
+ violation("Art. 10 num. 7 lit. c", "Código de tipo de documento 01 para la Factura",
101
+ "el correlativo declara el tipo #{invoice.correlative.document_type} " \
102
+ "(#{invoice.correlative.document_type_name})")
103
+ end
104
+
105
+ # Art. 11 num. 1 lit. d, e, f — a document with nothing on it describes no
106
+ # transferencia de bienes ni prestación de servicios.
107
+ def line_items_present
108
+ return if invoice.line_items.any?
109
+
110
+ violation("Art. 11 num. 1 lit. d, e y f",
111
+ "Descripción detallada, cantidad de unidades y valor unitario",
112
+ "la factura no tiene líneas de detalle")
113
+ end
114
+
115
+ # Art. 11 num. 1 lit. a and b for taxpayers; num. 2 lit. a for final
116
+ # consumers.
117
+ def customer_identification
118
+ customer = invoice.customer
119
+ return violation("Art. 11", "Datos del cliente", "no se configuró el cliente") if customer.nil?
120
+
121
+ if customer.taxpayer? || customer.exonerado?
122
+ return if !customer.name.empty? && customer.rtn
123
+
124
+ return violation("Art. 11 num. 1 lit. a y b",
125
+ "Nombres y Apellidos o Razón Social, y RTN del cliente",
126
+ "el cliente que sustenta crédito fiscal debe identificarse con nombre y RTN")
127
+ end
128
+
129
+ nil
130
+ end
131
+
132
+ # Art. 11 num. 2 — above L 10,000.00 the consumidor final's data becomes
133
+ # mandatory: "nombres y apellidos, el tipo y número de documento de
134
+ # identificación en el espacio destinado al RTN".
135
+ def consumidor_final_threshold
136
+ customer = invoice.customer
137
+ return unless customer.is_a?(Customer::ConsumidorFinal)
138
+
139
+ # The threshold is a sum *in lempiras*, so a foreign-currency invoice is
140
+ # measured by its Lempira equivalent — otherwise an anonymous sale of
141
+ # US$50,000 would slip past a rule written for L 10,000. A foreign
142
+ # invoice without a rate cannot be converted, but it already fails the
143
+ # Art. 11 closing-paragraph rule, so nothing goes unreported.
144
+ amount = invoice.foreign_currency? ? invoice.total_in_lempiras : invoice.total
145
+ return if amount.nil?
146
+ return unless customer.identification_required?(amount)
147
+ return if customer.identified?
148
+
149
+ violation("Art. 11 num. 2",
150
+ "Datos del consumidor final en ventas superiores a L 10,000.00",
151
+ "el total (#{invoice.total}#{" ≡ #{amount}" if invoice.foreign_currency?}) " \
152
+ "excede el umbral y deben consignarse nombres y apellidos junto al tipo y " \
153
+ "número de documento de identificación")
154
+ end
155
+
156
+ # Art. 10 num. 8 and Art. 11 num. 4 — an exonerated purchaser's supporting
157
+ # numbers.
158
+ def exonerado_documents
159
+ customer = invoice.customer
160
+ results = []
161
+
162
+ if customer.is_a?(Customer::Exonerado) && !customer.supported?
163
+ results << violation("Art. 10 num. 8 y Art. 11 num. 4",
164
+ "Orden de Compra Exenta, Constancia del Registro de Exonerados o Registro SAG",
165
+ "el adquirente exonerado no tiene ninguno de los tres documentos de respaldo")
166
+ end
167
+
168
+ # An exonerado line without an exonerado buyer is a contradiction the
169
+ # document would carry on its face.
170
+ exonerado_lines = invoice.line_items.any? { |item| item.treatment.exonerado? }
171
+ if exonerado_lines && !customer.exonerado?
172
+ results << violation("Art. 11 num. 4",
173
+ "Ventas a Obligados Tributarios Exonerados",
174
+ "hay líneas con tratamiento exonerado pero el cliente no está registrado como exonerado")
175
+ end
176
+
177
+ results
178
+ end
179
+
180
+ # Art. 11, closing paragraph: "En ambos casos, cuando el Obligado
181
+ # Tributario emita facturas con otra denominación monetaria, debe indicar
182
+ # la tasa de cambio vigente a la fecha de emisión."
183
+ def foreign_currency_rate
184
+ return unless invoice.foreign_currency?
185
+
186
+ rate = invoice.exchange_rate
187
+ if rate.nil?
188
+ return violation("Art. 11 (párrafo final)",
189
+ "Tasa de cambio vigente a la fecha de emisión",
190
+ "la factura está en #{invoice.currency} y no indica tasa de cambio")
191
+ end
192
+
193
+ unless rate.currency == invoice.currency
194
+ return violation("Art. 11 (párrafo final)",
195
+ "Tasa de cambio vigente a la fecha de emisión",
196
+ "la tasa convierte desde #{rate.currency} pero la factura está en #{invoice.currency}")
197
+ end
198
+
199
+ return if rate.current_on?(invoice.issue_date)
200
+
201
+ violation("Art. 11 (párrafo final)",
202
+ "Tasa de cambio vigente a la fecha de emisión",
203
+ "la tasa es del #{rate.date} y la factura se emitió el #{invoice.issue_date}")
204
+ end
205
+
206
+ # Art. 11 num. 3 — "Para respaldar el crédito fiscal en los casos que la
207
+ # factura sustente ventas exentas y gravadas, se reconocerán únicamente
208
+ # las ventas gravadas." Not a defect: a notice, so the document can say so
209
+ # and the buyer is not misled about what it supports.
210
+ def mixed_supply_notice
211
+ return unless invoice.summary.mixed_supply?
212
+ return unless invoice.customer.taxpayer?
213
+
214
+ violation("Art. 11 num. 3",
215
+ "Crédito fiscal limitado a las ventas gravadas",
216
+ "la factura mezcla ventas gravadas y no gravadas; sólo #{invoice.summary.credito_fiscal_base} " \
217
+ "sustenta crédito fiscal",
218
+ severity: :warning)
219
+ end
220
+ end
221
+ end
222
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Invoicehn
4
+ module Compliance
5
+ # One failed legal requirement, carrying the article it comes from so the
6
+ # operator can look it up rather than trust the library's paraphrase.
7
+ class Violation
8
+ SEVERITIES = %i[error warning].freeze
9
+
10
+ attr_reader :article, :requirement, :detail, :severity
11
+
12
+ def initialize(article:, requirement:, detail: nil, severity: :error)
13
+ raise ArgumentError, "severidad desconocida: #{severity.inspect}" unless SEVERITIES.include?(severity)
14
+
15
+ @article = article
16
+ @requirement = requirement
17
+ @detail = detail
18
+ @severity = severity
19
+ freeze
20
+ end
21
+
22
+ def error? = @severity == :error
23
+ def warning? = @severity == :warning
24
+
25
+ def to_s
26
+ base = "#{@article}: #{@requirement}"
27
+ @detail ? "#{base} — #{@detail}" : base
28
+ end
29
+
30
+ def to_h
31
+ {
32
+ "article" => @article,
33
+ "requirement" => @requirement,
34
+ "detail" => @detail,
35
+ "severity" => @severity.to_s
36
+ }.compact
37
+ end
38
+
39
+ def inspect = "#<Invoicehn::Compliance::Violation #{self}>"
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Invoicehn
6
+ # Where the library keeps the issuer profile, the authorizations, the
7
+ # correlative counters and the issued documents.
8
+ #
9
+ # Art. 43 obliges the operator to keep fiscal documents ordered and
10
+ # chronological for the Código Tributario prescription period, and Art. 53
11
+ # num. 3 requires "la persistencia y disponibilidad inmediata de la
12
+ # información actual e histórica". A directory of plain JSON files satisfies
13
+ # both and stays readable without this library — which matters for records
14
+ # that must outlive the software that wrote them.
15
+ class Config
16
+ DEFAULT_DIRNAME = ".invoicehn"
17
+ ENV_VAR = "INVOICEHN_HOME"
18
+
19
+ attr_reader :home
20
+
21
+ def initialize(home: nil)
22
+ @home = File.expand_path(home || ENV[ENV_VAR] || File.join(Dir.home, DEFAULT_DIRNAME))
23
+ end
24
+
25
+ def issuer_path = File.join(@home, "issuer.json")
26
+ def authorizations_path = File.join(@home, "authorizations.json")
27
+ def sequences_path = File.join(@home, "sequences.json")
28
+ def settings_path = File.join(@home, "settings.json")
29
+ def documents_dir = File.join(@home, "documentos")
30
+ def ledger_path = File.join(@home, "ledger.jsonl")
31
+ def lock_path = File.join(@home, "sequence.lock")
32
+
33
+ # Issued documents are filed by year and month so the directory stays
34
+ # navigable and mirrors the chronological order Art. 43 asks for.
35
+ def document_path(correlative, issue_date)
36
+ dir = File.join(documents_dir, format("%04d", issue_date.year), format("%02d", issue_date.month))
37
+ File.join(dir, "#{correlative}.json")
38
+ end
39
+
40
+ def ensure_home!
41
+ FileUtils.mkdir_p(@home)
42
+ FileUtils.mkdir_p(documents_dir)
43
+ @home
44
+ end
45
+
46
+ def initialized? = File.exist?(issuer_path)
47
+
48
+ def to_s = @home
49
+ end
50
+ end