Kalkulátor faktur

fakturace DPH zaokrouhlování

Fakturace energií koncovým zákazníkům s řadou regulovaných položek a povinností vypočítávat průměrné náklady na dodanou energii není vůbec jednoduchá záležitost. V již rozpracovaném projektu, ke kterému jsem se připojil, byly výpočty rozházené po různých místech ad-hoc (model spotřeby, model faktury, šablona faktury) a slátané horkou jehlou. Když přišla potřeba posílat faktury automaticky do účetního software (Abra Flexibee) a ten některé faktury odmítal přijmout, neboť vypočtená DPH z jednotlivých položek neseděla na DPH za celkovou částku, byl potřeba rázný zásah.

K tomu jsem si vysnil nástroj, který by mi umožnil naházet všechny fakturované položky na jednu hromadu, spočítat DPH za celou fakturu a DPH za jednotlivé položky pak zkorigovat tak, jak jsem vyčetl z účetní příručky, a potom mi zase umožnil s pomocí štítků a filtrovacího systému položky z té hromady vyjmout a vypsat je v patřičných oddílech faktury a jednoduše je sčítat.

A tak jsem takový nástroj vyrobil.

Zdrojový kód

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

Problém 1: Zaokrouhlování DPH

Při počítání DPH po položkách snadno vznikne rozdíl, kdy součet DPH vypočtené pro jednotlivé položky nesedí s DPH vypočtené z celého základu daně za fakturu.

items = [
{ price: 1.07, vat: (1.07 * 0.21).round(2) }, # 0.22
{ price: 1.07, vat: (1.07 * 0.21).round(2) }, # 0.22
{ price: 1.07, vat: (1.07 * 0.21).round(2) }, # 0.22
]
total = items.sum { |i| i[:price] } # 3.21
sum_vat = items.sum { |i| i[:vat] } # 0.66
# Správná DPH z celku: (3.21 * 0.21).round(2) # 0.67
# Odchylka: 0.01 Kč - faktura nesedí!

InvoiceCalculator tento problém řeší metodou finalize: spočítá přesnou DPH z celkového součtu (pro každou sazbu) a odchylku rozdělí proporcionálně mezi položky:

ic = InvoiceCalculator.new(currency: 'CZK')
ic.add_item(unit_price: "1.07".to_d, vat_rate: 21, currency: 'CZK')
ic.add_item(unit_price: "1.07".to_d, vat_rate: 21, currency: 'CZK')
ic.add_item(unit_price: "1.07".to_d, vat_rate: 21, currency: 'CZK')
ic.finalize
ic.items.map(&:vat) # [0.22, 0.23, 0.22] - odchylka rozložena
ic.items.sum(&:vat) # 0.67 - sedí s DPH z celku
ic.sum.vat # 0.67

Metoda funguje i obráceně — pokud jsou zadány ceny s DPH (unit_price_with_vat), opraví místo DPH základ daně.

Zamčení položek

Při opravách (dobropis) potřebujeme zachovat původní hodnotu některých položek. Kalkulátor rozlišuje dva typy „zámku":

finalize upravuje pouze odemčené položky a nikdy nepřesáhne konfigurovaný max_delta_per_item.

Ukázka zaokrouhlení DPH

Jak finalize() opraví odchylky při zaokrouhlování DPH na úrovni jednotlivých položek.

ic = InvoiceCalculator.new(currency: 'CZK')
pocet.to_i.times { ic.add_item(unit_price: cena.to_d, vat_rate: 21, currency: 'CZK') }
sum_vat = ic.items.sum(&:vat)
total = ic.items.sum(&:total)
precise = (total * ("21".to_d / 100)).round(2)
delta = precise - sum_vat
puts "=== Před finalize ==="
puts "Součet DPH z položek: #{sum_vat} Kč"
puts "Přesná DPH z celku: #{precise} Kč"
puts "Odchylka: #{delta} Kč"
ic.finalize
puts ""
puts "=== Po finalize — jednotlivé položky ==="
ic.items.each_with_index do |item, i|
puts "Položka #{"%2d" % (i + 1)}: bez DPH=#{item.total} Kč, DPH=#{item.vat} Kč, s DPH=#{item.total_with_vat} Kč"
end
puts ""
puts "Celkem bez DPH: #{ic.items.sum(&:total)} Kč"
puts "Celkem DPH: #{ic.items.sum(&:vat)} Kč"
puts "Celkem s DPH: #{ic.items.sum(&:total_with_vat)} Kč"

Problém 2: Složitost šablon a výpočtů

Faktura musí zobrazovat různé součty např.: součet za jedno odměrné místo, součet nákladů za 1 MWh, součet nákladů za měsíc, platba za samotnou energii, platba za distribuci. Z toho důvodu si model faktury držel různé druhy položek v různých seznamech a speciálních strukturách na jedno použití. To vše muselo být předpočítáno na různých místech a množství lokálních a instančních proměnných s nejasným významem rostlo. To vše bylo nahrazeno štítky např. "commodity vt el_om_26" znamená, že jde o platbu za dodanou el. energii ve vysokém tarifu pro odběrné místo s ID 26 (proměnná om_tag).

Podrobný výpis položek

Ukázka sekce silové el. energie na faktuře

Výpis takovéto sekce pak v šabloně vypadal takto

