Class: Ksef::FA3::Invoice
- Inherits:
-
Data
- Object
- Data
- Ksef::FA3::Invoice
- Includes:
- Canonical, DocumentMapping, Provenance, Summaries
- Defined in:
- lib/ksef/fa3/invoice.rb,
lib/ksef/fa3/invoice.rb
Overview
Computation, defaults and serialisation for Invoice.
Constant Summary collapse
- ROUNDING_STRATEGIES =
Both strategies are permitted by Polish VAT law, and silently choosing one creates one-grosz mismatches against a user's ERP (DESIGN.md §7.3).
i[per_line per_summary].freeze
- DEFAULT_ANNOTATIONS =
The default annotations: all five flags plus the three wrapper elements are mandatory (§8.2), and every one of them is the same on an ordinary domestic invoice. Defaulting them to "not applicable" is what makes such an invoice possible without the caller knowing any of this.
They are a default, not a constant of the format. Until 2026-08-24 they were emitted unconditionally, which meant parsing an invoice that declared cash accounting (
P_16), reverse charge (P_18), split payment (P_18A) or an actual VAT exemption (Zwolnienie/P_19) and re-serialising it silently reset every one of them to "does not apply" — and, for the exemption, wrote a positiveP_19Nasserting that none applied. Because the element paths are identical either way, Provenance#unmapped_elements could not see it. These are declarations with tax consequences; Parser now reads them and #annotations carries them. { "P_16" => Formatting.flag(false), "P_17" => Formatting.flag(false), "P_18" => Formatting.flag(false), "P_18A" => Formatting.flag(false), "Zwolnienie" => { "P_19N" => "1" }, "NoweSrodkiTransportu" => { "P_22N" => "1" }, "P_23" => Formatting.flag(false), "PMarzy" => { "P_PMarzyN" => "1" } }.freeze
- STATED_TOTALS_TYPES =
The invoice kinds whose tax summary may be stated rather than derived from the rows. Lives here rather than on Parser because both halves need it: the parser reads a stated summary for these types, and ModelValidator reports one set on any other — where it would be emitted verbatim, never read back, and disagree with the lines with nothing to notice (docs/REFERENCE.md §8.4).
"May", not "always". Across the
ZAL/ROZfamily the stated buckets never equal the row totals (§8.5's table): aZALhas no rows, and aROZstates what remains after the advance, which this document does not contain enough to compute. AKORis more varied — Przykład 3's single delta row reproduces its buckets exactly. That is why SummaryChecks keys the requirement to what a document carries rather than to its type; making it type-based would reject that worked example. %w[KOR ZAL ROZ UPR KOR_ZAL KOR_ROZ].freeze
- NEEDS_LINES =
"An invoice needs at least one line, unless it states its own totals. " \ "A collective correction may have no FaWiersz at all — see " \ "Ksef::FA3::Totals and docs/REFERENCE.md §8.4."
- IDENTITY =
Everything except #raw_document: the fields that make this invoice this invoice. Provenance reads this to decide what equality, hashing and
#inspectcover. (members - [:raw_document]).freeze
Instance Attribute Summary collapse
-
#advances ⇒ Object
readonly
Returns the value of attribute advances.
-
#annotations ⇒ Object
readonly
Returns the value of attribute annotations.
-
#attachment ⇒ Object
readonly
Returns the value of attribute attachment.
-
#buyer ⇒ Object
readonly
Returns the value of attribute buyer.
-
#correction ⇒ Object
readonly
Returns the value of attribute correction.
-
#currency ⇒ Object
readonly
Returns the value of attribute currency.
-
#invoice_type ⇒ Object
readonly
Returns the value of attribute invoice_type.
-
#issue_date ⇒ Object
readonly
Returns the value of attribute issue_date.
-
#issued_at ⇒ Object
readonly
Returns the value of attribute issued_at.
-
#lines ⇒ Object
readonly
Returns the value of attribute lines.
-
#number ⇒ Object
readonly
Returns the value of attribute number.
-
#order ⇒ Object
readonly
Returns the value of attribute order.
-
#raw_document ⇒ Object
readonly
Returns the value of attribute raw_document.
-
#rounding ⇒ Object
readonly
Returns the value of attribute rounding.
-
#seller ⇒ Object
readonly
Returns the value of attribute seller.
-
#stated_gross ⇒ Object
readonly
Returns the value of attribute stated_gross.
-
#totals ⇒ Object
readonly
Returns the value of attribute totals.
Class Method Summary collapse
-
.positioned(lines) ⇒ Object
Line#row_numbermeans "this row's number is not its position" — nil says "number me by position". -
.rows_for(lines, rounding:, totals:) ⇒ Object
The two constructor invariants that are about the line list rather than a field, kept together because both have to hold before anything is stored.
-
.scaled_gross(stated_gross, totals, rows, rounding) ⇒ Object
P_15as the document stated it, for an invoice whose summary is otherwise derived from its rows.
Instance Method Summary collapse
-
#errors(max_bytes: DocumentValidator.default_max_bytes(attachment: !attachment.nil?)) ⇒ Array<Issue>
Every validation tier that exists, model first (DESIGN.md §7.7).
-
#initialize(seller:, buyer:, number:, issue_date:, lines: [], currency: "PLN", issued_at: nil, rounding: :per_line, invoice_type: "VAT", annotations: nil, correction: nil, totals: nil, order: nil, advances: [], raw_document: nil, stated_gross: nil, attachment: nil) ⇒ Invoice
constructor
issued_atis normalised to the string the document will carry, which is the same rule Address and Line follow: the model stores the document's representation, not the caller's input. ATimerenders to"2026-08-22T10:00:00Z"here rather than at serialisation, so an invoice built from a Time and the same invoice parsed back from XML are one object — the round-trip law of DESIGN.md §7.6 would otherwise fail on a field whose value never actually changed. -
#to_xml ⇒ Object
Net totals per rate code, in the order the lines first mention each rate, so the.
- #valid? ⇒ Boolean
- #validate! ⇒ Object
-
#warnings ⇒ Array<Issue>
Tier 3 (§7.7): reconciliation between figures the document states independently.
Methods included from Summaries
derived_gross, #gross_total, net_by_rate, #net_by_rate, #net_total, per_line, per_summary, summable, #vat_by_rate, vat_by_rate, #vat_total
Methods included from DocumentMapping
Methods included from Canonical
Methods included from Provenance
#==, #deconstruct_keys, #fully_mapped?, #hash, #inspect, #source_errors, #to_h, #unmapped_elements
Constructor Details
#initialize(seller:, buyer:, number:, issue_date:, lines: [], currency: "PLN", issued_at: nil, rounding: :per_line, invoice_type: "VAT", annotations: nil, correction: nil, totals: nil, order: nil, advances: [], raw_document: nil, stated_gross: nil, attachment: nil) ⇒ Invoice
issued_at is normalised to the string the document will carry, which is the same
rule Address and Line follow: the model stores the document's representation,
not the caller's input. A Time renders to "2026-08-22T10:00:00Z" here rather
than at serialisation, so an invoice built from a Time and the same invoice parsed
back from XML are one object — the round-trip law of DESIGN.md §7.6 would otherwise
fail on a field whose value never actually changed.
nil is left alone, and means something different: not "no timestamp" but "stamp it
when you serialise". Such an invoice is not fully determined and cannot round-trip
to an equal object, which is a property of that choice rather than a defect.
lines may be empty only when the invoice states its own totals. FaWiersz is
minOccurs="0", and a collective correction legitimately has no rows — but without
rows and without stated totals there is nothing to compute a summary from, and the
result would be a document declaring zero tax on an invoice that means to declare
some.
102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 |
# File 'lib/ksef/fa3/invoice.rb', line 102 def initialize(seller:, buyer:, number:, issue_date:, lines: [], currency: "PLN", issued_at: nil, rounding: :per_line, invoice_type: "VAT", annotations: nil, correction: nil, totals: nil, order: nil, advances: [], raw_document: nil, stated_gross: nil, attachment: nil) rows = self.class.rows_for(lines, rounding: rounding, totals: totals) super( seller: seller, buyer: buyer, lines: rows, rounding: rounding, number: Formatting.text(number), currency: Formatting.text(currency), invoice_type: Formatting.text(invoice_type), correction: correction, totals: totals, order: order, advances: Correction.wrap(advances).dup.freeze, raw_document: raw_document, # `Zalacznik` is a sibling of `Fa`, not a child, so it takes part in no summary and # no arithmetic — it is carried and re-emitted, nothing more (DESIGN.md §7.4). # # Wrapped **here**, not only in {Builder#attachment}: `Invoice.new` and `#with` are # both public, so canonicalisation outside the constructor is canonicalisation that # can be walked around — the `stated_gross` lesson, three methods below. attachment: .nil? ? nil : Attachment.wrap(), stated_gross: self.class.scaled_gross(stated_gross, totals, rows, rounding), # `issue_date` is canonicalised for the same reason `issued_at` is: a String and the # Date it denotes must not produce two unequal invoices (§8.2b). issue_date: Formatting.to_date(issue_date), issued_at: issued_at.nil? ? nil : Formatting.date_time(issued_at), # Defaulted here rather than at serialisation, so a built invoice and the same # invoice parsed back hold the same value and compare equal. annotations: annotations || DEFAULT_ANNOTATIONS ) end |
Instance Attribute Details
#advances ⇒ Object (readonly)
Returns the value of attribute advances
16 17 18 |
# File 'lib/ksef/fa3/invoice.rb', line 16 def advances @advances end |
#annotations ⇒ Object (readonly)
Returns the value of attribute annotations
16 17 18 |
# File 'lib/ksef/fa3/invoice.rb', line 16 def annotations @annotations end |
#attachment ⇒ Object (readonly)
Returns the value of attribute attachment
16 17 18 |
# File 'lib/ksef/fa3/invoice.rb', line 16 def end |
#buyer ⇒ Object (readonly)
Returns the value of attribute buyer
16 17 18 |
# File 'lib/ksef/fa3/invoice.rb', line 16 def buyer @buyer end |
#correction ⇒ Object (readonly)
Returns the value of attribute correction
16 17 18 |
# File 'lib/ksef/fa3/invoice.rb', line 16 def correction @correction end |
#currency ⇒ Object (readonly)
Returns the value of attribute currency
16 17 18 |
# File 'lib/ksef/fa3/invoice.rb', line 16 def currency @currency end |
#invoice_type ⇒ Object (readonly)
Returns the value of attribute invoice_type
16 17 18 |
# File 'lib/ksef/fa3/invoice.rb', line 16 def invoice_type @invoice_type end |
#issue_date ⇒ Object (readonly)
Returns the value of attribute issue_date
16 17 18 |
# File 'lib/ksef/fa3/invoice.rb', line 16 def issue_date @issue_date end |
#issued_at ⇒ Object (readonly)
Returns the value of attribute issued_at
16 17 18 |
# File 'lib/ksef/fa3/invoice.rb', line 16 def issued_at @issued_at end |
#lines ⇒ Object (readonly)
Returns the value of attribute lines
16 17 18 |
# File 'lib/ksef/fa3/invoice.rb', line 16 def lines @lines end |
#number ⇒ Object (readonly)
Returns the value of attribute number
16 17 18 |
# File 'lib/ksef/fa3/invoice.rb', line 16 def number @number end |
#order ⇒ Object (readonly)
Returns the value of attribute order
16 17 18 |
# File 'lib/ksef/fa3/invoice.rb', line 16 def order @order end |
#raw_document ⇒ Object (readonly)
Returns the value of attribute raw_document
16 17 18 |
# File 'lib/ksef/fa3/invoice.rb', line 16 def raw_document @raw_document end |
#rounding ⇒ Object (readonly)
Returns the value of attribute rounding
16 17 18 |
# File 'lib/ksef/fa3/invoice.rb', line 16 def rounding @rounding end |
#seller ⇒ Object (readonly)
Returns the value of attribute seller
16 17 18 |
# File 'lib/ksef/fa3/invoice.rb', line 16 def seller @seller end |
#stated_gross ⇒ Object (readonly)
Returns the value of attribute stated_gross
16 17 18 |
# File 'lib/ksef/fa3/invoice.rb', line 16 def stated_gross @stated_gross end |
#totals ⇒ Object (readonly)
Returns the value of attribute totals
16 17 18 |
# File 'lib/ksef/fa3/invoice.rb', line 16 def totals @totals end |
Class Method Details
.positioned(lines) ⇒ Object
Line#row_number means "this row's number is not its position" — nil says "number
me by position". Only the invoice knows a line's position, so only the invoice can
canonicalise: a caller who states row_number: 1 on the first line describes exactly
the document a caller who states nothing does, and leaving the two unequal broke
DESIGN.md §7.6's round-trip law for an ERP that always supplies row numbers.
each_with_index.map returns a new array, so a caller's own is never modified — and
that copy is what rows_for freezes, lines being an invariant of the object rather
than a view onto theirs.
183 184 185 186 187 188 189 |
# File 'lib/ksef/fa3/invoice.rb', line 183 def self.positioned(lines) lines.each_with_index.map do |line, index| next line unless line.is_a?(Line) && line.row_number == index + 1 line.with(row_number: nil) end end |
.rows_for(lines, rounding:, totals:) ⇒ Object
The two constructor invariants that are about the line list rather than a field, kept together because both have to hold before anything is stored.
162 163 164 165 166 167 168 169 170 171 172 |
# File 'lib/ksef/fa3/invoice.rb', line 162 def self.rows_for(lines, rounding:, totals:) unless ROUNDING_STRATEGIES.include?(rounding) raise ValidationError, "Unknown rounding strategy #{rounding.inspect}; expected one of #{ROUNDING_STRATEGIES.inspect}" end rows = positioned(lines.nil? ? [] : lines) raise ValidationError, NEEDS_LINES if rows.empty? && totals.nil? rows.freeze end |
.scaled_gross(stated_gross, totals, rows, rounding) ⇒ Object
P_15 as the document stated it, for an invoice whose summary is otherwise derived
from its rows. nil for a built invoice with nothing to state, nil when #totals is
present (a stated summary already carries its own gross), and nil when it equals
what the rows derive — the Line#row_number rule, and here for the same reason:
kept unconditionally, a built invoice would be unequal to itself parsed back and
DESIGN.md §7.6 would fail.
That canonicalisation lives here rather than in the parser deliberately. It was in the
parser until the 2026-08-26 audit, which pointed out that positioned does the
equivalent job for row_number in the model, so Invoice.new(stated_gross:) and
#with(stated_gross:) — both public — bypassed it and broke the round-trip law with
no diagnostic.
Why the field exists at all: P_15 is mandatory in Fa, so every document states
it, and a derived gross need not equal the stated one. The Ministry's Przykład 1 is the
witness — its nets are computed back from round gross prices of 2000/50/1 and rounded
down, so the rows total 2050.99 against a stated P_15 of 2051. Deriving it re-emitted
the invoice a grosz cheaper, with #unmapped_elements silent (the element is present
either way) and #errors empty. A stated amount altered in silence — the same class as
P_9A (§8.6), and found the same way, by measuring rather than by reading the code.
153 154 155 156 157 158 |
# File 'lib/ksef/fa3/invoice.rb', line 153 def self.scaled_gross(stated_gross, totals, rows, rounding) return nil if stated_gross.nil? || totals gross = Formatting.decimal(stated_gross).round(Formatting::AMOUNT_SCALE) gross == Summaries.derived_gross(rows, rounding) ? nil : gross end |
Instance Method Details
#errors(max_bytes: DocumentValidator.default_max_bytes(attachment: !attachment.nil?)) ⇒ Array<Issue>
Every validation tier that exists, model first (DESIGN.md §7.7). Document and schema checks then run on the same bytes; their relative order is not fixed by §7.7 and does not matter, since neither can affect the other's input.
Tier 1a first, and nothing else runs if it fails. ModelValidator's contract is
that a model it passes can be serialized; a model it rejects generally cannot, because
serialisation raises on a bad NIP, a nameless seller or a rate code with no summary
bucket. Attempting #to_xml anyway would replace a list of addressed errors with a
single exception about whichever one came first. ("A line with no derivable net" was
on that list until 2026-08-26, when such a row became legal — Line#net answers nil
and the row serialises without a P_11. The short-circuit's justification survives on
the remaining three; the guard against that row is now tier 1's alone.)
Then DocumentValidator (tier 1b) and Validator (tier 2) on the same bytes.
Tier 3 is deliberately not here. BusinessValidator reconciles figures the document states independently, and a Polish invoice priced from round gross prices routinely fails that check while being perfectly legal — so making it an error would refuse legal documents, which is exactly what got KSeF's own proposed business rule withdrawn (docs/REFERENCE.md §15.6). It is advisory, and lives on #warnings.
max_bytes defaults from the document rather than from a constant: an invoice carrying
an attachment is allowed 3 MB, and defaulting to 1 MB refused legal documents.
221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 |
# File 'lib/ksef/fa3/invoice.rb', line 221 def errors(max_bytes: DocumentValidator.default_max_bytes(attachment: !.nil?)) # **First**, and before the short-circuit: a document that is not well-formed cannot be # made so by anything below, and libxml2's recovery means every later tier sees a tree # that looks fine ({Provenance#source_errors}). source = source_errors model = ModelValidator.errors_for(self) return source + model unless model.empty? document = to_xml source + DocumentValidator.errors_for(document, max_bytes: max_bytes) + Validator.errors_for(document).map { || Issue.new(field: "schema", message: ) } rescue Ksef::Error => e # A serialisation refusal the model tier did not anticipate. Reported rather than # raised, so `#errors` always answers the question it was asked. [Issue.new(field: "document", message: e.)] end |
#to_xml ⇒ Object
Net totals per rate code, in the order the lines first mention each rate, so the
192 |
# File 'lib/ksef/fa3/invoice.rb', line 192 def to_xml = Serializer.new(to_fa3).to_xml |
#valid? ⇒ Boolean
247 |
# File 'lib/ksef/fa3/invoice.rb', line 247 def valid?(**) = errors(**).empty? |
#validate! ⇒ Object
250 251 252 253 254 255 256 |
# File 'lib/ksef/fa3/invoice.rb', line 250 def validate!(**) found = errors(**) return true if found.empty? raise ValidationError, "Invoice #{number.inspect} is not valid:\n#{found.sort.map { |issue| " - #{issue}" }.join("\n")}" end |
#warnings ⇒ Array<Issue>
Tier 3 (§7.7): reconciliation between figures the document states independently. Advisory — these never make an invoice invalid and never block a send. A warning here means "these numbers do not add up; that may be fine", not "KSeF will reject this" (docs/REFERENCE.md §17.1, and §14.3 for the precedent).
245 |
# File 'lib/ksef/fa3/invoice.rb', line 245 def warnings = BusinessValidator.warnings_for(self) |