Class: Ksef::FA3::Invoice

Inherits:
Data
  • Object
show all
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 positive P_19N asserting 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/ROZ family the stated buckets never equal the row totals (§8.5's table): a ZAL has no rows, and a ROZ states what remains after the advance, which this document does not contain enough to compute. A KOR is 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 #inspect cover.

(members - [:raw_document]).freeze

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

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

#summary_buckets, #to_fa3

Methods included from Canonical

#with

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: attachment.nil? ? nil : Attachment.wrap(attachment),
    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

Returns:

  • (Object) —

    the current value of 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

Returns:

  • (Object) —

    the current value of 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

Returns:

  • (Object) —

    the current value of attachment



16
17
18
# File 'lib/ksef/fa3/invoice.rb', line 16

def attachment
  @attachment
end

#buyer ⇒ Object (readonly)

Returns the value of attribute buyer

Returns:

  • (Object) —

    the current value of 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

Returns:

  • (Object) —

    the current value of 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

Returns:

  • (Object) —

    the current value of 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

Returns:

  • (Object) —

    the current value of 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

Returns:

  • (Object) —

    the current value of 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

Returns:

  • (Object) —

    the current value of 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

Returns:

  • (Object) —

    the current value of 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

Returns:

  • (Object) —

    the current value of 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

Returns:

  • (Object) —

    the current value of 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

Returns:

  • (Object) —

    the current value of 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

Returns:

  • (Object) —

    the current value of 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

Returns:

  • (Object) —

    the current value of 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

Returns:

  • (Object) —

    the current value of 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

Returns:

  • (Object) —

    the current value of 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.

Raises:



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.

Parameters:

  • max_bytes (Integer) (defaults to: DocumentValidator.default_max_bytes(attachment: !attachment.nil?)) —

    the document-size ceiling for this context. KSeF's 1 MB is a default that an organisation can have raised on application (docs/REFERENCE.md §15.5); pass the value GET /limits/context reports if yours differs.

Returns:

  • (Array<Issue>) —

    empty when the invoice is sound



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: !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 { |message| Issue.new(field: "schema", message: 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.message)]
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

Returns:

  • (Boolean)


247
# File 'lib/ksef/fa3/invoice.rb', line 247

def valid?(**) = errors(**).empty?

#validate! ⇒ Object

Raises:



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).

Returns:

  • (Array<Issue>) —

    empty when nothing disagrees, or when nothing states two figures independently to compare



245
# File 'lib/ksef/fa3/invoice.rb', line 245

def warnings = BusinessValidator.warnings_for(self)