<% start, stop, qty, total = nil, nil, 0, 0 %>
<table class="invoice_summary" width="95%" style="margin:20px 0">
<tr style="border-bottom:1px solid black;margin:5px 0">
<th>Doúčtování silové el. energie</th>
<% if items.size > 0 %>
<th style="font-size: 80%">Období</th>
<% end %>
<th class="num" style="font-size: 80%">Množství</th>
<th style="font-size: 80%">Jednotky</th>
<th class="num" style="font-size: 80%">Jedn. cena</th>
<th class="num" style="font-size: 80%">Cena bez DPH</th>
</tr>
<%# VT %>
<% items.each do |item| %>
<tr>
<% start = item[:start] if !start || start > item[:start] %>
<% stop = item[:stop] if !stop || stop < item[:stop] %>
<% qty += item[:qty] %>
<% total += item[:total_vt].round(2) %>
<td>Časové pásmo VT</td>
<% if items.size > 0 %>
<td style="font-size: 80%"><%= "#{item[:start]} - #{item[:stop]}" %></td>
<% end %>
<td class="num" style="font-size: 80%"><%= num3(item[:qty_vt].to_f / 1000) %></td>
<td style="font-size: 80%">MWh</td>
<td class='num' style="font-size: 80%"><%= num2 item[:price_vt] %></td>
<td class="num"><%= num2 item[:total_vt] %></td>
</tr>
<% end %>
<%# NT %>
<% items.each do |item| %>
<% next if item[:qty_nt].to_f == 0 %>
<tr>
<% total += item[:total_nt].round(2) %>
<td>Časové pásmo NT</td>
<% if items.size > 0 %>
<td style="font-size: 80%"><%= "#{item[:start]} - #{item[:stop]}" %></td>
<% end %>
<td class="num" style="font-size: 80%"><%= num3(item[:qty_nt].to_f / 1000) %></td>
<td style="font-size: 80%">MWh</td>
<td class='num' style="font-size: 80%"><%= num2 item[:price_nt] %></td>
<td class="num"><%= num2 item[:total_nt] %></td>
</tr>
<% end %>
<%# monthly %>
<% items.each do |item| %>
<% next if item[:total_month].to_f == 0 %>
<tr>
<% total += item[:total_month].round(2) %>
<td>Pevná cena za měsíc</td>
<% if items.size > 0 %>
<td style="font-size: 80%"><%= "#{item[:start]} - #{item[:stop]}" %></td>
<% end %>
<td class="num" style="font-size: 80%"><%= num3 item[:months] %></td>
<td style="font-size: 80%">měsíc</td>
<td class='num' style="font-size: 80%"><%= num2 item[:price_month] %></td>
<td class="num"><%= num2 item[:total_month] %></td>
</tr>
<% end %>
<%# tax %>
<tr>
<% total += qty * Invoice::TAX_ELECTRICITY / 1000 %>
<td>Spotřební daň</td>
<% if items.size > 0 %>
<td></td>
<% end %>
<td class="num" style="font-size: 80%"><%= num3 qty.to_f / 1000 %></td>
<td style="font-size: 80%">MWh</td>
<td class="num" style="font-size: 80%"><%= num2 Invoice::TAX_ELECTRICITY %></td>
<td class="num"><%= num2 qty * Invoice::TAX_ELECTRICITY / 1000 %></td>
</tr>
<%# total %>
<tr style="border-top:1px solid black;margin:5px 0">
<th <%= 'colspan="2"'.html_safe if items.size > 0 %>>Celkem za období od <%= start %> do <%= stop %></th>
<th class="num"><%= num3 qty.to_f / 1000 %></th>
<th>MWh</th>
<th></th>
<th class="num"><%= num2 total %></th>
</tr>
<% om_total += total %>
</table>

Po zavedení `InvoiceCalculator` takto

<table class="invoice_summary" width="95%" style="margin:20px 0">
<tr style="border-bottom:1px solid black;margin:5px 0">
<th>Doúčtování silové el. energie</th>
<th style="font-size: 80%">Období</th>
<th class="num" style="font-size: 80%">Množství</th>
<th style="font-size: 80%">Jednotky</th>
<th class="num" style="font-size: 80%">Jedn. cena</th>
<th class="num" style="font-size: 80%">Cena bez DPH</th>
</tr>
<%# VT, NT, monthly %>
<% ["vt", "nt", "month"].each do |tag| %>
<% calc.filter_items("commodity", om_tag, tag).each do |item| %>
<tr>
<td><%= item.description %></td>
<td style="font-size: 80%"><%= "#{item.extras[:start]} - #{item.extras[:stop]}" %></td>
<td class="num" style="font-size: 80%"><%= num3(item.count) %></td>
<td style="font-size: 80%"><%= item.unit %></td>
<td class='num' style="font-size: 80%"><%= num2 item.unit_price %></td>
<td class="num"><%= num2 item.total %></td>
</tr>
<% end %>
<% end %>
<%# tax %>
<% if (tax_items = calc.filter_items("commodity", om_tag, "tax")).any? %>
<tr>
<td><%= tax_items.first.description %></td>
<td style="font-size: 80%"></td>
<td class="num" style="font-size: 80%"><%= num3(tax_items.sum(&:count)) %></td>
<td style="font-size: 80%"><%= tax_items.first.unit %></td>
<td class='num' style="font-size: 80%"><%= num2 tax_items.first.unit_price %></td>
<td class="num"><%= num2 tax_items.sum(&:total) %></td>
</tr>
<% end %>
<%# total %>
<tr style="border-top:1px solid black;margin:5px 0">
<th colspan="2">Celkem za období od <%= start %> do <%= stop %></th>
<% calc.with_sum("commodity", om_tag, except: "tax month") do |s| %>
<th class="num"><%= num3 s.count %></th>
<th><%= s.unit %></th>
<% end %>
<th></th>
<th class="num"><%= num2 calc.sum("commodity", om_tag).total %></th>
</tr>
</table>

Rekapitulace jednotlivých kategorií plateb

Ukázka sekce rekapitulace na faktuře

S předpočítanými hodnotami z modelu @invoice

