Module: Ksef::FA3::Summaries

Included in:
Invoice
Defined in:
lib/ksef/fa3/summaries.rb

Overview

The summary arithmetic of an invoice: what each rate code contributes, and the three totals the document carries.

Extracted from Invoice on 2026-08-26, when tier 3 arrived and gave these methods a second caller. Two reasons beyond the class-length gate. They are one coherent subject — every one of them answers "what does this invoice come to?" — and BusinessValidator reconciles against them, so it helps to have somewhere to point at rather than a span of a 300-line class.

The :per_line / :per_summary split lives here too. Both are permitted by Polish VAT law and they differ by a grosz, which is why the strategy is an explicit field rather than a choice this gem makes quietly (DESIGN.md §7.3).

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.derived_gross(lines, rounding) ⇒ BigDecimal

Returns what #gross_total would answer with nothing stated.

Returns:

  • (BigDecimal) —

    what #gross_total would answer with nothing stated



57
58
59
60
# File 'lib/ksef/fa3/summaries.rb', line 57

def derived_gross(lines, rounding)
  net_by_rate(lines).values.sum(BigDecimal(0)) +
    vat_by_rate(lines, rounding).values.sum(BigDecimal(0))
end

.net_by_rate(lines) ⇒ Object

The same arithmetic as the instance methods, over a bare line list — so Invoice#initialize can ask "what would this invoice derive?" before there is an invoice to ask. That is what lets stated_gross canonicalise in the model, where Invoice.positioned canonicalises row_number, rather than only in the parser (docs/REFERENCE.md §17.2).



24
25
26
27
28
# File 'lib/ksef/fa3/summaries.rb', line 24

def net_by_rate(lines)
  summable(lines).each_with_object({}) do |line, acc|
    acc[line.vat_rate] = (acc[line.vat_rate] || BigDecimal(0)) + line.net
  end
end

.per_line(lines) ⇒ Object

Round each line, then sum. Matches an ERP that prices line by line.



63
64
65
66
67
# File 'lib/ksef/fa3/summaries.rb', line 63

def per_line(lines)
  summable(lines).each_with_object({}) do |line, acc|
    acc[line.vat_rate] = (acc[line.vat_rate] || BigDecimal(0)) + line.vat
  end
end

.per_summary(nets) ⇒ Object

Sum the nets, then round once. Fewer rounding events, so it can differ from :per_line by a grosz — which is exactly why the choice is explicit.



71
72
73
74
75
76
77
# File 'lib/ksef/fa3/summaries.rb', line 71

def per_summary(nets)
  nets.to_h do |code, net|
    percentage = VatRate.percentage(code)
    rounded = percentage ? (net * percentage / 100).round(Formatting::AMOUNT_SCALE) : BigDecimal(0)
    [code, rounded]
  end
end

.summable(lines) ⇒ Object

StanPrzed rows are excluded, and that is a correctness fix rather than a tidy. A row marked "stan przed korektą" states the position as it was; a correction shows it beside its replacement. Adding the two together answers a question nobody asked: on the Ministry's Przykład 2 it gave 3089.42 — 1626.01 before plus 1463.41 after — against a net_total of −162.60. A caller building a per-rate VAT report over downloaded invoices got a figure nineteen times the truth, with no error and a passing #valid?.

Nothing that derives a summary is affected: tier 1's SummaryChecks already refuses a state_before row on an invoice that derives, so these rows only ever appear where the summary is stated.

A non-Line entry is skipped rather than raised on, matching what ModelValidator#line_errors and SummaryChecks already tolerate — this runs inside Invoice.new, and blowing up here would turn a reportable tier-1 issue into a NoMethodError outside this gem's hierarchy.



46
47
48
# File 'lib/ksef/fa3/summaries.rb', line 46

def summable(lines)
  lines.select { |line| line.is_a?(Line) && !line.state_before && line.summarised? }
end

.vat_by_rate(lines, rounding) ⇒ Object



50
51
52
53
54
# File 'lib/ksef/fa3/summaries.rb', line 50

def vat_by_rate(lines, rounding)
  return per_line(lines) if rounding == :per_line

  per_summary(net_by_rate(lines))
end

Instance Method Details

#gross_total ⇒ Object

A stated P_15 wins over a derived one — it is what the document says is owed, and bucket-level rounding means the derivation can be a grosz out (Invoice.scaled_gross).



104
# File 'lib/ksef/fa3/summaries.rb', line 104

def gross_total = totals&.gross || stated_gross || (net_total + vat_total)

#net_by_rate ⇒ Hash{String => BigDecimal}

This is what the lines say, always — Invoice#totals does not enter into it, because a stated summary is keyed by bucket and cannot be resolved back to rate codes (§8.1a). For a correction that states its own summary the two are different questions, and an invoice with no lines answers {} here while #net_total answers what the document declares.

A row with no amount, or none with a rate to bucket it under, contributes nothing. It is legal — see Line#net — and tier 1 is what stops one appearing on an invoice that derives its summary from its rows.

Returns:

  • (Hash{String => BigDecimal}) —

    rate code => net, insertion-ordered by first appearance so the serialiser's output is stable for a given invoice



91
# File 'lib/ksef/fa3/summaries.rb', line 91

def net_by_rate = Summaries.net_by_rate(lines)

#net_total ⇒ Object

The three figures the document actually carries: read from Invoice#totals when the invoice states them, computed from the lines otherwise.



98
# File 'lib/ksef/fa3/summaries.rb', line 98

def net_total = totals ? totals.net : net_by_rate.values.sum(BigDecimal(0))

#vat_by_rate ⇒ Hash{String => BigDecimal}

Returns:

  • (Hash{String => BigDecimal})


94
# File 'lib/ksef/fa3/summaries.rb', line 94

def vat_by_rate = Summaries.vat_by_rate(lines, rounding)

#vat_total ⇒ Object



99
# File 'lib/ksef/fa3/summaries.rb', line 99

def vat_total = totals ? totals.vat : vat_by_rate.values.sum(BigDecimal(0))