Prohlížeč zdrojového kódu
app/examples/invoice_calculator/invoice_calculator.rb
require 'bigdecimal'
require 'bigdecimal/util'
require 'set'
class InvoiceCalculator
# Represents an item on an invoice.
class Item
attr_reader :description, :tags, :vat_rate, :unit_price, :unit_price_with_vat, :count, :unit, :extras, :total, :vat, :total_with_vat, :currency, :precision
# Arguments:
# `description` - text description of item
# `vat_rate` - in percents (required)
# `count` - used to compute total from unit price, defaults to 1
# `unit` - for display only
# `tags` - array of tags or string of tags separated with space, used for item filtering
# `extras` - arbitrary information attached to the item
# `billed` - if false, item is for show only, not counted towards totals, defaults to true
# `currency` - currency code (required)
# `precision` - computed values will be rounded to this many decimal places, defaults to 2
#
# Price must be supplied either as:
# (`unit_price` and/or `unit_price_with_vat` and `count` (defaults to 1)
# or
# (`total` and/or `total_with_vat`)
#
# If price is specified with VAT, item will be marked as total_with_vat_locked?
# If price is specified without VAT, item will be marked as total_locked?
# When both are locked, item cannot be corrected in `finalize`
#
# All numbers must be supplied as Integer or BigDecimal
def initialize(description: nil, vat_rate:, unit_price: nil, unit_price_with_vat: nil, total_price: nil, total_price_with_vat: nil, count: 1, unit: nil, tags: [], extras: {}, billed: true, currency: , precision: 2)
tags = tags.split(" ") if tags.is_a?(String)
@description = description
@tags = tags.is_a?(Array) ? tags : tags.split(" ")
@extras = extras
@unit = unit
@billed = billed
@precision = precision
@vat_rate = ensure_precision!(vat_rate)
@unit_price = ensure_precision(unit_price)
@unit_price_with_vat = ensure_precision(unit_price_with_vat)
@total = ensure_precision(total_price)
@total_with_vat = ensure_precision(total_price_with_vat)
@total_unlocked = true
@total_with_vat_unlocked = true
@currency = currency
raise ArgumentError, "unit_price, unit_price_with_vat, total_price or total_price_with_vat is required" if @unit_price.nil? and @unit_price_with_vat.nil? and @total.nil? and @total_with_vat.nil?
raise ArgumentError, "currency is required" if @currency.nil?
@count = ensure_precision!(count)
@total ||= (@unit_price * @count).round(precision) if @unit_price
@total_unlocked = false if @total
@total_with_vat ||= (@unit_price_with_vat * @count).round(precision) if @unit_price_with_vat
@total_with_vat_unlocked = false if @total_with_vat
# Derive the missing side(s)
if @total && @total_with_vat
@vat = @total_with_vat - @total
elsif @total
@vat = (@total * (@vat_rate / 100)).round(precision)
@total_with_vat = @total + @vat
elsif @total_with_vat
@vat = (@total_with_vat * (@vat_rate / (@vat_rate + 100))).round(precision)
@total = @total_with_vat - @vat
else
raise "This should have never happened."
end
end
# BigDecimal is required throughout to avoid floating-point drift in calculations.
def ensure_precision!(num)
raise ArgumentError, "Integer or BigDecimal required as input to InvoiceCalculator" unless num.is_a?(Integer) || num.is_a?(BigDecimal)
num.to_d
end
def ensure_precision(num)
num && ensure_precision!(num)
end
def total_unlocked?
@total_unlocked
end
def total_with_vat_unlocked?
@total_with_vat_unlocked
end
def billed?
@billed
end
# Shifts `delta` between vat and the unlocked total so the sum stays constant.
# Raises if the adjustment would flip the sign of either field (sanity check).
def apply_vat_delta(delta)
if total_unlocked?
@vat += delta
@total -= delta
raise "VAT gone mental and changed its sign" if @vat * @total < 0
elsif total_with_vat_unlocked?
@vat += delta
@total_with_vat += delta
raise "VAT gone mental and changed its sign" if @vat * @total < 0
else
raise "Cannot apply_vat_delta to locked item."
end
end
# friendly output for debugging
def to_s
" #<Item: #{description} #{vat_rate}%: #{unit_price} x #{count}: total=#{total} vat=#{vat} total_with_vat=#{total_with_vat} currency=#{currency} ##{tags}#{billed? ? "" : " NOT BILLED" }>"
end
# adds other item to self, keeps description, tags and extras of the first argument
def +(other)
raise ArgumentError("Incompatible items #{self}, #{other}") unless vat_rate == other.vat_rate && unit == other.unit && unit_price == other.unit_price && unit_price_with_vat == other.unit_price_with_vat && billed? == other.billed? && currency == other.currency
self.class.new(
description: description,
vat_rate: vat_rate,
unit: unit,
unit_price: unit_price,
unit_price_with_vat: unit_price_with_vat,
total_price: total + other.total,
total_price_with_vat: total_with_vat + other.total_with_vat,
count: count + other.count,
billed: billed?,
tags: tags.dup,
extras: extras.dup,
currency: currency
)
end
end
# A container for holding sums generated by `sum` method on an Invoice.
# Holds either overall aggregate (@sum) and per-VAT-rate aggregates (@subs) accessible through [] operator,
# or just one subaggregate (@subs will be empty).
#
# This way both types of sums share the same interface.
#
# Example:
# invoice_calculator.sum => `Sum` - overall sum with per-VAT-rate aggregates
# invoice_calculator.sum[21] => `Sum` - sum of items with given VAT rate
class Sum
# `sums` Hash<vat_rate, hash_with_summed_attributes>
# nil key holds the overall aggregate and will be stored in @sum
# aggregates for individual VAT rates (if present) will be stored in @subs
#
# See: `InvoiceCalculator#sum`
def initialize(currency, sums)
@sum = {}
@subs = {}
@currency = currency
sums.each do |k, v|
if k.nil?
@sum = v
else
@subs[k.to_d] = self.class.new(currency, nil => v)
end
end
end
# delegate to @sum
def values_at(*keys)
@sum.values_at(*keys)
end
# Returns the sub-sum for a specific VAT rate, or a zero-filled placeholder when
# no items with that rate exist (avoids nil checks in templates).
def [](vat_rate)
@subs[vat_rate.to_d] || Sum.new(@currency, nil => {currency: @currency, total: 0.to_d, vat: 0.to_d, total_with_vat: 0.to_d, vat_rate: vat_rate.to_d, count: 0})
end
def to_h
@sum
end
# Iterates per VAT rate when sub-sums exist, or yields self for a flat sum.
# Skips zero-total sums to avoid blank rows in invoice templates.
def each_vat_rate
if @subs.any?
@subs.each do |_, sum|
yield sum
end
else
yield self unless self.total.zero?
end
end
# Delegate common aggregate field accessors directly to the underlying hash.
[:total, :vat, :total_with_vat, :unit, :vat_rate, :count, :extras].each do |method|
define_method(method) do
@sum[method]
end
end
# friendly output for debugging
def to_s
" #<Sum #{@currency} #{@sum.except(:extras).collect {|k, v| "#{k}=#{v}"}.join(" ")}" + (@subs.any? ? "\n" + @subs.collect {|_, s| " #{s}"}.join("\n") + "\n " : "") + ">"
end
end
# InvoiceCalculator itself starts here
attr_reader :items, :vat_summary, :totals, :currency, :precision
def initialize(currency: 'CZK', precision: 2)
@items = []
@totals = nil
@currency = currency
@precision = precision
end
# Convenience shortcuts that delegate to the overall sum.
def total
totals&.total
end
def vat
totals&.vat
end
def total_with_vat
totals&.total_with_vat
end
# Adding an item invalidates the cached totals so they are recalculated on next access.
# See: InvoiceCalculator::Item for argument descriptions
def add_item(description: nil, vat_rate:, unit_price: nil, unit_price_with_vat: nil, total_price: nil, total_price_with_vat: nil, count: 1, unit: nil, tags: [], extras: {}, billed: true, currency: @currency, precision: @precision)
@totals = nil
@items << Item.new(description: description, vat_rate: vat_rate, unit_price: unit_price, unit_price_with_vat: unit_price_with_vat, total_price: total_price, total_price_with_vat: total_price_with_vat, count: count, unit: unit, tags: tags, extras: extras, billed: billed, currency: currency, precision: precision)
end
# Updates items for each vat_rate in such way so
# sum(:total) + sum(:vat) = sum(:total_with_vat)
# AND
# sum(:vat) = sum(:total) * (vat_rate / 100)
# OR (in case that all items have total_with_vat_locked?)
# sum(:vat) = sum(:total_with_vat) * ( vat_rate / (vat_rate + 100) )
#
# `max_delta_per_item` - finalize will fail if correction for any item gets higher than this to satisfy previous conditions.
# That may happen if there are very bad locked items and not enough unlocked ones.
# Defaults to 2 of least significant digit.
def finalize(max_delta_per_item: 2 * "0.1".to_d ** precision)
# categorizes items by VAT rate
categories = Hash.new {|h, k| h[k] = {vat_rate: k, total: 0, total_with_vat: 0, vat: 0, items: []}}
items.select(&:billed?).each do |item|
cat = categories[item.vat_rate]
cat[:items] << item
cat[:total] += item.total
cat[:vat] += item.vat
cat[:total_with_vat] += item.total_with_vat
end
# corrects each category
categories.values.each do |cat|
if items.any?(&:total_unlocked?)
k = (cat[:vat_rate] / (cat[:vat_rate] + 100))
precise_vat = (cat[:total_with_vat] * k).round(precision)
tweakables = cat[:items].select(&:total_unlocked?)
# difference between VAT from individual items vs. VAT calculated from total
delta = precise_vat - cat[:vat]
# sets corrected total value
cat[:vat] += delta
cat[:total] -= delta
elsif items.any?(&:total_with_vat_unlocked?)
precise_vat = (cat[:total] * (cat[:vat_rate] / 100)).round(precision)
tweakables = cat[:items].select(&:total_with_vat_unlocked?)
# difference between VAT from individual items vs. VAT calculated from total
delta = precise_vat - cat[:vat]
# sets correct total value
cat[:vat] += delta
cat[:total_with_vat] += delta
else
# nothing to tweak...
next
end
if delta != 0
# Distribute the delta proportionally by each item's VAT share.
# Items with zero VAT are excluded as they can't absorb any portion.
tweakables.select! {|t| t.vat != 0}
total_vat = tweakables.sum {|t| t.vat.abs}
raise "Delta too big in `finalize`. Delta #{delta}, tweakables #{tweakables.size}" if delta.abs > max_delta_per_item * tweakables.size
# Iterates over tweakables and applies delta whenever remaining error weighted by tweakable.vat
# is bigger than precision step.
while tweakables.any?
specific_delta = delta / total_vat
tweakable = tweakables.shift
total_vat -= tweakable.vat.abs
tweak = (specific_delta * tweakable.vat.abs).round(precision)
tweakable.apply_vat_delta(tweak)
delta -= tweak
end
end
end
@totals = nil
end
# Filters items by tag inclusion/exclusion. Tags can be passed as separate arguments
# or as a single space-separated string.
#
# All `tags` must match for item to be accepted.
# Any of `except` matching will cause item to be rejected.
def filter_items(*tags, except: [])
tags = tags.first.split(" ") if tags.size == 1 && tags.first.is_a?(String)
except = except.split(" ") if except.is_a?(String)
items.select {|i| tags.all? {|t| i.tags.include?(t)} && except.none? {|t| i.tags.include?(t)}}
end
# Aggregates billed items matching the tag filter into a Sum. Each item is counted
# twice — once in the overall nil-keyed aggregate and once in its VAT-rate bucket —
# so the Sum can answer both flat and per-rate queries without a second pass.
# unit and count are only exposed when all matched items share the same unit.
def sum(*tags, except: [])
sums = Hash.new do |h, k|
h[k] = {
currency: @currency,
total: 0,
vat: 0,
total_with_vat: 0,
unit: Set.new,
vat_rate: Set.new,
count: 0,
extras: [],
}
end
sums[nil] # initialize for case of empty filter
filter_items(*tags, except: except).select(&:billed?).each do |item|
if item.currency != @currency
raise "Item has wrong currency #{item.currency}"
end
# update aggregates for overall sum and for item.vat_rate
[nil, item.vat_rate].each do |key|
s = sums[key]
s[:total] += item.total
s[:vat] += item.vat
s[:total_with_vat] += item.total_with_vat
s[:count] += item.count
s[:unit].add(item.unit)
s[:vat_rate].add(item.vat_rate)
s[:extras] << item.extras
end
end
sums.each do |k, s|
if s[:unit].size == 1
unit, count = s[:unit].first, s[:count]
else
unit, count = nil, nil
end
s.merge!(
unit: unit,
count: count,
vat_rate: s[:vat_rate].size == 1 ? s[:vat_rate].first : nil
)
end
Sum.new(@currency, sums)
end
# Block form of sum — yields the Sum so callers can use it in an ERB assign-and-render pattern.
def with_sum(*tags, except: [], &block)
yield sum(*tags, except: except)
end
# friendly output for debugging
def to_s
"#<InvoiceCalculator(#{@currency}):\n#{items.collect(&:to_s).join("\n")}\n#{totals}\n>"
end
# cached sum for the whole invoice
def totals
@totals ||= sum
end
# Returns a new InvoiceCalculator with all items converted to target_currency.
# `rates` is a hash of "FROM:TO" or "TO:FROM" string keys to BigDecimal multipliers;
# (price_czk = price_eur * "EUR:CZK" OR price_czk = price_eur / "CZK:EUR")
# the inverse rate is applied automatically when only the reverse pair is supplied.
# Locked totals are preserved as locked in the new calculator so finalize behaviour
# remains consistent after conversion.
def convert_to(target_currency, rates, precision: 2)
out = InvoiceCalculator.new(currency: target_currency, precision: precision)
items.each do |item|
if item.currency == target_currency
rate = 1.to_d
elsif rate = rates["#{item.currency}:#{target_currency}"]
raise ArgumentError, "Integer or BigDecimal required as input to InvoiceCalculator" unless rate.is_a?(Integer) || rate.is_a?(BigDecimal)
elsif rate = rates["#{target_currency}:#{item.currency}"]
raise ArgumentError, "Integer or BigDecimal required as input to InvoiceCalculator" unless rate.is_a?(Integer) || rate.is_a?(BigDecimal)
rate = 1.to_d / rate
else
raise ArgumentError, "Missing conversion rate for #{target_currency}:#{item.currency}"
end
rate = rate.to_d
out.add_item(
description: item.description,
tags: item.tags,
vat_rate: item.vat_rate,
unit_price: item.unit_price && (item.unit_price * rate).round(precision),
unit_price_with_vat: item.unit_price_with_vat && (item.unit_price_with_vat * rate).round(precision),
count: item.count,
unit: item.unit,
extras: item.extras,
currency: target_currency,
precision: precision,
total_price: item.total_unlocked? ? nil : (item.total * rate).round(precision),
total_price_with_vat: item.total_with_vat_unlocked? ? nil : (item.total_with_vat * rate).round(precision),
billed: item.billed?
)
end
out
end
end