<table class="invoice_summary" width="95%" style="margin:20px 0">
<tr style="border-bottom:1px solid black;margin:5px 0">
<th>REKAPITULACE (celkem za fakturační období)</th>
<th>Sazba DPH</th>
<th>Základ DPH (Kč)</th>
<th>DPH (Kč)</th>
<th>Celkem s DPH (Kč)</th>
</tr>
<tr>
<td>Celkem za silovou elektřinu</td>
<td><%= @invoice.vat_rate %>%</td>
<td class="num"><%= num2 @invoice.energy %></td>
<td class="num"><%= num2 @invoice.energy_vat %></td>
<td class="num"><%= num2 @invoice.energy_with_vat %></td>
</tr>
<tr>
<td>Celkem za regulované služby</td>
<td><%= @invoice.vat_rate %>%</td>
<td class="num"><%= num2 @invoice.total_delivery %></td>
<td class="num"><%= num2 @invoice.distribution_vat %></td>
<td class="num"><%= num2 @invoice.distribution_with_vat %></td>
</tr>
<%# other / irregular payments %>
<% if @invoice.other %>
<% opv = @invoice.other_per_vat_rate %>
<% opv.each do |vat, items| %>
<tr>
<td>Celkem za ostatní služby</td>
<td><%= vat %>%</td>
<td class="num"><%= num2(sum = items.sum{|x| x[:value]}) %></td>
<td class="num"><%= num2(vatsum = sum * vat / 100) %></td>
<td class="num"><%= num2(sum + vatsum) %></td>
<%# TODO this isn't counted in K ÚHRADĚ %>
</tr>
<% end %>
<% end %>
<%# penalizations %>
<% if @invoice.penalties.any? %>
<tr>
<td>Penalizace pozdních plateb</td>
<td><%= 0 %>%</td>
<td class="num"><%= num2 @invoice.penalty_total %></td>
<td class="num"><%= num2 0 %></td>
<td class="num"><%= num2 @invoice.penalty_total %></td>
</tr>
<% end %>
<tr>
<td>Daň z elektřiny</td>
<td><%= @invoice.vat_rate %>%</td>
<td class="num"><%= num2 @invoice.tax %></td>
<td class="num"><%= num2 @invoice.tax_vat %></td>
<td class="num"><%= num2 @invoice.tax_with_vat %></td>
</tr>
<% if @invoice.advance != 0 %>
<tr>
<td>Zúčtované zálohy</td>
<td><%= @invoice.vat_rate %>%</td>
<td class="num"><%= num2 -@invoice.advance %></td>
<td class="num"><%= num2 -@invoice.advance_vat %></td>
<td class="num"><%= num2 -@invoice.advance_with_vat %></td>
</tr>
<% end %>
<tr>
<td>Rozdíl k úhradě</td>
<td><%= @invoice.vat_rate %>%</td>
<td class="num"><%= num2 @invoice.outstanding %></td>
<td class="num"><%= num2 @invoice.outstanding_vat %></td>
<td class="num"><%= num2 @invoice.outstanding_with_vat %></td>
</tr>
<tr>
<td>Zaokrouhlení</td>
<td>&nbsp;</td>
<td>&nbsp;</td>
<td>&nbsp;</td>
<td class="num"><%= num2 @invoice.rounding %></td>
</tr>
<tr style="border-top:1px solid black;margin:5px 0">
<th>K ÚHRADĚ</th>
<th></th>
<th></th>
<th></th>
<th class="num"><b style="font-size: 120%"><%= num0 @invoice.to_pay %></b></th>
</tr>
</table>

S použitím funkce sumy a filtrováním podle štítků

<%
calc = @invoice.calculator
summary = [
["Celkem za silovou elektřinu", calc.sum("commodity", except: "tax")],
["Celkem za regulované služby", calc.sum("delivery")],
["Celkem za ostatní služby", calc.sum("other")],
["Penalizace pozdních plateb", calc.sum("penalty")],
["Daň z elektřiny", calc.sum("tax")],
["Zúčtované zálohy", calc.sum("advance")],
]
%>
<table class="invoice_summary" width="95%" style="margin:20px 0">
<tr style="border-bottom:1px solid black;margin:5px 0">
<th>REKAPITULACE (celkem za fakturační období)</th>
<th>Sazba DPH</th>
<th>Základ DPH (Kč)</th>
<th>DPH (Kč)</th>
<th>Celkem s DPH (Kč)</th>
</tr>
<%# za všechny sekce vyjmenované výše %>
<% summary.each do |title, sum| %>
<% sum.each_vat_rate do |s| %>
<tr>
<td><%= title %></td>
<td><%= num s.vat_rate %>%</td>
<td class="num"><%= num2 s.total %></td>
<td class="num"><%= num2 s.vat %></td>
<td class="num"><%= num2 s.total_with_vat %></td>
</tr>
<% end %>
<% end %>
<% calc.with_sum(except: "rounding") do |s| %>
<tr>
<td>Rozdíl k úhradě</td>
<td><%= s.vat_rate &&num(s.vat_rate) + "%" %></td>
<td class="num"><%= num2 s.total %></td>
<td class="num"><%= num2 s.vat %></td>
<td class="num"><%= num2 s.total_with_vat %></td>
</tr>
<% end %>
<tr>
<td>Zaokrouhlení</td>
<td>&nbsp;</td>
<td>&nbsp;</td>
<td>&nbsp;</td>
<td class="num"><%= num2 calc.sum("rounding").total_with_vat %></td>
</tr>
<tr style="border-top:1px solid black;margin:5px 0">
<th>K ÚHRADĚ</th>
<th></th>
<th></th>
<th></th>
<th class="num"><b style="font-size: 120%"><%= num calc.total_with_vat %></b></th>
</tr>
</table>

Ukázka filtrování položek štítky

Sčítání a filtrování položek faktury podle štítků — základ pro generování dílčích součtů.

