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,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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Invoicehn
4
+ VERSION = "0.1.0"
5
+ 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