Module: Ksef::FA3::BusinessValidator
- Defined in:
- lib/ksef/fa3/business_validator.rb
Overview
Tier 3 — the business tier (DESIGN.md §7.7): reconciliation between figures a document states independently of one another. It answers a question the other tiers cannot — do these numbers agree? — and it answers it as a warning.
Why it holds one rule, and why that rule cannot be an error
There is no upstream catalogue to implement. docs/REFERENCE.md §15.6 records the
search: no file in ksef-api states a reconciliation rule, and the one business
validation KSeF ever proposed was withdrawn back to analysis after the community
showed it rejected legal invoices.
The rule here is grounded empirically — §15.6's grounding 3, measurement over the
Ministry's 26 worked examples — and not definitionally. That distinction was got wrong
once already and is worth stating plainly: P_15 is annotated "Kwota należności
ogółem" and says nothing about the buckets, and §15.6's own sentence is "Nothing says
P_15 equals the sum of the rate buckets." The definitional rule one might reach for
instead — P_13_1 is a sum, so check it against the rows — is not implementable
here: ten of the fourteen modelled stated-summary samples falsify it, because a
correction's buckets are deltas, an advance's are pre-payments and a settlement's are
remainders (§8.4, §8.5).
And a Polish invoice priced from round gross prices routinely fails this rule while
being perfectly legal. The Ministry's own Przykład 1 is one: its nets are back-computed
w stu from gross prices of 2000/50/1 and rounded down, so the buckets sum a grosz under
P_15. The gap grows with the number of such lines — two of them and it is two grosze —
so no tolerance sized from this corpus can be sound, because the corpus never varies
the dimension the error scales with. A tier that refused those invoices would repeat
precisely the mistake that got issue #837 withdrawn.
So this reports and never refuses: Invoice#warnings, not #errors, and nothing here
can block Client#send_invoice. That is the §14.3 precedent — the UPO
receiving-party mismatch is a warning for the same reason, so that a schema opinion
cannot stand between a legal document and its being filed.
Constant Summary collapse
- TOLERANCE =
One grosz, and honestly arbitrary beyond that. It is what the single corpus witness shows and no more: 22 of the 26 samples reconcile to the cent, Przykład 1 misses by 0.01, and three state no buckets to reconcile at all. It is not derived from rounding arithmetic — per-bucket tax rounding moves Przykład 1's total by 0.0007, not by a grosz — so it neither is nor pretends to be a bound (docs/REFERENCE.md §17.1).
BigDecimal("0.01")
- MISMATCH =
"the rate buckets sum to %<sum>s but P_15 states %<gross>s, a difference of " \ "%<delta>s. This is often legitimate — an invoice whose nets are computed " \ "back from round gross prices differs by a grosz or so per line — and KSeF " \ "is not known to reject it. Worth checking against your source figures " \ "(docs/REFERENCE.md §17.1)."
- RULES =
Each rule is a public singleton method taking the invoice and answering an Issue or nil. Adding one means adding a method, a name here, and — per §15.6 — a ledger entry saying what grounds it.
%i[summary_reconciliation].freeze
Class Method Summary collapse
- .buckets_in(node) ⇒ Object
-
.document_summary(document) ⇒ Object
The
Wtwins are excluded, and that is load-bearing rather than tidy:P_14_1Wis the PLN equivalent ofP_14_1on a foreign-currency invoice, not a second tax — Przykład 20 states 13560 + 3118.80 against aP_15of 16678.80, and counting its 14036.16 twin gives 30714.96. - .mismatch(sum, gross, delta) ⇒ Object
-
.stated_summary(invoice) ⇒ Hash?
{buckets:, gross:}as stated, or nil when nothing states them independently — a built invoice derives both from its rows and has nothing to reconcile against itself. -
.summary_reconciliation(invoice) ⇒ Object
Σ P_13_* + Σ P_14_* ≈ P_15, over what the document states. -
.warnings_for(invoice) ⇒ Array<Issue>
Advisory; empty when nothing disagrees.
Class Method Details
.buckets_in(node) ⇒ Object
118 119 120 121 122 123 |
# File 'lib/ksef/fa3/business_validator.rb', line 118 def buckets_in(node) Totals::ELEMENTS.each_with_object({}) do |name, found| text = node.at_xpath(name)&.text found[name] = Formatting.decimal(text) unless text.nil? end end |
.document_summary(document) ⇒ Object
The W twins are excluded, and that is load-bearing rather than tidy: P_14_1W is
the PLN equivalent of P_14_1 on a foreign-currency invoice, not a second tax —
Przykład 20 states 13560 + 3118.80 against a P_15 of 16678.80, and counting its
14036.16 twin gives 30714.96. Totals::ELEMENTS is the list, built from the
generated schema metadata, so there is one definition of "a bucket".
100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 |
# File 'lib/ksef/fa3/business_validator.rb', line 100 def document_summary(document) return nil if document.nil? fa = document.dup.remove_namespaces!.at_xpath("//Fa") gross = fa&.at_xpath("P_15")&.text return nil if gross.nil? { buckets: buckets_in(fa), gross: Formatting.decimal(gross) } rescue Ksef::ValidationError # **An advisory check must not raise.** `Parser#readable_gross` already tolerates an # empty or unparseable `P_15`, for the stated reason that a document being read to # find out why KSeF rejected it may be one whose `P_15` is the problem — and this # method then re-read the same element off the same retained document with no such # tolerance, undoing it one method later. A summary this cannot read is a summary it # has nothing to say about. nil end |
.mismatch(sum, gross, delta) ⇒ Object
125 126 127 128 129 130 |
# File 'lib/ksef/fa3/business_validator.rb', line 125 def mismatch(sum, gross, delta) Issue.new(field: "summary", message: format( MISMATCH, sum: Formatting.amount(sum), gross: Formatting.amount(gross), delta: Formatting.amount(delta) )) end |
.stated_summary(invoice) ⇒ Hash?
Returns {buckets:, gross:} as stated, or nil when nothing states them
independently — a built invoice derives both from its rows and has nothing to
reconcile against itself.
89 90 91 92 93 |
# File 'lib/ksef/fa3/business_validator.rb', line 89 def stated_summary(invoice) return { buckets: invoice.totals.buckets, gross: invoice.totals.gross } if invoice.totals document_summary(invoice.raw_document) end |
.summary_reconciliation(invoice) ⇒ Object
Σ P_13_* + Σ P_14_* ≈ P_15, over what the document states.
It compares two figures the document carries independently. It deliberately does not reconcile a derived summary against a derived total: those come from the same rows, so a difference measures this model's own rounding regime rather than anything about the invoice — and it did, before the 2026-08-26 audit, which is how an ordinary built invoice came to be accused of a two-grosz error.
Reading the buckets from the document rather than from the model also keeps the rule
honest about what it cannot represent: a document using P_13_5/P_14_5 (OSS) or
P_13_11 (margin) states buckets no rate code reaches (VatRate.unreachable_elements),
and comparing the model's derivation against the document's total accused those of an
arithmetic error that was really this model's own incompleteness.
77 78 79 80 81 82 83 84 |
# File 'lib/ksef/fa3/business_validator.rb', line 77 def summary_reconciliation(invoice) stated = stated_summary(invoice) return nil if stated.nil? || stated[:buckets].empty? sum = stated[:buckets].values.sum(BigDecimal(0)) delta = sum - stated[:gross] delta.abs <= TOLERANCE ? nil : mismatch(sum, stated[:gross], delta) end |