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,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Invoicehn
|
|
7
|
+
# Allocates correlativos, gap-free and without reuse.
|
|
8
|
+
#
|
|
9
|
+
# Art. 10 num. 7 lit. d: "Los ocho dígitos restantes, corresponderán a la
|
|
10
|
+
# numeración correlativa de la Factura que deberá iniciarse en uno
|
|
11
|
+
# (00000001)."
|
|
12
|
+
#
|
|
13
|
+
# Counters are keyed by the (establecimiento, punto de emisión, tipo de
|
|
14
|
+
# documento) triple — the "identificador del documento" — because SAR
|
|
15
|
+
# authorizes a range per emission point and document type (Art. 59), and each
|
|
16
|
+
# such series advances independently.
|
|
17
|
+
#
|
|
18
|
+
# Allocation and persistence happen inside one exclusive file lock, so two
|
|
19
|
+
# processes issuing at the same moment cannot land on the same number or skip
|
|
20
|
+
# one.
|
|
21
|
+
class Sequence
|
|
22
|
+
attr_reader :config
|
|
23
|
+
|
|
24
|
+
def initialize(config = Config.new)
|
|
25
|
+
@config = config
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The number that would be issued next, without consuming it.
|
|
29
|
+
def peek(identifier)
|
|
30
|
+
with_lock(shared: true) do
|
|
31
|
+
counters = read_counters
|
|
32
|
+
build(identifier, (counters[identifier] || 0) + 1)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def issued_count(identifier)
|
|
37
|
+
with_lock(shared: true) { read_counters[identifier] || 0 }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Consumes and returns the next correlativo. The block, if given, runs
|
|
41
|
+
# inside the lock and receives the allocated number — pass the persistence
|
|
42
|
+
# step in so allocation and recording are one atomic step and a crash cannot
|
|
43
|
+
# burn a number without producing a document.
|
|
44
|
+
def allocate(identifier)
|
|
45
|
+
with_lock do
|
|
46
|
+
counters = read_counters
|
|
47
|
+
correlative = build(identifier, (counters[identifier] || 0) + 1)
|
|
48
|
+
|
|
49
|
+
result = block_given? ? yield(correlative) : correlative
|
|
50
|
+
|
|
51
|
+
counters[identifier] = correlative.sequence
|
|
52
|
+
write_counters(counters)
|
|
53
|
+
|
|
54
|
+
result
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Aligns the counter with an authorization that does not start at 1 — a
|
|
59
|
+
# successor range continuing the numbering, for instance.
|
|
60
|
+
def align_to(authorization)
|
|
61
|
+
with_lock do
|
|
62
|
+
counters = read_counters
|
|
63
|
+
current = counters[authorization.identifier] || 0
|
|
64
|
+
floor = authorization.range_start.sequence - 1
|
|
65
|
+
|
|
66
|
+
if current < floor
|
|
67
|
+
counters[authorization.identifier] = floor
|
|
68
|
+
write_counters(counters)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
counters[authorization.identifier]
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def counters = with_lock(shared: true) { read_counters }
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def build(identifier, sequence)
|
|
80
|
+
establishment, emission_point, document_type = identifier.to_s.split("-")
|
|
81
|
+
|
|
82
|
+
unless establishment && emission_point && document_type
|
|
83
|
+
raise ValidationError,
|
|
84
|
+
"identificador inválido: #{identifier.inspect} (se espera NNN-NNN-NN)"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
Correlative.new(establishment: establishment, emission_point: emission_point,
|
|
88
|
+
document_type: document_type, sequence: sequence)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def read_counters
|
|
92
|
+
path = @config.sequences_path
|
|
93
|
+
return {} unless File.exist?(path)
|
|
94
|
+
|
|
95
|
+
content = File.read(path)
|
|
96
|
+
return {} if content.strip.empty?
|
|
97
|
+
|
|
98
|
+
JSON.parse(content)
|
|
99
|
+
rescue JSON::ParserError => e
|
|
100
|
+
raise Error, "contador de correlativos dañado en #{path}: #{e.message}"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def write_counters(counters)
|
|
104
|
+
path = @config.sequences_path
|
|
105
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
106
|
+
tmp = "#{path}.tmp#{Process.pid}"
|
|
107
|
+
File.write(tmp, "#{JSON.pretty_generate(counters)}\n")
|
|
108
|
+
File.rename(tmp, path)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# flock on a dedicated lock file. The lock file is never truncated, so
|
|
112
|
+
# holding it costs nothing and it survives as long as the data directory.
|
|
113
|
+
def with_lock(shared: false)
|
|
114
|
+
FileUtils.mkdir_p(File.dirname(@config.lock_path))
|
|
115
|
+
|
|
116
|
+
File.open(@config.lock_path, File::RDWR | File::CREAT, 0o644) do |lock|
|
|
117
|
+
lock.flock(shared ? File::LOCK_SH : File::LOCK_EX)
|
|
118
|
+
begin
|
|
119
|
+
yield
|
|
120
|
+
ensure
|
|
121
|
+
lock.flock(File::LOCK_UN)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Invoicehn
|
|
4
|
+
# Renders an amount as Spanish words for the "importe total en números y
|
|
5
|
+
# letras" of Art. 11 num. 1 lit. k).
|
|
6
|
+
#
|
|
7
|
+
# The currency name is a parameter, not a constant: a USD invoice must read
|
|
8
|
+
# DÓLARES. Hard-coding LEMPIRAS would make the document misstate its own
|
|
9
|
+
# amount.
|
|
10
|
+
module SpanishNumerals
|
|
11
|
+
UNITS = %w[
|
|
12
|
+
CERO UNO DOS TRES CUATRO CINCO SEIS SIETE OCHO NUEVE DIEZ
|
|
13
|
+
ONCE DOCE TRECE CATORCE QUINCE DIECISÉIS DIECISIETE DIECIOCHO DIECINUEVE
|
|
14
|
+
].freeze
|
|
15
|
+
|
|
16
|
+
TENS = {
|
|
17
|
+
20 => "VEINTE", 30 => "TREINTA", 40 => "CUARENTA", 50 => "CINCUENTA",
|
|
18
|
+
60 => "SESENTA", 70 => "SETENTA", 80 => "OCHENTA", 90 => "NOVENTA"
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
# 21-29 contract into a single word and carry an accent on 22, 23 and 26.
|
|
22
|
+
TWENTIES = {
|
|
23
|
+
21 => "VEINTIUNO", 22 => "VEINTIDÓS", 23 => "VEINTITRÉS", 24 => "VEINTICUATRO",
|
|
24
|
+
25 => "VEINTICINCO", 26 => "VEINTISÉIS", 27 => "VEINTISIETE", 28 => "VEINTIOCHO",
|
|
25
|
+
29 => "VEINTINUEVE"
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
HUNDREDS = {
|
|
29
|
+
100 => "CIENTO", 200 => "DOSCIENTOS", 300 => "TRESCIENTOS", 400 => "CUATROCIENTOS",
|
|
30
|
+
500 => "QUINIENTOS", 600 => "SEISCIENTOS", 700 => "SETECIENTOS",
|
|
31
|
+
800 => "OCHOCIENTOS", 900 => "NOVECIENTOS"
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
MAX = 999_999_999_999
|
|
35
|
+
|
|
36
|
+
module_function
|
|
37
|
+
|
|
38
|
+
# Money -> "MIL DOSCIENTOS TREINTA Y CUATRO LEMPIRAS CON 56/100"
|
|
39
|
+
def money_to_words(money)
|
|
40
|
+
rounded = money.round_statutory.amount
|
|
41
|
+
negative = rounded.negative?
|
|
42
|
+
integer, cents = rounded.abs.to_s("F").split(".")
|
|
43
|
+
integer = integer.to_i
|
|
44
|
+
cents = cents.to_s.ljust(2, "0")[0, 2]
|
|
45
|
+
|
|
46
|
+
noun = integer == 1 ? singular_of(money.currency) : plural_of(money.currency)
|
|
47
|
+
|
|
48
|
+
# "uno" apocopates directly before the noun: un lempira, veintiún
|
|
49
|
+
# lempiras, ciento un dólares. Appending the raw cardinal would print
|
|
50
|
+
# "UNO LEMPIRA", which is not Spanish — and this is the line an accountant
|
|
51
|
+
# reads aloud.
|
|
52
|
+
words = "#{apocopate(integer_to_words(integer))} #{noun} CON #{cents}/100"
|
|
53
|
+
negative ? "MENOS #{words}" : words
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def singular_of(currency) = Money::CURRENCIES.fetch(currency.to_s.upcase)[:singular]
|
|
57
|
+
def plural_of(currency) = Money::CURRENCIES.fetch(currency.to_s.upcase)[:plural]
|
|
58
|
+
|
|
59
|
+
# Cardinal in the masculine form, which is what both LEMPIRA and DÓLAR take.
|
|
60
|
+
def integer_to_words(number)
|
|
61
|
+
number = Integer(number)
|
|
62
|
+
raise ValidationError, "número negativo" if number.negative?
|
|
63
|
+
raise ValidationError, "número fuera de rango: #{number}" if number > MAX
|
|
64
|
+
|
|
65
|
+
return UNITS[0] if number.zero?
|
|
66
|
+
|
|
67
|
+
millions, remainder = number.divmod(1_000_000)
|
|
68
|
+
parts = []
|
|
69
|
+
parts << millions_phrase(millions) if millions.positive?
|
|
70
|
+
parts << below_million(remainder) if remainder.positive?
|
|
71
|
+
parts.join(" ")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def millions_phrase(millions)
|
|
75
|
+
# "UN MILLÓN", not "UNO MILLÓN" — uno apocopates before a noun.
|
|
76
|
+
return "UN MILLÓN" if millions == 1
|
|
77
|
+
|
|
78
|
+
"#{apocopate(below_million(millions))} MILLONES"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def below_million(number)
|
|
82
|
+
thousands, remainder = number.divmod(1_000)
|
|
83
|
+
parts = []
|
|
84
|
+
|
|
85
|
+
if thousands.positive?
|
|
86
|
+
# 1000 is "MIL", never "UN MIL".
|
|
87
|
+
parts << (thousands == 1 ? "MIL" : "#{apocopate(below_thousand(thousands))} MIL")
|
|
88
|
+
end
|
|
89
|
+
parts << below_thousand(remainder) if remainder.positive?
|
|
90
|
+
parts.join(" ")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def below_thousand(number)
|
|
94
|
+
return "" if number.zero?
|
|
95
|
+
return "CIEN" if number == 100
|
|
96
|
+
|
|
97
|
+
hundreds = (number / 100) * 100
|
|
98
|
+
remainder = number % 100
|
|
99
|
+
parts = []
|
|
100
|
+
parts << HUNDREDS[hundreds] if hundreds.positive?
|
|
101
|
+
parts << below_hundred(remainder) if remainder.positive?
|
|
102
|
+
parts.join(" ")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def below_hundred(number)
|
|
106
|
+
return "" if number.zero?
|
|
107
|
+
return UNITS[number] if number < 20
|
|
108
|
+
return TWENTIES[number] if TWENTIES.key?(number)
|
|
109
|
+
|
|
110
|
+
tens = (number / 10) * 10
|
|
111
|
+
unit = number % 10
|
|
112
|
+
unit.zero? ? TENS[tens] : "#{TENS[tens]} Y #{UNITS[unit]}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# "UNO" becomes "UN" directly before a noun — MIL, MILLONES, or the currency
|
|
116
|
+
# name: veintiún mil, un lempira, ciento un dólares.
|
|
117
|
+
def apocopate(phrase)
|
|
118
|
+
phrase
|
|
119
|
+
.sub(/\bVEINTIUNO\z/, "VEINTIÚN")
|
|
120
|
+
.sub(/\bUNO\z/, "UN")
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "date"
|
|
6
|
+
|
|
7
|
+
module Invoicehn
|
|
8
|
+
module Storage
|
|
9
|
+
# Append-only store for issued documents, plus the issuer profile and the
|
|
10
|
+
# authorizations on file.
|
|
11
|
+
#
|
|
12
|
+
# An issued document is never rewritten in place. The one exception is
|
|
13
|
+
# annulment (Art. 41), which replaces the record with its annulled form —
|
|
14
|
+
# the correlative stays consumed either way, which is what keeps the series
|
|
15
|
+
# auditable.
|
|
16
|
+
class JsonStore
|
|
17
|
+
attr_reader :config
|
|
18
|
+
|
|
19
|
+
def initialize(config = Config.new)
|
|
20
|
+
@config = config
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# --- issuer -------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
def issuer
|
|
26
|
+
data = read_json(@config.issuer_path)
|
|
27
|
+
data && Issuer.from_h(data)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def save_issuer(issuer)
|
|
31
|
+
write_json(@config.issuer_path, issuer.to_h)
|
|
32
|
+
issuer
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# --- authorizations ------------------------------------------------
|
|
36
|
+
|
|
37
|
+
def authorizations
|
|
38
|
+
Array(read_json(@config.authorizations_path)).map { |h| Authorization.from_h(h) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# One identifier accumulates authorizations over time: a range is
|
|
42
|
+
# exhausted, SAR grants a successor, and the old one stays on file as part
|
|
43
|
+
# of the record.
|
|
44
|
+
def add_authorization(authorization)
|
|
45
|
+
existing = authorizations
|
|
46
|
+
duplicate = existing.find do |a|
|
|
47
|
+
a.cai == authorization.cai && a.range_start == authorization.range_start
|
|
48
|
+
end
|
|
49
|
+
raise ValidationError, "esa autorización ya está registrada" if duplicate
|
|
50
|
+
|
|
51
|
+
write_json(@config.authorizations_path, (existing + [authorization]).map(&:to_h))
|
|
52
|
+
authorization
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def authorizations_for(identifier)
|
|
56
|
+
authorizations.select { |a| a.identifier == identifier }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The authorization that should be used next for an identifier: still
|
|
60
|
+
# current, and with room left in its range.
|
|
61
|
+
def active_authorization(identifier, next_sequence: nil, on: Date.today)
|
|
62
|
+
candidates = authorizations_for(identifier).reject { |a| a.expired?(on) }
|
|
63
|
+
return nil if candidates.empty?
|
|
64
|
+
|
|
65
|
+
if next_sequence
|
|
66
|
+
covering = candidates.select { |a| a.covers?(sequence_to_correlative(identifier, next_sequence)) }
|
|
67
|
+
return covering.min_by { |a| a.range_start.sequence } if covering.any?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
candidates.min_by { |a| a.range_start.sequence }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# --- documents -----------------------------------------------------
|
|
74
|
+
|
|
75
|
+
def save_document(invoice)
|
|
76
|
+
path = @config.document_path(invoice.correlative, invoice.issue_date)
|
|
77
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
78
|
+
|
|
79
|
+
if File.exist?(path) && !invoice.annulled?
|
|
80
|
+
raise ImmutableDocument,
|
|
81
|
+
"la factura #{invoice.correlative} ya está registrada y no puede modificarse (Art. 41)"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
write_json(path, invoice.to_h)
|
|
85
|
+
invoice
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def find(correlative)
|
|
89
|
+
correlative = Correlative.parse(correlative.to_s)
|
|
90
|
+
path = Dir.glob(File.join(@config.documents_dir, "*", "*", "#{correlative}.json")).first
|
|
91
|
+
raise DocumentNotFound, "no existe la factura #{correlative}" unless path
|
|
92
|
+
|
|
93
|
+
Invoice.from_h(read_json(path))
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def exists?(correlative)
|
|
97
|
+
Dir.glob(File.join(@config.documents_dir, "*", "*", "#{correlative}.json")).any?
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Documents in chronological order, which is the order Art. 43 requires
|
|
101
|
+
# them to be kept in.
|
|
102
|
+
def all(from: nil, to: nil)
|
|
103
|
+
Dir.glob(File.join(@config.documents_dir, "*", "*", "*.json"))
|
|
104
|
+
.map { |path| Invoice.from_h(read_json(path)) }
|
|
105
|
+
.select { |inv| within?(inv.issue_date, from, to) }
|
|
106
|
+
.sort_by { |inv| [inv.issue_date, inv.correlative.to_s] }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# --- settings ------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
def settings = read_json(@config.settings_path) || {}
|
|
112
|
+
|
|
113
|
+
def save_settings(hash)
|
|
114
|
+
write_json(@config.settings_path, settings.merge(hash))
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
def sequence_to_correlative(identifier, sequence)
|
|
120
|
+
establishment, emission_point, document_type = identifier.split("-")
|
|
121
|
+
Correlative.new(establishment: establishment, emission_point: emission_point,
|
|
122
|
+
document_type: document_type, sequence: sequence)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def within?(date, from, to)
|
|
126
|
+
return false if from && date < from
|
|
127
|
+
return false if to && date > to
|
|
128
|
+
|
|
129
|
+
true
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def read_json(path)
|
|
133
|
+
return nil unless File.exist?(path)
|
|
134
|
+
|
|
135
|
+
content = File.read(path)
|
|
136
|
+
return nil if content.strip.empty?
|
|
137
|
+
|
|
138
|
+
JSON.parse(content)
|
|
139
|
+
rescue JSON::ParserError => e
|
|
140
|
+
raise Error, "archivo dañado en #{path}: #{e.message}"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Written to a temporary file and renamed, so an interrupted write cannot
|
|
144
|
+
# leave a half-written fiscal record behind.
|
|
145
|
+
def write_json(path, data)
|
|
146
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
147
|
+
tmp = "#{path}.tmp#{Process.pid}"
|
|
148
|
+
File.write(tmp, "#{JSON.pretty_generate(data)}\n")
|
|
149
|
+
File.rename(tmp, path)
|
|
150
|
+
data
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Invoicehn
|
|
4
|
+
# The per-rate breakdown an invoice must display.
|
|
5
|
+
#
|
|
6
|
+
# Art. 11 num. 1 requires:
|
|
7
|
+
# g) "Discriminación de los valores exentos, exonerados y de los gravados
|
|
8
|
+
# con alícuota cero, cuando corresponda"
|
|
9
|
+
# h) "Subtotales sujetos a los impuestos discriminados por tarifa o alícuota"
|
|
10
|
+
# i) "Discriminación de los impuestos por tarifa o alícuota"
|
|
11
|
+
# l) "Discriminación de los descuentos y rebajas otorgados" (Acuerdo 725-2018)
|
|
12
|
+
#
|
|
13
|
+
# Acuerdo 725-2018 also added Art. 10 num. 10, "Descuentos y rebajas
|
|
14
|
+
# otorgados", to the *format* requirements. That makes the discounts line part
|
|
15
|
+
# of the invoice layout rather than something shown only when a discount
|
|
16
|
+
# exists — so #discount is always available and the renderers always print it,
|
|
17
|
+
# zero or not.
|
|
18
|
+
#
|
|
19
|
+
# ISV is computed on the summed base per rate and rounded once, rather than
|
|
20
|
+
# rounded per line and then summed. The norm does not settle the question —
|
|
21
|
+
# Ley del ISV Art. 9 speaks of the charge "sobre el precio del artículo
|
|
22
|
+
# vendido o servicio prestado", which reads per item, while Art. 11 lit. h/i
|
|
23
|
+
# require the invoice to *display* a subtotal and a tax per rate. Rounding
|
|
24
|
+
# once per rate is what keeps those printed figures consistent: rounding each
|
|
25
|
+
# line and summing can produce a tax total that does not equal the printed
|
|
26
|
+
# rate times the printed subtotal, and that discrepancy is exactly what an
|
|
27
|
+
# auditor would question. The choice is recorded here because it is a reading
|
|
28
|
+
# of an open point, not a settled rule.
|
|
29
|
+
class TaxSummary
|
|
30
|
+
# One row of the breakdown: everything the document must show for a single
|
|
31
|
+
# tax treatment.
|
|
32
|
+
Bucket = Struct.new(:treatment, :gross, :discount, :base, :isv, keyword_init: true) do
|
|
33
|
+
def total = base + isv
|
|
34
|
+
def empty? = gross.zero? && discount.zero?
|
|
35
|
+
def to_s = "#{treatment}: base #{base}, ISV #{isv}"
|
|
36
|
+
|
|
37
|
+
def to_h
|
|
38
|
+
{
|
|
39
|
+
"treatment" => treatment.key.to_s,
|
|
40
|
+
"label" => treatment.label,
|
|
41
|
+
"rate" => treatment.rate.to_s("F"),
|
|
42
|
+
"gross" => gross.to_h,
|
|
43
|
+
"discount" => discount.to_h,
|
|
44
|
+
"base" => base.to_h,
|
|
45
|
+
"isv" => isv.to_h
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
attr_reader :currency, :buckets
|
|
51
|
+
|
|
52
|
+
def initialize(line_items, currency: "HNL")
|
|
53
|
+
@currency = currency
|
|
54
|
+
@buckets = build_buckets(Array(line_items))
|
|
55
|
+
freeze
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Only the treatments actually present, in the order the law lists them
|
|
59
|
+
# (exento, exonerado, gravado 0%, then the taxed rates).
|
|
60
|
+
def present_buckets = @buckets.reject(&:empty?)
|
|
61
|
+
|
|
62
|
+
def bucket_for(treatment)
|
|
63
|
+
key = TaxTreatment.fetch(treatment).key
|
|
64
|
+
@buckets.find { |b| b.treatment.key == key }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def gross = sum_of(:gross)
|
|
68
|
+
def discount = sum_of(:discount)
|
|
69
|
+
|
|
70
|
+
# The sum of all bases, taxed and untaxed alike — the invoice's subtotal
|
|
71
|
+
# after discounts and before ISV.
|
|
72
|
+
def subtotal = sum_of(:base)
|
|
73
|
+
|
|
74
|
+
def isv_total = sum_of(:isv)
|
|
75
|
+
|
|
76
|
+
def total = subtotal + isv_total
|
|
77
|
+
|
|
78
|
+
# Art. 11 num. 3: "Para respaldar el crédito fiscal en los casos que la
|
|
79
|
+
# factura sustente ventas exentas y gravadas, se reconocerán únicamente las
|
|
80
|
+
# ventas gravadas." This is the portion of the invoice that supports the
|
|
81
|
+
# buyer's crédito fiscal.
|
|
82
|
+
def credito_fiscal_base
|
|
83
|
+
Money.sum(taxed_buckets.map(&:base), currency: @currency)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def taxed_buckets = present_buckets.select { |b| b.treatment.taxed? }
|
|
87
|
+
def untaxed_buckets = present_buckets.reject { |b| b.treatment.taxed? }
|
|
88
|
+
|
|
89
|
+
# True when the invoice mixes taxed and untaxed supplies, which is the
|
|
90
|
+
# condition Art. 11 num. 3 addresses.
|
|
91
|
+
def mixed_supply?
|
|
92
|
+
taxed_buckets.any? && untaxed_buckets.any?
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def to_h
|
|
96
|
+
{
|
|
97
|
+
"currency" => @currency,
|
|
98
|
+
"buckets" => present_buckets.map(&:to_h),
|
|
99
|
+
"gross" => gross.to_h,
|
|
100
|
+
"discount" => discount.to_h,
|
|
101
|
+
"subtotal" => subtotal.to_h,
|
|
102
|
+
"isv_total" => isv_total.to_h,
|
|
103
|
+
"total" => total.to_h
|
|
104
|
+
}
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def build_buckets(line_items)
|
|
110
|
+
TaxTreatment.all.map do |treatment|
|
|
111
|
+
lines = line_items.select { |item| item.treatment == treatment }
|
|
112
|
+
|
|
113
|
+
gross = Money.sum(lines.map(&:gross), currency: @currency).round_statutory
|
|
114
|
+
discount = Money.sum(lines.map(&:discount), currency: @currency).round_statutory
|
|
115
|
+
|
|
116
|
+
# Rounded once here, then taxed — so the printed ISV equals the printed
|
|
117
|
+
# rate applied to the printed base.
|
|
118
|
+
base = Money.sum(lines.map(&:taxable_base), currency: @currency).round_statutory
|
|
119
|
+
isv = (base * treatment.rate).round_statutory
|
|
120
|
+
|
|
121
|
+
Bucket.new(treatment: treatment, gross: gross, discount: discount, base: base, isv: isv).freeze
|
|
122
|
+
end.freeze
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def sum_of(field)
|
|
126
|
+
Money.sum(@buckets.map { |b| b.public_send(field) }, currency: @currency)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
module Invoicehn
|
|
6
|
+
# How a line is treated for Impuesto Sobre Ventas.
|
|
7
|
+
#
|
|
8
|
+
# Art. 11 num. 1 lit. g) requires "Discriminación de los valores exentos,
|
|
9
|
+
# exonerados y de los gravados con alícuota cero", and lit. h) and i) require
|
|
10
|
+
# subtotals and taxes broken out "por tarifa o alícuota". Three of these
|
|
11
|
+
# categories yield no tax but must still be told apart on the face of the
|
|
12
|
+
# document, so the treatment is modelled as an identity rather than as a bare
|
|
13
|
+
# rate.
|
|
14
|
+
#
|
|
15
|
+
# Rates come from Decreto 278-2013 Art. 16 (La Gaceta 33,316, in force
|
|
16
|
+
# 1 January 2014), which reformed Art. 6 of the Ley del Impuesto Sobre Ventas:
|
|
17
|
+
# a general rate of 15% and 18% on "las bebidas alcohólicas, cerveza y
|
|
18
|
+
# cigarrillos al igual que los boletos aéreos de clase ejecutiva".
|
|
19
|
+
class TaxTreatment
|
|
20
|
+
include Comparable
|
|
21
|
+
|
|
22
|
+
attr_reader :key, :rate, :label
|
|
23
|
+
|
|
24
|
+
def initialize(key, rate, label, order)
|
|
25
|
+
@key = key.to_sym
|
|
26
|
+
@rate = rate
|
|
27
|
+
@label = label
|
|
28
|
+
@order = order
|
|
29
|
+
freeze
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# No tax applies — the good or service is outside the tax's reach
|
|
33
|
+
# (Ley del ISV Art. 15).
|
|
34
|
+
EXENTO = new(:exento, BigDecimal("0"), "Exento", 0)
|
|
35
|
+
|
|
36
|
+
# Taxable in principle, but the purchaser holds an exoneration. Art. 10
|
|
37
|
+
# num. 8 and Art. 11 num. 4 require the exoneration's supporting numbers on
|
|
38
|
+
# the invoice.
|
|
39
|
+
EXONERADO = new(:exonerado, BigDecimal("0"), "Exonerado", 1)
|
|
40
|
+
|
|
41
|
+
# Taxed at zero. Art. 12: "En caso de exportaciones con mercancías gravadas,
|
|
42
|
+
# por concepto del Impuesto Sobre Ventas, los Obligados Tributarios deben
|
|
43
|
+
# extender la Factura con tasa cero."
|
|
44
|
+
GRAVADO_0 = new(:gravado_0, BigDecimal("0"), "Gravado 0%", 2)
|
|
45
|
+
|
|
46
|
+
# The general rate.
|
|
47
|
+
GRAVADO_15 = new(:gravado_15, BigDecimal("0.15"), "Gravado 15%", 3)
|
|
48
|
+
|
|
49
|
+
# The special rate. Which goods fall here is ambiguously drafted across
|
|
50
|
+
# sources — the decree names only "clase ejecutiva" for air tickets while
|
|
51
|
+
# the Art. 6 enumeration it amends adds "otros productos elaborados de
|
|
52
|
+
# tabaco". Deciding whether a given product qualifies is the operator's
|
|
53
|
+
# call; this library only computes once that call is made.
|
|
54
|
+
GRAVADO_18 = new(:gravado_18, BigDecimal("0.18"), "Gravado 18%", 4)
|
|
55
|
+
|
|
56
|
+
ALL = {
|
|
57
|
+
exento: EXENTO,
|
|
58
|
+
exonerado: EXONERADO,
|
|
59
|
+
gravado_0: GRAVADO_0,
|
|
60
|
+
gravado_15: GRAVADO_15,
|
|
61
|
+
gravado_18: GRAVADO_18
|
|
62
|
+
}.freeze
|
|
63
|
+
|
|
64
|
+
GENERAL_RATE = BigDecimal("0.15")
|
|
65
|
+
SPECIAL_RATE = BigDecimal("0.18")
|
|
66
|
+
|
|
67
|
+
class << self
|
|
68
|
+
def fetch(key)
|
|
69
|
+
return key if key.is_a?(TaxTreatment)
|
|
70
|
+
|
|
71
|
+
ALL.fetch(key.to_s.to_sym) do
|
|
72
|
+
raise ValidationError,
|
|
73
|
+
"tratamiento fiscal desconocido: #{key.inspect} (válidos: #{ALL.keys.join(", ")})"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def all = ALL.values
|
|
78
|
+
|
|
79
|
+
# The categories that must be shown separately even though they carry no
|
|
80
|
+
# tax (Art. 11 num. 1 lit. g).
|
|
81
|
+
def untaxed = [EXENTO, EXONERADO, GRAVADO_0]
|
|
82
|
+
|
|
83
|
+
def taxed = [GRAVADO_15, GRAVADO_18]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def taxed? = @rate.positive?
|
|
87
|
+
def untaxed? = !taxed?
|
|
88
|
+
def exonerado? = @key == :exonerado
|
|
89
|
+
def exento? = @key == :exento
|
|
90
|
+
|
|
91
|
+
# 15% renders as "15%", not "15.0%".
|
|
92
|
+
def rate_percent = (@rate * 100).to_i
|
|
93
|
+
|
|
94
|
+
def to_s = @label
|
|
95
|
+
def to_sym = @key
|
|
96
|
+
def inspect = "#<Invoicehn::TaxTreatment #{@key}>"
|
|
97
|
+
|
|
98
|
+
def <=>(other) = other.is_a?(self.class) ? @order <=> other.instance_variable_get(:@order) : nil
|
|
99
|
+
|
|
100
|
+
def ==(other) = other.is_a?(self.class) && other.key == @key
|
|
101
|
+
alias eql? ==
|
|
102
|
+
|
|
103
|
+
def hash = @key.hash
|
|
104
|
+
|
|
105
|
+
# The five categories above are the complete set the law recognises; a
|
|
106
|
+
# sixth cannot be invented at runtime.
|
|
107
|
+
private_class_method :new
|
|
108
|
+
end
|
|
109
|
+
end
|
data/lib/invoicehn.rb
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "invoicehn/version"
|
|
4
|
+
require_relative "invoicehn/errors"
|
|
5
|
+
require_relative "invoicehn/locale"
|
|
6
|
+
require_relative "invoicehn/money"
|
|
7
|
+
require_relative "invoicehn/spanish_numerals"
|
|
8
|
+
require_relative "invoicehn/rtn"
|
|
9
|
+
require_relative "invoicehn/correlative"
|
|
10
|
+
require_relative "invoicehn/tax_treatment"
|
|
11
|
+
require_relative "invoicehn/line_item"
|
|
12
|
+
require_relative "invoicehn/tax_summary"
|
|
13
|
+
require_relative "invoicehn/issuer"
|
|
14
|
+
require_relative "invoicehn/customer"
|
|
15
|
+
require_relative "invoicehn/authorization"
|
|
16
|
+
require_relative "invoicehn/exchange_rate"
|
|
17
|
+
require_relative "invoicehn/compliance/violation"
|
|
18
|
+
require_relative "invoicehn/compliance/validator"
|
|
19
|
+
require_relative "invoicehn/invoice"
|
|
20
|
+
require_relative "invoicehn/config"
|
|
21
|
+
require_relative "invoicehn/storage/json_store"
|
|
22
|
+
require_relative "invoicehn/sequence"
|
|
23
|
+
require_relative "invoicehn/ledger"
|
|
24
|
+
require_relative "invoicehn/issuance"
|
|
25
|
+
require_relative "invoicehn/renderers/text"
|
|
26
|
+
require_relative "invoicehn/renderers/json"
|
|
27
|
+
require_relative "invoicehn/renderers/pdf"
|
|
28
|
+
|
|
29
|
+
# Facturación electrónica/computarizada para Honduras conforme al Reglamento del
|
|
30
|
+
# Régimen de Facturación (Acuerdo 481-2017 y sus reformas 609-2017, 725-2018 y
|
|
31
|
+
# 817-2018).
|
|
32
|
+
#
|
|
33
|
+
# This library enforces *document content* compliance. It cannot confer
|
|
34
|
+
# authorization: registering in the Régimen de Facturación (Art. 45), enrolling
|
|
35
|
+
# as autoimpresor (Art. 47), filing the Declaración Jurada for a sistema
|
|
36
|
+
# computarizado (Art. 53), and obtaining the CAI, authorized range and fecha
|
|
37
|
+
# límite de emisión (Arts. 59-61) all remain the operator's obligations before
|
|
38
|
+
# any document produced here is valid.
|
|
39
|
+
module Invoicehn
|
|
40
|
+
end
|