ic = InvoiceCalculator.new(currency: 'CZK')
ic.add_item(description: 'Silová el. VT', unit_price: 2340, count: "1.8".to_d, unit: "MWh", vat_rate: 21, tags: 'komodita vt')
ic.add_item(description: 'Silová el. NT', unit_price: 1800, count: "0.7".to_d, unit: "MWh", vat_rate: 21, tags: 'komodita nt')
ic.add_item(description: 'Silová el. měsíc', unit_price: 1800, count: "6.0".to_d, unit: "měs.", vat_rate: 21, tags: 'komodita mesic')
ic.add_item(description: 'Distribuce el. VT', unit_price: 1250, count: "1.8".to_d, unit: "MWh", vat_rate: 21, tags: 'distribuce vt')
ic.add_item(description: 'Distribuce el. NT', unit_price: 900, count: "0.7".to_d, unit: "MWh", vat_rate: 21, tags: 'distribuce nt')
ic.add_item(description: 'Distribuce el. měsíc', unit_price: 1180, count: "6.0".to_d, unit: "měs.", vat_rate: 21, tags: 'distribuce mesic')
ic.add_item(description: 'Penále z prodlení', unit_price: 100, count: "2.3".to_d, unit: "měs.", vat_rate: 0, tags: 'penale')
ic.finalize
puts "=== Celá faktura ==="
puts ic
puts ""
puts "=== Položky dle filtru: '#{filter}', vyjma: '#{except}' ==="
puts ic.filter_items(filter, except: except)
puts ""
puts "=== Suma dle filtru: '#{filter}', vyjma: '#{except}' ==="
puts ic.sum(filter, except: except)
puts ""

Problém 3: Přepočet měn

Později přibyla možnost pro zákazníky zasmluvnit cenu komodity v eurech, nebo možnost nechat si vystavit fakturu v eurech. K tomu bylo potřeba přidat funkci přepočtu měn. Každá položka proto nese informaci o měně, v níž je cena vedena a položky, které nesouhlasí s měnou faktury jsou při finalizaci přepočteny nastaveným kurzem

Ukázka přepočtu měny

Zákazník má zasmluvněnou cenu energie v EUR, regulovaná složka zůstává v CZK. Máte na výběr fakturaci v CZK, EUR nebo INV (neznámý kurz způsobí chybu).

ic = InvoiceCalculator.new(currency: 'CZK')
ic.add_item(description: 'Silová el. VT', unit_price: "102.50".to_d, count: "1.8".to_d, unit: "MWh", vat_rate: 21, currency: "EUR")
ic.add_item(description: 'Silová el. NT', unit_price: "78.20".to_d, count: "0.7".to_d, unit: "MWh", vat_rate: 21, currency: "EUR")
ic.add_item(description: 'Silová el. měsíc', unit_price: 10, count: "6.0".to_d, unit: "měs.", vat_rate: 21, currency: "EUR")
ic.add_item(description: 'Distribuce el. VT', unit_price: 1250, count: "1.8".to_d, unit: "MWh", vat_rate: 21, currency: "CZK")
ic.add_item(description: 'Distribuce el. NT', unit_price: 900, count: "0.7".to_d, unit: "MWh", vat_rate: 21, currency: "CZK")
ic.add_item(description: 'Distribuce el. měsíc', unit_price: 1180, count: "6.0".to_d, unit: "měs.", vat_rate: 21, currency: "CZK")
ic.add_item(description: 'Penále z prodlení', unit_price: 100, count: "2.3".to_d, unit: "měs.", vat_rate: 0, currency: "CZK")
ic = ic.convert_to(currency, {"EUR:CZK" => "24.4".to_d})
ic.finalize
puts "=== Celá faktura ==="
puts ic
puts ""

Testy

Implementace má pochopitelně i kompletní unit testy

Unit test v RSpec

