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