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