require "rspec/core"
RSpec.reset
RSpec.configuration.start_time = Time.now
RSpec.describe "InvoiceCalculator" do
describe "#finalize" do
context "when having some items that have total_with_vat_unlocked" do
it "tweaks the items VAT so total VAT is more precise" do
ic = InvoiceCalculator.new
ic.add_item(unit_price: "1.07".to_d, vat_rate: 21)
ic.add_item(unit_price: "1.07".to_d, vat_rate: 21)
ic.add_item(unit_price: "1.07".to_d, vat_rate: 21)
# vat summed from individual items (0.66)
expect(ic.items.sum(&:vat)).to eq("0.66".to_d)
# is lower than vat computed from total (0.67)
expect((ic.items.sum(&:total) * "0.21".to_d).round(2)).to eq("0.67".to_d)
# let's fix that
ic.finalize
# now it's correct
expect(ic.items.collect(&:total_with_vat)).to eq ["1.29".to_d, "1.30".to_d, "1.29".to_d]
expect(ic.items.collect(&:vat)).to eq ["0.22".to_d, "0.23".to_d, "0.22".to_d]
expect(ic.items.sum(&:total_with_vat)).to eq("3.88".to_d)
expect(ic.items.sum(&:vat)).to eq("0.67".to_d)
end
it "respects given precision" do
ic = InvoiceCalculator.new(precision: 1)
ic.add_item(unit_price: "1.07".to_d, vat_rate: 21)
ic.add_item(unit_price: "1.07".to_d, vat_rate: 21)
ic.add_item(unit_price: "1.07".to_d, vat_rate: 21)
# vat summed from individual items (0.6)
expect(ic.items.sum(&:vat)).to eq("0.60".to_d)
# is lower than vat computed from total (0.7)
expect((ic.items.sum(&:total) * "0.21".to_d).round(1)).to eq("0.7".to_d)
# let's fix that
ic.finalize
# now it's correct
expect(ic.items.collect(&:total_with_vat)).to eq ["1.3".to_d, "1.4".to_d, "1.3".to_d]
expect(ic.items.collect(&:vat)).to eq ["0.2".to_d, "0.3".to_d, "0.2".to_d]
expect(ic.items.sum(&:total_with_vat)).to eq("4.0".to_d)
expect(ic.items.sum(&:vat)).to eq("0.7".to_d)
end
it "does not tweak locked items" do
ic = InvoiceCalculator.new
ic.add_item(unit_price: "1.07".to_d, unit_price_with_vat: "1.29".to_d, vat_rate: 21)
ic.add_item(unit_price: "1.07".to_d, vat_rate: 21)
ic.add_item(unit_price: "1.07".to_d, unit_price_with_vat: "1.29".to_d, vat_rate: 21)
ic.finalize
expect(ic.items.collect(&:vat)).to eq ["0.22".to_d, "0.23".to_d, "0.22".to_d]
end
end
context "when having some items that have total_unlocked" do
it "tweaks unlocked items" do
ic = InvoiceCalculator.new
ic.add_item(unit_price_with_vat: "1.29".to_d, vat_rate: 21)
ic.add_item(unit_price_with_vat: "1.29".to_d, vat_rate: 21)
ic.add_item(unit_price_with_vat: "1.29".to_d, vat_rate: 21)
k = (21.to_d / 121.to_d).round(4)
# vat summed from individual items (0.66)
expect(ic.items.sum(&:vat)).to eq("0.66".to_d)
# is lower than vat computed from total (0.67)
expect((ic.items.sum(&:total_with_vat) * k).round(2)).to eq("0.67".to_d)
# let's fix that
ic.finalize
# now it's correct
expect(ic.items.collect(&:total)).to eq ["1.07".to_d, "1.06".to_d, "1.07".to_d]
expect(ic.items.collect(&:vat)).to eq ["0.22".to_d, "0.23".to_d, "0.22".to_d]
expect(ic.items.sum(&:total)).to eq("3.2".to_d)
expect(ic.items.sum(&:vat)).to eq("0.67".to_d)
end
it "skips locked items" do
ic = InvoiceCalculator.new
ic.add_item(unit_price: "1.07".to_d, unit_price_with_vat: "1.29".to_d, vat_rate: 21)
ic.add_item(unit_price: "1.07".to_d, unit_price_with_vat: "1.29".to_d, vat_rate: 21)
ic.add_item(unit_price: "1.07".to_d, vat_rate: 21)
ic.finalize
expect(ic.items.collect(&:total_with_vat)).to eq ["1.29".to_d, "1.29".to_d, "1.30".to_d]
end
end
context "with negative delta" do
it "applies delta to items proportionally" do
ic = InvoiceCalculator.new
ic.add_item(unit_price: "0.01".to_d, vat_rate: 21) # vat != precise_vat
ic.add_item(unit_price: 10, vat_rate: 21)
ic.add_item(unit_price: 500, vat_rate: 21)
ic.add_item(unit_price: 1000, vat_rate: 21)
ic.add_item(unit_price: 2000, unit_price_with_vat: 2430, vat_rate: 21) # off by 10
expect(ic.items.collect {|i| [i.currency, i.vat_rate, i.total, i.vat, i.total_with_vat].collect(&:to_s).join(' ')}).
to eq [
"CZK 21.0 0.01 0.0 0.01",
"CZK 21.0 10.0 2.1 12.1",
"CZK 21.0 500.0 105.0 605.0",
"CZK 21.0 1000.0 210.0 1210.0",
"CZK 21.0 2000.0 430.0 2430.0",
]
ic.finalize(max_delta_per_item: 10)
expect(ic.items.collect {|i| [i.currency, i.vat_rate, i.total, i.vat, i.total_with_vat].collect(&:to_s).join(' ')}).
to eq [
"CZK 21.0 0.01 0.0 0.01",
"CZK 21.0 10.0 2.03 12.03",
"CZK 21.0 500.0 101.69 601.69",
"CZK 21.0 1000.0 203.38 1203.38",
"CZK 21.0 2000.0 430.0 2430.0",
]
end
end
context "with positive delta" do
it "applies delta to items proportionally" do
ic = InvoiceCalculator.new
ic.add_item(unit_price: "0.01".to_d, vat_rate: 21) # vat != precise_vat
ic.add_item(unit_price: 10, vat_rate: 21)
ic.add_item(unit_price: 500, vat_rate: 21)
ic.add_item(unit_price: 1000, vat_rate: 21)
ic.add_item(unit_price: 2000, unit_price_with_vat: 2410, vat_rate: 21) # off by 10
expect(ic.items.collect {|i| [i.currency, i.vat_rate, i.total, i.vat, i.total_with_vat].collect(&:to_s).join(' ')}).
to eq [
"CZK 21.0 0.01 0.0 0.01",
"CZK 21.0 10.0 2.1 12.1",
"CZK 21.0 500.0 105.0 605.0",
"CZK 21.0 1000.0 210.0 1210.0",
"CZK 21.0 2000.0 410.0 2410.0",
]
ic.finalize(max_delta_per_item: 10)
expect(ic.items.collect {|i| [i.currency, i.vat_rate, i.total, i.vat, i.total_with_vat].collect(&:to_s).join(' ')}).
to eq [
"CZK 21.0 0.01 0.0 0.01",
"CZK 21.0 10.0 2.17 12.17",
"CZK 21.0 500.0 108.31 608.31",
"CZK 21.0 1000.0 216.62 1216.62",
"CZK 21.0 2000.0 410.0 2410.0",
]
end
end
it "handles different vat rates and locked/unlocked combinations and creates #vat_summary" do
ic = InvoiceCalculator.new
ic.add_item(unit_price: "22.2".to_d, vat_rate: 15, count: 17)
ic.add_item(unit_price: "150.66".to_d, vat_rate: 21, count: "0.57".to_d)
ic.add_item(unit_price_with_vat: "1000".to_d, vat_rate: 21)
ic.add_item(unit_price_with_vat: "1000".to_d, vat_rate: 15)
ic.add_item(unit_price_with_vat: 12, vat_rate: 0)
ic.add_item(unit_price: 2000, unit_price_with_vat: 2425, vat_rate: 21) # off by 5
expect(ic.items.collect {|i| [i.currency, i.vat_rate, i.total, i.vat, i.total_with_vat].collect(&:to_s).join(' ')}).
to eq [
"CZK 15.0 377.4 56.61 434.01",
"CZK 21.0 85.88 18.03 103.91",
"CZK 21.0 826.45 173.55 1000.0",
"CZK 15.0 869.57 130.43 1000.0",
"CZK 0.0 12.0 0.0 12.0",
"CZK 21.0 2000.0 425.0 2425.0",
]
ic.finalize(max_delta_per_item: 20)
expect(ic.items.collect {|i| [i.currency, i.vat_rate, i.total, i.vat, i.total_with_vat].collect(&:to_s).join(' ')}).
to eq [
"CZK 15.0 377.4 56.61 434.01",
"CZK 21.0 85.88 18.03 103.91",
"CZK 21.0 830.57 169.43 1000.0",
"CZK 15.0 869.57 130.43 1000.0",
"CZK 0.0 12.0 0.0 12.0",
"CZK 21.0 2000.0 425.0 2425.0",
]
expect([0, 15, 21].collect {|vat_rate| ic.sum[vat_rate].values_at(:currency, :vat_rate, :total, :vat, :total_with_vat).collect(&:to_s).join " "}).
to eq [
"CZK 0.0 12.0 0.0 12.0",
"CZK 15.0 1246.97 187.04 1434.01",
"CZK 21.0 2916.45 612.46 3528.91",
]
end
it "handles negative items" do
ic = InvoiceCalculator.new
ic.add_item(unit_price: "860.77".to_d, vat_rate: 21)
ic.add_item(unit_price: "860.77".to_d, vat_rate: 21)
ic.add_item(unit_price: "0.01".to_d, vat_rate: 21)
ic.add_item(unit_price: -200, vat_rate: 21)
expect(ic.items.collect {|i| [i.currency, i.vat_rate, i.total, i.vat, i.total_with_vat].collect(&:to_s).join(' ')}).
to eq [
"CZK 21.0 860.77 180.76 1041.53",
"CZK 21.0 860.77 180.76 1041.53",
"CZK 21.0 0.01 0.0 0.01",
"CZK 21.0 -200.0 -42.0 -242.0",
]
ic.finalize
expect(ic.items.collect {|i| [i.currency, i.vat_rate, i.total, i.vat, i.total_with_vat].collect(&:to_s).join(' ')}).
to eq [
"CZK 21.0 860.77 180.76 1041.53",
"CZK 21.0 860.77 180.77 1041.54",
"CZK 21.0 0.01 0.0 0.01",
"CZK 21.0 -200.0 -42.0 -242.0",
]
end
it "handles negative items with big delta" do
ic = InvoiceCalculator.new
ic.add_item(unit_price: "860.77".to_d, unit_price_with_vat: "1051".to_d, vat_rate: 21)
ic.add_item(unit_price: "0.01".to_d, vat_rate: 21)
ic.add_item(unit_price: -200, vat_rate: 21)
expect(ic.items.collect {|i| [i.currency, i.vat_rate, i.total, i.vat, i.total_with_vat].collect(&:to_s).join(' ')}).
to eq [
"CZK 21.0 860.77 190.23 1051.0",
"CZK 21.0 0.01 0.0 0.01",
"CZK 21.0 -200.0 -42.0 -242.0",
]
ic.finalize(max_delta_per_item: 20)
expect(ic.items.collect {|i| [i.currency, i.vat_rate, i.total, i.vat, i.total_with_vat].collect(&:to_s).join(' ')}).
to eq [
"CZK 21.0 860.77 190.23 1051.0",
"CZK 21.0 0.01 0.0 0.01",
"CZK 21.0 -200.0 -51.47 -251.47",
]
end
it "handles locked negative items" do
ic = InvoiceCalculator.new
ic.add_item(unit_price: "860.77".to_d, vat_rate: 21)
ic.add_item(unit_price: "0.01".to_d, vat_rate: 21)
ic.add_item(unit_price: -200, unit_price_with_vat: -230, vat_rate: 21)
expect(ic.items.collect {|i| [i.currency, i.vat_rate, i.total, i.vat, i.total_with_vat].collect(&:to_s).join(' ')}).
to eq [
"CZK 21.0 860.77 180.76 1041.53",
"CZK 21.0 0.01 0.0 0.01",
"CZK 21.0 -200.0 -30.0 -230.0",
]
ic.finalize(max_delta_per_item: 20)
expect(ic.items.collect {|i| [i.currency, i.vat_rate, i.total, i.vat, i.total_with_vat].collect(&:to_s).join(' ')}).
to eq [
"CZK 21.0 860.77 168.76 1029.53",
"CZK 21.0 0.01 0.0 0.01",
"CZK 21.0 -200.0 -30.0 -230.0",
]
end
end
describe "InvoiceCalculator::Item" do
it "complains if you try to pass float" do
expect {InvoiceCalculator::Item.new(unit_price: 1.20, vat_rate: 21, currency: "CZK")}.to raise_error(ArgumentError)
expect {InvoiceCalculator::Item.new(unit_price: 1, vat_rate: 21.0, currency: "CZK")}.to raise_error(ArgumentError)
expect {InvoiceCalculator::Item.new(unit_price: 1, vat_rate: 21, count: 4.5, currency: "CZK")}.to raise_error(ArgumentError)
end
it "complains if currency is not given" do
expect {InvoiceCalculator::Item.new(unit_price: 1, vat_rate: 21, count: 4, currency: "CZK")}.not_to raise_error
expect {InvoiceCalculator::Item.new(unit_price: 1, vat_rate: 21, count: 4)}.to raise_error(ArgumentError)
end
it "computes total from unit price and count" do
i = InvoiceCalculator::Item.new(unit_price: 1, vat_rate: 21, count: 20, currency: "CZK")
expect(i.total).to eq "20.00".to_d
expect(i.vat).to eq "4.20".to_d
expect(i.total_with_vat).to eq "24.20".to_d
end
it "computes missing VAT correctly" do
i = InvoiceCalculator::Item.new(unit_price: 1, vat_rate: 21, currency: "CZK")
expect(i.total).to eq "1.00".to_d
expect(i.vat).to eq "0.21".to_d
expect(i.total_with_vat).to eq "1.21".to_d
i = InvoiceCalculator::Item.new(unit_price_with_vat: "1.21".to_d, vat_rate: 21, currency: "CZK")
expect(i.total).to eq "1.00".to_d
expect(i.vat).to eq "0.21".to_d
expect(i.total_with_vat).to eq "1.21".to_d
i = InvoiceCalculator::Item.new(unit_price: 1, vat_rate: 21, unit_price_with_vat: "1.22".to_d, currency: "CZK")
expect(i.total).to eq "1.00".to_d
expect(i.vat).to eq "0.22".to_d
expect(i.total_with_vat).to eq "1.22".to_d
end
it "marks given value as locked" do
i = InvoiceCalculator::Item.new(unit_price: 1, vat_rate: 21, currency: "CZK")
expect(i.total_unlocked?).to be false
expect(i.total_with_vat_unlocked?).to be true
i = InvoiceCalculator::Item.new(unit_price_with_vat: "1.21".to_d, vat_rate: 21, currency: "CZK")
expect(i.total_unlocked?).to be true
expect(i.total_with_vat_unlocked?).to be false
i = InvoiceCalculator::Item.new(unit_price: 1, unit_price_with_vat: "1.21".to_d, vat_rate: 21, currency: "CZK")
expect(i.total_unlocked?).to be false
expect(i.total_with_vat_unlocked?).to be false
end
describe "#apply_vat_delta" do
context "when total_unlocked?" do
it "subtracts delta from VAT and total_with_vat" do
i = InvoiceCalculator::Item.new(unit_price: 1, vat_rate: 21, currency: "CZK")
expect(i.vat).to eq "0.21".to_d
expect(i.total_with_vat).to eq "1.21".to_d
i.apply_vat_delta("0.01".to_d)
expect(i.vat).to eq "0.22".to_d
expect(i.total_with_vat).to eq "1.22".to_d
end
end
context "when total_with_vat_unlocked?" do
it "adds delta to VAT and adds to total" do
i = InvoiceCalculator::Item.new(unit_price_with_vat: "1.21".to_d, vat_rate: 21, currency: "CZK")
expect(i.vat).to eq "0.21".to_d
expect(i.total).to eq "1.00".to_d
i.apply_vat_delta("0.01".to_d)
expect(i.vat).to eq "0.22".to_d
expect(i.total).to eq "0.99".to_d
end
end
context "when locked" do
it "raises error" do
i = InvoiceCalculator::Item.new(unit_price: 1, unit_price_with_vat: "1.21".to_d, vat_rate: 21, currency: "CZK")
expect {i.apply_vat_delta("0.01".to_d)}.to raise_error("Cannot apply_vat_delta to locked item.")
end
end
end
end
describe "#sum" do
it "sums selected items and returns the result as a Hash" do
ic = InvoiceCalculator.new
ic.add_item(unit_price: 10, vat_rate: 21, tags: ["el_om", "el_om_1", "vt"], count: 20, unit: "kWh")
ic.add_item(unit_price: 5, vat_rate: 21, tags: ["el_om", "el_om_1", "nt"], count: 30, unit: "kWh")
ic.add_item(unit_price: 10, vat_rate: 21, tags: ["el_om", "el_om_2", "vt"], count: 20, unit: "kWh")
ic.add_item(unit_price: 5, vat_rate: 21, tags: ["el_om", "el_om_2", "nt"], count: 30, unit: "kWh")
ic.finalize
expect(ic.sum.to_h).to eq({
total: 700,
vat: 147,
total_with_vat: 847,
vat_rate: 21,
count: 100,
unit: "kWh",
currency: "CZK",
extras: [{}, {}, {}, {}],
})
expect(ic.sum("el_om_1").to_h).to eq({
total: 350,
vat: "73.5".to_d,
total_with_vat: "423.5".to_d,
vat_rate: 21,
count: 50,
unit: "kWh",
currency: "CZK",
extras: [{}, {}],
})
expect(ic.sum("el_om_1", except: "vt").to_h).to eq({
total: 150,
vat: "31.5".to_d,
total_with_vat: "181.5".to_d,
vat_rate: 21,
count: 30,
unit: "kWh",
currency: "CZK",
extras: [{}],
})
end
it "doesn't mix apples and bananas" do
ic = InvoiceCalculator.new
ic.add_item(unit_price: 10, vat_rate: 21, tags: ["el_om", "el_om_1", "vt"], count: 20, unit: "kWh")
ic.add_item(unit_price: 5, vat_rate: 21, tags: ["el_om", "el_om_1", "nt"], count: 30, unit: "kWh")
ic.add_item(unit_price: 10, vat_rate: 21, tags: ["gas_om", "gas_om_2"], count: 20, unit: "m3")
ic.add_item(unit_price: 100, vat_rate: 15, tags: ["books"], count: 2)
ic.finalize
expect(ic.sum.values_at(:currency, :vat_rate, :count, :unit)).to eq ["CZK", nil, nil, nil]
expect(ic.sum("el_om").values_at(:currency, :vat_rate, :count, :unit)).to eq ["CZK", 21, 50, "kWh"]
expect(ic.sum("gas_om").values_at(:currency, :vat_rate, :count, :unit)).to eq ["CZK", 21, 20, "m3"]
expect(ic.sum("books").values_at(:currency, :vat_rate, :count, :unit)).to eq ["CZK", 15, 2, nil]
end
it "allows to access the vat rates" do
ic = InvoiceCalculator.new
ic.add_item(unit_price: 10, vat_rate: 21, tags: ["el_om", "el_om_1", "vt"], count: 20, unit: "kWh")
ic.add_item(unit_price: 5, vat_rate: 21, tags: ["el_om", "el_om_1", "nt"], count: 30, unit: "kWh")
ic.add_item(unit_price: 10, vat_rate: 21, tags: ["gas_om", "gas_om_2"], count: 20, unit: "m3")
ic.add_item(unit_price: 100, vat_rate: 15, tags: ["books"], count: 2)
ic.finalize
expect(ic.sum.total).to eq 750
expect(ic.sum[21].total).to eq 550
expect(ic.sum[15].total_with_vat).to eq 230
end
end
describe "totals" do
it "allows convenient access to total sum of invoice" do
ic = InvoiceCalculator.new
ic.add_item(unit_price: 10, vat_rate: 21, tags: ["el_om", "el_om_1", "vt"], count: 20, unit: "kWh")
ic.add_item(unit_price: 5, vat_rate: 21, tags: ["el_om", "el_om_1", "nt"], count: 30, unit: "kWh")
ic.add_item(unit_price: 10, vat_rate: 21, tags: ["gas_om", "gas_om_2"], count: 20, unit: "m3")
ic.add_item(unit_price: 100, vat_rate: 15, tags: ["books"], count: 2)
# works without finalizing
expect(ic.total).to eq 750
expect(ic.vat).to eq 145.5
expect(ic.total_with_vat).to eq 895.5
ic.finalize
# and after finalizing too
expect(ic.total).to eq 750
expect(ic.vat).to eq 145.5
expect(ic.total_with_vat).to eq 895.5
end
it "updates with added items" do
ic = InvoiceCalculator.new
ic.add_item(unit_price: 10, vat_rate: 21, tags: ["el_om", "el_om_1", "vt"], count: 20, unit: "kWh")
expect(ic.total).to eq 200
ic.add_item(unit_price: 5, vat_rate: 21, tags: ["el_om", "el_om_1", "nt"], count: 30, unit: "kWh")
expect(ic.total).to eq 350
end
end
describe "currency handling" do
it "remembers the given currency" do
ic = InvoiceCalculator.new
ic.add_item(unit_price: 10, vat_rate: 21)
ic.add_item(unit_price: 1, vat_rate: 21, currency: "EUR")
expect(ic.items.collect {|i| [i.currency, i.vat_rate, i.total, i.vat, i.total_with_vat].collect(&:to_s).join(' ')}).
to eq [
"CZK 21.0 10.0 2.1 12.1",
"EUR 21.0 1.0 0.21 1.21"
]
end
it "doesn't add different currencies together" do
ic = InvoiceCalculator.new
ic.add_item(unit_price: 10, vat_rate: 21)
ic.add_item(unit_price: 1, vat_rate: 21, currency: "EUR")
expect { ic.sum }.to raise_error(/wrong currency/)
end
it "can be converted to specific currency" do
ic = InvoiceCalculator.new
ic.add_item(unit_price: 10, vat_rate: 21)
ic.add_item(unit_price: 1, vat_rate: 21, currency: "EUR")
ic = ic.convert_to("EUR", {"EUR:CZK" => "25.00".to_d})
expect(ic.items.collect {|i| [i.currency, i.vat_rate, i.total, i.vat, i.total_with_vat].collect(&:to_s).join(' ')}).
to eq [
"EUR 21.0 0.4 0.08 0.48",
"EUR 21.0 1.0 0.21 1.21"
]
expect(ic.sum.values_at(:currency, :total, :vat, :total_with_vat).collect(&:to_s).join(' ')).to eq("EUR 1.4 0.29 1.69")
end
it "can be converted to the other way" do
ic = InvoiceCalculator.new
ic.add_item(unit_price: 10, vat_rate: 21)
ic.add_item(unit_price: 1, vat_rate: 21, currency: "EUR")
ic = ic.convert_to("CZK", {"EUR:CZK" => "25.00".to_d})
expect(ic.items.collect {|i| [i.currency, i.vat_rate, i.total, i.vat, i.total_with_vat].collect(&:to_s).join(' ')}).
to eq [
"CZK 21.0 10.0 2.1 12.1",
"CZK 21.0 25.0 5.25 30.25"
]
expect(ic.sum.values_at(:currency, :total, :vat, :total_with_vat).collect(&:to_s).join(' ')).to eq("CZK 35.0 7.35 42.35")
end
it "can be finalized and converted and still make sense" do
ic = InvoiceCalculator.new
ic.add_item(unit_price: "1.07".to_d, vat_rate: 21)
ic.add_item(unit_price: "1.07".to_d, vat_rate: 21)
ic.add_item(unit_price: "1.07".to_d, vat_rate: 21)
ic.finalize
expect(ic.items.collect {|i| [i.currency, i.vat_rate, i.total, i.vat, i.total_with_vat].collect(&:to_s).join(' ')}).
to eq [
"CZK 21.0 1.07 0.22 1.29",
"CZK 21.0 1.07 0.23 1.3",
"CZK 21.0 1.07 0.22 1.29",
]
ic = ic.convert_to("EUR", {"EUR:CZK" => "26.70".to_d})
ic.finalize
expect(ic.items.collect {|i| [i.currency, i.vat_rate, i.total, i.vat, i.total_with_vat].collect(&:to_s).join(' ')}).
to eq [
"EUR 21.0 0.04 0.01 0.05",
"EUR 21.0 0.04 0.01 0.05",
"EUR 21.0 0.04 0.01 0.05",
]
ic = ic.convert_to("CZK", {"EUR:CZK" => "26.70".to_d})
ic.finalize
expect(ic.items.collect {|i| [i.currency, i.vat_rate, i.total, i.vat, i.total_with_vat].collect(&:to_s).join(' ')}).
to eq [
"CZK 21.0 1.07 0.22 1.29",
"CZK 21.0 1.07 0.23 1.3",
"CZK 21.0 1.07 0.22 1.29",
]
end
end
end
run_rspec