Module: Ksef::FA3::Parser

Extended by:
NodeReader
Defined in:
lib/ksef/fa3/parser.rb

Overview

Reads an FA(3) document back into the model (DESIGN.md §7.6).

The asymmetry with the serializer is the whole design

Serializer is total: every model it is given becomes a document. The parser cannot be, because FA(3) is far larger than this model. A real invoice carries Podmiot3, PodmiotUpowazniony, DaneKontaktowe, OkresFa, Zalacznik, Platnosc, currency-conversion twins of every tax bucket — and this models VAT, the correction KOR, and the advance-payment pair ZAL and ROZ.

So parsing takes what it understands and keeps the whole document on Invoice#raw_document. Nothing is lost, but nothing is silently invented either: Ksef::FA3::Provenance#unmapped_elements names exactly what re-serialisation would drop, which a caller should look at before calling #to_xml on a document they did not write. parse → to_xml is not a safe edit-in-place operation, and that is a property of the model's coverage rather than a bug to fix here.

Three things it deliberately does not do

It does not recompute what the document states. P_11 is read straight into Line#net_amount rather than being derived from P_8B × P_9A. The two disagree in real documents — upstream's own invoice-template-fa-3-with-custom-Subject3.xml has a row of 20 × 1000 whose net is 18000, because a line may carry a discount — and the document's own figure is the authority. Deriving it would quietly rewrite an invoice.

It does not validate. A caller who wants that has Invoice#validate! — which runs every tier that exists, 1a, 1b and 2 — and can run it on the result. Parsing a document in order to inspect why KSeF rejected it is a normal thing to want, and a parser that refuses invalid input cannot do it.

It does not verify the checksum of a NIP it reads. Subject#to_fa3 does that on the way out. Reading is not the moment to reject: an invoice already in KSeF is a fact whatever its NIP, and PROD is the only environment that checks the digits anyway (docs/REFERENCE.md §15.3).

Constant Summary collapse

SUPPORTED_TYPES =

All seven of TRodzajFaktury, as of 2026-08-26. A spec asserts this list equals the schema's enumeration, so a future revision that adds a type fails loudly here rather than being refused at runtime by #supported_type!.

%w[VAT KOR ZAL ROZ UPR KOR_ZAL KOR_ROZ].freeze

Constants included from NodeReader

NodeReader::NAMESPACES, NodeReader::PREFIX

Class Method Summary collapse

Class Method Details

.parse(xml) ⇒ Invoice

Returns with Invoice#raw_document set.

Parameters:

  • xml (String, Nokogiri::XML::Document)

Returns:

Raises:

  • (Ksef::ValidationError) —

    if the input is not a parseable FA(3) invoice, or uses a construct this model cannot represent at all



55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
# File 'lib/ksef/fa3/parser.rb', line 55

def parse(xml)
  document = xml.is_a?(Nokogiri::XML::Document) ? xml : Nokogiri::XML(xml)
  root = verified_root(document)
  fa_node = require_element(root, "Fa", context: Serializer::ROOT)
  # **Both before anything else is read**, because `Invoice.new`'s keyword arguments
  # evaluate in source order: anything a later argument depends on has to exist before
  # the call rather than be computed inside it.
  #
  # Reading the type inside {#build} let the row reader run first — and the Ministry's
  # collective corrections carry no `FaWiersz` at all, so a `KOR` was refused with
  # "Invoice has no FaWiersz rows". True, and the wrong diagnosis: it blamed a
  # perfectly good document for lacking something its type does not need. A ZAL is the
  # same case, its rows being `minOccurs="0"` and explicitly "opcjonalny dla faktury
  # zaliczkowej". `totals` is now the field that decides whether rows are required at
  # all, so it has to be read here too.
  #
  # The type is **collapsed before comparing**: `RodzajFaktury` is a token, so
  # `<RodzajFaktury> VAT </RodzajFaktury>` is schema-valid and means `VAT`. Compared
  # raw it was refused, with a message reading "This is a  VAT  invoice".
  type = supported_type!(Formatting.text(text(fa_node, "RodzajFaktury")) || "VAT")
  totals = Invoice::STATED_TOTALS_TYPES.include?(type) ? CorrectionReader.totals_from(fa_node) : nil

  # **Before** building, not after: the strategy decides what the rows derive, and the
  # constructor drops a `stated_gross` that merely repeats it. Copying an invoice to
  # change its strategy afterwards therefore threw the document's `P_15` away.
  build(document, root, fa_node, type: type, totals: totals,
                                 rounding: rounding_for(fa_node, totals))
end