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,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module Invoicehn
|
|
6
|
+
# Issues a Factura: picks the authorization, allocates the correlativo,
|
|
7
|
+
# validates against the Reglamento, persists, and posts to the ledger.
|
|
8
|
+
#
|
|
9
|
+
# The whole sequence happens inside the allocator's lock, so a document is
|
|
10
|
+
# either fully recorded or its number was never consumed. Nothing here can
|
|
11
|
+
# produce a number without a document behind it.
|
|
12
|
+
class Issuance
|
|
13
|
+
attr_reader :store, :sequence, :ledger
|
|
14
|
+
|
|
15
|
+
def initialize(config: Config.new, store: nil, sequence: nil, ledger: nil)
|
|
16
|
+
@config = config
|
|
17
|
+
@store = store || Storage::JsonStore.new(config)
|
|
18
|
+
@sequence = sequence || Sequence.new(config)
|
|
19
|
+
@ledger = ledger || Ledger::JsonlLedger.new(config)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @param identifier [String] "NNN-NNN-NN", the establecimiento / punto de
|
|
23
|
+
# emisión / tipo de documento triple. Defaults to the casa matriz's first
|
|
24
|
+
# emission point issuing a Factura.
|
|
25
|
+
# @param issue_date [Date] pinned to today by default. Art. 43 obliges
|
|
26
|
+
# chronological custody, and a ledger whose numbering disagrees with its
|
|
27
|
+
# dates is the first thing an audit questions — so backdating is not
|
|
28
|
+
# offered through the CLI.
|
|
29
|
+
def issue(customer:, line_items:, identifier: "000-001-01", currency: "HNL",
|
|
30
|
+
exchange_rate: nil, notes: nil, issue_date: Date.today)
|
|
31
|
+
issuer = @store.issuer
|
|
32
|
+
raise NoAuthorization, "no se ha configurado el emisor; ejecute «invoicehn setup»" if issuer.nil?
|
|
33
|
+
|
|
34
|
+
@sequence.allocate(identifier) do |correlative|
|
|
35
|
+
authorization = pick_authorization(identifier, correlative, issue_date)
|
|
36
|
+
|
|
37
|
+
invoice = Invoice.new(
|
|
38
|
+
correlative: correlative,
|
|
39
|
+
issuer: issuer,
|
|
40
|
+
customer: customer,
|
|
41
|
+
authorization: authorization,
|
|
42
|
+
line_items: line_items,
|
|
43
|
+
issue_date: issue_date,
|
|
44
|
+
currency: currency,
|
|
45
|
+
exchange_rate: exchange_rate,
|
|
46
|
+
notes: notes
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
invoice.validate!(on: issue_date)
|
|
50
|
+
@store.save_document(invoice)
|
|
51
|
+
@ledger.record(invoice, event: :emision)
|
|
52
|
+
invoice
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Art. 41 — annulment. The correlativo stays consumed; the sequence never
|
|
57
|
+
# goes backwards.
|
|
58
|
+
def annul(correlative, reason:, on: Date.today)
|
|
59
|
+
invoice = @store.find(correlative)
|
|
60
|
+
annulled = invoice.annul(reason: reason, on: on)
|
|
61
|
+
|
|
62
|
+
@store.save_document(annulled)
|
|
63
|
+
@ledger.record(annulled, event: :anulacion)
|
|
64
|
+
annulled
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# What `invoicehn check` reports: whether issuing is possible right now, and
|
|
68
|
+
# what is about to go wrong.
|
|
69
|
+
def health(identifier: "000-001-01", on: Date.today)
|
|
70
|
+
issuer = @store.issuer
|
|
71
|
+
next_correlative = @sequence.peek(identifier)
|
|
72
|
+
authorizations = @store.authorizations_for(identifier)
|
|
73
|
+
active = @store.active_authorization(identifier, next_sequence: next_correlative.sequence, on: on)
|
|
74
|
+
|
|
75
|
+
{
|
|
76
|
+
identifier: identifier,
|
|
77
|
+
issuer_configured: !issuer.nil?,
|
|
78
|
+
issuer_complete: issuer&.complete? || false,
|
|
79
|
+
issuer_missing: issuer&.missing_fields || [],
|
|
80
|
+
authorizations: authorizations.size,
|
|
81
|
+
active_authorization: active,
|
|
82
|
+
next_correlative: next_correlative,
|
|
83
|
+
issued: @sequence.issued_count(identifier),
|
|
84
|
+
remaining: active ? (active.range_end.sequence - next_correlative.sequence + 1) : 0,
|
|
85
|
+
days_remaining: active&.days_remaining(on),
|
|
86
|
+
# Art. 42 — expired authorizations holding unused documents must be
|
|
87
|
+
# reported to SAR within the first 10 business days of the next month.
|
|
88
|
+
lapsed_with_unused: lapsed_with_unused(identifier, on: on),
|
|
89
|
+
ready: !issuer.nil? && issuer.complete? && !active.nil?
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def pick_authorization(identifier, correlative, on)
|
|
96
|
+
authorization = @store.active_authorization(identifier, next_sequence: correlative.sequence, on: on)
|
|
97
|
+
|
|
98
|
+
if authorization.nil?
|
|
99
|
+
on_file = @store.authorizations_for(identifier)
|
|
100
|
+
raise NoAuthorization, <<~MSG.strip if on_file.empty?
|
|
101
|
+
no hay autorización registrada para #{identifier}; regístrela con «invoicehn auth add»
|
|
102
|
+
(el CAI, el rango y la fecha límite los otorga el SAR, Arts. 59-61)
|
|
103
|
+
MSG
|
|
104
|
+
|
|
105
|
+
raise NoAuthorization, <<~MSG.strip
|
|
106
|
+
ninguna autorización vigente cubre el correlativo #{correlative};
|
|
107
|
+
las registradas están vencidas o agotadas. Solicite una nueva al SAR.
|
|
108
|
+
MSG
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
authorization.assert_usable!(correlative, on: on)
|
|
112
|
+
authorization
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Authorizations past their fecha límite that still had numbers left.
|
|
116
|
+
def lapsed_with_unused(identifier, on:)
|
|
117
|
+
issued = @sequence.issued_count(identifier)
|
|
118
|
+
|
|
119
|
+
@store.authorizations_for(identifier).select do |auth|
|
|
120
|
+
auth.expired?(on) && issued < auth.range_end.sequence
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Invoicehn
|
|
4
|
+
# The Obligado Tributario emitting the document.
|
|
5
|
+
#
|
|
6
|
+
# Art. 10 num. 1 — "Datos de identificación del emisor":
|
|
7
|
+
# a) Registro Tributario Nacional (RTN)
|
|
8
|
+
# b) Nombres y Apellidos, Razón o Denominación Social. "Los Obligados
|
|
9
|
+
# Tributarios como comerciantes individuales podrán sustituir sus Nombres
|
|
10
|
+
# y Apellidos por el nombre comercial registrado en el Registro
|
|
11
|
+
# Tributario Nacional (RTN)"
|
|
12
|
+
# c) Nombre Comercial
|
|
13
|
+
# d) Dirección de la casa matriz y del establecimiento donde esté localizado
|
|
14
|
+
# el punto de emisión
|
|
15
|
+
# e) Número telefónico
|
|
16
|
+
# f) Correo Electrónico
|
|
17
|
+
#
|
|
18
|
+
# All six are required by the article, so all six are required here. The
|
|
19
|
+
# printer's data (num. 9) is not modelled: it applies only to pre-printed
|
|
20
|
+
# invoices produced through an imprenta, and this library issues under the
|
|
21
|
+
# autoimpresor modality.
|
|
22
|
+
class Issuer
|
|
23
|
+
attr_reader :rtn, :legal_name, :trade_name, :headquarters_address,
|
|
24
|
+
:branch_address, :phone, :email
|
|
25
|
+
|
|
26
|
+
# @param branch_address [String, nil] address of the establishment holding
|
|
27
|
+
# the emission point. Defaults to the headquarters address, which is the
|
|
28
|
+
# correct value when issuing from the casa matriz.
|
|
29
|
+
def initialize(rtn:, legal_name:, trade_name:, headquarters_address:, phone:, email:,
|
|
30
|
+
branch_address: nil)
|
|
31
|
+
@rtn = rtn.is_a?(Rtn) ? rtn : Rtn.new(rtn)
|
|
32
|
+
@legal_name = legal_name.to_s.strip
|
|
33
|
+
@trade_name = trade_name.to_s.strip
|
|
34
|
+
@headquarters_address = headquarters_address.to_s.strip
|
|
35
|
+
branch = branch_address.to_s.strip
|
|
36
|
+
@branch_address = branch.empty? ? @headquarters_address : branch
|
|
37
|
+
@phone = phone.to_s.strip
|
|
38
|
+
@email = email.to_s.strip
|
|
39
|
+
|
|
40
|
+
freeze
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Returns the Art. 10 num. 1 fields that are missing, as human-readable
|
|
44
|
+
# Spanish descriptions. Empty means complete.
|
|
45
|
+
def missing_fields
|
|
46
|
+
{
|
|
47
|
+
"nombre o razón social (Art. 10 num. 1 lit. b)" => @legal_name,
|
|
48
|
+
"nombre comercial (Art. 10 num. 1 lit. c)" => @trade_name,
|
|
49
|
+
"dirección de la casa matriz (Art. 10 num. 1 lit. d)" => @headquarters_address,
|
|
50
|
+
"dirección del establecimiento del punto de emisión (Art. 10 num. 1 lit. d)" => @branch_address,
|
|
51
|
+
"número telefónico (Art. 10 num. 1 lit. e)" => @phone,
|
|
52
|
+
"correo electrónico (Art. 10 num. 1 lit. f)" => @email
|
|
53
|
+
}.select { |_label, value| value.nil? || value.empty? }.keys
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def complete? = missing_fields.empty?
|
|
57
|
+
|
|
58
|
+
def validate!
|
|
59
|
+
return self if complete?
|
|
60
|
+
|
|
61
|
+
raise ValidationError,
|
|
62
|
+
"faltan datos obligatorios del emisor: #{missing_fields.join("; ")}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# True when the emission point is somewhere other than the casa matriz, in
|
|
66
|
+
# which case both addresses must be shown.
|
|
67
|
+
def branch? = @branch_address != @headquarters_address
|
|
68
|
+
|
|
69
|
+
def to_h
|
|
70
|
+
{
|
|
71
|
+
"rtn" => @rtn.to_s,
|
|
72
|
+
"legal_name" => @legal_name,
|
|
73
|
+
"trade_name" => @trade_name,
|
|
74
|
+
"headquarters_address" => @headquarters_address,
|
|
75
|
+
"branch_address" => @branch_address,
|
|
76
|
+
"phone" => @phone,
|
|
77
|
+
"email" => @email
|
|
78
|
+
}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def self.from_h(hash)
|
|
82
|
+
new(
|
|
83
|
+
rtn: hash["rtn"],
|
|
84
|
+
legal_name: hash["legal_name"],
|
|
85
|
+
trade_name: hash["trade_name"],
|
|
86
|
+
headquarters_address: hash["headquarters_address"],
|
|
87
|
+
branch_address: hash["branch_address"],
|
|
88
|
+
phone: hash["phone"],
|
|
89
|
+
email: hash["email"]
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def to_s = "#{@legal_name} (RTN #{@rtn.formatted})"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
require "date"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
|
|
8
|
+
module Invoicehn
|
|
9
|
+
# The integration point Art. 53 num. 1 requires.
|
|
10
|
+
#
|
|
11
|
+
# "El sistema de facturación debe estar integrado al menos a un sistema
|
|
12
|
+
# contable o de inventarios." That integration is part of what the operator
|
|
13
|
+
# attests in the Declaración Jurada filed before being authorized as an
|
|
14
|
+
# autoimpresor, so it is a condition of the authorization and not an optional
|
|
15
|
+
# convenience.
|
|
16
|
+
#
|
|
17
|
+
# Subclass and override #record to post into real accounting or inventory
|
|
18
|
+
# software. The default implementation writes an append-only JSONL book, which
|
|
19
|
+
# satisfies the requirement standalone and feeds the Art. 53 num. 5 export.
|
|
20
|
+
class Ledger
|
|
21
|
+
# Called once per issued or annulled document.
|
|
22
|
+
#
|
|
23
|
+
# @param invoice [Invoice]
|
|
24
|
+
# @param event [Symbol] :emision or :anulacion
|
|
25
|
+
def record(invoice, event: :emision)
|
|
26
|
+
raise NotImplementedError, "#{self.class} debe implementar #record"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Art. 53 num. 5: "El Sistema debe tener la capacidad de generación de
|
|
30
|
+
# archivos tipo texto para su almacenamiento y traslado hacia la
|
|
31
|
+
# Administración Tributaria a través de servicios web o intercambio de
|
|
32
|
+
# protocolo."
|
|
33
|
+
def entries(from: nil, to: nil)
|
|
34
|
+
raise NotImplementedError, "#{self.class} debe implementar #entries"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The default: a plain append-only book on disk.
|
|
38
|
+
class JsonlLedger < Ledger
|
|
39
|
+
attr_reader :path
|
|
40
|
+
|
|
41
|
+
def initialize(config = Config.new)
|
|
42
|
+
super()
|
|
43
|
+
@path = config.ledger_path
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def record(invoice, event: :emision)
|
|
47
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
48
|
+
File.open(@path, "a") { |f| f.puts(JSON.generate(entry_for(invoice, event))) }
|
|
49
|
+
invoice
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def entries(from: nil, to: nil)
|
|
53
|
+
return [] unless File.exist?(@path)
|
|
54
|
+
|
|
55
|
+
File.readlines(@path, chomp: true).reject(&:empty?).map { |line| JSON.parse(line) }
|
|
56
|
+
.select { |e| within?(Date.parse(e["issue_date"]), from, to) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def entry_for(invoice, event)
|
|
62
|
+
{
|
|
63
|
+
"recorded_at" => Time.now.utc.iso8601,
|
|
64
|
+
"event" => event.to_s,
|
|
65
|
+
"correlative" => invoice.correlative.to_s,
|
|
66
|
+
"issue_date" => invoice.issue_date.iso8601,
|
|
67
|
+
"cai" => invoice.authorization.cai,
|
|
68
|
+
"customer" => invoice.customer.to_s,
|
|
69
|
+
"customer_rtn" => invoice.customer.rtn&.to_s,
|
|
70
|
+
"currency" => invoice.currency,
|
|
71
|
+
"subtotal" => invoice.subtotal.to_h["amount"],
|
|
72
|
+
"discount" => invoice.discount.to_h["amount"],
|
|
73
|
+
"isv" => invoice.isv_total.to_h["amount"],
|
|
74
|
+
"total" => invoice.total.to_h["amount"],
|
|
75
|
+
"status" => invoice.status
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def within?(date, from, to)
|
|
80
|
+
return false if from && date < from
|
|
81
|
+
return false if to && date > to
|
|
82
|
+
|
|
83
|
+
true
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Fans out to several ledgers, so the built-in book can stay in place while
|
|
88
|
+
# an accounting integration is added alongside it.
|
|
89
|
+
class Multi < Ledger
|
|
90
|
+
attr_reader :ledgers
|
|
91
|
+
|
|
92
|
+
def initialize(*ledgers)
|
|
93
|
+
super()
|
|
94
|
+
@ledgers = ledgers.flatten
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def record(invoice, event: :emision)
|
|
98
|
+
@ledgers.each { |l| l.record(invoice, event: event) }
|
|
99
|
+
invoice
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def entries(from: nil, to: nil)
|
|
103
|
+
@ledgers.first&.entries(from: from, to: to) || []
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
module Invoicehn
|
|
6
|
+
# One line of the invoice.
|
|
7
|
+
#
|
|
8
|
+
# Art. 11 num. 1 requires per line: lit. d) "Descripción detallada del bien
|
|
9
|
+
# vendido o del servicio prestado", lit. e) "Cantidad de unidades de bienes
|
|
10
|
+
# vendidos", lit. f) "Valor unitario del bien vendido o del servicio
|
|
11
|
+
# prestado", and — added by Acuerdo 725-2018 — lit. l) "Discriminación de los
|
|
12
|
+
# descuentos y rebajas otorgados".
|
|
13
|
+
#
|
|
14
|
+
# The discount reduces the taxable base. Ley del ISV Art. 3 closes the
|
|
15
|
+
# base-imponible article with: "No forman parte de la base gravable los
|
|
16
|
+
# descuentos efectivos que consten en la factura o documento equivalente,
|
|
17
|
+
# siempre que resulten normales según la costumbre comercial." Showing a
|
|
18
|
+
# discount while taxing the gross value would overcharge the customer the very
|
|
19
|
+
# tax that Art. 9 calls hurto to mis-collect.
|
|
20
|
+
class LineItem
|
|
21
|
+
attr_reader :description, :quantity, :unit_price, :discount, :treatment
|
|
22
|
+
|
|
23
|
+
# @param discount [Money, nil] absolute amount taken off this line's gross
|
|
24
|
+
# value, expressed in the invoice's currency.
|
|
25
|
+
def initialize(description:, quantity:, unit_price:, treatment:, discount: nil)
|
|
26
|
+
@description = description.to_s.strip
|
|
27
|
+
@quantity = coerce_quantity(quantity)
|
|
28
|
+
@unit_price = coerce_money(unit_price, "valor unitario")
|
|
29
|
+
@treatment = TaxTreatment.fetch(treatment)
|
|
30
|
+
@discount = discount.nil? ? Money.zero(@unit_price.currency) : coerce_money(discount, "descuento")
|
|
31
|
+
|
|
32
|
+
validate!
|
|
33
|
+
freeze
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def currency = @unit_price.currency
|
|
37
|
+
|
|
38
|
+
# Quantity times unit price, before any discount.
|
|
39
|
+
def gross = @unit_price * @quantity
|
|
40
|
+
|
|
41
|
+
def discounted? = @discount.positive?
|
|
42
|
+
|
|
43
|
+
# The amount that enters the taxable base for this line's rate.
|
|
44
|
+
def taxable_base = gross - @discount
|
|
45
|
+
|
|
46
|
+
# The tax this line contributes on its own. The invoice does not sum these:
|
|
47
|
+
# it rounds once per rate on the summed base, so that the printed ISV equals
|
|
48
|
+
# the printed rate times the printed subtotal. This is offered for line-level
|
|
49
|
+
# display and reconciliation only.
|
|
50
|
+
def isv = (taxable_base * @treatment.rate).round_statutory
|
|
51
|
+
|
|
52
|
+
def total = taxable_base + isv
|
|
53
|
+
|
|
54
|
+
def to_h
|
|
55
|
+
{
|
|
56
|
+
"description" => @description,
|
|
57
|
+
"quantity" => @quantity.to_s("F"),
|
|
58
|
+
"unit_price" => @unit_price.to_h,
|
|
59
|
+
"discount" => @discount.to_h,
|
|
60
|
+
"treatment" => @treatment.key.to_s
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.from_h(hash)
|
|
65
|
+
new(
|
|
66
|
+
description: hash["description"],
|
|
67
|
+
quantity: BigDecimal(hash["quantity"]),
|
|
68
|
+
unit_price: Money.from_h(hash["unit_price"]),
|
|
69
|
+
discount: Money.from_h(hash["discount"]),
|
|
70
|
+
treatment: hash["treatment"]
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def to_s
|
|
75
|
+
"#{@quantity.to_s("F")} × #{@unit_price} #{@description} (#{@treatment})"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def validate!
|
|
81
|
+
if @description.empty?
|
|
82
|
+
raise ValidationError,
|
|
83
|
+
"la descripción del bien o servicio es obligatoria (Art. 11 num. 1 lit. d)"
|
|
84
|
+
end
|
|
85
|
+
raise ValidationError, "la cantidad debe ser mayor que cero (Art. 11 num. 1 lit. e)" unless @quantity.positive?
|
|
86
|
+
raise ValidationError, "el valor unitario no puede ser negativo (Art. 11 num. 1 lit. f)" if @unit_price.negative?
|
|
87
|
+
raise ValidationError, "el descuento no puede ser negativo" if @discount.negative?
|
|
88
|
+
|
|
89
|
+
if @discount.currency != @unit_price.currency
|
|
90
|
+
raise CurrencyMismatch, "el descuento está en #{@discount.currency} y el precio en #{@unit_price.currency}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
return unless @discount > gross
|
|
94
|
+
|
|
95
|
+
raise ValidationError,
|
|
96
|
+
"el descuento (#{@discount}) excede el valor de la línea (#{gross})"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def coerce_quantity(value)
|
|
100
|
+
case value
|
|
101
|
+
when BigDecimal then value
|
|
102
|
+
when Integer then BigDecimal(value)
|
|
103
|
+
when Rational then BigDecimal(value, 20)
|
|
104
|
+
when String then BigDecimal(value)
|
|
105
|
+
when Float
|
|
106
|
+
raise ValidationError, "Float no está permitido en cantidades; use BigDecimal, Integer o String"
|
|
107
|
+
else
|
|
108
|
+
raise ValidationError, "cantidad no numérica: #{value.inspect}"
|
|
109
|
+
end
|
|
110
|
+
rescue ArgumentError
|
|
111
|
+
raise ValidationError, "cantidad no numérica: #{value.inspect}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def coerce_money(value, label)
|
|
115
|
+
return value if value.is_a?(Money)
|
|
116
|
+
|
|
117
|
+
raise ValidationError, "#{label} debe ser un Invoicehn::Money, se recibió #{value.class}"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Invoicehn
|
|
6
|
+
# Interface strings for the CLI, in Spanish or English.
|
|
7
|
+
#
|
|
8
|
+
# This deliberately covers *only* the interface. The Factura's own legends are
|
|
9
|
+
# legal text fixed by the Reglamento and live as constants in
|
|
10
|
+
# Renderers::Text — an English-locale CLI still issues a Spanish invoice,
|
|
11
|
+
# because the document's wording is not a presentation choice.
|
|
12
|
+
module Locale
|
|
13
|
+
DEFAULT = "es"
|
|
14
|
+
AVAILABLE = %w[es en].freeze
|
|
15
|
+
ENV_VAR = "INVOICEHN_LANG"
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
def current
|
|
19
|
+
@current ||= detect
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def current=(lang)
|
|
23
|
+
lang = lang.to_s.downcase[0, 2]
|
|
24
|
+
@current = AVAILABLE.include?(lang) ? lang : DEFAULT
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Looks up a dotted key: t("setup.title"). Interpolation uses %{name}
|
|
28
|
+
# placeholders. A missing key returns the key itself rather than raising —
|
|
29
|
+
# a broken label must never stop an invoice from being issued.
|
|
30
|
+
def t(key, **values)
|
|
31
|
+
string = dig(catalog(current), key) || dig(catalog(DEFAULT), key)
|
|
32
|
+
return key.to_s if string.nil?
|
|
33
|
+
|
|
34
|
+
values.empty? ? string : format(string, **values)
|
|
35
|
+
rescue KeyError
|
|
36
|
+
string
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def reset!
|
|
40
|
+
@current = nil
|
|
41
|
+
@catalogs = nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# Spanish unless English is asked for explicitly, via INVOICEHN_LANG or
|
|
47
|
+
# --lang. The system LANG is deliberately ignored: the users are Honduran
|
|
48
|
+
# businesses, and someone running an English-locale laptop still wants the
|
|
49
|
+
# Spanish interface next to a Spanish document.
|
|
50
|
+
def detect
|
|
51
|
+
normalize(ENV.fetch(ENV_VAR, DEFAULT))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def normalize(lang)
|
|
55
|
+
lang = lang.to_s.downcase[0, 2]
|
|
56
|
+
AVAILABLE.include?(lang) ? lang : DEFAULT
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def catalogs
|
|
60
|
+
@catalogs ||= AVAILABLE.to_h do |lang|
|
|
61
|
+
path = File.expand_path("../../config/locales/#{lang}.yml", __dir__)
|
|
62
|
+
data = File.exist?(path) ? (YAML.safe_load_file(path) || {}) : {}
|
|
63
|
+
[lang, data[lang] || {}]
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def catalog(lang) = catalogs.fetch(lang, {})
|
|
68
|
+
|
|
69
|
+
def dig(hash, key)
|
|
70
|
+
value = key.to_s.split(".").reduce(hash) do |acc, part|
|
|
71
|
+
acc.is_a?(Hash) ? acc[part] : nil
|
|
72
|
+
end
|
|
73
|
+
value.is_a?(String) ? value : nil
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
require "bigdecimal/util"
|
|
5
|
+
|
|
6
|
+
module Invoicehn
|
|
7
|
+
# An amount of money, held as a BigDecimal at full precision.
|
|
8
|
+
#
|
|
9
|
+
# Rounding to centavos happens only through #round_statutory, which implements
|
|
10
|
+
# Artículo 9 de la Ley del Impuesto Sobre Ventas (redactado por Decreto 135-94):
|
|
11
|
+
#
|
|
12
|
+
# "Cuando al calcular dicho gravamen, resulte una fracción menor de 0.005 de
|
|
13
|
+
# Lempira, deberá reducirse el recargo hasta la cifra de centavos próxima
|
|
14
|
+
# inferior, en cambio, si la fracción citada es igual o mayor de 0.005 de
|
|
15
|
+
# Lempira, entonces podrá subirse el cómputo hasta la cifra de centavos
|
|
16
|
+
# próxima superior. El recargo del impuesto al consumidor fuera de la regla
|
|
17
|
+
# establecida en el párrafo anterior, se considerará como hurto."
|
|
18
|
+
#
|
|
19
|
+
# Charging the customer an amount rounded outside that rule is characterised by
|
|
20
|
+
# the statute as theft, so this is a correctness requirement and not a policy
|
|
21
|
+
# choice. Float never appears in this class or in the tax path; test/test_no_float.rb
|
|
22
|
+
# enforces that.
|
|
23
|
+
class Money
|
|
24
|
+
include Comparable
|
|
25
|
+
|
|
26
|
+
SCALE = 2
|
|
27
|
+
CURRENCIES = {
|
|
28
|
+
"HNL" => { symbol: "L", singular: "LEMPIRA", plural: "LEMPIRAS" },
|
|
29
|
+
"USD" => { symbol: "$", singular: "DÓLAR", plural: "DÓLARES" }
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
attr_reader :amount, :currency
|
|
33
|
+
|
|
34
|
+
class << self
|
|
35
|
+
def zero(currency = "HNL")
|
|
36
|
+
new(0, currency)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Sums a collection, inferring the currency from the members. An empty
|
|
40
|
+
# collection has no currency to infer, so the caller supplies one.
|
|
41
|
+
def sum(monies, currency: "HNL")
|
|
42
|
+
monies = Array(monies)
|
|
43
|
+
return zero(currency) if monies.empty?
|
|
44
|
+
|
|
45
|
+
monies.reduce { |acc, m| acc + m }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def currency?(code)
|
|
49
|
+
CURRENCIES.key?(code.to_s.upcase)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def initialize(amount, currency = "HNL")
|
|
54
|
+
@currency = currency.to_s.upcase
|
|
55
|
+
raise ValidationError, "moneda no soportada: #{currency}" unless CURRENCIES.key?(@currency)
|
|
56
|
+
|
|
57
|
+
@amount = coerce_to_decimal(amount)
|
|
58
|
+
freeze
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Rounds to centavos under Ley del ISV Art. 9: a fraction of a centavo below
|
|
62
|
+
# 0.005 lempira rounds down, one at or above 0.005 rounds up. That is
|
|
63
|
+
# half-up at the centavo, with the tie going up.
|
|
64
|
+
def round_statutory
|
|
65
|
+
self.class.new(@amount.round(SCALE, BigDecimal::ROUND_HALF_UP), @currency)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def +(other)
|
|
69
|
+
combine(other) { |a, b| a + b }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def -(other)
|
|
73
|
+
combine(other) { |a, b| a - b }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Scaling by a quantity or a tax rate. The result keeps full precision;
|
|
77
|
+
# call #round_statutory at the point the amount becomes payable.
|
|
78
|
+
def *(other)
|
|
79
|
+
self.class.new(@amount * coerce_to_decimal(other), @currency)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def /(other)
|
|
83
|
+
divisor = coerce_to_decimal(other)
|
|
84
|
+
raise ZeroDivisionError, "división por cero" if divisor.zero?
|
|
85
|
+
|
|
86
|
+
self.class.new(@amount / divisor, @currency)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def -@
|
|
90
|
+
self.class.new(-@amount, @currency)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def zero? = @amount.zero?
|
|
94
|
+
def positive? = @amount.positive?
|
|
95
|
+
def negative? = @amount.negative?
|
|
96
|
+
|
|
97
|
+
def <=>(other)
|
|
98
|
+
return nil unless other.is_a?(self.class)
|
|
99
|
+
|
|
100
|
+
assert_same_currency!(other)
|
|
101
|
+
@amount <=> other.amount
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def ==(other)
|
|
105
|
+
other.is_a?(self.class) && other.currency == @currency && other.amount == @amount
|
|
106
|
+
end
|
|
107
|
+
alias eql? ==
|
|
108
|
+
|
|
109
|
+
def hash = [@amount, @currency].hash
|
|
110
|
+
|
|
111
|
+
def symbol = CURRENCIES.fetch(@currency)[:symbol]
|
|
112
|
+
def currency_word = CURRENCIES.fetch(@currency)[@amount.abs == 1 ? :singular : :plural]
|
|
113
|
+
|
|
114
|
+
# "L 1,234.56" — the symbol satisfies Art. 11 num. 1 lit. j).
|
|
115
|
+
def to_s
|
|
116
|
+
integer, fraction = to_fixed.delete("-").split(".")
|
|
117
|
+
grouped = integer.reverse.scan(/\d{1,3}/).join(",").reverse
|
|
118
|
+
"#{"-" if @amount.negative?}#{symbol} #{grouped}.#{fraction}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def inspect = "#<Invoicehn::Money #{self}>"
|
|
122
|
+
|
|
123
|
+
# Always two decimals: BigDecimal#to_s("F") would render 900 as "900.0",
|
|
124
|
+
# and a fiscal amount must show both centavo digits.
|
|
125
|
+
def to_fixed
|
|
126
|
+
rounded = @amount.round(SCALE, BigDecimal::ROUND_HALF_UP)
|
|
127
|
+
integer, fraction = rounded.abs.to_s("F").split(".")
|
|
128
|
+
"#{"-" if rounded.negative?}#{integer}.#{fraction.to_s.ljust(SCALE, "0")[0, SCALE]}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Serialised as a string so no downstream JSON parser can turn it into a Float.
|
|
132
|
+
def to_h
|
|
133
|
+
{ "amount" => to_fixed, "currency" => @currency }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def self.from_h(hash)
|
|
137
|
+
new(hash["amount"] || hash[:amount], hash["currency"] || hash[:currency] || "HNL")
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
def combine(other)
|
|
143
|
+
assert_same_currency!(other)
|
|
144
|
+
self.class.new(yield(@amount, other.amount), @currency)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def assert_same_currency!(other)
|
|
148
|
+
return if other.currency == @currency
|
|
149
|
+
|
|
150
|
+
raise CurrencyMismatch, "no se pueden combinar #{@currency} y #{other.currency}"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Accepts Integer, BigDecimal, Rational, or a numeric String. Float is
|
|
154
|
+
# rejected outright: admitting it here would let binary rounding error into
|
|
155
|
+
# a figure the statute makes criminal to get wrong.
|
|
156
|
+
def coerce_to_decimal(value)
|
|
157
|
+
case value
|
|
158
|
+
when BigDecimal then value
|
|
159
|
+
when Integer then BigDecimal(value)
|
|
160
|
+
when Rational then BigDecimal(value, 20)
|
|
161
|
+
when Money then value.amount
|
|
162
|
+
when String
|
|
163
|
+
begin
|
|
164
|
+
BigDecimal(value)
|
|
165
|
+
rescue ArgumentError
|
|
166
|
+
raise ValidationError, "importe no numérico: #{value.inspect}"
|
|
167
|
+
end
|
|
168
|
+
when Float
|
|
169
|
+
raise ValidationError,
|
|
170
|
+
"Float no está permitido en importes (Ley del ISV Art. 9); " \
|
|
171
|
+
"use BigDecimal, Integer o String, p. ej. \"#{value}\""
|
|
172
|
+
else
|
|
173
|
+
raise ValidationError, "importe no numérico: #{value.inspect}"
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|