ksef_client 0.1.0.rc1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1424 -0
- data/CONTRIBUTING.md +45 -6
- data/README.md +377 -25
- data/SECURITY.md +25 -2
- data/docs/REFERENCE.md +3099 -34
- data/docs/errors.md +33 -4
- data/docs/field_mapping.md +390 -0
- data/lib/ksef/auth/access_token.rb +150 -0
- data/lib/ksef/auth/authorization_policy.rb +88 -0
- data/lib/ksef/auth/challenge.rb +53 -0
- data/lib/ksef/auth/client.rb +143 -0
- data/lib/ksef/auth/initiation.rb +22 -0
- data/lib/ksef/auth/operation_status.rb +34 -0
- data/lib/ksef/auth/schema/schemat_auth_v2-0.xsd +109 -0
- data/lib/ksef/auth/schema/schemat_auth_v2-1.xsd +109 -0
- data/lib/ksef/auth/signature_template.rb +120 -0
- data/lib/ksef/auth/signer.rb +132 -0
- data/lib/ksef/auth/status.rb +69 -0
- data/lib/ksef/auth/token.rb +121 -0
- data/lib/ksef/auth/token_info.rb +27 -0
- data/lib/ksef/auth/token_request.rb +142 -0
- data/lib/ksef/auth/tokens.rb +24 -0
- data/lib/ksef/auth/validator.rb +84 -0
- data/lib/ksef/auth/xades.rb +40 -0
- data/lib/ksef/auth.rb +43 -0
- data/lib/ksef/client/receipt.rb +37 -0
- data/lib/ksef/client/session.rb +68 -0
- data/lib/ksef/client.rb +314 -0
- data/lib/ksef/crypto/certificate.rb +78 -0
- data/lib/ksef/crypto/digest.rb +25 -0
- data/lib/ksef/crypto/encryptor.rb +131 -0
- data/lib/ksef/crypto/public_keys.rb +146 -0
- data/lib/ksef/crypto.rb +66 -0
- data/lib/ksef/environments.rb +1 -1
- data/lib/ksef/errors.rb +35 -2
- data/lib/ksef/fa3/address.rb +64 -0
- data/lib/ksef/fa3/advance_checks.rb +55 -0
- data/lib/ksef/fa3/advance_invoice.rb +55 -0
- data/lib/ksef/fa3/advance_reader.rb +59 -0
- data/lib/ksef/fa3/attachment.rb +43 -0
- data/lib/ksef/fa3/attachment_checks.rb +121 -0
- data/lib/ksef/fa3/attachment_reader.rb +99 -0
- data/lib/ksef/fa3/attachment_table.rb +98 -0
- data/lib/ksef/fa3/builder/advances.rb +57 -0
- data/lib/ksef/fa3/builder/corrections.rb +46 -0
- data/lib/ksef/fa3/builder/subjects.rb +71 -0
- data/lib/ksef/fa3/builder.rb +149 -0
- data/lib/ksef/fa3/business_validator.rb +134 -0
- data/lib/ksef/fa3/canonical.rb +44 -0
- data/lib/ksef/fa3/corrected_invoice.rb +53 -0
- data/lib/ksef/fa3/correction.rb +151 -0
- data/lib/ksef/fa3/correction_checks.rb +106 -0
- data/lib/ksef/fa3/correction_reader.rb +88 -0
- data/lib/ksef/fa3/data_block.rb +75 -0
- data/lib/ksef/fa3/document_mapping.rb +142 -0
- data/lib/ksef/fa3/document_validator.rb +174 -0
- data/lib/ksef/fa3/element_tree.rb +49 -0
- data/lib/ksef/fa3/field_checks.rb +151 -0
- data/lib/ksef/fa3/formatting.rb +273 -0
- data/lib/ksef/fa3/generated/enums.rb +652 -0
- data/lib/ksef/fa3/generated/types.rb +3308 -0
- data/lib/ksef/fa3/invoice.rb +259 -0
- data/lib/ksef/fa3/issue.rb +32 -0
- data/lib/ksef/fa3/line.rb +162 -0
- data/lib/ksef/fa3/meta_entry.rb +45 -0
- data/lib/ksef/fa3/model_validator.rb +219 -0
- data/lib/ksef/fa3/nip.rb +59 -0
- data/lib/ksef/fa3/node_reader.rb +46 -0
- data/lib/ksef/fa3/order.rb +55 -0
- data/lib/ksef/fa3/order_line.rb +70 -0
- data/lib/ksef/fa3/parser.rb +208 -0
- data/lib/ksef/fa3/provenance.rb +137 -0
- data/lib/ksef/fa3/rounding_inference.rb +87 -0
- data/lib/ksef/fa3/row_reader.rb +66 -0
- data/lib/ksef/fa3/serializer.rb +140 -0
- data/lib/ksef/fa3/subject.rb +128 -0
- data/lib/ksef/fa3/subject_checks.rb +126 -0
- data/lib/ksef/fa3/subject_reader.rb +76 -0
- data/lib/ksef/fa3/summaries.rb +107 -0
- data/lib/ksef/fa3/summary_checks.rb +75 -0
- data/lib/ksef/fa3/table_column.rb +49 -0
- data/lib/ksef/fa3/totals.rb +119 -0
- data/lib/ksef/fa3/validator.rb +79 -0
- data/lib/ksef/fa3/vat_rate.rb +94 -0
- data/lib/ksef/fa3.rb +59 -0
- data/lib/ksef/http/connection.rb +47 -4
- data/lib/ksef/http/json_decoder.rb +53 -0
- data/lib/ksef/http/retry.rb +105 -0
- data/lib/ksef/invoices/client.rb +72 -0
- data/lib/ksef/ksef_number.rb +150 -0
- data/lib/ksef/sessions/invoice_codes.rb +70 -0
- data/lib/ksef/sessions/invoice_state.rb +68 -0
- data/lib/ksef/sessions/online.rb +169 -0
- data/lib/ksef/sessions/session_codes.rb +73 -0
- data/lib/ksef/sessions/session_state.rb +48 -0
- data/lib/ksef/sessions/status.rb +138 -0
- data/lib/ksef/sessions/upo_page.rb +36 -0
- data/lib/ksef/sessions.rb +91 -0
- data/lib/ksef/upo/client.rb +154 -0
- data/lib/ksef/upo/document.rb +74 -0
- data/lib/ksef/upo/schema/upo-v4-3.xsd +293 -0
- data/lib/ksef/upo/validation.rb +37 -0
- data/lib/ksef/upo/validator.rb +124 -0
- data/lib/ksef/upo.rb +55 -0
- data/lib/ksef/version.rb +1 -1
- data/lib/ksef.rb +18 -0
- metadata +105 -4
data/docs/REFERENCE.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Verification ledger
|
|
2
2
|
|
|
3
3
|
Every fact the implementation depends on, with its source and retrieval date, per
|
|
4
|
-
DESIGN.md §0
|
|
4
|
+
DESIGN.md §0 rule 2 and §2. **Nothing about endpoint paths, XML element names, namespace
|
|
5
5
|
URIs or cryptographic parameters may enter the code unless it appears here.**
|
|
6
6
|
|
|
7
7
|
Entries are `value + source URL + date`. When this ledger and DESIGN.md disagree, the
|
|
@@ -25,6 +25,8 @@ retained here as `LICENSE.upstream.txt`).
|
|
|
25
25
|
| `lib/ksef/fa3/schema/bazowe/ElementarneTypyDanych_v10-0E.xsd` | `faktury/schemy/FA/bazowe/…` | `8daf4d3771de200b26b697294cc906a2add3de9acfbbd97f4b1bd4fc0e5ecb2f` |
|
|
26
26
|
| `lib/ksef/fa3/schema/bazowe/KodyKrajow_v10-0E.xsd` | `faktury/schemy/FA/bazowe/…` | `48be2a9f181d7ff80f185c62491ba12604c5cacbbe21af8e2aaaf2c585bbd214` |
|
|
27
27
|
| `lib/ksef/fa3/schema/bazowe/StrukturyDanych_v10-0E.xsd` | `faktury/schemy/FA/bazowe/…` | `cb08348374598e1e716e086c40d740390fb9e1bfa3aba1f4ec4cba0e1ef6d60f` |
|
|
28
|
+
| `lib/ksef/auth/schema/schemat_auth_v2-0.xsd` | `auth/schemy/schemat_auth_v2-0.xsd` | see `docs/artifacts.sha256` |
|
|
29
|
+
| `lib/ksef/auth/schema/schemat_auth_v2-1.xsd` | `auth/schemy/schemat_auth_v2-1.xsd` | see `docs/artifacts.sha256` |
|
|
28
30
|
|
|
29
31
|
`rake verify:artifacts` re-checks these digests; treat a mismatch as an upstream change,
|
|
30
32
|
not as a local bug.
|
|
@@ -42,6 +44,258 @@ The schemas are published under the repository's MIT licence, which permits
|
|
|
42
44
|
redistribution. **Decision: bundle the XSD in the gem.** The first-run
|
|
43
45
|
fetch-and-cache fallback contemplated by DESIGN.md §7.7 tier 2 is not needed.
|
|
44
46
|
|
|
47
|
+
**This reasoning does not generalise to every schema in the repository, and the
|
|
48
|
+
distinction is load-bearing.** It holds for the FA(3), auth and UPO schemas, which are the
|
|
49
|
+
Ministry's own work and therefore covered by its MIT licence. It does *not* hold for the
|
|
50
|
+
W3C, OASIS and ETSI schemas that upstream redistributes inside its PEF bundle — the
|
|
51
|
+
Ministry's licence cannot relicense someone else's document. Those are pinned to
|
|
52
|
+
`spec/fixtures/xades/` and deliberately **not** packaged; see §4.3. Two questions decide
|
|
53
|
+
where a pinned schema goes:
|
|
54
|
+
|
|
55
|
+
1. **Is it needed at runtime, or only by the tests?** Test-only artifacts belong in
|
|
56
|
+
`spec/fixtures/` regardless of licence, because shipping them is dead weight.
|
|
57
|
+
2. **Whose document is it?** If the answer is not "Ministerstwo Finansów", this section's
|
|
58
|
+
MIT reasoning does not apply and bundling needs its own justification.
|
|
59
|
+
|
|
60
|
+
Anything that would move a third-party schema into `lib/` — for instance offering runtime
|
|
61
|
+
signature validation — is a licensing decision, not a refactor, and belongs with the human
|
|
62
|
+
(DESIGN.md §12).
|
|
63
|
+
|
|
64
|
+
### 1.3 Pinned prose documentation
|
|
65
|
+
|
|
66
|
+
`ksef-api` holds **77 files**; the four schemas and the OpenAPI document above were
|
|
67
|
+
originally the only ones pinned. That was a mistake: most of what §9 listed as
|
|
68
|
+
"unverified" was sitting in the same repository, in prose. The normative subset is now
|
|
69
|
+
mirrored under `docs/upstream/`, preserving upstream paths, at the **same commit
|
|
70
|
+
`1c34fe27`**. Verified byte-for-byte against the upstream tree listing on download.
|
|
71
|
+
|
|
72
|
+
| Local path (under `docs/upstream/`) | Feeds |
|
|
73
|
+
|---|---|
|
|
74
|
+
| `uwierzytelnianie.md` | §4, §4.3, §4.5 — the whole auth flow |
|
|
75
|
+
| `auth/podpis-xades.md` | §4.3, §4.4 — XAdES allow-list, certificate attributes |
|
|
76
|
+
| `auth/testowe-certyfikaty-i-podpisy-xades.md` | §4.6 — TEST bootstrap |
|
|
77
|
+
| `auth/sesje.md` | §4.7 — auth session revocation |
|
|
78
|
+
| `auth/context-identifier-*.md`, `auth/subject-identifier-type-*.md` | §4.1 — verbatim request examples |
|
|
79
|
+
| `bezpieczenstwo/klucze-publiczne-do-szyfrowania.md` | §10.2 — key distribution and rotation |
|
|
80
|
+
| `sesja-interaktywna.md` | §11 — online session semantics |
|
|
81
|
+
| `faktury/sesje/sesja-sprawdzenie-stanu-i-pobranie-upo.md` | §12 — status and UPO retrieval |
|
|
82
|
+
| `faktury/numer-ksef.md` | §13 — KSeF number structure and CRC-8 |
|
|
83
|
+
| `faktury/weryfikacja-faktury.md` | §15 — invoice admission checks; the tier-1 specification |
|
|
84
|
+
| `limity/limity-api.md`, `limity/limity.md` | §6, §6.1 — rate and size limits |
|
|
85
|
+
| `srodowiska.md` | §2, §11.3 — environments, accepted schema versions |
|
|
86
|
+
| `dane-testowe-scenariusze.md` | §6a — TEST data provisioning |
|
|
87
|
+
| `tokeny-ksef.md` | §4.1, §6a.2, §6a.3 — KSeF token issuance, permitted contexts, confidentiality |
|
|
88
|
+
|
|
89
|
+
(The `tokeny-ksef.md` row was missing until 2026-08-23 though the file was pinned all
|
|
90
|
+
along. It is load-bearing: it is the sole source for "a token can only be issued in a `Nip`
|
|
91
|
+
or `InternalId` context", which is what restricts `Auth::Token::CONTEXT_TYPES` to two of
|
|
92
|
+
the contract's four.)
|
|
93
|
+
|
|
94
|
+
The UPO schema is pinned to `lib/ksef/upo/schema/upo-v4-3.xsd` (in `lib/`, following the
|
|
95
|
+
precedent set by the auth schemas: pinned ahead of the code that consumes it) and the six
|
|
96
|
+
worked UPO examples to `spec/fixtures/upo/`.
|
|
97
|
+
|
|
98
|
+
Two entries share a digest — `auth/context-identifier-nip.md` and
|
|
99
|
+
`auth/subject-identifier-type-certificate-subject.md` are byte-identical upstream, both
|
|
100
|
+
illustrating the same NIP-plus-`certificateSubject` request. That is not a download error.
|
|
101
|
+
|
|
102
|
+
**Completed 2026-08-26: every markdown file in the repository is now pinned**, 32 of 32.
|
|
103
|
+
|
|
104
|
+
The earlier position was that `uprawnienia.md`, `api-changelog.md`, `certyfikaty-KSeF.md`,
|
|
105
|
+
`kody-qr.md`, `offline/`, `pobieranie-faktur/`, `sesja-wsadowa.md` and
|
|
106
|
+
`przeglad-kluczowych-zmian-ksef-api-2-0.md` were **deliberately** out, on the reasoning that
|
|
107
|
+
prose churns on editorial fixes and a `verify:artifacts` failure should mean *a fact changed*
|
|
108
|
+
rather than that someone corrected a typo — so only documents a milestone actually derives
|
|
109
|
+
facts from belong in the manifest.
|
|
110
|
+
|
|
111
|
+
**The rule was right and its application was wrong**, for at least two files. `offlineMode`
|
|
112
|
+
and `hashOfCorrectedInvoice` are parameters of `Sessions::Online#send_invoice`, which shipped
|
|
113
|
+
in Phase 2; `tryby-offline.md` and `offline/korekta-techniczna.md` are the documents that
|
|
114
|
+
define them, and by the paragraph's own test they were always documents this milestone
|
|
115
|
+
derives facts from. Nobody noticed because the *OpenAPI* described both fields, and a
|
|
116
|
+
one-line field description reads like the whole story until you find the page that is not.
|
|
117
|
+
See §16, which is what reading them produced — including a rule that changes how KSeF
|
|
118
|
+
classifies an ordinary invoice this gem sends today.
|
|
119
|
+
|
|
120
|
+
| Local path (under `docs/upstream/`) | Feeds |
|
|
121
|
+
|---|---|
|
|
122
|
+
| `tryby-offline.md` | §16, §16.1 — the three offline regimes, and `offlineMode` |
|
|
123
|
+
| `offline/automatyczne-okreslanie-trybu-offline.md` | §16.1 — KSeF reclassifying an online invoice as offline |
|
|
124
|
+
| `offline/korekta-techniczna.md` | §16.2 — technical correction, and `hashOfCorrectedInvoice` |
|
|
125
|
+
| `sesja-wsadowa.md` | §16.3, §14.1 — batch packaging; a second witness to the IV error |
|
|
126
|
+
| `pobieranie-faktur/*.md` | §16.4 — invoice download, export packages, High Water Mark |
|
|
127
|
+
| `api-changelog.md` | §16.5 — API version history |
|
|
128
|
+
| `przeglad-kluczowych-zmian-ksef-api-2-0.md` | §16.5 — the 1.0 → 2.0 overview |
|
|
129
|
+
| `certyfikaty-KSeF.md`, `kody-qr.md`, `uprawnienia.md` | pinned, **not yet read** — 0.2/0.3 scope |
|
|
130
|
+
| `README.md` | the repository's own index of the above |
|
|
131
|
+
|
|
132
|
+
Three of those are pinned without being digested, and the table says so rather than implying
|
|
133
|
+
a reading that did not happen. The eight PNGs stay unpinned: two are session state diagrams
|
|
134
|
+
and would be worth reading, but a binary cannot be diffed into a fact.
|
|
135
|
+
|
|
136
|
+
Still unpinned, and genuinely out of scope: the **PEF** (`PEF(3)`, `PEF_KOR(3)` and fourteen
|
|
137
|
+
more UBL `bazowe` schemas — three of the seventeen are already pinned to `spec/fixtures/xades/`
|
|
138
|
+
for signature validation), **`FA_RR(1)`**, and **`FA(2)`**. Worth knowing what those are:
|
|
139
|
+
`FA_RR` is the flat-rate farmer invoice and `PEF` is the Peppol/UBL structure, both accepted
|
|
140
|
+
by KSeF and neither an eighth `RodzajFaktury`. So "all seven invoice types are modelled" is
|
|
141
|
+
true, and "this gem covers every invoice KSeF accepts" would not be.
|
|
142
|
+
|
|
143
|
+
### 1.4 The FA(3) sample corpus comes from two *other* repositories
|
|
144
|
+
|
|
145
|
+
DESIGN.md §7.6 requires the parser's round-trip law to run against "Ministry-published
|
|
146
|
+
FA(3) sample files". **There are none in `ksef-api`** — its `faktury/` tree holds schemas,
|
|
147
|
+
prose and UPO examples, and not one example invoice. The samples exist, in two sibling
|
|
148
|
+
CIRFMF repositories, and were pinned 2026-08-24:
|
|
149
|
+
|
|
150
|
+
| Local path (under `spec/fixtures/fa3/`) | Source | Why this one |
|
|
151
|
+
|---|---|---|
|
|
152
|
+
| `ksef-pdf-generator/invoice.xml` | `CIRFMF/ksef-pdf-generator` @ `2b7c1da`, `assets/invoice.xml` | The primary sample: 13 KB, richly populated, and **the only candidate with no placeholders** |
|
|
153
|
+
| `ksef-client-csharp/invoice-template-fa-3.xml` | `CIRFMF/ksef-client-csharp` @ `406904d`, `KSeF.Client.Tests.Core/Templates/` | The minimal case |
|
|
154
|
+
| `ksef-client-csharp/invoice-template-fa-3-with-custom-Subject3.xml` | same | Carries `Podmiot3`, which our model cannot represent — the fixture that proves the parser does not silently drop it |
|
|
155
|
+
| `ksef-client-csharp/invoice-template-fa-3-with-disallowed-unicode-characters.xml` | same | XSD-valid and rejected by KSeF; the whole argument for tier 1 (§15.1) |
|
|
156
|
+
|
|
157
|
+
**All four validate against our pinned FA(3) schema** — measured 2026-08-24, zero errors,
|
|
158
|
+
**after the placeholder substitution described below**. That qualifier is not pedantry: the
|
|
159
|
+
three C# templates as pinned each fail on `#nip#` against `TNrNIP`, so anyone validating the
|
|
160
|
+
bytes on disk to check this ledger would get a non-empty error list and conclude it lies. The
|
|
161
|
+
pdf-generator sample needs no substitution and validates as it stands. What the result does
|
|
162
|
+
show is worth having: our XSD rewriting (§8.3) and our validator agree with upstream's own
|
|
163
|
+
test corpus.
|
|
164
|
+
|
|
165
|
+
Three practical notes:
|
|
166
|
+
|
|
167
|
+
- **The C# ones are templates, not documents.** They contain `#nip#`, `#nipOdbiorca#` and
|
|
168
|
+
`#invoice_number#` placeholders, and `#nip#` is not a legal `TNrNIP`. The pinned bytes
|
|
169
|
+
stay verbatim so their digests verify; substitution happens in the spec helper, which
|
|
170
|
+
keeps the two concerns apart. The pdf-generator sample needs no substitution.
|
|
171
|
+
- **They are test-only, so §1.2's first question settles the location**: `spec/fixtures/`,
|
|
172
|
+
never `lib/`, and the gemspec's `docs/*.md` + `lib/**` file list does not package them.
|
|
173
|
+
- **Licences differ, and one differs in substance.** `ksef-client-csharp` is MIT
|
|
174
|
+
© 2025 Ministerstwo Finansów; `ksef-pdf-generator` is MIT © 2025 **CIRF** — a different
|
|
175
|
+
copyright holder from every other artifact here. Both texts are retained beside the
|
|
176
|
+
fixtures as `LICENSE.ksef-client-csharp.txt` and `LICENSE.ksef-pdf-generator.txt`, as MIT
|
|
177
|
+
requires of a redistributor. Neither is in the digest manifest: a licence changing is a
|
|
178
|
+
legal event, not a changed fact about KSeF, and it should not read as an upstream
|
|
179
|
+
data change.
|
|
180
|
+
|
|
181
|
+
Being outside `ksef-api`, these four are **not** covered by §1's single-commit pin. Each row
|
|
182
|
+
above carries its own repository and commit.
|
|
183
|
+
|
|
184
|
+
**What a `verify:artifacts` failure actually means, here and everywhere:** the *local file
|
|
185
|
+
changed*. The task compares bytes on disk against recorded digests and never contacts
|
|
186
|
+
upstream, so an upstream repository moving is invisible to it — checking that is a manual job
|
|
187
|
+
against the commits recorded above. §1's "treat a mismatch as an upstream change" describes
|
|
188
|
+
the usual *cause* (someone re-downloaded), not the mechanism.
|
|
189
|
+
|
|
190
|
+
One practical note for a re-verifier: upstream's licence file in `ksef-client-csharp` is
|
|
191
|
+
`LICENCE.txt`, with the British spelling. There is no `LICENSE`, and a fetch of that name
|
|
192
|
+
404s.
|
|
193
|
+
|
|
194
|
+
### 1.5 The Ministry's own worked examples — and the first artifact from outside CIRFMF
|
|
195
|
+
|
|
196
|
+
Pinned 2026-08-24 to `spec/fixtures/fa3/mf-samples/`. **Twenty-six FA(3) invoices covering all
|
|
197
|
+
seven `RodzajFaktury` values** — 12 `VAT`, 5 `KOR`, 3 `KOR_ZAL`, 2 `ROZ`, 2 `UPR`, 1 `KOR_ROZ`,
|
|
198
|
+
1 `ZAL` — plus a five-page descriptions PDF that is *not* redistributed.
|
|
199
|
+
|
|
200
|
+
| | |
|
|
201
|
+
|---|---|
|
|
202
|
+
| Package | *Przykładowe pliki dla struktury logicznej e-Faktury FA(3)* |
|
|
203
|
+
| URL | `https://ksef.podatki.gov.pl/media/e5cia0ey/przykladowe-pliki-dla-struktury-logicznej-e-faktury-fa-3.zip` |
|
|
204
|
+
| Archive SHA-256 | `41ebd3c57144951c65d68a36fbe433285b5791a86a8bd46cb059503e3f8b1e10` (200 512 bytes) |
|
|
205
|
+
| Retrieved | 2026-08-24 |
|
|
206
|
+
|
|
207
|
+
**This is the only corpus of non-`VAT` invoice types that exists.** Two independent sweeps of
|
|
208
|
+
every CIRFMF repository — all branches, the whole history of all six — found every FA(3) fixture
|
|
209
|
+
to be `RodzajFaktury=VAT`. The two files named "pef-correction" are UBL 2.1 `CreditNote`
|
|
210
|
+
documents, not FA(3) corrections. So DESIGN.md §7.4's remaining six types had no worked example
|
|
211
|
+
anywhere until this package was found, by following an issue in `CIRFMF/ksef-schematy` to the
|
|
212
|
+
Ministry's downloads page.
|
|
213
|
+
|
|
214
|
+
All twenty-six validate against the pinned FA(3) XSD with **zero errors** (measured 2026-08-24),
|
|
215
|
+
which is a second independent confirmation that §8.3's in-memory import rewriting is sound.
|
|
216
|
+
|
|
217
|
+
**They earned their keep the same day, and kept doing so.** `KOR` was built against the five
|
|
218
|
+
corrections here and nothing else — no upstream code models an FA(3) correction — and **all
|
|
219
|
+
five** parse, re-serialise and validate (four did until 2026-08-26; Przykład 7 was the fifth,
|
|
220
|
+
and §8.6 is what unblocked it). Every structural decision in §8.4 was taken by reading them, and two
|
|
221
|
+
were taken *against* what the schema documentation alone would have suggested: Przykład 2 gives
|
|
222
|
+
a before/after pair the same `NrWierszaFa` where the annotation says *"z odrębną numeracją"*,
|
|
223
|
+
and three of the five make it plain that a correction's summary cannot be derived from its rows.
|
|
224
|
+
§8.4a is a third finding they produced: their KSeF numbers do not satisfy §13's checksum. `ZAL`
|
|
225
|
+
and `ROZ` followed on 2026-08-25 (§8.5) and the last three types on 2026-08-26 (§8.6), each
|
|
226
|
+
built the same way. **Twenty-two of the twenty-six now parse, re-serialise, validate and
|
|
227
|
+
round-trip**; the four that do not are refused for a construct — two priced gross, two
|
|
228
|
+
identifying their buyer by something other than a NIP — rather than for their type.
|
|
229
|
+
|
|
230
|
+
#### The licence position, and the decision taken
|
|
231
|
+
|
|
232
|
+
**This is the first artifact pinned from outside the CIRFMF GitHub organisation, and §1.2's MIT
|
|
233
|
+
reasoning does not reach it.** That grant arrives through the repositories; these files are in
|
|
234
|
+
none of them.
|
|
235
|
+
|
|
236
|
+
`podatki.gov.pl` states site-wide that using its content *"niezależnie od celu i sposobu
|
|
237
|
+
korzystania, nie wymaga zgody Ministerstwa Finansów"* — regardless of purpose or manner, requires
|
|
238
|
+
no consent from the Ministry — with CC BY 3.0 PL for material marked as copyright. **But the files
|
|
239
|
+
are hosted on `ksef.podatki.gov.pl`**, a subdomain carrying no licence statement of its own, and
|
|
240
|
+
they bear no internal notice. Whether the parent statement reaches the subdomain is an
|
|
241
|
+
interpretive question nobody has answered in writing.
|
|
242
|
+
|
|
243
|
+
**Decision (human, 2026-08-24, DESIGN.md §12): redistribute the twenty-six XML files as test
|
|
244
|
+
fixtures, with attribution.** `spec/fixtures/fa3/mf-samples/NOTICE.md` carries the source, the
|
|
245
|
+
retrieval date, the archive checksum, the licence statement in Polish and English, and the caveat
|
|
246
|
+
above stated plainly. The descriptions PDF is *not* redistributed — it is referenced by URL and
|
|
247
|
+
checksum only, which needs no licence at all. Nothing here is packaged: the gemspec ships
|
|
248
|
+
`lib/**` and `docs/*.md`, and a built gem contains **zero fixtures** — the property that
|
|
249
|
+
matters here. (An earlier revision also gave a file count; it has moved since and is not worth
|
|
250
|
+
pinning.)
|
|
251
|
+
|
|
252
|
+
Filenames were normalised to `przyklad-01.xml` … `przyklad-26.xml`. Upstream's carry Polish
|
|
253
|
+
diacritics and an inconsistent `FA_3_`/`Fa_3_` prefix, which travel badly across case-insensitive
|
|
254
|
+
filesystems and Windows checkouts; the **bytes are untouched**, and the bytes are what
|
|
255
|
+
`docs/artifacts.sha256` pins. `NOTICE.md` holds the full mapping.
|
|
256
|
+
|
|
257
|
+
#### What the corpus establishes, beyond being a corpus
|
|
258
|
+
|
|
259
|
+
- **`Σ P_13_* + Σ P_14_* == P_15` holds exactly in 22 of the 26, and 24 count only if you let
|
|
260
|
+
`0 == 0` stand as a witness** — Przykład 5 and 13 state no buckets and a `P_15` of zero, so
|
|
261
|
+
they reconcile trivially and tier 3 skips them before comparing. Three samples state no
|
|
262
|
+
buckets at all (5, 13, 16), not one. Corrected 2026-08-26; the earlier "24 of 26" inflated
|
|
263
|
+
the evidence by two.
|
|
264
|
+
The two exceptions are the useful part, and they constrain any future tier 3: **Przykład 1 is
|
|
265
|
+
off by 0.01** (1666.66 + 383.33 + 0.95 + 0.05 = 2050.99 against a stated `P_15` of 2051).
|
|
266
|
+
|
|
267
|
+
**The cause, corrected twice.** An early revision called it "a gross-priced invoice"; it is
|
|
268
|
+
not, and could not be, since it states `P_9A`/`P_11` throughout. A later one called it
|
|
269
|
+
bucket-level tax rounding; **that is arithmetically false** — the exact taxes are 383.3318
|
|
270
|
+
and 0.0475, which round individually to 383.33 and 0.05 and sum to 383.38, exactly what
|
|
271
|
+
rounding their sum gives. Per-bucket tax rounding moves this invoice's total by 0.0007.
|
|
272
|
+
|
|
273
|
+
The real cause is in the rows: the nets are computed back *w stu* from **round gross prices**
|
|
274
|
+
of 2000, 50 and 1, and rounded **down** — `2000 / 1.23 = 1626.0163`, where two-place rounding
|
|
275
|
+
gives `1626.02` and the document states `1626.01`. Substitute the correctly-rounded net and
|
|
276
|
+
the invoice reconciles exactly, with no tolerance needed. `P_15` is 2000 + 50 + 1.
|
|
277
|
+
|
|
278
|
+
This matters for more than accuracy: under the tax-rounding story the error is bounded by the
|
|
279
|
+
five tax buckets, so a grosz of tolerance nearly covers it. Under the real mechanism it is
|
|
280
|
+
bounded by the **number of lines** and by the issuer's rounding convention — which is to say
|
|
281
|
+
not bounded at all. Two such lines and the gap is two grosze. That is why tier 3's rule is a
|
|
282
|
+
**warning** and not an error (§17.1). Both corrections made 2026-08-26.
|
|
283
|
+
And
|
|
284
|
+
**Przykład 16 (`UPR`) omits the buckets entirely**, the descriptions PDF stating that a
|
|
285
|
+
simplified invoice may carry `P_15` alone. **A zero-tolerance equality check would reject the
|
|
286
|
+
Ministry's own first example**, so that rule needs a one-grosz tolerance and a
|
|
287
|
+
bucket-presence guard — see §15.6.
|
|
288
|
+
- **A `KOR` carries deltas in the buckets**, confirming the XSD annotations: Przykład 2 uses
|
|
289
|
+
before/after rows (`StanPrzed=1` on the before row, same `NrWierszaFa`) with header
|
|
290
|
+
`P_13_1=-162.60`, `P_14_1=-37.40`, `P_15=-200`, reconciling exactly. Amounts are unpadded
|
|
291
|
+
(`P_15=2051`, `P_15=-200`), so any comparison must be numeric and never on strings.
|
|
292
|
+
- **Four of the twelve `VAT` samples are beyond this model**, which is a measured map of 0.1's
|
|
293
|
+
limits rather than a guess: `08` and `19` price rows **gross** (`P_9B`/`P_11A` under art. 106e
|
|
294
|
+
ust. 7-8) where the model carries net pricing only, and `22`/`23` identify the buyer by
|
|
295
|
+
`NrVatUE` and by `NrID` (§8.2a). Each is refused with a message naming the construct and saying
|
|
296
|
+
the document is fine; `spec/ksef/fa3/round_trip_spec.rb` asserts every one, and asserts that no
|
|
297
|
+
`VAT` sample is left unclassified.
|
|
298
|
+
|
|
45
299
|
---
|
|
46
300
|
|
|
47
301
|
## 2. Environments — resolves DESIGN.md §6.1 [VERIFY]
|
|
@@ -141,19 +395,482 @@ reference number or the KSeF number.
|
|
|
141
395
|
|
|
142
396
|
## 4. Authentication
|
|
143
397
|
|
|
398
|
+
Source: `uwierzytelnianie.md` (10.07.2025) plus the pinned spec. Retrieved 2026-08-22.
|
|
399
|
+
|
|
144
400
|
- Security scheme: a single HTTP **`Bearer`** scheme, `bearerFormat: JWT`
|
|
145
401
|
(`components.securitySchemes.Bearer`).
|
|
402
|
+
- **Exactly two authentication methods exist**, and both share the same challenge
|
|
403
|
+
prologue and the same token-redemption epilogue:
|
|
404
|
+
1. **Qualified electronic signature (XAdES)** — an `AuthTokenRequest` XML document
|
|
405
|
+
signed with a certificate. The authenticating subject is read *from the signing
|
|
406
|
+
certificate*. `POST /auth/xades-signature`, `Content-Type: application/xml`.
|
|
407
|
+
2. **KSeF token** — a JSON document carrying a previously issued token.
|
|
408
|
+
`POST /auth/ksef-token`.
|
|
146
409
|
- `POST /auth/challenge` returns `AuthenticationChallengeResponse`, required fields:
|
|
147
410
|
`challenge`, `timestamp` (date-time), `timestampMs` (int64, Unix ms), `clientIp`.
|
|
411
|
+
- **Challenge lifetime is 10 minutes** — now *verified* from `uwierzytelnianie.md`
|
|
412
|
+
("Czas życia challenge'a wynosi 10 minut"), superseding the earlier note in this
|
|
413
|
+
section that it was documentation-hearsay and unconfirmed.
|
|
148
414
|
- `clientIp` in the challenge response ties into the `ip-not-allowed` authorisation
|
|
149
415
|
failure (§5.3): the API pins the session to the IP seen at authentication.
|
|
150
|
-
-
|
|
151
|
-
|
|
152
|
-
|
|
416
|
+
- The authenticating subject must already hold at least one active permission in the
|
|
417
|
+
requested context, or no access token is issued.
|
|
418
|
+
|
|
419
|
+
### 4.1 The `AuthTokenRequest` document
|
|
420
|
+
|
|
421
|
+
Pinned at `lib/ksef/auth/schema/schemat_auth_v2-{0,1}.xsd` (§1).
|
|
422
|
+
|
|
423
|
+
**Send the 2.0 namespace.** An earlier revision of this section said "both versions are
|
|
424
|
+
accepted by the API; v2.1 is current" and this gem was built against 2.1 on that basis.
|
|
425
|
+
That was an inference, not a verified fact, and checking the reference implementations
|
|
426
|
+
(2026-08-22) contradicted it — see §14.4. Every piece of available evidence points at 2.0:
|
|
427
|
+
|
|
428
|
+
| Source | Namespace |
|
|
429
|
+
|---|---|
|
|
430
|
+
| `ksef-client-csharp`, `AuthenticationTokenRequest.cs` | `[XmlRoot(Namespace = "…/auth/token/2.0")]` |
|
|
431
|
+
| `ksef-client-java`, JAXB-generated `TContextIdentifier` etc. | generated against `…/2.0` |
|
|
432
|
+
| `ksef-client-java`, its own bundled `AuthTokenRequest.xsd` | `targetNamespace="…/2.0"` |
|
|
433
|
+
| all five worked examples in `CIRFMF/ksef-api` | `xmlns="…/2.0"` |
|
|
434
|
+
|
|
435
|
+
Nothing observed emits 2.1. **Whether the API accepts 2.1 at all is unverified** and needs
|
|
436
|
+
a live TEST call; until then 2.0 is the only defensible default.
|
|
437
|
+
|
|
438
|
+
Validation is a separate question from what to send, because **v2.0's file does not compile
|
|
439
|
+
as a schema** (§14.4). The rules therefore come from v2.1's file with its target namespace
|
|
440
|
+
rewritten in memory to match the document — the same technique `Ksef::FA3::Validator` uses
|
|
441
|
+
for its remote `schemaLocation`, and legitimate here because the two files are structurally
|
|
442
|
+
identical: diffing them shows only the namespace and the three IP patterns differ, and
|
|
443
|
+
v2.1's are the correct ones.
|
|
444
|
+
|
|
445
|
+
| Fact | Value |
|
|
446
|
+
|---|---|
|
|
447
|
+
| Target namespace (v2.1) | `http://ksef.mf.gov.pl/auth/token/2.1` |
|
|
448
|
+
| `elementFormDefault` | `qualified`; `attributeFormDefault` `unqualified` |
|
|
449
|
+
| Root element | `AuthTokenRequest` (no namespace prefix in upstream's examples — default namespace) |
|
|
450
|
+
| Required children, in order | `Challenge`, `ContextIdentifier`, `SubjectIdentifierType` |
|
|
451
|
+
| `SubjectIdentifierType` values | `certificateSubject`, `certificateFingerprint` |
|
|
452
|
+
| Optional | `AuthorizationPolicy` → `AllowedIps` → `Ip4Address` / `Ip4Range` / `Ip4Mask`, each 0–**10** |
|
|
453
|
+
|
|
454
|
+
`AllowedIps` is itself mandatory *inside* `AuthorizationPolicy`, so the policy element
|
|
455
|
+
cannot be present but empty.
|
|
456
|
+
|
|
457
|
+
**`Challenge` is strictly formatted** — `xsd:token`, length exactly **36**, pattern
|
|
458
|
+
`\d{8}-CR-[A-F0-9]{10}-[A-F0-9]{10}-[A-F0-9]{2}`. Verified 2026-08-22 that upstream's own
|
|
459
|
+
example `20250604-CR-461EA5B000-537A6BA15D-D7` satisfies it, that lowercase hex is
|
|
460
|
+
rejected, and that the literal `CR` is required. Worth validating client-side before
|
|
461
|
+
spending a signature on it. Note the shape matches §12's reference numbers, `CR` being the
|
|
462
|
+
kind tag for a challenge.
|
|
463
|
+
|
|
464
|
+
`ContextIdentifier` is a **choice of four** in v2.1 — not three:
|
|
465
|
+
|
|
466
|
+
| Element | Pattern | Usable? |
|
|
467
|
+
|---|---|---|
|
|
468
|
+
| `Nip` | `[1-9]((\d[1-9])\|([1-9]\d))\d{7}` | yes |
|
|
469
|
+
| `InternalId` | the NIP pattern, then `-\d{5}` | yes |
|
|
470
|
+
| `NipVatUe` | NIP, `-`, then an EU VAT number by member state | **no — see §14.4** |
|
|
471
|
+
| `PeppolId` | `^P[A-Z]{2}[0-9]{6}$` | **no — see §14.4** |
|
|
472
|
+
|
|
473
|
+
Note the NIP pattern here is *structural* (first digit non-zero, positions 2–3 not both
|
|
474
|
+
zero), not a checksum. It is weaker than `Ksef::FA3::NIP`'s check-digit validation, so
|
|
475
|
+
both are worth applying.
|
|
476
|
+
|
|
477
|
+
Per `tokeny-ksef.md`, a KSeF token can only be issued in a **`Nip` or `InternalId`**
|
|
478
|
+
context, which is fortunate given the state of the other two.
|
|
479
|
+
|
|
480
|
+
Signature form: enveloped or enveloping; **detached is rejected**.
|
|
481
|
+
|
|
482
|
+
Self-signed certificates are accepted on **TEST only**. `srodowiska.md` is explicit that
|
|
483
|
+
this is why TEST contexts are not isolated between integrators.
|
|
484
|
+
|
|
485
|
+
### 4.2 Tokens
|
|
486
|
+
|
|
487
|
+
`/auth/token/redeem` exchanges a completed authentication for the token pair;
|
|
488
|
+
`/auth/token/refresh` renews. This confirms the [VERIFY] in DESIGN.md §6.3's shared
|
|
489
|
+
epilogue — the API does issue a refresh token alongside the access token. (That section's
|
|
490
|
+
numbered lists stop at 3; there is no step 4 to cite.)
|
|
491
|
+
|
|
492
|
+
| Token | Lifetime | Notes |
|
|
493
|
+
|---|---|---|
|
|
494
|
+
| `accessToken` | short (docs say "kilkanaście minut"). **Read expiry from the response's `validUntil`, not the JWT `exp`** — the contract's `TokenInfo` requires `token` + `validUntil` precisely so no decoding is needed, and DESIGN.md §4.3 excludes the `jwt` dependency | sent as `Authorization: Bearer` |
|
|
495
|
+
| `refreshToken` | **up to 7 days**, reusable | renews the access token without re-authenticating |
|
|
496
|
+
|
|
497
|
+
**Revocation is not immediate.** An `accessToken` stays valid until its `exp` even if the
|
|
498
|
+
user's permissions change in the meantime. Never treat possession of a live token as
|
|
499
|
+
proof of current authorisation.
|
|
500
|
+
|
|
501
|
+
**`/auth/token/redeem` is single-use.** `uwierzytelnianie.md` §4: the endpoint returns the
|
|
502
|
+
token pair *once* for a completed authentication, and every subsequent call with the same
|
|
503
|
+
`authenticationToken` returns **400**. A retry wrapper around redemption would turn a
|
|
504
|
+
transient network blip into a permanently unusable authentication — this endpoint must
|
|
505
|
+
surface its errors, which the POST-never-retried rule (DESIGN.md §6.7) already guarantees.
|
|
506
|
+
|
|
507
|
+
The full flow, verified end to end from `uwierzytelnianie.md` (retrieved 2026-08-22):
|
|
508
|
+
|
|
509
|
+
| Step | Call | Auth header | Notes |
|
|
510
|
+
|---|---|---|---|
|
|
511
|
+
| 1 | `POST /auth/challenge` | none | public endpoint, 60 req/s per IP; TTL 10 min |
|
|
512
|
+
| 2 | build + sign `AuthTokenRequest` | — | offline |
|
|
513
|
+
| 3 | `POST /auth/xades-signature` | none | returns `referenceNumber` + `authenticationToken` |
|
|
514
|
+
| 4 | `GET /auth/{referenceNumber}` | `Bearer {authenticationToken}` | poll to completion |
|
|
515
|
+
| 5 | `POST /auth/token/redeem` | `Bearer {authenticationToken}` | **once only** → `accessToken` + `refreshToken` |
|
|
516
|
+
| 6 | `POST /auth/token/refresh` | `Bearer {refreshToken}` | renews `accessToken` |
|
|
517
|
+
|
|
518
|
+
Note the header at steps 4–5 is the *temporary* `authenticationToken`, not an
|
|
519
|
+
`accessToken`. Conflating them is the obvious implementation error here.
|
|
520
|
+
|
|
521
|
+
**Step 4 must poll without a deadline on DEMO and PROD.** Those environments verify the
|
|
522
|
+
signing certificate's status with the issuer over OCSP/CRL, and the operation legitimately
|
|
523
|
+
reports "in progress" until the issuer answers — the docs state the duration depends on the
|
|
524
|
+
certificate provider. A client that gives up after a fixed timeout will report failure for
|
|
525
|
+
authentications that were about to succeed. (On TEST, self-signed certificates skip this.)
|
|
526
|
+
|
|
527
|
+
#### 4.2a Step 6 measured — `/auth/token/refresh`, recorded 2026-08-26
|
|
528
|
+
|
|
529
|
+
The last of the six to be exercised against the live service, and the only one whose response
|
|
530
|
+
had never been seen. Recorded into `spec/cassettes/the_token_refresh_flow_recorded/`, so these
|
|
531
|
+
are observations rather than readings of the contract.
|
|
532
|
+
|
|
533
|
+
**The response carries `accessToken` and nothing else:**
|
|
534
|
+
|
|
535
|
+
```json
|
|
536
|
+
{ "accessToken": { "token": "…", "validUntil": "2026-08-26T13:45:24.2432483+00:00" } }
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
Three things follow, each of which the implementation had assumed:
|
|
153
540
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
541
|
+
| Assumption | Now |
|
|
542
|
+
|---|---|
|
|
543
|
+
| `Auth::Client#refresh` reads `body["accessToken"]` | **confirmed.** The envelope matches the redeem response's `accessToken` member, not a bare `TokenInfo` |
|
|
544
|
+
| `AccessToken#renew!` replaces the access token and keeps the refresh token | **confirmed.** No `refreshToken` is returned, so there is nothing to replace it with. A client that expected a rotated refresh token would have to re-authenticate every renewal |
|
|
545
|
+
| The refresh token's life is "up to 7 days" | **exactly 7 days on TEST**, to the second: redeemed `13:30:06.5720816`, valid until `2026-09-02T13:30:06.5720816` |
|
|
546
|
+
|
|
547
|
+
**A renewed access token gets a full fresh lifetime, not the remainder of the old one.** The
|
|
548
|
+
redeemed token was valid until `13:45:06`; refreshing eight seconds later returned one valid
|
|
549
|
+
until `13:45:24` — fifteen minutes from the refresh, not from the original issue. So a
|
|
550
|
+
long-running session renews indefinitely without re-authenticating, bounded only by the refresh
|
|
551
|
+
token's seven days.
|
|
552
|
+
|
|
553
|
+
**Fifteen minutes is what "kilkanaście minut" means here.** Four measurements across the tier's
|
|
554
|
+
three cassettes, as `validUntil` minus the moment the response was received: **900.2, 898.7,
|
|
555
|
+
890.6, 900.2** seconds.
|
|
556
|
+
|
|
557
|
+
**Read those to the nearest second, not to the tenth.** `recorded_at` is an RFC 2822 timestamp
|
|
558
|
+
with whole-second granularity, so each figure carries about a second of error — which is also why
|
|
559
|
+
two of them read as 900.2 rather than 900.0, and why the claim that measuring from delivery "can
|
|
560
|
+
only under-estimate" is not something these four numbers demonstrate. The underlying grant *is*
|
|
561
|
+
exactly 900 s: the refresh response's `validUntil` is 900.0000000 s after the mint instant its own
|
|
562
|
+
body implies.
|
|
563
|
+
|
|
564
|
+
The spread is ours, not KSeF's. The 890.6 figure is a redeem whose authentication needed four
|
|
565
|
+
polls over nine seconds — KSeF dates the grant from when it minted the token, and we can only
|
|
566
|
+
measure from when we took delivery. That is why {Ksef::Auth::AccessToken} records `@acquired_at`
|
|
567
|
+
rather than trusting a constant: a lifetime measured from delivery is never longer than the real
|
|
568
|
+
one, so it refreshes slightly early rather than slightly late.
|
|
569
|
+
|
|
570
|
+
Nothing here is hard-coded. This is a TEST observation, not a documented guarantee, so the
|
|
571
|
+
threshold stays a proportion of the observed lifetime.
|
|
572
|
+
|
|
573
|
+
### 4.3 XAdES signature requirements — resolves the §9 blocker
|
|
574
|
+
|
|
575
|
+
Source: `auth/podpis-xades.md`. **The specification is an allow-list, not a single
|
|
576
|
+
mandated combination**, which is the key finding: there is no exact byte-shape to
|
|
577
|
+
reverse-engineer, only a permitted set to choose from.
|
|
578
|
+
|
|
579
|
+
| Aspect | Permitted |
|
|
580
|
+
|---|---|
|
|
581
|
+
| Form | **enveloped** or **enveloping**. Detached is rejected. |
|
|
582
|
+
| Profiles | XAdES-BES, -EPES, -T, -LT, -C, -X, -XL, -A, -ERS, and BASELINE-B/-T/-LT/-LTA |
|
|
583
|
+
| Transforms | XPath `not(ancestor-or-self::ds:Signature)`, xmldsig-filter2, `#enveloped-signature`, `#base64`, c14n11 (±comments), exc-c14n (±comments), REC-xml-c14n-20010315 (±comments) |
|
|
584
|
+
| `SignatureMethod` | RSASSA-PKCS1-v1_5 or RSASSA-PSS (sha1/256/384/512, plus sha3-\* for PSS), min **2048-bit**; or ECDSA (sha1/256/384/512, sha3-\*), min **256-bit** curve |
|
|
585
|
+
| `DigestMethod` | sha1, sha256, sha384, sha512, sha3-256, sha3-384, sha3-512 — chosen **independently** of `SignatureMethod` |
|
|
586
|
+
|
|
587
|
+
`DigestMethod` governs both `SignedInfo/Reference/DigestMethod` and the qualifying
|
|
588
|
+
properties, notably `SigningCertificate/CertDigest/DigestMethod`.
|
|
589
|
+
|
|
590
|
+
**Implementation choice for this gem:** enveloped, XAdES-BES, `#enveloped-signature`
|
|
591
|
+
transform, exclusive c14n, `rsa-sha256`, `xmlenc#sha256` digest. Every one of those is
|
|
592
|
+
explicitly listed above, so the combination needs no further verification. Corroborated
|
|
593
|
+
2026-08-22 against both clients: C# builds exactly this shape in `SignatureService`, and
|
|
594
|
+
Java asks DSS for `SignaturePackaging.ENVELOPED` with `DigestAlgorithm.SHA256` and
|
|
595
|
+
`SignatureAlgorithm.RSA_SHA256`.
|
|
596
|
+
|
|
597
|
+
Neither client states its `SignedInfo` `CanonicalizationMethod` — C# leaves .NET's
|
|
598
|
+
`SignedXml` default in place and Java leaves it to DSS — so exclusive c14n is *this gem's*
|
|
599
|
+
choice rather than a copied one. It is safe because the allow-list above permits both c14n
|
|
600
|
+
1.0 and exclusive c14n, and exclusive avoids namespace-inheritance surprises when the
|
|
601
|
+
signature is lifted between documents.
|
|
602
|
+
|
|
603
|
+
#### Two facts the allow-list does not state
|
|
604
|
+
|
|
605
|
+
Both taken from `ksef-client-csharp`'s `KSeF.Client/Api/Services/SignatureService.cs`
|
|
606
|
+
(retrieved 2026-08-22) and recorded with that provenance rather than treated as common
|
|
607
|
+
knowledge:
|
|
608
|
+
|
|
609
|
+
| Fact | Value | Why it matters |
|
|
610
|
+
|---|---|---|
|
|
611
|
+
| `Type` on the `SignedProperties` reference | `http://uri.etsi.org/01903#SignedProperties` | Fixed by ETSI TS 101 903, absent from the Ministry's allow-list, and appears in no pinned artifact |
|
|
612
|
+
| `SigningTime` is **backdated one minute** | `CertificateTimeBuffer = TimeSpan.FromMinutes(-1)` | Unexplained upstream, but plainly a clock-skew guard: a signing time fractionally in the future relative to the server's clock invites rejection, and being a minute early costs nothing |
|
|
613
|
+
|
|
614
|
+
The remaining structural details, all mirrored: `Id="Signature"` and
|
|
615
|
+
`Id="SignedProperties"` as literal identifiers; the document reference is `URI=""` with the
|
|
616
|
+
enveloped transform *then* c14n; the `SignedProperties` reference carries only the c14n
|
|
617
|
+
transform; `KeyInfo` holds `X509Data/X509Certificate`; `IssuerSerial` uses the issuer name
|
|
618
|
+
in RFC 2253 order and the serial in decimal.
|
|
619
|
+
|
|
620
|
+
**One namespace subtlety worth stating explicitly**, because getting it wrong produces a
|
|
621
|
+
document that looks correct and verifies nowhere: inside `xades:QualifyingProperties` the
|
|
622
|
+
reference implementation declares `xmlns` = the **xmldsig** namespace, so `DigestMethod`,
|
|
623
|
+
`DigestValue`, `X509IssuerName` and `X509SerialNumber` are written *unprefixed* yet belong
|
|
624
|
+
to xmldsig, not to XAdES — even though they sit inside `xades:CertDigest` and
|
|
625
|
+
`xades:IssuerSerial`.
|
|
626
|
+
|
|
627
|
+
**Built directly on Nokogiri and stdlib `openssl`, adding no dependency.** Decided
|
|
628
|
+
2026-08-22 after confirming every primitive the signature and §10 need is already
|
|
629
|
+
available (measured, both on 3.2.11 and 4.0.6):
|
|
630
|
+
|
|
631
|
+
| Need | Available as |
|
|
632
|
+
|---|---|
|
|
633
|
+
| Exclusive c14n | `Nokogiri::XML::XML_C14N_EXCLUSIVE_1_0` (also `_1_0` = 0 and `_1_1` = 2) via `Node#canonicalize` |
|
|
634
|
+
| `rsa-sha256` signature | `OpenSSL::PKey::RSA#sign("SHA256", …)` — 256-byte output for a 2048-bit key |
|
|
635
|
+
| RSA-OAEP SHA-256 + MGF1-SHA-256 | `#encrypt(data, rsa_padding_mode: "oaep", rsa_oaep_md: "sha256", rsa_mgf1_md: "sha256")` — the plain `#public_encrypt` will **not** do, it cannot set the MGF1 digest |
|
|
636
|
+
| AES-256-CBC / PKCS#7 | `OpenSSL::Cipher.new("aes-256-cbc")`, padding on by default; `#random_key` / `#random_iv` give the 32 and 16 bytes of §10.1 |
|
|
637
|
+
|
|
638
|
+
`openssl` is a **default gem** on both Rubies (3.1.0 on 3.2.11, 4.0.2 on 4.0.6), so
|
|
639
|
+
requiring it is not a new runtime dependency and needs no gemspec entry — the same status
|
|
640
|
+
as `date`, and unlike `bigdecimal`, which had to be declared because it became a *bundled*
|
|
641
|
+
gem in Ruby 3.4.
|
|
642
|
+
|
|
643
|
+
#### Signature namespaces, and where they are pinned
|
|
644
|
+
|
|
645
|
+
Read from pinned artifacts rather than recalled, per the never-invent-a-namespace-URI rule.
|
|
646
|
+
Upstream redistributes the W3C and ETSI schemas inside its PEF bundle, so they are
|
|
647
|
+
available at the same commit as everything else:
|
|
648
|
+
|
|
649
|
+
| Namespace | Pinned as |
|
|
650
|
+
|---|---|
|
|
651
|
+
| `http://www.w3.org/2000/09/xmldsig#` | `spec/fixtures/xades/UBL-xmldsig-core-schema-2.1.xsd` |
|
|
652
|
+
| `http://uri.etsi.org/01903/v1.3.2#` | `spec/fixtures/xades/UBL-XAdESv132-2.1.xsd` |
|
|
653
|
+
| `http://uri.etsi.org/01903/v1.4.1#` | `spec/fixtures/xades/UBL-XAdESv141-2.1.xsd` |
|
|
654
|
+
|
|
655
|
+
Measured 2026-08-22: all three **compile offline**. Their `xsd:import` locations are
|
|
656
|
+
*relative* (`UBL-xmldsig-core-schema-2.1.xsd`), so unlike the FA(3) schema they need no
|
|
657
|
+
in-memory `schemaLocation` rewrite, and `xmldsig-core` imports nothing at all. A minimal
|
|
658
|
+
enveloped `ds:Signature` using exclusive c14n, `rsa-sha256` and `xmlenc#sha256` validates
|
|
659
|
+
against the xmldsig schema, and the same document with `SignatureValue` removed is
|
|
660
|
+
rejected — so this gives the signer real structural validation, not a rubber stamp.
|
|
661
|
+
|
|
662
|
+
**Placed under `spec/fixtures/`, not `lib/`, on purpose.** Two reasons. Validating a
|
|
663
|
+
signature is a test-time concern — the client signs, and KSeF verifies — so nothing at
|
|
664
|
+
runtime needs these files. And they are W3C and ETSI documents redistributed by OASIS and
|
|
665
|
+
then by the Ministry; their terms are not the repository's MIT licence that §1.2 relied on
|
|
666
|
+
for bundling the FA(3) schemas. Keeping them out of the gem sidesteps a redistribution
|
|
667
|
+
question we do not need to answer.
|
|
668
|
+
|
|
669
|
+
`ksef-client-csharp`'s `CertTestApp` (§4.6) is held in reserve as a debugging aid, not a
|
|
670
|
+
build-time input: if TEST rejects a signature with a message that does not say why, its
|
|
671
|
+
`--output file` signed XML gives something concrete to diff against. Installing a .NET SDK
|
|
672
|
+
is therefore optional, and deliberately not a prerequisite for this work.
|
|
673
|
+
|
|
674
|
+
For ECDSA, `SignatureValue` is `R || S` fixed-field concatenation per XMLDSIG 1.1 /
|
|
675
|
+
RFC 4050 §3.3 — **not** DER, which is what OpenSSL emits by default. Only relevant if
|
|
676
|
+
ECDSA support is added; RSA avoids the issue.
|
|
677
|
+
|
|
678
|
+
### 4.4 Certificate subject requirements
|
|
679
|
+
|
|
680
|
+
Source: `auth/podpis-xades.md`. Accepted certificate types: qualified personal (PESEL or
|
|
681
|
+
NIP), qualified organisation seal (NIP), Trusted Profile (ePUAP), KSeF-issued internal
|
|
682
|
+
certificate (not qualified, but honoured), and Peppol service-provider certificates.
|
|
683
|
+
|
|
684
|
+
| Certificate kind | Required subject attributes | Identifier pattern |
|
|
685
|
+
|---|---|---|
|
|
686
|
+
| Qualified personal signature | `givenName` (2.5.4.42), `surname` (2.5.4.4), `serialNumber` (2.5.4.5), `commonName` (2.5.4.3), `countryName` (2.5.4.6) | `(PNOPL\|PESEL).*?(\d{11})` or `(TINPL\|NIP).*?(\d{10})` |
|
|
687
|
+
| Qualified organisation seal | `organizationName` (2.5.4.10), `organizationIdentifier` (2.5.4.97), `commonName`, `countryName` | `(VATPL).*?(\d{10})` |
|
|
688
|
+
|
|
689
|
+
An organisation seal **must not** carry `givenName` or `surname`. The `.*?` in each
|
|
690
|
+
pattern accommodates the hyphenated form the reference clients emit — `TINPL-1234567890`,
|
|
691
|
+
`VATPL-1234567890`.
|
|
692
|
+
|
|
693
|
+
Where a qualified certificate lacks a usable identifier in 2.5.4.5, authentication is
|
|
694
|
+
still possible by pre-granting permissions against the certificate's **SHA-256
|
|
695
|
+
fingerprint** and using `SubjectIdentifierType` = `certificateFingerprint`.
|
|
696
|
+
|
|
697
|
+
### 4.5 KSeF-token authentication — the encrypted payload
|
|
698
|
+
|
|
699
|
+
Source: `uwierzytelnianie.md` §2.2. The plaintext is:
|
|
700
|
+
|
|
701
|
+
```
|
|
702
|
+
{ksefToken}|{timestampMs}
|
|
703
|
+
```
|
|
704
|
+
|
|
705
|
+
UTF-8 encoded, where `timestampMs` is the `timestamp` from the `POST /auth/challenge`
|
|
706
|
+
response **as Unix milliseconds**. Encrypt with the KSeF public key using
|
|
707
|
+
**RSA-OAEP with SHA-256 and MGF1-SHA-256**, then Base64-encode into `encryptedToken`.
|
|
708
|
+
|
|
709
|
+
The timestamp is not decoration — the docs are explicit that it acts as a nonce, so that a
|
|
710
|
+
captured ciphertext cannot be replayed into a later session. Reusing a stale timestamp, or
|
|
711
|
+
sending a locally generated one, defeats that and will not match the challenge.
|
|
712
|
+
|
|
713
|
+
Corroborated by the pinned contract (checked 2026-08-23): the `POST /auth/ksef-token`
|
|
714
|
+
operation description names **RSA-OAEP with SHA-256**, the `token|timestamp` framing, and
|
|
715
|
+
the timestamp "jako liczba milisekund od 1 stycznia 1970 roku". So the encryption of this
|
|
716
|
+
payload is stated by the contract as well as by the prose, and both are asserted in
|
|
717
|
+
`spec/openapi_contract_spec.rb`.
|
|
718
|
+
|
|
719
|
+
`InitTokenAuthenticationRequest` requires `challenge`, `contextIdentifier` and
|
|
720
|
+
`encryptedToken`; `publicKeyId` and `authorizationPolicy` are optional. **Send
|
|
721
|
+
`publicKeyId` anyway** — see §10.3. The `authorizationPolicy` here carries the same three
|
|
722
|
+
IP lists as the XAdES document's, JSON-cased (`ip4Addresses`, `ip4Ranges`, `ip4Masks`) and
|
|
723
|
+
capped at ten entries each, which independently corroborates §4.1's cap.
|
|
724
|
+
|
|
725
|
+
The 400 table for this operation names **`21111`** — "Nieprawidłowe wyzwanie autoryzacyjne",
|
|
726
|
+
an invalid authorisation challenge — alongside `21470` (§10.2) and `21405`. That is the
|
|
727
|
+
cheapest of the three to avoid, so the challenge format of §4.1 is checked locally before
|
|
728
|
+
the request is built.
|
|
729
|
+
|
|
730
|
+
`ECDsa` exists as an alternative encryption method in both reference clients. Out of scope
|
|
731
|
+
for 0.1; RSA is the documented default.
|
|
732
|
+
|
|
733
|
+
### 4.6 TEST bootstrap — resolves DESIGN.md §12 item 4's central unknown
|
|
734
|
+
|
|
735
|
+
Source: `auth/testowe-certyfikaty-i-podpisy-xades.md`.
|
|
736
|
+
|
|
737
|
+
**Self-signed certificates are permitted on TEST only**, and upstream ships a console app
|
|
738
|
+
that does the whole bootstrap: `KSeF.Client.Tests.CertTestApp`. It generates a test
|
|
739
|
+
certificate, builds and XAdES-signs `AuthTokenRequest`, submits it, polls to completion,
|
|
740
|
+
and returns the JWT pair. `--output file` writes both the certificate and the **signed
|
|
741
|
+
XML** to disk, which is exactly the reference artifact needed to check our own signature
|
|
742
|
+
against.
|
|
743
|
+
|
|
744
|
+
The document instructs installing .NET 10, but the project multi-targets
|
|
745
|
+
`net8.0;net9.0;net10.0` (`KSeF.Client.Tests.CertTestApp.csproj` @ `ksef-client-csharp`,
|
|
746
|
+
retrieved 2026-08-22), so any of those SDKs suffices.
|
|
747
|
+
|
|
748
|
+
### 4.7 Auth session management
|
|
749
|
+
|
|
750
|
+
Source: `auth/sesje.md`. `GET /auth/sessions` lists active authentication sessions
|
|
751
|
+
(`continuationToken` paging). `DELETE /auth/sessions/current` and
|
|
752
|
+
`DELETE /auth/sessions/{referenceNumber}` revoke one.
|
|
753
|
+
|
|
754
|
+
Revocation invalidates the associated **`refreshToken` only** — already-issued
|
|
755
|
+
`accessToken`s stay valid to their `exp`. Consistent with §4.2: there is no way to kill a
|
|
756
|
+
live access token.
|
|
757
|
+
|
|
758
|
+
**`authenticationMethod` is deprecated** (noted 2026-08-23). The contract marks
|
|
759
|
+
`AuthenticationOperationStatusResponse.authenticationMethod` `deprecated: true` and adds
|
|
760
|
+
`authenticationMethodInfo` (`category` / `code` / `displayName`) beside it; both are
|
|
761
|
+
required, so nothing breaks today. `Ksef::Auth::OperationStatus` still reads the deprecated
|
|
762
|
+
field. Migrating is not urgent — the value is informational and this gem never branches on
|
|
763
|
+
it — but it should happen before upstream removes the field, and the successor is the one
|
|
764
|
+
to read for new work.
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
### 4.8 Authentication status codes
|
|
768
|
+
|
|
769
|
+
`GET /auth/{referenceNumber}` returns **HTTP 200** carrying a `StatusInfo` whose `code` is
|
|
770
|
+
*not* an HTTP status — it describes the asynchronous operation. The Ministry's *prose* names
|
|
771
|
+
only "in progress" and "succeeded", and says the full list "will be available in the
|
|
772
|
+
endpoint's technical documentation".
|
|
773
|
+
|
|
774
|
+
**Source: the pinned OpenAPI contract** —
|
|
775
|
+
`components.schemas.AuthenticationOperationStatusResponse.properties.status.description`
|
|
776
|
+
carries the complete table. Re-sourced 2026-08-23, and the correction matters twice over.
|
|
777
|
+
|
|
778
|
+
An earlier revision of this section cited
|
|
779
|
+
`KSeF.Client.Core/Models/ApiResponses/AuthenticationStatusCodeResponse.cs` in
|
|
780
|
+
`ksef-client-csharp` and said this was "a reference-implementation constant, not something
|
|
781
|
+
the contract states". That was wrong, and it **understated our own confidence**: the prose
|
|
782
|
+
saying the list is not yet documented was taken at its word, and nobody looked in the
|
|
783
|
+
contract, which is a *first-tier* artifact. Reading it changed the table in three ways —
|
|
784
|
+
so the lesson generalises: when upstream prose says a fact is undocumented, check the
|
|
785
|
+
OpenAPI descriptions before reaching for a reference implementation.
|
|
786
|
+
|
|
787
|
+
| Code | Meaning | Terminal? |
|
|
788
|
+
|---|---|---|
|
|
789
|
+
| 100 | authentication in progress | no — the only code that means keep polling |
|
|
790
|
+
| 200 | succeeded | yes |
|
|
791
|
+
| 415 | failed — subject holds no permissions in this context | yes |
|
|
792
|
+
| 425 | authentication and its refresh tokens revoked by the user | yes |
|
|
793
|
+
| 450 | token problem — eight distinct causes, see below | yes |
|
|
794
|
+
| 460 | certificate invalid, chain error, untrusted, revoked, suspended or malformed | yes |
|
|
795
|
+
| 470 | authorisation methods of a deceased person | yes |
|
|
796
|
+
| **480** | **authentication blocked — suspected security incident** | yes, and **not** retryable |
|
|
797
|
+
| 500 | unknown error | yes |
|
|
798
|
+
| 550 | cancelled by the system; retry later | yes, but retryable |
|
|
799
|
+
|
|
800
|
+
The three corrections:
|
|
801
|
+
|
|
802
|
+
1. **`480` exists and was missing entirely.** "Uwierzytelnienie zablokowane — podejrzenie
|
|
803
|
+
incydentu bezpieczeństwa. Skontaktuj się z Ministerstwem Finansów." It is absent from the
|
|
804
|
+
C# enum, which is why it was absent here and from `Ksef::Auth::Status` until 2026-08-23.
|
|
805
|
+
It is the one code whose correct response is neither a retry nor a fix on the client side:
|
|
806
|
+
the user must contact the Ministry. Retrying it is the worst available move, given §6
|
|
807
|
+
records that repeated suspicious behaviour lengthens a block.
|
|
808
|
+
2. **`450` collapses eight causes, not four** — a malformed, mistimed, revoked or inactive
|
|
809
|
+
token, *plus* a bad authorisation challenge, bad token encryption, bad token encoding, and
|
|
810
|
+
a token not usable in the requested context. `460` collapses six certificate ones. The
|
|
811
|
+
distinction arrives only in `StatusInfo.description`, so surface the server's wording
|
|
812
|
+
rather than a code-to-string table of our own.
|
|
813
|
+
3. **`400` and `401` are not contract codes.** They appear in the C# enum only. Kept as named
|
|
814
|
+
constants because the server may still send them, but their absence from the contract is
|
|
815
|
+
upstream's choice, not an omission here.
|
|
816
|
+
|
|
817
|
+
Asserted against the contract in `spec/openapi_contract_spec.rb`, so this provenance cannot
|
|
818
|
+
regress silently a second time.
|
|
819
|
+
|
|
820
|
+
**Treat any unrecognised code as terminal.** Assuming otherwise polls a dead operation for
|
|
821
|
+
ever, and the docs already warn that on DEMO and PROD a legitimate 100 can persist for as
|
|
822
|
+
long as the certificate issuer's OCSP/CRL response takes (§4.2) — so "still 100" cannot be
|
|
823
|
+
distinguished from "stuck" by elapsed time alone.
|
|
824
|
+
|
|
825
|
+
---
|
|
826
|
+
|
|
827
|
+
### 4.9 `json` 3.0.0 breaks Faraday 2.14.3 — a shim with a removal trigger
|
|
828
|
+
|
|
829
|
+
Not a KSeF fact; recorded here because it is the one place a dependency's behaviour reaches
|
|
830
|
+
shipped transport code, and because the shim must be deleted rather than forgotten.
|
|
831
|
+
|
|
832
|
+
**What happened.** `json 3.0.0` (released 2026-09) dropped the second **positional** argument to
|
|
833
|
+
`JSON.parse`; options are keyword-only now. `Faraday::Response::Json#parse` still calls
|
|
834
|
+
`decoder.public_send(method_name, body, @parser_options || {})`, so under json 3 every JSON
|
|
835
|
+
response raised `Faraday::ParsingError: wrong number of arguments (given 2, expected 1)`.
|
|
836
|
+
Measured in a clone of this repository bundled against json 3.0.0: **1580 examples,
|
|
837
|
+
163 failures**, all one cause. With the shim: **0 failures**, coverage gates enforced.
|
|
838
|
+
|
|
839
|
+
**It reached users, not just CI.** The gemspec requires `faraday "~> 2.0"`, faraday declares
|
|
840
|
+
`json >= 0`, and **faraday 2.14.3 is the latest release** — so `gem install ksef_client` produced
|
|
841
|
+
a client that could not read any API response.
|
|
842
|
+
|
|
843
|
+
**Why it arrived with no commit.** `Gemfile.lock` is gitignored by library convention
|
|
844
|
+
(DESIGN.md §4.1), so CI resolves fresh — and **rubocop 1.90.0 relaxed its own `json ~> 2.3` pin
|
|
845
|
+
to `>= 2.3`**, which is what let json 3.0.0 into the resolve. A transitive development pin had
|
|
846
|
+
been shielding the build, invisibly and by accident.
|
|
847
|
+
|
|
848
|
+
**The fix.** Faraday's response middleware accepts a caller-supplied decoder, read from *inside*
|
|
849
|
+
`parser_options`. `Ksef::HTTP::JsonDecoder.call(body, _options = nil)` calls `JSON.parse(body)`,
|
|
850
|
+
and `HTTP::Connection.build` wires it. This keeps every other behaviour the middleware provides
|
|
851
|
+
— the content-type match with its `;` split (so `application/problem+json; charset=utf-8` still
|
|
852
|
+
parses), the `respond_to?(:to_str)` guard, blank body to `nil`, the `StandardError`/`SyntaxError`
|
|
853
|
+
rescue, and `Faraday::ParsingError` wrapping — and leaves the ordering spec that pins the
|
|
854
|
+
middleware by class working untouched.
|
|
855
|
+
|
|
856
|
+
Three measured traps, all recorded on the code:
|
|
857
|
+
|
|
858
|
+
| Trap | Behaviour |
|
|
859
|
+
|---|---|
|
|
860
|
+
| `parser_options` in a frozen constant | `FrozenError` on the first request — the middleware reads the decoder with a destructive `delete` |
|
|
861
|
+
| One `parser_options` hash shared by two connections | the first request empties it; the second silently reverts to `::JSON.parse` and fails again |
|
|
862
|
+
| `decoder: JSON` (Faraday's `respond_to?(:load)` branch) | `JSON.load(body, {})` returns **nil** for a valid body, no exception |
|
|
863
|
+
|
|
864
|
+
So the hash stays a fresh literal in `build`, and the decoder is given in array form.
|
|
865
|
+
|
|
866
|
+
**Removal trigger.** Faraday *merged* json 3 support in
|
|
867
|
+
[PR #1687](https://github.com/lostisland/faraday/pull/1687) on 2026-08-12 and has **not released
|
|
868
|
+
it** — 2.14.3 shipped 2026-06-16. When a release containing it exists, raise the gemspec floor to
|
|
869
|
+
that version and delete `JsonDecoder`, its wiring in `HTTP::Connection`, the copy in
|
|
870
|
+
`spec/ksef/http/retry_spec.rb`, and the guard example in `spec/ksef/http/connection_spec.rb`.
|
|
871
|
+
`JsonDecoder`'s optional second parameter is what makes the interim safe in both worlds: the
|
|
872
|
+
merged fix splats the options as keywords, and `(body, _options = nil)` satisfies that call shape
|
|
873
|
+
as well as today's.
|
|
157
874
|
|
|
158
875
|
---
|
|
159
876
|
|
|
@@ -256,9 +973,77 @@ Source: `limity/limity-api.md` (dated 22.11.2025), retrieved 2026-08-21.
|
|
|
256
973
|
Live budgets are introspectable at runtime via `GET /rate-limits`, `GET /limits/context`
|
|
257
974
|
and `GET /limits/subject`.
|
|
258
975
|
|
|
976
|
+
### 6.1 Documented per-endpoint ceilings
|
|
977
|
+
|
|
978
|
+
Source: `limity/limity-api.md` (22.11.2025), retrieved 2026-08-22. Columns are
|
|
979
|
+
req/s · req/min · req/h. These are PROD defaults.
|
|
980
|
+
|
|
981
|
+
| Endpoint | s | min | h |
|
|
982
|
+
|---|---|---|---|
|
|
983
|
+
| `POST /sessions/online` | 10 | 30 | 120 |
|
|
984
|
+
| `POST /sessions/online/{ref}/invoices` | 10 | 30 | 180 |
|
|
985
|
+
| `POST /sessions/online/{ref}/close` | 10 | 30 | 120 |
|
|
986
|
+
| `POST /sessions/batch` | 10 | 20 | 60 |
|
|
987
|
+
| `POST /sessions/batch/{ref}/close` | 10 | 20 | 60 |
|
|
988
|
+
| `GET /sessions/{ref}/invoices/{invoiceRef}` | 30 | 120 | 1200 |
|
|
989
|
+
| `GET /sessions` | **5** | **10** | **60** |
|
|
990
|
+
| `GET /sessions/{ref}/invoices` | 10 | 20 | 200 |
|
|
991
|
+
| `GET /sessions/{ref}/invoices/failed` | 10 | 20 | 200 |
|
|
992
|
+
| `GET /sessions/*` (other) | 10 | 120 | 1200 |
|
|
993
|
+
| `POST /invoices/query/metadata` | 8 | 16 | 20 |
|
|
994
|
+
| `POST /invoices/exports` | 8 | 16 | 20 |
|
|
995
|
+
| `GET /invoices/exports/{ref}` | 10 | 60 | 600 |
|
|
996
|
+
| `GET /invoices/ksef/{ksefNumber}` | 8 | 16 | 64 |
|
|
997
|
+
| everything else | 10 | 30 | 120 |
|
|
998
|
+
| `POST /auth/challenge` (unauthenticated) | 60 per IP | — | — |
|
|
999
|
+
| `POST /auth/ksef-token` | 60 | — | — |
|
|
1000
|
+
| `GET /security/public-key-certificates` | 60 | — | — |
|
|
1001
|
+
|
|
1002
|
+
The last two are read from each operation's own `x-rate-limits` in the pinned spec
|
|
1003
|
+
(2026-08-23) rather than from `limity-api.md`, which lists neither. Both sit well above the
|
|
1004
|
+
"everything else" default, which is consistent with their being on the pre-authentication
|
|
1005
|
+
path — but it also means the per-endpoint values in the spec, not just the prose table,
|
|
1006
|
+
are the authority.
|
|
1007
|
+
|
|
1008
|
+
Three consequences for the client design:
|
|
1009
|
+
|
|
1010
|
+
- **`GET /sessions` is the tightest budget in the API** at 10/min. Session *polling* must
|
|
1011
|
+
use `GET /sessions/{ref}` or the per-invoice status endpoint (1200/h), never a list scan.
|
|
1012
|
+
- **Batch part uploads are exempt from rate limiting entirely**, and the docs recommend
|
|
1013
|
+
uploading parts in parallel. Relevant for 0.2.
|
|
1014
|
+
- The per-hour ceilings bite long before the per-second ones. `POST /invoices/exports` at
|
|
1015
|
+
20/h is 1 per 3 minutes sustained — retry backoff has to be sized against req/h, not
|
|
1016
|
+
req/s.
|
|
1017
|
+
|
|
1018
|
+
Environment multipliers: **TEST is 10× the PROD defaults**; **DEMO replicates PROD
|
|
1019
|
+
exactly**. TEST can be pushed to PROD-like behaviour with
|
|
1020
|
+
`POST /testdata/rate-limits/production`, set arbitrarily with `POST /testdata/rate-limits`,
|
|
1021
|
+
and reset with `DELETE /testdata/rate-limits`.
|
|
1022
|
+
|
|
1023
|
+
Higher download limits apply **20:00–06:00**; the values are unpublished pending
|
|
1024
|
+
production tuning.
|
|
1025
|
+
|
|
1026
|
+
### 6.2 Size and volume limits
|
|
1027
|
+
|
|
1028
|
+
Source: `limity/limity.md` (21.10.2025).
|
|
1029
|
+
|
|
1030
|
+
| Parameter | Default |
|
|
1031
|
+
|---|---|
|
|
1032
|
+
| Max invoice size, no attachment | **1 MB** |
|
|
1033
|
+
| Max invoice size, with attachment | **3 MB** |
|
|
1034
|
+
| Max invoices per session (online or batch) | **10 000** |
|
|
1035
|
+
|
|
1036
|
+
Per authenticated subject, KSeF certificate ceilings are 300 enrolments / 100 active for a
|
|
1037
|
+
NIP identifier, and 12 / 6 for PESEL or a certificate fingerprint.
|
|
1038
|
+
|
|
1039
|
+
Both are adjustable on TEST via `POST /testdata/limits/context/session` and
|
|
1040
|
+
`POST /testdata/limits/subject/certificate` (with matching `DELETE`s to restore defaults),
|
|
1041
|
+
which makes the 1 MB invoice-size rejection path testable without constructing a real 1 MB
|
|
1042
|
+
invoice.
|
|
1043
|
+
|
|
259
1044
|
---
|
|
260
1045
|
|
|
261
|
-
## 6a. Provisioning TEST credentials (DESIGN.md §12
|
|
1046
|
+
## 6a. Provisioning TEST credentials (DESIGN.md §12 item 4)
|
|
262
1047
|
|
|
263
1048
|
Sources: `dane-testowe-scenariusze.md` (05.08.2025), `tokeny-ksef.md` (29.06.2025),
|
|
264
1049
|
`srodowiska.md`, and the pinned spec. Retrieved 2026-08-22.
|
|
@@ -273,7 +1058,8 @@ A test NIP must still pass the standard checksum: digits 1–9 weighted by
|
|
|
273
1058
|
`6,5,7,2,3,4,5,6,7`, summed, `mod 11`, which must equal digit 10 (and must not be 10).
|
|
274
1059
|
Verified against every NIP appearing in the upstream docs — `7762811692`, `7980332920`,
|
|
275
1060
|
`3755747347` — and against the two in DESIGN.md §8, `9999999999` and `1111111111`. All
|
|
276
|
-
six are checksum-valid, which independently confirms
|
|
1061
|
+
six are checksum-valid, which independently confirms **DESIGN.md** §7.2's NIP algorithm.
|
|
1062
|
+
(Named explicitly: inside this document a bare "§7.2" would read as §7's second item.)
|
|
277
1063
|
|
|
278
1064
|
You then register the NIP on TEST:
|
|
279
1065
|
|
|
@@ -289,7 +1075,7 @@ needs no prior credentials. (They exist on TEST only; see §2.)
|
|
|
289
1075
|
`createdDate` caveat: when re-creating test data under the same identifier, the date must
|
|
290
1076
|
be **later** than the previous one — not equal, not earlier.
|
|
291
1077
|
|
|
292
|
-
### 6a.2 The token needs a one-time XAdES authentication —
|
|
1078
|
+
### 6a.2 The token needs a one-time XAdES authentication — which is why XAdES is in 0.1
|
|
293
1079
|
|
|
294
1080
|
`tokeny-ksef.md`: *"Wygenerowanie tokena KSeF jest możliwe wyłącznie po jednorazowym
|
|
295
1081
|
uwierzytelnieniu się podpisem elektronicznym (XAdES)."* — a KSeF token can be generated
|
|
@@ -299,18 +1085,170 @@ The pinned spec corroborates it: `POST /tokens` declares `security: [{Bearer: []
|
|
|
299
1085
|
needs an existing session, while `/auth/xades-signature` needs none. There is no
|
|
300
1086
|
unauthenticated path to a first token.
|
|
301
1087
|
|
|
302
|
-
**Consequence for this gem
|
|
303
|
-
0.3
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
1088
|
+
**Consequence for this gem — and why the roadmap changed.** This finding is what moved
|
|
1089
|
+
XAdES from 0.3 into 0.1 (DESIGN.md §6.3, decided 2026-08-22). A token-only client cannot
|
|
1090
|
+
issue its own first credential, which would have forced every user — and our own nightly
|
|
1091
|
+
CI — to bootstrap via somebody else's client.
|
|
1092
|
+
|
|
1093
|
+
**Retired 2026-08-22.** This section used to say the interim workaround was a one-time
|
|
1094
|
+
out-of-band mint via the official C# client. That is no longer necessary — the certificate
|
|
1095
|
+
flow has landed, and `rake auth:bootstrap` does the whole chain in this gem. See §6a.3.
|
|
1096
|
+
|
|
1097
|
+
### 6a.3 `rake auth:bootstrap`
|
|
1098
|
+
|
|
1099
|
+
Implemented in `tasks/ksef_bootstrap.rb` — outside `lib/`, so never packaged, but covered
|
|
1100
|
+
by `spec/tasks/ksef_bootstrap_spec.rb` against stubs rather than left as an untested
|
|
1101
|
+
script. A checksum bug here would otherwise surface as an opaque rejection from a remote
|
|
1102
|
+
server.
|
|
1103
|
+
|
|
1104
|
+
What it does, in order:
|
|
1105
|
+
|
|
1106
|
+
1. invents a NIP and a PESEL, both checksum-valid, the NIP also shaped to satisfy the auth
|
|
1107
|
+
schema's `TNIP` pattern (§4.1);
|
|
1108
|
+
2. `POST /testdata/person` — unauthenticated, which is the only reason the chain is not
|
|
1109
|
+
circular;
|
|
1110
|
+
3. generates a self-signed certificate carrying `serialNumber=PNOPL-<pesel>` (§4.4), or
|
|
1111
|
+
uses a real qualified certificate if one is supplied;
|
|
1112
|
+
4. runs the full §4.2 flow: challenge → sign → submit → poll → redeem;
|
|
1113
|
+
5. `POST /tokens` with the access token, requesting `InvoiceRead` and `InvoiceWrite`;
|
|
1114
|
+
6. prints `KSEF_TEST_NIP` and `KSEF_TEST_TOKEN`, which are stored as **environment**
|
|
1115
|
+
secrets on `ksef-test` — not repository secrets. A repository secret is readable by
|
|
1116
|
+
every workflow in the repo, which for a live KSeF credential is more exposure than
|
|
1117
|
+
it needs; only a job declaring the environment can read an environment secret. The
|
|
1118
|
+
environment is also restricted to the `main` branch, so a pushed branch cannot
|
|
1119
|
+
claim it and read the token.
|
|
1120
|
+
|
|
1121
|
+
**PESEL checksum**, needed for step 1 and not previously ledgered: weights
|
|
1122
|
+
`1,3,7,9,1,3,7,9,1,3` across the first ten digits; the eleventh is `(10 - sum % 10) % 10`.
|
|
1123
|
+
Confirmed the way §6a.1 confirmed the NIP algorithm — it validates every PESEL the upstream
|
|
1124
|
+
documentation ships (`15062788702`, `30112206276`, `38092277125`, `88102341294`) and
|
|
1125
|
+
rejects those values with the check digit altered.
|
|
1126
|
+
|
|
1127
|
+
**PESEL is a structured identifier, and KSeF enforces it.** Learned from the API itself,
|
|
1128
|
+
2026-08-23 — `POST /testdata/person` rejected a checksum-perfect PESEL with:
|
|
1129
|
+
|
|
1130
|
+
```
|
|
1131
|
+
400 [21405] Żądanie jest nieprawidłowe. Invalid PESEL format.
|
|
1132
|
+
```
|
|
1133
|
+
|
|
1134
|
+
The first six digits encode a **birth date**, with the century folded into the month field:
|
|
1135
|
+
|
|
1136
|
+
| Month field | Century |
|
|
1137
|
+
|---|---|
|
|
1138
|
+
| 01–12 | 1900s |
|
|
1139
|
+
| 21–32 | 2000s |
|
|
1140
|
+
| 41–52 | 2100s |
|
|
1141
|
+
| 61–72 | 2200s |
|
|
1142
|
+
| 81–92 | 1800s |
|
|
1143
|
+
|
|
1144
|
+
Confirmed by decoding every PESEL the upstream docs ship: `15062788702` → 1915-06-27,
|
|
1145
|
+
`30112206276` → 1930-11-22, `38092277125` → 1938-09-22, `88102341294` → 1988-10-23. All
|
|
1146
|
+
four are plain 1900s dates.
|
|
1147
|
+
|
|
1148
|
+
Nothing upstream *states* this. `dane-testowe-scenariusze.md` shows PESELs in examples but
|
|
1149
|
+
never describes their structure, and the OpenAPI schema types the field as `string`. **This
|
|
1150
|
+
is the first fact in this ledger whose source is the API's own behaviour** rather than a
|
|
1151
|
+
document or a reference implementation — a strictly more reliable tier than anything above
|
|
1152
|
+
it, and the only one that cannot be obtained offline.
|
|
1153
|
+
|
|
1154
|
+
The generator draws births from 1950–1999; the validator accepts the full scheme range,
|
|
1155
|
+
because 1800s and 2000s PESELs are legitimate even if implausible for a test person.
|
|
1156
|
+
|
|
1157
|
+
**NIP checksum, stated precisely** because it is easy to get backwards: the check digit
|
|
1158
|
+
*is* the weighted sum `mod 11`, **not** `11 - (sum mod 11)`. Confirmed against four NIPs
|
|
1159
|
+
from the upstream docs. A sum of 10 is unrepresentable, so that draw is discarded.
|
|
1160
|
+
|
|
1161
|
+
The task refuses any environment whose `test_data_api?` capability is false, so DEMO is
|
|
1162
|
+
refused as well as PROD — the `/testdata/*` endpoints exist on TEST only, and the guard is
|
|
1163
|
+
on the capability rather than the name so a `custom` environment cannot slip past.
|
|
308
1164
|
|
|
309
1165
|
Tokens are minted in a `Nip` or `InternalId` context with a fixed permission set chosen at
|
|
310
1166
|
creation — changing permissions requires a new token. For this gem's integration suite,
|
|
311
1167
|
`InvoiceRead` and `InvoiceWrite` are the relevant ones. Treat the token as a confidential
|
|
312
1168
|
secret (`tokeny-ksef.md` says so explicitly).
|
|
313
1169
|
|
|
1170
|
+
### 6a.4 Verified against live TEST, 2026-08-23
|
|
1171
|
+
|
|
1172
|
+
`rake auth:bootstrap` completed against the TEST environment and produced a usable KSeF
|
|
1173
|
+
token. Because a token can only be minted with an `accessToken`, which requires a redeemed
|
|
1174
|
+
authentication, which requires status `200`, that single outcome establishes several things
|
|
1175
|
+
that no amount of offline testing could:
|
|
1176
|
+
|
|
1177
|
+
- **KSeF accepts the XAdES-BES signature this gem produces.** The combination chosen in
|
|
1178
|
+
§4.3 — enveloped, exclusive c14n, `rsa-sha256`, `xmlenc#sha256`, with the ETSI `Type` URI
|
|
1179
|
+
on the `SignedProperties` reference — is accepted in practice, not merely permitted by
|
|
1180
|
+
the allow-list.
|
|
1181
|
+
- **The 2.0 namespace is correct** (§14.4). The document was sent with
|
|
1182
|
+
`xmlns="http://ksef.mf.gov.pl/auth/token/2.0"` and was not rejected. Whether 2.1 would
|
|
1183
|
+
also be accepted remains unverified; there is now no reason to find out.
|
|
1184
|
+
- **`/testdata/person` really is unauthenticated**, as §6a.1 read from the contract.
|
|
1185
|
+
- **A self-signed certificate is accepted on TEST** (§4.6), carrying the PESEL as
|
|
1186
|
+
`serialNumber=PNOPL-<pesel>` (§4.4).
|
|
1187
|
+
- **The §4.2 steps the bootstrap exercises work as ledgered** — challenge, submission,
|
|
1188
|
+
polling on `StatusInfo.code`, and single-use redemption. Scoped deliberately (corrected
|
|
1189
|
+
2026-08-23): `POST /auth/token/refresh` is *not* among them, so nothing here vouches for
|
|
1190
|
+
refresh, and `POST /auth/ksef-token` did not exist at the time. **Refresh was closed
|
|
1191
|
+
separately on 2026-08-26** by the recorded tier — see §4.2a, which is what makes all six
|
|
1192
|
+
steps of §4.2 observed rather than five.
|
|
1193
|
+
|
|
1194
|
+
Two failures on the way there, both bugs on this side rather than upstream: a local OpenSSL
|
|
1195
|
+
trust store with no CA bundle (see §6a.5), and the PESEL structure above.
|
|
1196
|
+
|
|
1197
|
+
### 6a.5 A missing CA bundle looks like a broken server certificate
|
|
1198
|
+
|
|
1199
|
+
Also learned the hard way, 2026-08-23. On a machine where Homebrew's `openssl@3` is
|
|
1200
|
+
installed but `/usr/local/etc/openssl@3/cert.pem` is absent, Ruby has **no trust store at
|
|
1201
|
+
all**, and the failure reads:
|
|
1202
|
+
|
|
1203
|
+
```
|
|
1204
|
+
certificate verify failed (self-signed certificate in certificate chain)
|
|
1205
|
+
```
|
|
1206
|
+
|
|
1207
|
+
which points at the wrong thing entirely. KSeF's chain is genuine —
|
|
1208
|
+
`*.ksef.mf.gov.pl` → `GeoTrust TLS RSA CA G1` → `DigiCert Global Root G2` — and the "self-signed
|
|
1209
|
+
certificate" being complained about is that DigiCert root, self-signed as every root is.
|
|
1210
|
+
With no store to chain to, OpenSSL reports the last certificate it saw.
|
|
1211
|
+
|
|
1212
|
+
`curl` succeeds throughout, because it uses Apple's store rather than OpenSSL's, so a
|
|
1213
|
+
reachability check proves nothing about what Ruby will do.
|
|
1214
|
+
|
|
1215
|
+
Fix without weakening anything: point OpenSSL at a real bundle,
|
|
1216
|
+
`SSL_CERT_FILE=/usr/local/etc/ca-certificates/cert.pem`, or reinstall Homebrew's
|
|
1217
|
+
`ca-certificates` to restore the symlink. **Never by disabling verification** — that is a
|
|
1218
|
+
hard rule, and the misleading error message makes it a tempting one to break.
|
|
1219
|
+
|
|
1220
|
+
### 6a.6 `/testdata/person` grants permissions asynchronously
|
|
1221
|
+
|
|
1222
|
+
Learned from the API, 2026-08-23 — the second fact in this ledger whose source is
|
|
1223
|
+
observed behaviour rather than a document. `POST /testdata/person` returns **200**, but the
|
|
1224
|
+
Owner grant it describes is **not yet in effect**. Authenticating immediately afterwards
|
|
1225
|
+
fails with status **415**, "Brak przypisanych uprawnień" (no assigned permissions), returned
|
|
1226
|
+
on the *first* poll rather than after a `100`.
|
|
1227
|
+
|
|
1228
|
+
Measured, with a fresh NIP and PESEL each time:
|
|
1229
|
+
|
|
1230
|
+
| Wait after registering | Result |
|
|
1231
|
+
|---|---|
|
|
1232
|
+
| none, isolated call after an idle period | success |
|
|
1233
|
+
| none, three calls in quick succession | success, **415**, **415** |
|
|
1234
|
+
| ten seconds, three calls in quick succession | success, success, success |
|
|
1235
|
+
|
|
1236
|
+
So it is not a fixed latency but a queue: an isolated grant lands within the ~2 s the auth
|
|
1237
|
+
flow takes, and consecutive ones do not. That is why a single manual `rake auth:bootstrap`
|
|
1238
|
+
worked first time while a three-example integration suite failed every example.
|
|
1239
|
+
|
|
1240
|
+
**415 is genuinely ambiguous here**, which is the important part. It is both "the grant has
|
|
1241
|
+
not landed yet" and the legitimate permanent answer for a subject with no rights. So:
|
|
1242
|
+
|
|
1243
|
+
- the **integration suite** may retry it, and does, because it provisioned the identity
|
|
1244
|
+
moments earlier and knows which meaning applies;
|
|
1245
|
+
- **library code must never retry a 415.** There it is a final answer, and retrying would
|
|
1246
|
+
hammer the API over a permissions problem no retry can fix.
|
|
1247
|
+
|
|
1248
|
+
The suite now provisions **one** identity per run and settles for ten seconds, rather than
|
|
1249
|
+
one per example — fewer test people in a shared environment, and one wait instead of three.
|
|
1250
|
+
|
|
1251
|
+
|
|
314
1252
|
---
|
|
315
1253
|
|
|
316
1254
|
## 7. Divergences from DESIGN.md
|
|
@@ -360,7 +1298,225 @@ Source: pinned `schemat_FA(3)_v1-0E.xsd` (§1).
|
|
|
360
1298
|
`elementFormDefault="qualified"` means **every** element must be namespace-qualified in
|
|
361
1299
|
the instance document — the serializer cannot emit unprefixed children.
|
|
362
1300
|
|
|
363
|
-
### 8.1
|
|
1301
|
+
### 8.1 Structural shape (measured 2026-08-22, drives the codegen)
|
|
1302
|
+
|
|
1303
|
+
The schema is not a flat set of named types, which is what a reader might reasonably
|
|
1304
|
+
expect. Counts below are from the pinned file and are asserted by
|
|
1305
|
+
`spec/ksef/fa3/generated_spec.rb`.
|
|
1306
|
+
|
|
1307
|
+
| Fact | Value |
|
|
1308
|
+
|---|---|
|
|
1309
|
+
| Global elements | 1 (`Faktura`) |
|
|
1310
|
+
| Named complexTypes | 7 |
|
|
1311
|
+
| Anonymous complexTypes reachable from the root | 51 |
|
|
1312
|
+
| Max nesting depth | 7 |
|
|
1313
|
+
| `xsd:sequence` | 86 |
|
|
1314
|
+
| `xsd:choice` | 19 |
|
|
1315
|
+
| `xsd:all` / `xsd:group` / `xsd:any` | 0 |
|
|
1316
|
+
| Named simpleTypes with enumerations | 21 across all pinned schemas |
|
|
1317
|
+
| `xsd:simpleContent` extensions | 2 |
|
|
1318
|
+
|
|
1319
|
+
Two consequences the generator has to honour:
|
|
1320
|
+
|
|
1321
|
+
1. **Leaf element names are not unique.** `DaneKontaktowe` appears under all four subject
|
|
1322
|
+
elements, so generated metadata is keyed by element *path*
|
|
1323
|
+
(`Faktura/Podmiot1/DaneKontaktowe`), which also stays stable if upstream adds a fifth.
|
|
1324
|
+
2. **Four types have a top-level `xsd:choice`** — `Zwolnienie`, `NoweSrodkiTransportu`,
|
|
1325
|
+
`PMarzy` and `FakturaZaliczkowa` (all reached via `Faktura/Fa/...`). Anything that
|
|
1326
|
+
flattens a type's root compositor converts their "exactly one of" into "all of these,
|
|
1327
|
+
in order", which a validator would then accept. Choice structure must be preserved,
|
|
1328
|
+
not flattened.
|
|
1329
|
+
|
|
1330
|
+
`TStawkaPodatku` has 14 values and **half of them are not numeric** — seven rates
|
|
1331
|
+
(`23`, `22`, `8`, `7`, `5`, `4`, `3`) alongside seven codes (`0 KR`, `0 WDT`, `0 EX`,
|
|
1332
|
+
`zw`, `oo`, `np I`, `np II`). Any numeric coercion in the VAT path corrupts the latter,
|
|
1333
|
+
so rate codes are carried as strings throughout and only the *amounts* are `BigDecimal`.
|
|
1334
|
+
|
|
1335
|
+
### 8.1a Rate buckets — resolves DESIGN.md §7.3 [VERIFY]
|
|
1336
|
+
|
|
1337
|
+
Read from the pinned schema — but from **two** places in it, and one column is weaker than
|
|
1338
|
+
the others, which is worth stating because it is how two bugs survived here.
|
|
1339
|
+
|
|
1340
|
+
The `P_13_*`/`P_14_*` annotations give the **Covers** column, and they name percentages only
|
|
1341
|
+
for buckets 1–3; they name **no rate code anywhere**. The **Rate codes** column is therefore
|
|
1342
|
+
matched from the other side, against `TStawkaPodatku`'s own enumeration annotations — a
|
|
1343
|
+
different part of the schema, and the part that was not re-read when this table was first
|
|
1344
|
+
written. Where both sides name the same statutory article the match is exact and first-tier
|
|
1345
|
+
(that is how `np I`/`np II` are settled below). Where neither does — `4` and `3` sharing
|
|
1346
|
+
bucket 4 — the only evidence is `ksef-pdf-generator`'s UI labels *"4% lub 3%"* and *"OSS"*,
|
|
1347
|
+
and **that file is not pinned in this repository** (§1.4 pins only that repo's
|
|
1348
|
+
`assets/invoice.xml`). Treat that one row as second-tier. Getting this wrong misreports VAT.
|
|
1349
|
+
|
|
1350
|
+
| Net | Tax | Covers | Rate codes |
|
|
1351
|
+
|---|---|---|---|
|
|
1352
|
+
| `P_13_1` | `P_14_1` | Standard rate ("stawka podstawowa") | `23`, `22` |
|
|
1353
|
+
| `P_13_2` | `P_14_2` | First reduced rate | `8`, `7` |
|
|
1354
|
+
| `P_13_3` | `P_14_3` | Second reduced rate | `5` |
|
|
1355
|
+
| `P_13_4` | `P_14_4` | Flat rate for passenger taxis | `4`, `3` |
|
|
1356
|
+
| `P_13_5` | `P_14_5` | Special procedure, Act ch. XII s. 6a | — (see below) |
|
|
1357
|
+
| `P_13_6_1` | — | 0% excluding intra-EU supply and export | `0 KR` |
|
|
1358
|
+
| `P_13_6_2` | — | 0% intra-EU supply of goods (WDT) | `0 WDT` |
|
|
1359
|
+
| `P_13_6_3` | — | 0% export | `0 EX` |
|
|
1360
|
+
| `P_13_7` | — | Exempt from tax | `zw` |
|
|
1361
|
+
| `P_13_8` | — | Supply outside the country, **excluding** what `P_13_5` and `P_13_9` take | `np I` |
|
|
1362
|
+
| `P_13_9` | — | Services under Act art. 100(1)(4) | `np II` |
|
|
1363
|
+
| `P_13_10` | — | Reverse charge, buyer is the taxpayer (art. 17) | `oo` |
|
|
1364
|
+
| `P_13_11` | — | Margin scheme (art. 119, 120) | — (see below) |
|
|
1365
|
+
| `P_15` | | **Total amount due** (gross) | |
|
|
1366
|
+
|
|
1367
|
+
Four things that fall out of this and matter for the builder:
|
|
1368
|
+
|
|
1369
|
+
- **The zero-rated and exempt buckets have no `P_14_*` counterpart.** There is no tax
|
|
1370
|
+
amount to report, so a summary builder must not emit a paired tax field for them —
|
|
1371
|
+
the schema has no element to put it in.
|
|
1372
|
+
- **`np I` and `np II` are different buckets, and the schema says so twice.** `np II` is
|
|
1373
|
+
*"niepodlegajace opodatkowaniu na terytorium kraju, świadczenie usług o których mowa w art.
|
|
1374
|
+
100 ust. 1 pkt 4 ustawy"*, and `P_13_9` is *"Suma wartości świadczenia usług, o których mowa
|
|
1375
|
+
w art. 100 ust. 1 pkt 4 ustawy"* — the same statutory scope, word for word. Meanwhile
|
|
1376
|
+
`P_13_8` reads *"z wyłączeniem kwot wykazanych w polach P_13_5 i **P_13_9**"*, and `np I`
|
|
1377
|
+
excludes those same transactions, so `P_13_8` is the one bucket `np II` may not go in.
|
|
1378
|
+
**Until 2026-08-26 both codes mapped to `P_13_8`**, misstating intra-EU services on an
|
|
1379
|
+
XSD-valid document that tier 1 passed. Found by audit. No corpus witness exists — no `np`
|
|
1380
|
+
code appears in any pinned sample — so the schema's own annotations are the whole evidence,
|
|
1381
|
+
and they are decisive. **Third instance of this class**, after the shared-bucket
|
|
1382
|
+
accumulation bug and rate code `3`.
|
|
1383
|
+
|
|
1384
|
+
- **Bucket 4 takes rate codes `4` *and* `3`; buckets 5 and 11 take none.** Each rate bucket
|
|
1385
|
+
pairs a current rate with the one it replaced — 23/22, 8/7, 4/3 — and bucket 5 is not a rate
|
|
1386
|
+
bucket at all: `P_13_5` is the special procedure of *"dziale XII w rozdziale 6a"* and
|
|
1387
|
+
`P_14_5` is **"kwota podatku od wartości dodanej"**, foreign VAT under OSS, whose per-line
|
|
1388
|
+
rate lives in `P_12_XII` (a percentage) rather than in `P_12`. `P_13_11` is unreachable for
|
|
1389
|
+
its own reason: the margin scheme is declared through `Adnotacje/PMarzy`, not a rate.
|
|
1390
|
+
**Until 2026-08-24 this table mapped code `3` to bucket 5**, so a domestic 3% sale was
|
|
1391
|
+
declared as OSS foreign VAT on an XSD-valid document. Found by comparing against
|
|
1392
|
+
`ksef-pdf-generator`, whose summary labels read *"4% lub 3%"* for bucket 4 and *"OSS"* for
|
|
1393
|
+
bucket 5 — the only official code that renders these buckets. **That table row was itself
|
|
1394
|
+
left uncorrected until 2026-08-26**, two commits after the code was fixed, so a reader
|
|
1395
|
+
trusting the table over this bullet would have reintroduced the bug.
|
|
1396
|
+
`VatRate.unreachable_elements` names buckets 5 and 11, and a spec now asserts every *other*
|
|
1397
|
+
net bucket is reachable — an incomplete list there is what let `P_13_9` look like a
|
|
1398
|
+
deliberate gap.
|
|
1399
|
+
- **The mapping is many-to-one, so summaries must *accumulate*.** `"23"` and `"22"` both
|
|
1400
|
+
report into `P_13_1`/`P_14_1`, `"8"` and `"7"` into bucket 2, `"4"` and `"3"` into
|
|
1401
|
+
`P_13_4`/`P_14_4`. Assigning per rate code rather than summing lets the last code win — which is
|
|
1402
|
+
exactly the bug found on 2026-08-24: an invoice with a 23% line of 100 and a 22% line of 200
|
|
1403
|
+
emitted `P_13_1=200.00` and `P_14_1=44.00` while `P_15` still carried the correct `367.00`.
|
|
1404
|
+
The tax base was understated by a third, `P_13_1 + P_14_1 ≠ P_15`, and **the document was
|
|
1405
|
+
XSD-valid** — the schema cannot see an arithmetic inconsistency, which is precisely the
|
|
1406
|
+
reconciliation class that validator tier 3 exists for (§15.6). Historical rates like 22%
|
|
1407
|
+
make this reachable on real invoices, not just in theory.
|
|
1408
|
+
- **`P_14_1W`, `P_14_2W`, `P_14_3W`, `P_14_4W`** are the foreign-currency variants,
|
|
1409
|
+
carrying the tax amount converted per the Act when the invoice is issued in a currency
|
|
1410
|
+
other than PLN. They sit alongside their base field in the same sequence, so a
|
|
1411
|
+
non-PLN invoice populates both.
|
|
1412
|
+
|
|
1413
|
+
`P_15Z` and `P_15ZK` relate to advance-payment invoices and their corrections, which are
|
|
1414
|
+
0.1 scope but not Phase 1 (DESIGN.md §7.4 puts ZAL after VAT and KOR).
|
|
1415
|
+
|
|
1416
|
+
### 8.2 Non-obvious mandatory elements
|
|
1417
|
+
|
|
1418
|
+
Discovered by validating against the schema rather than by reading it. `JST`/`GV` are asserted
|
|
1419
|
+
in `spec/ksef/fa3/validator_spec.rb`; the `Adnotacje` list is enforced by
|
|
1420
|
+
`Ksef::FA3::Invoice::DEFAULT_ANNOTATIONS` and by tier 1's key check rather than by an assertion
|
|
1421
|
+
of its own.
|
|
1422
|
+
|
|
1423
|
+
**`Podmiot2` (the buyer) requires both `JST` and `GV`** — `minOccurs="1"` on each, and typed
|
|
1424
|
+
by an **anonymous inline restriction of `xsd:integer`** enumerating `1` and `2`, so those are
|
|
1425
|
+
the only permitted values. (Not `etd:TWybor1_2`, as an earlier revision of this line said: the
|
|
1426
|
+
schema does use that type for `P_16`–`P_23`, but not here, and the generated metadata records
|
|
1427
|
+
`base: "xsd:integer"` for both. Corrected by audit 2026-08-26.)
|
|
1428
|
+
|
|
1429
|
+
| Element | Meaning | `"1"` |
|
|
1430
|
+
|---|---|---|
|
|
1431
|
+
| `JST` | Buyer is a subordinate unit of a local-government body | yes |
|
|
1432
|
+
| `GV` | Buyer is a member of a VAT group | yes |
|
|
1433
|
+
|
|
1434
|
+
Every invoice must therefore state these two facts about its buyer, even for an ordinary
|
|
1435
|
+
domestic B2B sale where both answers are "no" (`"2"`). Omitting either makes the document
|
|
1436
|
+
schema-invalid, and no prose in the integrator documentation flags it — the builder must
|
|
1437
|
+
default them rather than leave them to the caller to discover.
|
|
1438
|
+
|
|
1439
|
+
Also mandatory and easy to miss, all inside `Fa/Adnotacje`: `P_16`, `P_17`, `P_18`,
|
|
1440
|
+
`P_18A`, `P_23`, plus the three wrapper elements `Zwolnienie`, `NoweSrodkiTransportu` and
|
|
1441
|
+
`PMarzy` — each of which is one of the four types whose root compositor is a choice
|
|
1442
|
+
(§8.1), so exactly one branch of each must be present.
|
|
1443
|
+
|
|
1444
|
+
### 8.2a The two subjects are not symmetric, and the buyer's name is optional
|
|
1445
|
+
|
|
1446
|
+
Read straight from the schema while writing the parser (2026-08-24), because upstream's own
|
|
1447
|
+
corpus contains a document that only makes sense if this is true.
|
|
1448
|
+
|
|
1449
|
+
`TPodmiot1` — the seller's identity — is a plain sequence of two mandatory elements:
|
|
1450
|
+
|
|
1451
|
+
```
|
|
1452
|
+
NIP, Nazwa
|
|
1453
|
+
```
|
|
1454
|
+
|
|
1455
|
+
`TPodmiot2` — the buyer's — is a **four-way choice** followed by an *optional* name:
|
|
1456
|
+
|
|
1457
|
+
```
|
|
1458
|
+
choice( NIP | (KodUE, NrVatUE) | ([KodKraju], NrID) | BrakID )
|
|
1459
|
+
sequence minOccurs="0" ( Nazwa )
|
|
1460
|
+
```
|
|
1461
|
+
|
|
1462
|
+
Three consequences, and each one bit:
|
|
1463
|
+
|
|
1464
|
+
- **A buyer may have no `Nazwa` at all.** `invoice-template-fa-3-with-disallowed-unicode-characters.xml`
|
|
1465
|
+
has exactly that, and a parser that treats the name as mandatory rejects a document
|
|
1466
|
+
upstream ships as valid. `Ksef::FA3::Subject` therefore allows a nil name, and refuses one
|
|
1467
|
+
only for a seller — enforced in `#to_fa3`, which is the only place that knows the role.
|
|
1468
|
+
- **An absent name must be omitted, not written empty.** `Nazwa` is `TZnakowy512`, so
|
|
1469
|
+
`<Nazwa/>` fails its minimum length: emitting an empty element would turn a legal buyer
|
|
1470
|
+
into an invalid document.
|
|
1471
|
+
- **A buyer need not have a NIP.** `NrVatUE` covers an EU counterparty, `NrID` a
|
|
1472
|
+
non-EU one, and `BrakID` an unidentified buyer. This model carries a NIP only, so the
|
|
1473
|
+
parser reports the other three as a **limitation of the model** rather than as a malformed
|
|
1474
|
+
document — the distinction matters, because the invoice is fine and it is us who cannot
|
|
1475
|
+
hold it.
|
|
1476
|
+
|
|
1477
|
+
### 8.2b FA(3) has no structured address, and neither does this model
|
|
1478
|
+
|
|
1479
|
+
`TAdres` is `KodKraju` + `AdresL1` + optional `AdresL2` + optional `GLN`, where both address
|
|
1480
|
+
lines are free text of up to 512 characters. There is no street, no city, no postal code.
|
|
1481
|
+
|
|
1482
|
+
**Checked against the only official code that reads an FA(3) address**, per CLAUDE.md's
|
|
1483
|
+
Workflow rule ("Unsure how KSeF behaves? Read the official C#/Java clients before guessing"): `ksef-pdf-generator`'s `src/lib-public/generators/FA3/Adres.ts`
|
|
1484
|
+
renders exactly `AdresL1`, `AdresL2`, `KodKraju` and `GLN` — nothing else exists to render.
|
|
1485
|
+
Neither the C# nor the Java client models FA(3) at all; both take the document as bytes. So
|
|
1486
|
+
the flat shape is upstream's, not a simplification of it.
|
|
1487
|
+
|
|
1488
|
+
`Ksef::FA3::Address` still *accepts* `street:`, `city:` and `postal_code:`, because that is
|
|
1489
|
+
how an address arrives from a human or an ERP. It does not **retain** them: they compose into
|
|
1490
|
+
`line1` at construction and the parts are gone. That is a deliberate rule, and it now applies
|
|
1491
|
+
to three types:
|
|
1492
|
+
|
|
1493
|
+
| Type | Stores | Not stored |
|
|
1494
|
+
|---|---|---|
|
|
1495
|
+
| `Address` | `line1`, `line2`, `country` | `street`, `city`, `postal_code` — composed on the way in |
|
|
1496
|
+
| `Line` | `BigDecimal`, rounded to its element's scale | the `Integer`/`String`/finer value it arrived as |
|
|
1497
|
+
| `Invoice` | `issued_at` as the document's own string | the `Time` it may have been given |
|
|
1498
|
+
| `Invoice` | `issue_date` as a `Date` | the `String` it may have been given |
|
|
1499
|
+
| `Invoice` | `annotations` as the document states them | nothing — they used to be discarded entirely |
|
|
1500
|
+
|
|
1501
|
+
The `Line` and `issue_date` rows were added on 2026-08-24, after a review found the rule
|
|
1502
|
+
stated but not followed: `issue_date` kept whatever the caller passed, and `Line` canonicalised
|
|
1503
|
+
the *type* but not the *value*, holding `150.125` where the document will carry `150.13`
|
|
1504
|
+
(`TKwotowy` is `fractionDigits="2"`; `TIlosci` is `6`). Both broke the round-trip law for a
|
|
1505
|
+
field whose value never really changed, and the `Line` one also made a line's own arithmetic
|
|
1506
|
+
disagree with the invoice as printed — a net derived from a price finer than the one shown.
|
|
1507
|
+
|
|
1508
|
+
**The rule: the model stores the document's representation, not the caller's input.** Two
|
|
1509
|
+
things fall out of it. A `Float` or a malformed address is refused at construction, where the
|
|
1510
|
+
caller can see what it passed, instead of at serialisation. And an invoice built from
|
|
1511
|
+
structured parts is genuinely `==` to the same invoice parsed back from XML — which is what
|
|
1512
|
+
lets DESIGN.md §7.6's round-trip law be stated as an equality rather than as a vaguer
|
|
1513
|
+
"equivalence". Retaining the inputs would invent distinctions FA(3), KSeF and every reader of
|
|
1514
|
+
the invoice are all blind to.
|
|
1515
|
+
|
|
1516
|
+
`GLN` is not carried. It is optional, nothing needs it yet, and a parsed document that has
|
|
1517
|
+
one reports it through `Invoice#unmapped_elements` rather than losing it silently.
|
|
1518
|
+
|
|
1519
|
+
### 8.3 Import chain and offline validation
|
|
364
1520
|
|
|
365
1521
|
```
|
|
366
1522
|
schemat_FA(3)_v1-0E.xsd
|
|
@@ -379,25 +1535,1934 @@ memory**, parsing the XSD into a document, editing the attribute, and compiling
|
|
|
379
1535
|
base URI pointing at the schema directory. The pinned file on disk must stay byte-for-byte
|
|
380
1536
|
identical so the §1 digests keep verifying.
|
|
381
1537
|
|
|
1538
|
+
### 8.4 The correction, `KOR` — read from the XSD and measured against the Ministry's five
|
|
1539
|
+
|
|
1540
|
+
Source: the pinned FA(3) XSD for the structure, and the five `KOR` samples of §1.5 for what
|
|
1541
|
+
a real one looks like. Recorded 2026-08-24, when `KOR` was built.
|
|
1542
|
+
|
|
1543
|
+
The correction elements are an **anonymous `<xsd:sequence minOccurs="0">`** sitting between
|
|
1544
|
+
`RodzajFaktury` and `ZaliczkaCzesciowa` in `Fa`:
|
|
1545
|
+
|
|
1546
|
+
| Element | Occurs | Carried as |
|
|
1547
|
+
|---|---|---|
|
|
1548
|
+
| `PrzyczynaKorekty` | 0–1, `TZnakowy` | `Correction#reason` |
|
|
1549
|
+
| `TypKorekty` | 0–1, `TTypKorekty` (1/2/3) | `Correction#effect` |
|
|
1550
|
+
| `DaneFaKorygowanej` | **1–50 000** | `Correction#corrected` |
|
|
1551
|
+
| `OkresFaKorygowanej` | 0–1, `TZnakowy` | `Correction#period` |
|
|
1552
|
+
| `NrFaKorygowany` | 0–1, `TZnakowy` | `Correction#corrected_number` |
|
|
1553
|
+
| `Podmiot1K` | 0–1 | `Correction#previous_seller` |
|
|
1554
|
+
| `Podmiot2K` | 0–101 | `Correction#previous_buyers` |
|
|
1555
|
+
| `P_15ZK` | 0–1, `TKwotowy` | `Correction#paid_before` — scoped to `KOR_ZAL`/`KOR_ROZ`, and it means a different thing on each; see §8.6 |
|
|
1556
|
+
| `KursWalutyZK` | 0–1, `TIlosci` | `Correction#exchange_rate_before`; shares an anonymous `<xsd:sequence minOccurs="0">` with `P_15ZK` and is itself `minOccurs="0"` **inside** it, so `P_15ZK` alone is valid — which is what all four samples do — and `KursWalutyZK` alone is not |
|
|
1557
|
+
|
|
1558
|
+
Four consequences that are easy to get wrong:
|
|
1559
|
+
|
|
1560
|
+
**The group is optional; `DaneFaKorygowanej` is not optional within it.** So a `KOR` with no
|
|
1561
|
+
correction elements at all is schema-valid, while one carrying `PrzyczynaKorekty` and nothing
|
|
1562
|
+
else is not. `Correction` refuses the latter at construction and the parser reads the former
|
|
1563
|
+
as simply having no correction — inventing one would change the document.
|
|
1564
|
+
|
|
1565
|
+
**`DaneFaKorygowanej` ends in a choice**, not two optional fields: either `NrKSeF` +
|
|
1566
|
+
`NrKSeFFaKorygowanej`, or `NrKSeFN` alone — *"znacznik faktury korygowanej wystawionej poza
|
|
1567
|
+
KSeF"*. The two *markers* — `NrKSeF` and `NrKSeFN` — are `etd:TWybor1`, which has exactly one
|
|
1568
|
+
member, `"1"`: they are present or absent, with no "no" to write. (The first branch is a
|
|
1569
|
+
sequence of the marker plus `NrKSeFFaKorygowanej`, which is a `TNumerKSeF`.) Modelled as one nil-able `ksef_number`, so
|
|
1570
|
+
emitting both branches or neither is unrepresentable.
|
|
1571
|
+
|
|
1572
|
+
**`IDNabywcy` is the link between `Podmiot2K` and the `Podmiot2` it corrects** — *"unikalny
|
|
1573
|
+
klucz powiązania danych nabywcy na fakturach korygujących"*. Both sides carry it, a correction
|
|
1574
|
+
may name up to 101 previous buyers, and nothing else pairs them. It is therefore a field of
|
|
1575
|
+
`Subject`, not an element to lose. `Podmiot2K` has **no `JST`/`GV`**; `Podmiot1K` makes `Adres`
|
|
1576
|
+
mandatory as `Podmiot1` does.
|
|
1577
|
+
|
|
1578
|
+
**`StanPrzed` marks a row as the state before correction** — *"w przypadku gdy korekta …
|
|
1579
|
+
jest dokonywana w sposób polegający na wykazaniu danych przed korektą i po korekcie jako
|
|
1580
|
+
osobnych wierszy"*. Przykład 2 gives such a pair **the same `NrWierszaFa`** despite the same
|
|
1581
|
+
sentence saying *"z odrębną numeracją"*, and pairs them by `UU_ID` too. Since `UU_ID` is not
|
|
1582
|
+
modelled, the shared row number is the only link left, so `Line#row_number` carries it — but
|
|
1583
|
+
only when it differs from the row's position, or a parsed ordinary invoice would stop equalling
|
|
1584
|
+
the built invoice it came from (DESIGN.md §7.6).
|
|
1585
|
+
|
|
1586
|
+
#### A correction's summaries are read, never computed
|
|
1587
|
+
|
|
1588
|
+
This is the substantive modelling decision. An ordinary `VAT` invoice's `P_13_*`/`P_14_*` are a
|
|
1589
|
+
function of its rows. A `KOR`'s are **deltas**, and FA(3) does not require its rows to determine
|
|
1590
|
+
them. Measured over the five samples:
|
|
1591
|
+
|
|
1592
|
+
| Sample | Rows | How the summary relates to them |
|
|
1593
|
+
|---|---|---|
|
|
1594
|
+
| Przykład 2 | before/after pair | after − before = the stated delta |
|
|
1595
|
+
| Przykład 3 | one row, already a delta | equals the stated delta |
|
|
1596
|
+
| Przykład 5 | **none** | `P_15` = 0, no buckets; a pure `Podmiot2K` data correction |
|
|
1597
|
+
| Przykład 6 | **none** | six corrected invoices, a period, buckets stated outright |
|
|
1598
|
+
| Przykład 7 | one row with **no amounts at all** | `P_7`, `CN`, unit and quantity only |
|
|
1599
|
+
|
|
1600
|
+
So three of the five cannot derive a summary from their rows at any price. `Invoice#totals`
|
|
1601
|
+
holds what the document states, and the parser fills it for every `KOR`. `Ksef::FA3::Totals`
|
|
1602
|
+
is keyed by **element name**, not rate code, because that mapping is not invertible — `"23"`
|
|
1603
|
+
and `"22"` share `P_13_1` (§8.1a) — and because it is the document's own representation
|
|
1604
|
+
(§8.2b). `Invoice#lines` may then be empty, which the constructor permits **only** when totals
|
|
1605
|
+
are stated.
|
|
1606
|
+
|
|
1607
|
+
The after − before rule that Przykład 2 satisfies is **deliberately not implemented**. One
|
|
1608
|
+
witness is not a rule, no first-tier source states it, and deriving a correction's tax base
|
|
1609
|
+
from an inference is the kind of thing §15.6 warns against. Callers state the delta.
|
|
1610
|
+
|
|
1611
|
+
#### The one cross-field rule tier 1 carries
|
|
1612
|
+
|
|
1613
|
+
**The XSD states this outright.** The anonymous sequence carries its own annotation:
|
|
1614
|
+
|
|
1615
|
+
> Dane dla przypadków, gdy pole RodzajFaktury przyjmuje wartości KOR, KOR_ZAL lub KOR_ROZ
|
|
1616
|
+
|
|
1617
|
+
— *data for the cases where the RodzajFaktury field takes the values KOR, KOR_ZAL or
|
|
1618
|
+
KOR_ROZ*. So the rule is a first-tier fact, not an inference from `PrzyczynaKorekty`'s
|
|
1619
|
+
*"dla faktur korygujących"*, as an earlier revision of this section said.
|
|
1620
|
+
|
|
1621
|
+
What the XSD **cannot do is enforce it**: the group sits in the same sequence whatever the
|
|
1622
|
+
type, so a `VAT` invoice carrying `DaneFaKorygowanej` validates clean (measured, by injecting
|
|
1623
|
+
the group into Przykład 1). That gap is exactly what tier 1 is for, and `ModelValidator`
|
|
1624
|
+
reports it. **The converse is not checked** — a `KOR` without the group is schema-valid, and
|
|
1625
|
+
complaining would be inventing a rule.
|
|
1626
|
+
|
|
1627
|
+
#### 8.4a The Ministry's KSeF numbers do not satisfy §13's checksum
|
|
1628
|
+
|
|
1629
|
+
Measured 2026-08-24: **all six distinct `NrKSeFFaKorygowanej` values in the five `KOR` samples
|
|
1630
|
+
fail the CRC-8 of §13**. The commonest, `9999999999-20230908-8BEF280C8D35-4D`, appears in
|
|
1631
|
+
**eleven of the twenty-six samples** — every `KOR`, every `KOR_ZAL`, the `KOR_ROZ` and both
|
|
1632
|
+
`ROZ`. It is the **only** KSeF-number reference in eight of those eleven; przyklad-06, -07 and
|
|
1633
|
+
-18 carry others alongside it. (Two earlier revisions were wrong here — first "three samples",
|
|
1634
|
+
then "the sole reference in three of them", which counted only the `KOR` samples where it
|
|
1635
|
+
stands alone. Corrected by audit 2026-08-26.) All six
|
|
1636
|
+
match `TNumerKSeF`'s pattern — every sample is XSD-valid — so they are well-formed
|
|
1637
|
+
placeholders rather than numbers KSeF ever issued.
|
|
1638
|
+
|
|
1639
|
+
Our CRC-8 is not the thing that is wrong: it reproduces the documented example of §13, and
|
|
1640
|
+
agreed with a real number assigned on live TEST (recorded in DESIGN.md §11's run record for
|
|
1641
|
+
`32692339217`, asserted by `spec/integration/session_flow_spec.rb`). So **tier 1 does not
|
|
1642
|
+
check the checksum of a referenced KSeF number.** Nothing upstream says KSeF verifies it, and
|
|
1643
|
+
rejecting on it would make tier 1 refuse the Ministry's own documents — the same failure as
|
|
1644
|
+
the whitespace-collapse bug, whose record is the header comment of
|
|
1645
|
+
`lib/ksef/fa3/field_checks.rb`. Do not add it.
|
|
1646
|
+
|
|
1647
|
+
#### 8.4b The five-lens audit of `KOR`, and the three facts it settled
|
|
1648
|
+
|
|
1649
|
+
Run 2026-08-24 against the merged commit, five independent read-only lenses. Three of the five
|
|
1650
|
+
found the same top defect without conferring, which is worth recording as much as the defect
|
|
1651
|
+
is: **a rule enforced on one side of a boundary is not enforced.**
|
|
1652
|
+
|
|
1653
|
+
**The derived summary.** §8.4 says a correction's summaries are *read, never computed*. That
|
|
1654
|
+
was true of {Parser} and false of the serializer: `DocumentMapping#summary` fell back to the
|
|
1655
|
+
line-derived buckets whenever no `Totals` was given, and nothing required a *built* correction
|
|
1656
|
+
to give one. Since a `StanPrzed` row is the position **as it was before the correction** — an
|
|
1657
|
+
amount already invoiced on the original document — summing it counts it twice with the wrong
|
|
1658
|
+
sign. The Ministry's Przykład 2, built through this gem's own DSL without `f.totals`, declared
|
|
1659
|
+
`P_15 = 3799.98` for a correction whose value is `-200.00`: a refund emitted as a charge,
|
|
1660
|
+
XSD-valid, tier 1 silent, `#unmapped_elements` empty. Tier 1a now requires stated totals
|
|
1661
|
+
whenever a line carries `state_before` — scoped to the marker rather than to the type, because
|
|
1662
|
+
a correction whose rows already *are* the deltas computes correctly and Przykład 3 is exactly
|
|
1663
|
+
that shape.
|
|
1664
|
+
|
|
1665
|
+
**`Integer()` honours radix prefixes.** `NrWierszaFa` is `TNaturalny`, whose lexical space
|
|
1666
|
+
permits leading zeros, and fixed-width row numbering is an ordinary ERP convention. `Integer`
|
|
1667
|
+
without an explicit base reads `"010"` as **octal eight**, so a schema-valid document stating
|
|
1668
|
+
row ten parsed as row eight and re-serialised as `8` — in the one field that pairs a
|
|
1669
|
+
correction's before/after rows once `UU_ID` is dropped. `"08"` failed the other way, raising
|
|
1670
|
+
on a document the schema accepts. Fixed with base 10 on the String branch. The spec that
|
|
1671
|
+
should have caught it used `"03"`, where octal and decimal agree.
|
|
1672
|
+
|
|
1673
|
+
**The two pinned artifacts disagree about a KSeF number, and each is right.** The OpenAPI
|
|
1674
|
+
contract's `KsefNumber` pattern admits the NIP issuer form alone:
|
|
1675
|
+
|
|
1676
|
+
```
|
|
1677
|
+
^([1-9](\d[1-9]|[1-9]\d)\d{7})-…
|
|
1678
|
+
```
|
|
1679
|
+
|
|
1680
|
+
The FA(3) XSD's `TNumerKSeF` admits two more (XSD patterns are implicitly anchored, so it
|
|
1681
|
+
carries no leading `^`):
|
|
1682
|
+
|
|
1683
|
+
```
|
|
1684
|
+
^([1-9]((\d[1-9])|([1-9]\d))\d{7}|M\d{9}|[A-Z]{3}\d{7})-…
|
|
1685
|
+
```
|
|
1686
|
+
|
|
1687
|
+
They are not in conflict — they govern different things. The contract says what a **lookup
|
|
1688
|
+
URL** may contain, and `Ksef::KsefNumber` serves lookups (`Invoices::Client#download`,
|
|
1689
|
+
`UPO::Client#for_ksef_number`), so its narrowness is correct there. The XSD says what a
|
|
1690
|
+
**document** may reference. Judging a document field by `KsefNumber::FORMAT` flagged an
|
|
1691
|
+
XSD-valid `M123456789-…` reference as malformed, so tier 1 no longer judges the format at all
|
|
1692
|
+
and tier 2 owns it. **Do not widen `KsefNumber::FORMAT` to "fix" this** — that would loosen
|
|
1693
|
+
the lookup path against its own contract. The layouts are the same length either way, so §13's
|
|
1694
|
+
CRC-8 input rule is unaffected.
|
|
1695
|
+
|
|
1696
|
+
A fourth, smaller finding of the same shape: {Serializer} compared `values.keys.map(&:to_s)`
|
|
1697
|
+
against the schema when rejecting unknown element names, but wrote with `values.key?(name)`
|
|
1698
|
+
against a String. A symbol key therefore passed the check and was then **silently dropped**.
|
|
1699
|
+
Keys are normalised once, at the top of `write_children`.
|
|
1700
|
+
|
|
1701
|
+
### 8.5 The advance-payment pair, `ZAL` and `ROZ`
|
|
1702
|
+
|
|
1703
|
+
Source: the pinned FA(3) XSD for the structure, and the Ministry's `ZAL`, `ROZ`, `KOR_ZAL` and
|
|
1704
|
+
`KOR_ROZ` samples of §1.5 for what they contain. Recorded 2026-08-25.
|
|
1705
|
+
|
|
1706
|
+
A `ZAL` documents money received **before** the goods are delivered; a `ROZ` — art. 106f ust. 3
|
|
1707
|
+
— is the invoice issued once they are, settling what the advance invoices already covered. Two
|
|
1708
|
+
elements carry that, and both sit in `Fa` outside the correction group:
|
|
1709
|
+
|
|
1710
|
+
| Element | Occurs | Carried as |
|
|
1711
|
+
|---|---|---|
|
|
1712
|
+
| `Zamowienie` | 0–1 | `Invoice#order` |
|
|
1713
|
+
| `Zamowienie/WartoscZamowienia` | 1, `TKwotowy` | `Order#total` |
|
|
1714
|
+
| `Zamowienie/ZamowienieWiersz` | **1–10 000** | `Order#lines` |
|
|
1715
|
+
| `FakturaZaliczkowa` | 0–**100** | `Invoice#advances` |
|
|
1716
|
+
| `ZaliczkaCzesciowa` | 0–31 | **not modelled** — see below |
|
|
1717
|
+
|
|
1718
|
+
**A `ZAL` need not carry `FaWiersz`, and the Ministry's does not.** `Zamowienie` is
|
|
1719
|
+
*"zamówienie lub umowa, o których mowa w art. 106f ust. 1 pkt 4"* — the order or contract — and
|
|
1720
|
+
its positions stand in for invoice rows, in the currency the advance invoice was issued in.
|
|
1721
|
+
Przykład 10 carries two `ZamowienieWiersz` and no rows. **The schema permits rows even so**:
|
|
1722
|
+
`FaWiersz` is `minOccurs="0"`, described as *"węzeł opcjonalny dla faktury zaliczkowej"* —
|
|
1723
|
+
optional, not forbidden. Nothing here refuses a `ZAL` with rows, and this paragraph reports one
|
|
1724
|
+
witness rather than a rule. (An earlier revision stated it as a general fact; §8.4 declines to
|
|
1725
|
+
build rules on single witnesses and the same discipline applies here.)
|
|
1726
|
+
|
|
1727
|
+
**`WartoscZamowienia` is not `P_15`, and confusing them would be a large error.** It is
|
|
1728
|
+
*"wartość zamówienia lub umowy z uwzględnieniem kwoty podatku"* — the whole order **including
|
|
1729
|
+
tax**. In Przykład 10 it is `375 150` against a `P_15` of `20 000`: the order is the contract,
|
|
1730
|
+
`P_15` is the money received so far.
|
|
1731
|
+
|
|
1732
|
+
**An order position states its own tax.** `ZamowienieWiersz` carries both `P_11NettoZ`
|
|
1733
|
+
(*"wartość zamówionego towaru lub usługi bez kwoty podatku"*) and `P_11VatZ` (*"kwota podatku
|
|
1734
|
+
od zamówionego towaru lub usługi"*). `FaWiersz` has no per-row tax element and the tax is
|
|
1735
|
+
computed from `P_12`; here the document carries both, so `OrderLine` reads both and derives
|
|
1736
|
+
nothing. `StanPrzedZ` is `StanPrzed`'s twin, for a `KOR_ZAL` correcting an order position.
|
|
1737
|
+
|
|
1738
|
+
**`FakturaZaliczkowa`'s choice is inverted from `DaneFaKorygowanej`'s.** There, the marker
|
|
1739
|
+
pairs with the KSeF number. Here it pairs with the plain one:
|
|
1740
|
+
|
|
1741
|
+
- in KSeF → `NrKSeFFaZaliczkowej` **alone**;
|
|
1742
|
+
- outside KSeF → `NrKSeFZN` (*"znacznik faktury zaliczkowej wystawionej poza KSeF"*) followed
|
|
1743
|
+
by `NrFaZaliczkowej`.
|
|
1744
|
+
|
|
1745
|
+
So the two branches name *different fields*, and {AdvanceInvoice} carries both with exactly one
|
|
1746
|
+
required — unlike {CorrectedInvoice}, where one nil-able field was enough. Getting this
|
|
1747
|
+
backwards is easy and produces a schema-valid document asserting the wrong provenance.
|
|
1748
|
+
|
|
1749
|
+
#### Both types state their summary, measured rather than assumed
|
|
1750
|
+
|
|
1751
|
+
`Invoice::STATED_TOTALS_TYPES` gains `ZAL` and `ROZ`. Over every sample of each, the stated
|
|
1752
|
+
buckets **never** equal the row totals:
|
|
1753
|
+
|
|
1754
|
+
| Sample | Type | Σ row `P_11` | Σ stated `P_13_*` | `P_15` |
|
|
1755
|
+
|---|---|---|---|---|
|
|
1756
|
+
| Przykład 10 | `ZAL` | — (no rows) | 16 260.16 | 20 000 |
|
|
1757
|
+
| Przykład 14 | `ROZ` | 312 000.00 | 284 277.75 | 307 635 |
|
|
1758
|
+
| Przykład 17 | `ROZ` | 312 000.00 | 277 222.45 | 300 000 |
|
|
1759
|
+
| Przykład 11 | `KOR_ZAL` | — (no rows) | 2 221.34 | 0 |
|
|
1760
|
+
| Przykład 18 | `KOR_ROZ` | 624 000.00 | 7 055.30 | 7 635 |
|
|
1761
|
+
|
|
1762
|
+
A `ZAL` has nothing to derive from. A `ROZ` describes the goods in its rows and states the
|
|
1763
|
+
amount **remaining after the advance** in its buckets — and the advance is not in this
|
|
1764
|
+
document, so the model could not compute it even in principle. `ZaliczkaCzesciowa`'s own
|
|
1765
|
+
documentation confirms the shape: *"różnica kwoty w polu P_15 i sumy poszczególnych pól P_15Z
|
|
1766
|
+
stanowi kwotę pozostałą ponad płatności otrzymane przed wykonaniem"*.
|
|
1767
|
+
|
|
1768
|
+
Tier 1 therefore requires a stated summary whenever an order is present or an advance invoice
|
|
1769
|
+
is settled — the same rule, and the same module, as the `state_before` case of §8.4b. All three
|
|
1770
|
+
triggers are **structural**: an element the document either carries or does not. Whether the
|
|
1771
|
+
figures then reconcile is tier 3's, and tier 3 reports it as a warning (§17.1).
|
|
1772
|
+
|
|
1773
|
+
#### What is deliberately not modelled
|
|
1774
|
+
|
|
1775
|
+
- **`ZaliczkaCzesciowa`** — instalments of a partial advance. It appears in **none of the
|
|
1776
|
+
twenty-six samples**, so there is nothing to build against; it is a whole element and is
|
|
1777
|
+
therefore visible through `#unmapped_elements`.
|
|
1778
|
+
- ~~**`P_15ZK` / `KursWalutyZK`**~~ — **modelled 2026-08-26**, with `KOR_ZAL` and `KOR_ROZ`.
|
|
1779
|
+
See §8.6.
|
|
1780
|
+
- **`Platnosc`** and **`DodatkowyOpis`** — payment details and free-form key/value notes. Both
|
|
1781
|
+
appear across `VAT`, `ZAL` and `ROZ` alike and have never been modelled; both are
|
|
1782
|
+
path-visible. `Platnosc` on a `ZAL` carries *when* the advance was paid, which is worth
|
|
1783
|
+
having eventually, but it is not what makes a `ZAL` a `ZAL`.
|
|
1784
|
+
|
|
1785
|
+
|
|
1786
|
+
### 8.6 The last three types, and the row that states no amount
|
|
1787
|
+
|
|
1788
|
+
Recorded 2026-08-26, when `UPR`, `KOR_ZAL` and `KOR_ROZ` landed. **All seven `RodzajFaktury`
|
|
1789
|
+
values are now modelled**, and twenty-two of the Ministry's twenty-six samples parse,
|
|
1790
|
+
re-serialise, validate and round-trip. The four that do not are refused for a *construct* —
|
|
1791
|
+
two priced gross, two identifying their buyer by something other than a NIP — not for a type.
|
|
1792
|
+
|
|
1793
|
+
Two of the three needed almost nothing new. `KOR_ZAL` is the correction group of §8.4 plus the
|
|
1794
|
+
`Zamowienie` of §8.5; `KOR_ROZ` is the correction group plus `FakturaZaliczkowa`. Between them
|
|
1795
|
+
they added one element:
|
|
1796
|
+
|
|
1797
|
+
**`P_15ZK` means two different things, and the invoice type decides which.** Its documentation
|
|
1798
|
+
reads *"W przypadku korekt faktur zaliczkowych - kwota zapłaty przed korektą. W przypadku
|
|
1799
|
+
korekt faktur, o których mowa w art. 106f ust. 3 ustawy - kwota pozostała do zapłaty przed
|
|
1800
|
+
korektą"* — the amount **paid** before the correction on a `KOR_ZAL`, the amount **left to
|
|
1801
|
+
pay** before it on a `KOR_ROZ`. One element, two readings. `Correction#paid_before` carries the
|
|
1802
|
+
figure and does not try to name it more precisely than the schema does. All four `KOR_ZAL` and
|
|
1803
|
+
`KOR_ROZ` samples carry it; `KursWalutyZK`, which shares its anonymous sequence, appears in
|
|
1804
|
+
none of the twenty-six — and since that sequence makes `KursWalutyZK` optional *within* it,
|
|
1805
|
+
those four are documents with `P_15ZK` and no rate, which is valid.
|
|
1806
|
+
|
|
1807
|
+
`KursWalutyZK` is `TIlosci`, so **six decimal places, as a ceiling rather than a preference**.
|
|
1808
|
+
Held unrounded, the model carried a rate FA(3) cannot express and `#to_fa3` rounded it away at
|
|
1809
|
+
emit, so the invoice stopped equalling itself through a round-trip — with tier 2 silent,
|
|
1810
|
+
because what reached the document was valid. Found by audit 2026-08-26; it is §8.2b's rule, and
|
|
1811
|
+
the only field in the model that was not following it.
|
|
1812
|
+
|
|
1813
|
+
#### `UPR` needed a row that states no amount at all
|
|
1814
|
+
|
|
1815
|
+
A simplified invoice under art. 106e ust. 5 pkt 3 — the XSD's own gloss on the `UPR`
|
|
1816
|
+
enumeration — may omit a great deal. **Both Ministry samples name the goods and stop**, which
|
|
1817
|
+
is a measurement over n=2 and not a rule about the class:
|
|
1818
|
+
|
|
1819
|
+
| Sample | The whole row | Summary |
|
|
1820
|
+
|---|---|---|
|
|
1821
|
+
| Przykład 15 | `NrWierszaFa`, `P_7` | `P_13_1`, `P_14_1`, `P_15` |
|
|
1822
|
+
| Przykład 16 | `NrWierszaFa`, `P_7`, `P_12` | `P_15` alone |
|
|
1823
|
+
|
|
1824
|
+
Every child of `FaWiersz` but `NrWierszaFa` is `minOccurs="0"`, so this is not a special case
|
|
1825
|
+
in the schema — it is the schema's default, and the model was the thing being strict. (`FaWiersz`
|
|
1826
|
+
is declared with an **anonymous** `<xsd:complexType>`; there is no `TFaWiersz` to name, and
|
|
1827
|
+
several documents said there was until an audit checked. FA(3) names only seven complexTypes.)
|
|
1828
|
+
`Line` now carries every field optionally except `name:`, which stays a required *keyword* —
|
|
1829
|
+
pass `name: nil` deliberately, as the parser does — and **`Line#net` answers `nil` rather than
|
|
1830
|
+
raising**: "the row states no amount" is a state the model has to hold, and it is not the same
|
|
1831
|
+
as zero.
|
|
1832
|
+
|
|
1833
|
+
`#gross` answers nil for the same row, and so does **`#vat`, as of the 2026-08-26 audit**. It
|
|
1834
|
+
returned `BigDecimal(0)`, which conflated two different absences: a rate code that carries no
|
|
1835
|
+
tax (`zw`, `oo`, `np I`) really is zero, while a row with no amount has tax that is *unknown*.
|
|
1836
|
+
A caller's `lines.sum(&:vat)` under-reported in silence while `sum(&:net)` raised.
|
|
1837
|
+
|
|
1838
|
+
Three predicates and one scope decision go with this. `Line#priced?` is "states an amount";
|
|
1839
|
+
`Line#summarised?` is "states an amount *and* a rate", which is what it takes to reach a
|
|
1840
|
+
bucket. And `Invoice::STATED_TOTALS_TYPES` gained all three new types, so the parser **reads**
|
|
1841
|
+
their summaries rather than deriving them — which the samples above force rather than merely
|
|
1842
|
+
suggest: Przykład 15's summary cannot be derived from a row carrying only `P_7`, and Przykład
|
|
1843
|
+
16 states a `P_15` with no buckets at all. Same measurement as §8.5's for `ZAL`/`ROZ`.
|
|
1844
|
+
|
|
1845
|
+
**The same shape unblocked Przykład 7**, the one Ministry correction this model could not read
|
|
1846
|
+
— its single row names goods, a `CN` code and a quantity, with no amount anywhere. So all five
|
|
1847
|
+
corrections now go through, and the refusal that used to name it is gone.
|
|
1848
|
+
|
|
1849
|
+
#### What replaced the refusals, and why it had to
|
|
1850
|
+
|
|
1851
|
+
`RowReader` used to refuse two shapes: a row with no price, and a row with no `P_12`. Both are
|
|
1852
|
+
now read, because both are legal and the parser is documented not to validate. But an unpriced
|
|
1853
|
+
row on an invoice that *derives* its summary from its rows is a real hazard — the amount is
|
|
1854
|
+
simply absent from the tax base, and **neither tier can see it**: the XSD is blind to
|
|
1855
|
+
arithmetic, and `#unmapped_elements` to values. That is the §8.4b bug class again.
|
|
1856
|
+
|
|
1857
|
+
So tier 1 took it over, addressed to the line and scoped to where it costs something:
|
|
1858
|
+
|
|
1859
|
+
| Row states | On a deriving invoice | On one that states its summary |
|
|
1860
|
+
|---|---|---|
|
|
1861
|
+
| amount + rate | fine | fine |
|
|
1862
|
+
| amount, no rate | **reported** — no bucket to put it in | fine |
|
|
1863
|
+
| no amount | **reported** — absent from the tax base | fine, this is `UPR` |
|
|
1864
|
+
|
|
1865
|
+
`Invoice#net_by_rate` skips what it cannot place, and so does `#vat_rounded_per_line` — which
|
|
1866
|
+
is exactly why the rule is needed: without it the document would quietly understate.
|
|
1867
|
+
|
|
1868
|
+
**The guard class changed, and that is worth stating plainly.** These two shapes used to be
|
|
1869
|
+
*impossible* — `parse` refused them — and are now *reported if you ask*. `Ksef::Client`'s send
|
|
1870
|
+
path asks (`validate: true` by default), so the shipped route is unchanged; a caller who reaches
|
|
1871
|
+
for `#to_xml` directly is not covered, and a caller who parses a document, re-serialises it and
|
|
1872
|
+
archives the result will get a document whose tax base differs from the original's with nothing
|
|
1873
|
+
raised. Demonstrated on Przykład 1 with one `P_12` deleted — still XSD-valid — where the
|
|
1874
|
+
re-serialised `P_15` came back 50.01 lower, `#unmapped_elements` was silent (the element paths
|
|
1875
|
+
are identical), and even the round-trip law held, because a parsed invoice is already a fixed
|
|
1876
|
+
point. Only `#errors` catches it.
|
|
1877
|
+
|
|
1878
|
+
The one shape still refused at parse time is **gross pricing** (`P_9B`/`P_11A`), and the
|
|
1879
|
+
distinction is worth keeping: a gross-priced row carries a number this model has nowhere to
|
|
1880
|
+
put, so reading it would drop a real amount. An unpriced row carries no number to drop. Those
|
|
1881
|
+
two elements are the *only* ones in the row whose annotation cites art. 106e ust. 7-8, so the
|
|
1882
|
+
refusal is complete; `P_9B` alone went untested until 2026-08-26, because both Ministry samples
|
|
1883
|
+
carry `P_11A` as well and an `||` operand is not a branch either criterion can see.
|
|
1884
|
+
|
|
1885
|
+
#### A unit price is not a `TKwotowy`
|
|
1886
|
+
|
|
1887
|
+
Found by the same audit, and pre-dating this work. `P_9A` — and `P_9AZ` on an order position —
|
|
1888
|
+
is **`TKwotowy2`**: *"Wartość numeryczna 22 znaki max, w tym 8 znaków po przecinku"*, four
|
|
1889
|
+
times the precision of the `TKwotowy` amount it produces. The model rounded both to two places,
|
|
1890
|
+
so a row priced at `1626.0125` (schema-valid) was re-emitted as `1626.01` with `#errors` empty
|
|
1891
|
+
and `#unmapped_elements` empty — the §8.4b bug class applied to a number rather than a flag,
|
|
1892
|
+
and a **silent alteration of a stated amount**, which is the most serious kind of defect this
|
|
1893
|
+
model can have. `Formatting::UNIT_PRICE_SCALE` is 8; `Formatting.unit_price` pads to two places
|
|
1894
|
+
so `150` still reads `150.00`, and `TKwotowy2`'s pattern permits one to eight, so every
|
|
1895
|
+
existing document is byte-identical under the fix.
|
|
1896
|
+
|
|
1897
|
+
`P_10` (discount) and `P_9B` are `TKwotowy2` too; the model carries neither, and both are
|
|
1898
|
+
reported by `#unmapped_elements` as whole elements, so nothing is silently lost there.
|
|
1899
|
+
|
|
1900
|
+
|
|
382
1901
|
---
|
|
383
1902
|
|
|
1903
|
+
### 8.7 `Zalacznik`, the attachment — measured 2026-08-26
|
|
1904
|
+
|
|
1905
|
+
FA(3)'s attachment is **not a file**. There is no MIME type, no encoding, no bytes: `Zalacznik`
|
|
1906
|
+
is a structured document of headings, key/value metadata, paragraphs and tables, sitting as a
|
|
1907
|
+
**sibling of `Fa`** rather than one of its children. That placement is the first thing worth
|
|
1908
|
+
knowing — it takes part in no summary and no arithmetic, so modelling it could not affect a
|
|
1909
|
+
single tax figure.
|
|
1910
|
+
|
|
1911
|
+
Two of the Ministry's samples carry one (Przykład 24 and 25, both energy bills): one block, eight
|
|
1912
|
+
metadata pairs, three tables.
|
|
1913
|
+
|
|
1914
|
+
**Four facts the schema states that a naive model would get wrong:**
|
|
1915
|
+
|
|
1916
|
+
| Fact | Where | Consequence |
|
|
1917
|
+
|---|---|---|
|
|
1918
|
+
| `MetaDane` is mandatory (`minOccurs` defaults to 1; the schema writes only `maxOccurs="1000"`) | inside `BlokDanych` | It is the *only* mandatory child. A block with a heading and a table but no metadata is schema-invalid, so {DataBlock} refuses one at construction |
|
|
1919
|
+
| `Kol` and `WKom` each repeat 1..20, **related nowhere** — no `xsd:key`, `keyref` or `unique` in any of the four schemas | `TNaglowek` / `Wiersz` | **Rows are ragged.** Measured widths across the corpus: `[1, 9]`, `[1, 6]` and `[1, 9, 1, 9, …]` — one-cell rows label what follows, alternating rather than heading a block of them, and one of the three tables is six columns wide. Storing rows as a rectangle would invent cells or drop them |
|
|
1920
|
+
| `TZnakowy2` has `minLength="0"` | `NKom`, `WKom`, `SKom` | An **empty cell is legal**, and distinct from an absent one. `Formatting.text("")` returns `""` rather than nil precisely so it survives; a dropped cell shifts every cell after it |
|
|
1921
|
+
| `Kol/@Typ` is `use="required"` with six inline values | `Kol` | FA(3)'s only inline *attribute* enumeration — `date`, `datetime`, `dec`, `int`, `time`, `txt`. {TableColumn} reads them from the generated metadata, which is why §18.2's codegen fix came first |
|
|
1922
|
+
|
|
1923
|
+
`Suma` is `minOccurs="0"`, so a table with no summary row must not re-serialise with an empty one
|
|
1924
|
+
— {AttachmentTable#totals} is nil rather than `[]` for that reason.
|
|
1925
|
+
|
|
1926
|
+
**One defect no tier catches, and it is undecidable rather than unimplemented.** A column
|
|
1927
|
+
declared `dec` whose cells hold `"nie liczba"` is XSD-clean, silent to `#errors`, `#warnings`
|
|
1928
|
+
and `#unmapped_elements` alike — every cell is `TZnakowy2` whatever its column says. It is not
|
|
1929
|
+
checked because **a cell cannot be reliably associated with a column at all**: rows are ragged,
|
|
1930
|
+
so a one-cell label row belongs to no column and the association has no general answer. Recorded
|
|
1931
|
+
so it is not mistaken for an oversight.
|
|
1932
|
+
|
|
1933
|
+
**The reader refuses eight structurally-invalid shapes** rather than reading them — no
|
|
1934
|
+
`BlokDanych`, no `MetaDane`, a half-empty `MetaDane`, no `TNaglowek`, no `Wiersz`, an empty
|
|
1935
|
+
`Wiersz`, a missing or illegal `Kol/@Typ`. That is consistent with the parser refusing a
|
|
1936
|
+
nameless seller, and the cost is that a malformed attachment makes the invoice's *tax* figures
|
|
1937
|
+
unreadable. `AttachmentReader`'s own comment weighs it and names the alternative.
|
|
1938
|
+
|
|
1939
|
+
**Operational constraints are out of 0.1 scope and stay there** (DESIGN.md §7.4). Sending an
|
|
1940
|
+
invoice that carries an attachment needs prior opt-in in `e-Urząd Skarbowy` and, per **§15.5**,
|
|
1941
|
+
a batch session — with the exception that section records, an offline *technical correction* may
|
|
1942
|
+
use an interactive one. The size ceiling rises from 1 MB to 3 MB (**§6.2**), and both are
|
|
1943
|
+
**defaults rather than ceilings of the format**: `limity.md` heads them *"Wartość domyślna"* and
|
|
1944
|
+
`GET /limits/context` reports the live values. None of that is modelled here: this is the
|
|
1945
|
+
document, not the submission — but the size figure does reach code, because tier 1b must not
|
|
1946
|
+
refuse a document the format permits ({DocumentValidator::MAX_BYTES_WITH_ATTACHMENT}).
|
|
1947
|
+
|
|
1948
|
+
An earlier version of this paragraph cited "§16", which says nothing about attachments.
|
|
1949
|
+
|
|
384
1950
|
## 9. Still unverified
|
|
385
1951
|
|
|
386
1952
|
Carried forward; must be resolved before the code that depends on them is written
|
|
387
|
-
(DESIGN.md §0
|
|
388
|
-
|
|
389
|
-
- **
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
-
|
|
402
|
-
|
|
403
|
-
|
|
1953
|
+
(DESIGN.md §0 rule 2). Reviewed 2026-08-23 (second pass, after the crypto module).
|
|
1954
|
+
|
|
1955
|
+
- **Business-rule catalogue** for validation tier 3 (DESIGN.md §7.7). Still the only
|
|
1956
|
+
genuinely open blocker of the original set, and now **known to be absent rather than
|
|
1957
|
+
merely unread**. This bullet used to say `faktury/weryfikacja-faktury.md` was the next
|
|
1958
|
+
place to look. It was pinned 2026-08-24 and it is not that document: it specifies
|
|
1959
|
+
*technical admission* checks, which turned out to be the missing first-tier source for
|
|
1960
|
+
tier **1**. See §15 for what it does settle, and §15.6 for the search that establishes no
|
|
1961
|
+
file at commit `1c34fe27` states a reconciliation rule — plus the three possible
|
|
1962
|
+
groundings for tier 3 and which of them is safe to build on.
|
|
1963
|
+
- **Error-code catalogue.** Still open, but **narrowed again**: the *authentication
|
|
1964
|
+
operation* status codes are recorded at §4.8 **from the pinned contract** — not, as this
|
|
1965
|
+
bullet used to say, from the reference implementation; see §4.8 for why that distinction
|
|
1966
|
+
cost us code 480 — and the
|
|
1967
|
+
per-endpoint `ExceptionResponse` codes now known — and `docs/errors.md` lists all four —
|
|
1968
|
+
are `21405` (input validation),
|
|
1969
|
+
`21470` (unknown or withdrawn key, §10.2), `21111` (invalid authorisation challenge,
|
|
1970
|
+
§4.5) and `21157` (invalid package part size). The rest must be collected from the spec
|
|
1971
|
+
per operation, or observed — **and the collection is mechanical**: each operation's `400`
|
|
1972
|
+
response carries a Markdown table of its own codes in the `description`, which is where
|
|
1973
|
+
`21111` came from. The C# client has sibling files (`CertificateStatusCodeResponse`,
|
|
1974
|
+
`InvoiceInSessionStatusCodeResponse`, `OperationStatusCodeResponse`,
|
|
1975
|
+
`InvoiceExportStatusCodeResponse`) that will likely close the corresponding areas the
|
|
1976
|
+
same way when those subsystems are built.
|
|
1977
|
+
- ~~**Whether the KSeF-token timestamp is really enforced as a replay nonce.**~~ **Resolved
|
|
1978
|
+
2026-08-24: it is.** §4.5 recorded the claim from first-tier documentation and nothing
|
|
1979
|
+
offline could test it. `spec/integration/crypto_spec.rb` authenticates with a deliberately
|
|
1980
|
+
stale `timestampMs`, and live TEST refused it (run `32704511675`). The claim is now
|
|
1981
|
+
observed, not believed.
|
|
1982
|
+
- ~~**Whether `certificateId` and `publicKeyId` are derived as §10.2 states.**~~ **Resolved
|
|
1983
|
+
2026-08-24: both are.** The library never computes either — it echoes the server's
|
|
1984
|
+
`publicKeyId` back — so no offline test could check the derivation.
|
|
1985
|
+
`spec/integration/crypto_spec.rb` recomputes both from the real certificates and they
|
|
1986
|
+
match: `publicKeyId` is the Base64 SHA-256 of the DER `SubjectPublicKeyInfo`,
|
|
1987
|
+
`certificateId` the Base64 SHA-256 of the DER certificate (same run).
|
|
1988
|
+
- **Nightly higher rate limits** (§6.1) — the 20:00–06:00 values are explicitly
|
|
1989
|
+
unpublished pending production tuning. Do not hard-code a nightly multiplier.
|
|
1990
|
+
- ~~**Whether `upo.pages[].downloadUrl` arrives absolute or host-relative**~~ — **resolved
|
|
1991
|
+
2026-08-24: it arrives ABSOLUTE.** Observed on the first live nightly (run
|
|
1992
|
+
`32692339217`), which reported it explicitly. `Ksef::UPO::Client` handles both forms and
|
|
1993
|
+
will keep doing so — `srodowiska.md` promises only that a returned URL's *host* matches
|
|
1994
|
+
the environment called, not that the URL is absolute, so the relative branch stays as
|
|
1995
|
+
defence rather than dead code. (An earlier revision of this bullet said the field
|
|
1996
|
+
should simply be ignored; that was the superseded reading, corrected in §14.2 — the link
|
|
1997
|
+
is unmetered and hash-verified, so ignoring it costs something real.)
|
|
1998
|
+
|
|
1999
|
+
### 9.1 Resolved
|
|
2000
|
+
|
|
2001
|
+
Kept as a record so they are not re-investigated. The 2026-08-22 pass over the newly
|
|
2002
|
+
pinned prose (§1.3) closed four of the five items that were open, including both that were
|
|
2003
|
+
marked as blocking Phase 2.
|
|
2004
|
+
|
|
2005
|
+
| Item | Where it now lives |
|
|
2006
|
+
|---|---|
|
|
2007
|
+
| Challenge 10-minute validity | §4 — verified from `uwierzytelnianie.md`, no longer hearsay |
|
|
2008
|
+
| JWT lifetime and refresh mechanics | §4.2 — access token to its `exp`, refresh token up to 7 days |
|
|
2009
|
+
| `P_13_x` / `P_14_x` rate-bucket mapping | §8.1a — read from the XSD's own documentation |
|
|
2010
|
+
| Base URLs for all three environments | §2 — read from each environment's own OpenAPI document |
|
|
2011
|
+
| Error model and `Retry-After` semantics | §5 |
|
|
2012
|
+
| XSD redistribution terms | §1.2 — MIT, so the schemas are bundled |
|
|
2013
|
+
| TEST credential provisioning | §6a |
|
|
2014
|
+
| **Crypto parameters** *(was: blocks all of Phase 2)* | §10 — from `sesja-interaktywna.md` and `uwierzytelnianie.md`, corroborated by both reference clients |
|
|
2015
|
+
| **XAdES signature specifics** *(was: blocks Phase 2's first step)* | §4.3 — an allow-list, so no single shape had to be reverse-engineered |
|
|
2016
|
+
| **Session semantics** | §11 — 12-hour lifetime, many invoices, concurrent sessions permitted |
|
|
2017
|
+
| **UPO document format** | §12 — schema pinned to `lib/ksef/upo/schema/`, six examples to `spec/fixtures/upo/` |
|
|
2018
|
+
| TEST bootstrap for a real credential | §4.6 — upstream ships a console app that does it |
|
|
2019
|
+
|
|
2020
|
+
---
|
|
2021
|
+
|
|
2022
|
+
## 10. Cryptography — resolves DESIGN.md §6.4 [VERIFY]
|
|
2023
|
+
|
|
2024
|
+
Primary source: `sesja-interaktywna.md` "Wymagania wstępne" and `uwierzytelnianie.md` §2.2
|
|
2025
|
+
(upstream's own numbering) — **both first-tier documentation, not inferred from client
|
|
2026
|
+
behaviour.**
|
|
2027
|
+
Independently corroborated against `KSeF.Client/Api/Services/CryptographyService.cs`
|
|
2028
|
+
(`ksef-client-csharp`) and `DefaultCryptographyService.java` (`ksef-client-java`), both
|
|
2029
|
+
retrieved 2026-08-22. Where a line number is cited below it is from the C# file.
|
|
2030
|
+
|
|
2031
|
+
### 10.1 Parameters
|
|
2032
|
+
|
|
2033
|
+
| Purpose | Algorithm | Detail |
|
|
2034
|
+
|---|---|---|
|
|
2035
|
+
| Invoice payload | **AES-256-CBC**, **PKCS#7** padding | 256-bit key, 128-bit IV, 128-bit block (`CryptographyService.cs:486–490`) |
|
|
2036
|
+
| Symmetric key wrapping | **RSAES-OAEP**, SHA-256 digest + **MGF1-SHA-256** | `RSAEncryptionPadding.OaepSHA256` (`:133`, `:423`) |
|
|
2037
|
+
| KSeF token (§4.5) | **RSA-OAEP**, SHA-256 + MGF1-SHA-256 | over `{token}\|{timestampMs}` UTF-8 |
|
|
2038
|
+
| Key and IV generation | CSPRNG | 32 and 16 bytes respectively (`:498`, `:507`) |
|
|
2039
|
+
|
|
2040
|
+
A fresh symmetric key per session is **recommended** by the docs, not required.
|
|
2041
|
+
|
|
2042
|
+
`faktury/weryfikacja-faktury.md` states the same parameters as an admission requirement —
|
|
2043
|
+
a fifth witness, and the first from first-tier prose (§15.5). It writes the wrapping as
|
|
2044
|
+
"RSAES-OAEP (SHA-256/MGF1)", pairing the two digests in one breath, which is exactly the
|
|
2045
|
+
distinction the MGF1 row below exists to defend.
|
|
2046
|
+
|
|
2047
|
+
Java uses the JCE name `AES/CBC/PKCS5Padding`; for a 16-byte block PKCS#5 and PKCS#7 are
|
|
2048
|
+
the same padding, so this is not a divergence.
|
|
2049
|
+
|
|
2050
|
+
Ruby equivalents: `OpenSSL::Cipher.new("aes-256-cbc")` with `#random_key` / `#random_iv`,
|
|
2051
|
+
and `OpenSSL::PKey::RSA#public_encrypt` is **not** sufficient — OAEP with an explicit MGF1
|
|
2052
|
+
digest needs `OpenSSL::PKey::RSA#encrypt` with
|
|
2053
|
+
`rsa_padding_mode: "oaep", rsa_oaep_md: "sha256", rsa_mgf1_md: "sha256"`.
|
|
2054
|
+
|
|
2055
|
+
**No golden vectors exist upstream.** Neither client repository commits fixed
|
|
2056
|
+
plaintext/ciphertext pairs; `Compatibility/CryptoCompat*.cs` are polyfills for older .NET
|
|
2057
|
+
runtimes, not test vectors. This is acceptable: AES-256-CBC/PKCS#7 and RSA-OAEP-SHA256 are
|
|
2058
|
+
standard primitives that OpenSSL reproduces by construction, and NIST/RFC vectors validate
|
|
2059
|
+
them just as well as a C#-generated pair would. What is *not* standard, and therefore does
|
|
2060
|
+
need pinning down, is the framing — see §14.1.
|
|
2061
|
+
|
|
2062
|
+
**What is asserted instead** (implemented 2026-08-23, `spec/ksef/crypto_spec.rb` and
|
|
2063
|
+
`spec/ksef/crypto/encryptor_spec.rb`), replacing DESIGN.md §6.4's "port three vectors from
|
|
2064
|
+
the C# client":
|
|
2065
|
+
|
|
2066
|
+
| Parameter | How it is pinned |
|
|
2067
|
+
|---|---|
|
|
2068
|
+
| AES-256-CBC | **NIST SP 800-38A F.2.5** CBC-AES256.Encrypt, four blocks, byte for byte |
|
|
2069
|
+
| PKCS#7 | the padded output is one block longer than an exact multiple of 16 |
|
|
2070
|
+
| SHA-256 | **FIPS 180-4** vectors for `"abc"` and the empty string |
|
|
2071
|
+
| OAEP digest | the longest accepted plaintext is **190 bytes**, i.e. `k − 2·hLen − 2` with `hLen = 32`. With SHA-1 it would be 214, so this pins the digest without trusting the option name |
|
|
2072
|
+
| MGF1 digest | a ciphertext produced with MGF1-SHA-256 **fails** to decrypt under MGF1-SHA-1, so the two are genuinely distinguishable and the option is not being ignored |
|
|
2073
|
+
|
|
2074
|
+
The MGF1 row is the one that matters most. OpenSSL's MGF1 digest defaults to SHA-1, so
|
|
2075
|
+
setting `rsa_oaep_md` alone silently produces OAEP-SHA256-with-MGF1-SHA1 — a different
|
|
2076
|
+
scheme, which fails only at the far end. Verified on both 3.2.11 and 4.0.6.
|
|
2077
|
+
|
|
2078
|
+
### 10.2 Public key distribution and rotation
|
|
2079
|
+
|
|
2080
|
+
Source: `bezpieczenstwo/klucze-publiczne-do-szyfrowania.md` (05.05.2026).
|
|
2081
|
+
|
|
2082
|
+
`GET /security/public-key-certificates` is **unauthenticated** — the contract declares no
|
|
2083
|
+
global `security` and none on the operation, the same reading that §6a.1 applied to
|
|
2084
|
+
`/testdata/*`. So keys can be fetched before a credential exists, which is what makes the
|
|
2085
|
+
KSeF-token flow possible from a cold start. Its ceiling is **60 req/s**, matching
|
|
2086
|
+
`/auth/challenge` rather than the 10/30/120 default of §6.1. It returns a list of:
|
|
2087
|
+
|
|
2088
|
+
| Field | Meaning |
|
|
2089
|
+
|---|---|
|
|
2090
|
+
| `certificate` | X.509 in **DER, Base64-encoded, without PEM BEGIN/END armour** |
|
|
2091
|
+
| `certificateId` | SHA-256 of the DER certificate, Base64 |
|
|
2092
|
+
| `publicKeyId` | SHA-256 of the DER `SubjectPublicKeyInfo`, Base64 — **the selector sent back to the API** |
|
|
2093
|
+
| `validFrom`, `validTo` | validity window |
|
|
2094
|
+
| `usage` | array; known values `KsefTokenEncryption`, `SymmetricKeyEncryption` |
|
|
2095
|
+
|
|
2096
|
+
Certificates are issued by a qualified CA with `CN = Ministerstwo Finansów`.
|
|
2097
|
+
|
|
2098
|
+
**Selection rule** (documented, so not a judgement call): pick by `usage`, require validity
|
|
2099
|
+
at the moment of use, and where several are valid prefer **the latest `validFrom`**.
|
|
2100
|
+
|
|
2101
|
+
`publicKeyId` must be sent on `POST /auth/ksef-token`, `POST /sessions/online`,
|
|
2102
|
+
`POST /sessions/batch` and `POST /invoices/exports`. It appears in the spec as
|
|
2103
|
+
`EncryptionInfo.publicKeyId`.
|
|
2104
|
+
|
|
2105
|
+
Two distinct rotation modes, which the client must not conflate:
|
|
2106
|
+
|
|
2107
|
+
- **Re-certification** — new certificate, *same* key pair. `publicKeyId` is unchanged.
|
|
2108
|
+
- **Key rotation** — new key pair, so `publicKeyId` changes. Planned rotations publish the
|
|
2109
|
+
new certificate early and both appear for the same `usage` during the overlap; emergency
|
|
2110
|
+
rotations revoke the old one and drop it from the list immediately.
|
|
2111
|
+
|
|
2112
|
+
Because emergency rotation is possible at any time, **the certificate list must not be
|
|
2113
|
+
cached indefinitely**. The documented recovery path is specific: on **HTTP 400 with code
|
|
2114
|
+
`21470`** ("the supplied key identifier is unknown or refers to a withdrawn key"), re-fetch
|
|
2115
|
+
the list, re-select, and repeat the operation. This is the one case where a failed POST
|
|
2116
|
+
should be retried after remediation — and it is remediation, not a blind retry, so it does
|
|
2117
|
+
not conflict with the never-auto-retry-POST rule.
|
|
2118
|
+
|
|
2119
|
+
### 10.3 Decisions this gem made, that upstream does not state
|
|
2120
|
+
|
|
2121
|
+
Recorded here so they are not mistaken for ledgered facts, and so the reasoning survives.
|
|
2122
|
+
Implemented 2026-08-23 in `lib/ksef/crypto/`.
|
|
2123
|
+
|
|
2124
|
+
| Decision | Value | Why |
|
|
2125
|
+
|---|---|---|
|
|
2126
|
+
| Certificate cache TTL | **one hour**, `PublicKeys::DEFAULT_TTL` | §10.2 says "not indefinitely" and nothing more. An hour fetches once per process in practice while bounding how long a withdrawn key can linger; `refresh!` and `with_key_rotation` cover the rest |
|
|
2127
|
+
| `publicKeyId` | **always sent**, though the contract marks it nullable | It names *which* published key did the wrapping. Omitting it turns a key rotation from a `21470` you can remediate into an undecryptable payload with nothing to diagnose |
|
|
2128
|
+
| Unparseable `validFrom`/`validTo` | the certificate becomes **unselectable** | Fail closed. A nil window must not read as "no constraint" — that would wrap a payload under a key KSeF may already have withdrawn |
|
|
2129
|
+
| Unknown `usage` string | raises, rather than selecting nothing | A typo would otherwise present as "the Ministry publishes no such key", sending the reader to look for a rotation that never happened |
|
|
2130
|
+
| `Ksef::CryptoError` | a new branch of the DESIGN.md §6.7 hierarchy | Neither an `ApiError` (the request succeeded; the list has nothing usable) nor a `ConfigurationError` (documented as local and pre-request). The same reasoning that added `AuthorizationError` and `ResourceGoneError` (§7 item 4) |
|
|
2131
|
+
| Wrapping is done **by** the certificate | `Certificate#encrypt` | Keeps the public key and the OAEP parameters in one place, so no caller can wrap with the right key under the wrong padding |
|
|
2132
|
+
|
|
2133
|
+
One more, and it is the one worth stating: `Encryptor#seal` returns the ciphertext **and**
|
|
2134
|
+
the hash-and-size of both forms in a single object. §11.1 requires four integrity values
|
|
2135
|
+
per invoice and hashing the wrong artifact is silent — only the server can detect it — so
|
|
2136
|
+
the two digests are produced together rather than left to a caller to pair up.
|
|
2137
|
+
|
|
2138
|
+
---
|
|
2139
|
+
|
|
2140
|
+
## 11. Online session semantics — resolves the §9 session item
|
|
2141
|
+
|
|
2142
|
+
Source: `sesja-interaktywna.md` (10.07.2025), retrieved 2026-08-22.
|
|
2143
|
+
|
|
2144
|
+
| Fact | Value |
|
|
2145
|
+
|---|---|
|
|
2146
|
+
| Session lifetime | **12 hours** from creation; `validUntil` in the open response |
|
|
2147
|
+
| Invoices per session | many — up to the 10 000 cap of §6.2 |
|
|
2148
|
+
| Concurrent sessions | **permitted**, multiple per authentication |
|
|
2149
|
+
| Open cost | "lightweight and synchronous" |
|
|
2150
|
+
| Expiry behaviour | session closes automatically at `validUntil` |
|
|
2151
|
+
|
|
2152
|
+
Flow: `POST /sessions/online` → `POST /sessions/online/{ref}/invoices` (repeat) →
|
|
2153
|
+
`POST /sessions/online/{ref}/close`. Closing triggers **asynchronous** generation of the
|
|
2154
|
+
collective UPO; it is not available at the moment `close` returns.
|
|
2155
|
+
|
|
2156
|
+
### 11.1 The send-invoice request carries four integrity values
|
|
2157
|
+
|
|
2158
|
+
`POST /sessions/online/{ref}/invoices` requires the hash **and** size of *both* the
|
|
2159
|
+
plaintext and the encrypted document, plus the Base64 ciphertext:
|
|
2160
|
+
|
|
2161
|
+
- `invoiceHash` + size — SHA-256 of the **plaintext** XML
|
|
2162
|
+
- `encryptedInvoiceHash` + size — SHA-256 of the **ciphertext**
|
|
2163
|
+
- `encryptedInvoiceContent` — Base64 of the ciphertext
|
|
2164
|
+
|
|
2165
|
+
Computing either hash over the wrong artifact is an easy and silent mistake; both must be
|
|
2166
|
+
covered by tests.
|
|
2167
|
+
|
|
2168
|
+
Independently confirmed by `faktury/weryfikacja-faktury.md`, which lists "computing and
|
|
2169
|
+
verifying the hash of the invoice together with the file size" and the same for the
|
|
2170
|
+
encrypted invoice among the checks KSeF performs (§15.5). All four values are the
|
|
2171
|
+
service's, not ours to interpret.
|
|
2172
|
+
|
|
2173
|
+
### 11.2 Session open request
|
|
2174
|
+
|
|
2175
|
+
`OpenOnlineSessionRequest` = `formCode` + `encryption` (pinned spec, `components.schemas`).
|
|
2176
|
+
`formCode` is the triple `systemCode` / `schemaVersion` / `value` — for FA(3) that is
|
|
2177
|
+
`FA (3)` / `1-0E` / `FA`, consistent with §8's finding that `KodFormularza` is `FA` and
|
|
2178
|
+
`FA (3)` is the `kodSystemowy`. `encryption` is `EncryptionInfo` =
|
|
2179
|
+
`encryptedSymmetricKey` + `initializationVector` + `publicKeyId`.
|
|
2180
|
+
|
|
2181
|
+
**Send `X-KSeF-Feature: upo-v4-3` with it** — see §14.6. The header is not in the contract,
|
|
2182
|
+
but both reference clients send it, and it selects which UPO format the session will
|
|
2183
|
+
produce. Since this gem pins `upo-v4-3.xsd`, staying silent means accepting whatever the
|
|
2184
|
+
server defaults to, which may not be the version we can validate.
|
|
2185
|
+
|
|
2186
|
+
### 11.2a Decisions this gem made about sessions, that upstream does not state
|
|
2187
|
+
|
|
2188
|
+
The counterpart to §10.3, kept beside the subsystem it governs. Recorded so they are not
|
|
2189
|
+
mistaken for ledgered facts, and so the reasoning survives the person who had it.
|
|
2190
|
+
Implemented 2026-08-23 in `lib/ksef/sessions/`.
|
|
2191
|
+
|
|
2192
|
+
| Decision | Value | Why |
|
|
2193
|
+
|---|---|---|
|
|
2194
|
+
| **The `Encryptor` is bound to the `Session`** | `Session` carries the encryptor that opened it; `#send_invoice` takes no key | The single most consequential one — see below |
|
|
2195
|
+
| Session lifetime in the composite | **a fresh session per `send_invoice`**, with `client.session { }` for batching | Decided by the human, 2026-08-23. Reasoning in DESIGN.md §6.5 |
|
|
2196
|
+
| Bearer fetched per request | `#bearer` is called on each call, not captured at construction | A 12-hour session outlives a "kilkanaście minut" access token, so a long run must pick up {Auth::AccessToken}'s proactive refresh mid-flight |
|
|
2197
|
+
| Reference numbers shape-checked | character-set match before path interpolation | Keeps a garbled or hostile value out of a URL. Deliberately *not* a §13 checksum check: §12 warns the non-KSeF-number forms are unverified against that algorithm |
|
|
2198
|
+
| `X-KSeF-Feature` opt-out | `upo_version: nil` omits the header | The header itself is contract-silent (§14.6), so a caller must be able to decline our guess about it |
|
|
2199
|
+
| Granular layer is stateless | `Sessions::Online` holds no session | Not a decision so much as a copied one: both official clients thread the reference through as a parameter, and it matches `Auth::Client` |
|
|
2200
|
+
|
|
2201
|
+
**Why the encryptor belongs to the session.** The symmetric key is agreed *once*, in the
|
|
2202
|
+
session-open request, and every invoice in that session is encrypted under it. So an invoice
|
|
2203
|
+
encrypted with any other key is undecryptable at the far end — and the only symptom is
|
|
2204
|
+
per-invoice status **435, "błąd odszyfrowania pliku"** (§12.1), which arrives
|
|
2205
|
+
**asynchronously**, long after the send returned `202`. There is no synchronous error and
|
|
2206
|
+
nothing in the response to inspect.
|
|
2207
|
+
|
|
2208
|
+
A `send_invoice(session, xml, encryptor:)` signature would make that mistake a plausible
|
|
2209
|
+
typo. Binding the encryptor to the `Session` at open time makes it unrepresentable instead:
|
|
2210
|
+
there is no way to name the wrong key, because the caller never names one. This is the same
|
|
2211
|
+
move as {Ksef::Crypto::Encryptor#seal} returning both digests together (§10.3) — where a
|
|
2212
|
+
mistake is silent and remote, prefer an API shape that cannot express it over a comment
|
|
2213
|
+
warning against it.
|
|
2214
|
+
|
|
2215
|
+
### 11.3 Accepted schema versions differ by environment
|
|
2216
|
+
|
|
2217
|
+
Source: `srodowiska.md` (16.03.2026). **TEST accepts FA(2) as well as the current schemas;
|
|
2218
|
+
DEMO and PROD reject FA(2).** That is the trap worth remembering: an integration validated
|
|
2219
|
+
solely against TEST can be sending a schema production will refuse.
|
|
2220
|
+
|
|
2221
|
+
**The exact `formCode` triples come from the pinned contract, not from the prose**
|
|
2222
|
+
(corrected 2026-08-23 — `srodowiska.md` spells the PEF codes `FA_PEF(3)` / `FA_KOR_PEF(3)`,
|
|
2223
|
+
which are not the values the API accepts, and omits `FA_RR (1)` altogether).
|
|
2224
|
+
`OpenOnlineSessionRequest.formCode` declares exactly five:
|
|
2225
|
+
|
|
2226
|
+
| `systemCode` | `schemaVersion` | `value` |
|
|
2227
|
+
|---|---|---|
|
|
2228
|
+
| `FA (2)` | `1-0E` | `FA` |
|
|
2229
|
+
| **`FA (3)`** | **`1-0E`** | **`FA`** |
|
|
2230
|
+
| `PEF (3)` | `2-1` | `PEF` |
|
|
2231
|
+
| `PEF_KOR (3)` | `2-1` | `PEF` |
|
|
2232
|
+
| `FA_RR (1)` | `1-1E` | `FA_RR` |
|
|
2233
|
+
|
|
2234
|
+
FA(3) is the row this gem sends, and it is consistent with §8's finding that
|
|
2235
|
+
`KodFormularza` is `FA` while `FA (3)` is the `kodSystemowy` — note the space before the
|
|
2236
|
+
bracket in both. Take these three strings from the table above rather than assembling them,
|
|
2237
|
+
and never from `srodowiska.md`.
|
|
2238
|
+
|
|
2239
|
+
Also from the same document: test environments have a **maintenance window 16:00–18:00**
|
|
2240
|
+
(from 2025-10-01), which the nightly integration workflow should avoid.
|
|
2241
|
+
|
|
2242
|
+
---
|
|
2243
|
+
|
|
2244
|
+
## 12. Session status and UPO
|
|
2245
|
+
|
|
2246
|
+
Source: `faktury/sesje/sesja-sprawdzenie-stanu-i-pobranie-upo.md` (20.04.2026).
|
|
2247
|
+
|
|
2248
|
+
| Operation | Endpoint |
|
|
2249
|
+
|---|---|
|
|
2250
|
+
| List sessions | `GET /sessions` (filter by type/status; `continuationToken`) |
|
|
2251
|
+
| Session status | `GET /sessions/{ref}` |
|
|
2252
|
+
| Session invoices | `GET /sessions/{ref}/invoices` |
|
|
2253
|
+
| One invoice | `GET /sessions/{ref}/invoices/{invoiceRef}` |
|
|
2254
|
+
| Rejected only | `GET /sessions/{ref}/invoices/failed` |
|
|
2255
|
+
| UPO by invoice ref | `GET /sessions/{ref}/invoices/{invoiceRef}/upo` |
|
|
2256
|
+
| UPO by KSeF number | `GET /sessions/{ref}/invoices/ksef/{ksefNumber}/upo` |
|
|
2257
|
+
| Collective session UPO | `GET /sessions/{ref}/upo/{upoRef}` |
|
|
2258
|
+
|
|
2259
|
+
Session status carries `invoiceCount`, `successfulInvoiceCount`, `failedInvoiceCount`, and
|
|
2260
|
+
**after close** a `upo.pages[]` array of `{ referenceNumber, downloadUrl }`.
|
|
2261
|
+
|
|
2262
|
+
- The UPO is **XML, XAdES-signed by the Ministry of Finance**, conforming to the pinned
|
|
2263
|
+
`upo-v4-3.xsd`. It is the legal proof of receipt: archive the bytes **verbatim**.
|
|
2264
|
+
Re-serialising it — even losslessly by XML rules — risks invalidating the signature.
|
|
2265
|
+
- **A collective UPO holds at most 10 000 invoice entries**, which is why `pages[]` is an
|
|
2266
|
+
array. A client that reads only `pages[0]` silently loses proof of receipt for the rest.
|
|
2267
|
+
- Paging throughout this area is `continuationToken`-based, not offset-based.
|
|
2268
|
+
- `downloadUrl` has a path-prefix inconsistency — §14.2.
|
|
2269
|
+
|
|
2270
|
+
### 12.1 Status codes — three separate tables, all from the pinned contract
|
|
2271
|
+
|
|
2272
|
+
Retrieved 2026-08-23 from the contract's `SessionInvoiceStatusResponse.status` and
|
|
2273
|
+
`SessionStatusResponse.status` descriptions. Sourced from the contract deliberately, and not
|
|
2274
|
+
from `InvoiceInSessionStatusCodeResponse.cs`, after §4.8's lesson — and the caution paid:
|
|
2275
|
+
the C# enum lists `400`, `401` and `403` for invoice status, which **the contract does not
|
|
2276
|
+
declare at all**.
|
|
2277
|
+
|
|
2278
|
+
**Per invoice, in a session.** **Both `100` and `150` mean keep polling** — corrected
|
|
2279
|
+
2026-08-23, having first been written here as "`150` is the only one". That came from
|
|
2280
|
+
`OnlineSessionUtils.cs`, whose poller returns as soon as the code is anything but `150`, and
|
|
2281
|
+
it is wrong on the contract's own wording: `100` is *"przyjęta do dalszego przetwarzania"* —
|
|
2282
|
+
accepted for **further** processing — which is by definition not a final state, and an
|
|
2283
|
+
invoice sitting at `100` has no KSeF number yet. A poller that stops there reports a
|
|
2284
|
+
pending invoice as though it were decided.
|
|
2285
|
+
|
|
2286
|
+
So the rule this gem uses is **`code < 200` is in progress**, rather than a list of
|
|
2287
|
+
intermediate codes. That is deliberately the opposite of §4.8's
|
|
2288
|
+
treat-the-unknown-as-terminal rule for authentication, and the asymmetry is justified:
|
|
2289
|
+
authentication polls without a deadline, so an unrecognised code there must not loop for
|
|
2290
|
+
ever, whereas session polling is deadline-bounded, so an unknown intermediate code costs at
|
|
2291
|
+
worst one timeout — and "unresolved" is a more honest answer about an invoice than
|
|
2292
|
+
"prematurely final".
|
|
2293
|
+
|
|
2294
|
+
| Code | Meaning | Notes |
|
|
2295
|
+
|---|---|---|
|
|
2296
|
+
| **100** | **accepted for further processing** | **not terminal — keep polling** |
|
|
2297
|
+
| **150** | **processing** | **not terminal — keep polling** |
|
|
2298
|
+
| 200 | success | terminal, and the invoice has a KSeF number |
|
|
2299
|
+
| 405 | processing cancelled because the session failed | |
|
|
2300
|
+
| 410 | invalid permission scope | |
|
|
2301
|
+
| 415 | cannot send an invoice with an attachment | |
|
|
2302
|
+
| 430 | invoice file verification error | |
|
|
2303
|
+
| **435** | **file decryption error** | what a wrong key or IV produces — see below |
|
|
2304
|
+
| **440** | **duplicate invoice** | carries `originalSessionReferenceNumber` and `originalKsefNumber` |
|
|
2305
|
+
| 450 | invoice semantic verification error | the business-rule tier rejecting it |
|
|
2306
|
+
| 500 | unknown error | |
|
|
2307
|
+
| 550 | cancelled by the system; retry later | retryable |
|
|
2308
|
+
|
|
2309
|
+
Two of those earn their emphasis. **`435` is the code a §14.1 mistake produces** — prefix the
|
|
2310
|
+
IV to the ciphertext as upstream's prose instructs and this is what comes back, which makes
|
|
2311
|
+
it the single most useful code to surface verbatim. And **`440` is the only status carrying
|
|
2312
|
+
extensions**: on a duplicate the API hands back the session and KSeF number of the *original*
|
|
2313
|
+
submission, which is exactly what a caller needs to recover rather than guess. Do not discard
|
|
2314
|
+
those fields.
|
|
2315
|
+
|
|
2316
|
+
**Per session** — and the two session types have **different tables**, which is easy to miss.
|
|
2317
|
+
|
|
2318
|
+
| Code | Interactive | Batch |
|
|
2319
|
+
|---|---|---|
|
|
2320
|
+
| 100 | session open | batch session started |
|
|
2321
|
+
| 150 | — | processing |
|
|
2322
|
+
| **170** | **session closed** | — |
|
|
2323
|
+
| 200 | processed successfully | processed successfully |
|
|
2324
|
+
| 405 | — | package element verification error |
|
|
2325
|
+
| 415 | error decrypting the supplied key | error decrypting the supplied key |
|
|
2326
|
+
| 420 | — | invoice-per-session limit exceeded |
|
|
2327
|
+
| 430 | — | archive decompression error |
|
|
2328
|
+
| 435 | — | error decrypting archive parts |
|
|
2329
|
+
| 440 | cancelled — no invoices sent | cancelled — send window exceeded, or no invoices sent |
|
|
2330
|
+
| 445 | verification error, no valid invoices | verification error, no valid invoices |
|
|
2331
|
+
| 500 | — (batch only) | unknown error |
|
|
2332
|
+
|
|
2333
|
+
Three consequences for the online session layer:
|
|
2334
|
+
|
|
2335
|
+
- **`170` exists only for interactive sessions**, and interactive has no `150`. A poller
|
|
2336
|
+
written against the batch table would wait for a code that never arrives.
|
|
2337
|
+
- **`415` at session level is the RSA-OAEP wrap failing** — the server could not decrypt the
|
|
2338
|
+
symmetric key. Distinct from the per-invoice `435`, which is the AES payload failing. Those
|
|
2339
|
+
two codes localise a crypto fault to either the key or the payload, which is worth
|
|
2340
|
+
surfacing rather than collapsing.
|
|
2341
|
+
- **`440` "no invoices sent" means an opened-and-unused session is cancelled**, so opening
|
|
2342
|
+
one speculatively is not free.
|
|
2343
|
+
|
|
2344
|
+
### 12.2 The reference clients' polling defaults, and why ours differ
|
|
2345
|
+
|
|
2346
|
+
Both clients poll on a **fixed 1-second interval up to 60 attempts** — a 60-second ceiling —
|
|
2347
|
+
and treat `150` as the sole continue condition (`OnlineSessionUtils.cs`:
|
|
2348
|
+
`DefaultSleepTimeMs = 1000`, `DefaultMaxAttempts = 60`, `ProcessingStatusCode = 150`).
|
|
2349
|
+
|
|
2350
|
+
DESIGN.md §6.5 specifies capped exponential backoff instead — 1s, 2s, 4s … 30s, with a
|
|
2351
|
+
five-minute default deadline. Keep ours, for two reasons the reference clients do not have to
|
|
2352
|
+
care about: a library shared across many callers should not spend a per-hour rate budget at a
|
|
2353
|
+
fixed 1/s (§6.1 caps `GET /sessions/{ref}` at 1200/h, which 60 polls per invoice would eat
|
|
2354
|
+
quickly), and a 60-second ceiling is too short for a large session. Their *terminal condition* needed
|
|
2355
|
+
correcting too — see §12.1: **`code < 200` is in progress**, because `100` means "accepted for
|
|
2356
|
+
further processing" and a poller stopping there reports a pending invoice as settled. This
|
|
2357
|
+
sentence said "poll while the code is `150`" until 2026-08-23, the fourth copy of that claim
|
|
2358
|
+
to survive a correction pass.
|
|
2359
|
+
|
|
2360
|
+
### 12.3 Decisions this gem made about UPO retrieval, that upstream does not state
|
|
2361
|
+
|
|
2362
|
+
The third of these tables, after §10.3 (crypto) and §11.2a (sessions). Implemented
|
|
2363
|
+
2026-08-23 in `lib/ksef/upo/`.
|
|
2364
|
+
|
|
2365
|
+
| Decision | Value | Why |
|
|
2366
|
+
|---|---|---|
|
|
2367
|
+
| **A separate, credential-free connection** | `HTTP::Connection.storage` — no bearer, no `base_url` | §14.2 forbids sending the access token to a `downloadUrl`. A rule nobody can break beats a rule everybody must remember — see below |
|
|
2368
|
+
| **No parsed form on `Document`** | holds the received `String`; no `#to_xml`, no re-encode, `#write` uses `binwrite` | The Ministry's XAdES signature covers **octets**. A lossless XML round-trip can still yield a document that no longer verifies, so the parsed form is simply not offered |
|
|
2369
|
+
| `Ksef::IntegrityError` | a new branch of DESIGN.md §6.7 | A hash mismatch means *fetch it again* — nothing is wrong with the request, the credentials or the document. Not a `ValidationError` (the caller's data is fine) nor an `ApiError` (the response was a success) |
|
|
2370
|
+
| `#verifiable?` separate from `#verified?` | two booleans, not one tri-state | "Nothing to check" and "the check failed" need different responses. **Corrected 2026-08-23:** this row used to justify the split by claiming the metered route publishes no hash. It does — §5.5 records the header on four `200` responses, all three UPO routes and the invoice download — and the client was discarding it. Both paths now verify; the split still earns its place for a response that genuinely omits the header |
|
|
2371
|
+
| `#fetch` prefers the link | falls back to the metered route on expiry or absence | §14.2's resolution, in one call: the link is unmetered and hash-verified, and `GET /sessions` already allows only 10/min |
|
|
2372
|
+
| **`#fetch` judges the expiry against an injected clock** | `UPO::Client.new(clock:)`, threaded from `Ksef::Client` | It read `Time.now` until 2026-09-03, which made it the only route decision here the recorded tier's pinned clock could not reach. The link lives three days (§14.2), so a cassette replayed correctly for three days and then began requesting a fallback URL nothing had recorded. Same class as reading a token's expiry off the wall clock, one layer down |
|
|
2373
|
+
| A relative `downloadUrl` is resolved | against the API host; an absolute one used untouched | §9 still carries which form the live API sends as unverified, so both are handled rather than one guessed at |
|
|
2374
|
+
| KSeF numbers parsed before use | `for_ksef_number` runs §13's CRC-8 first | A mistyped number fails locally instead of as an opaque 404 |
|
|
2375
|
+
| `Ksef::Client#upo` uses the **metered** route | against this section's own preference for the link | Deliberate, and only for a single invoice: obtaining the unmetered link costs a metered status call first, so the direct route is one request against two. `#collective_upo` and `UPO::Client#fetch` prefer the link, where it pays |
|
|
2376
|
+
|
|
2377
|
+
**Why the connection split is the mechanism and not just tidiness.** A `downloadUrl` is a
|
|
2378
|
+
pre-signed Azure Blob URI that carries its own authorisation in the query string. Sending
|
|
2379
|
+
the KSeF access token to it would hand a live credential to third-party storage, and the
|
|
2380
|
+
contract says so explicitly — *"nie należy wysyłać tokenu dostępowego"*. That is a rule
|
|
2381
|
+
about something absent, and absences are exactly what code review misses: nobody notices
|
|
2382
|
+
the header that was not removed. Putting those requests on a connection that has no bearer
|
|
2383
|
+
to attach turns "we remembered" into "we cannot", which is the same reasoning as binding the
|
|
2384
|
+
encryptor to the session (§11.2a) and having `Encryptor#seal` produce both digests at once
|
|
2385
|
+
(§10.3).
|
|
2386
|
+
|
|
2387
|
+
Reference numbers share a shape: `YYYYMMDD-XX-<hex>-<hex>-CC`, where `XX` is a kind tag
|
|
2388
|
+
(`CR` challenge, `SB` batch session, `EU` UPO) and `CC` looks like the same CRC-8 checksum
|
|
2389
|
+
as §13. Only the KSeF-number form is documented; **do not validate the others against §13's
|
|
2390
|
+
algorithm** without verifying it first.
|
|
2391
|
+
|
|
2392
|
+
---
|
|
2393
|
+
|
|
2394
|
+
## 13. KSeF number structure — verified with a working example
|
|
2395
|
+
|
|
2396
|
+
Source: `faktury/numer-ksef.md` for the structure, **corrected against the pinned contract
|
|
2397
|
+
2026-08-23** for the length — see §13.1. The 2.0 format, 35 characters:
|
|
2398
|
+
|
|
2399
|
+
```
|
|
2400
|
+
9999999999-RRRRMMDD-FFFFFFFFFFFF-FF
|
|
2401
|
+
```
|
|
2402
|
+
|
|
2403
|
+
Seller NIP (10) · `-` · acceptance date `YYYYMMDD` (8) · `-` · technical part (12 uppercase
|
|
2404
|
+
hex) · `-` · CRC-8 checksum (2 uppercase hex).
|
|
2405
|
+
|
|
2406
|
+
CRC-8 parameters: **polynomial `0x07`, initial value `0x00`**, no reflection, no final XOR,
|
|
2407
|
+
computed over the **first 32 characters** (everything before the final hyphen), rendered as
|
|
2408
|
+
two uppercase hex digits.
|
|
2409
|
+
|
|
2410
|
+
Verified locally 2026-08-22 against the documented example — CRC-8 of
|
|
2411
|
+
`5265877635-20250826-0100001AF629` is `0xAF`, matching the published
|
|
2412
|
+
`5265877635-20250826-0100001AF629-AF`. This doubles as the golden vector for the
|
|
2413
|
+
implementation.
|
|
2414
|
+
|
|
2415
|
+
### 13.1 A 36-character legacy form exists, and the prose does not mention it
|
|
2416
|
+
|
|
2417
|
+
Found 2026-08-23, by a documentation review checking the code against the contract rather
|
|
2418
|
+
than against this section. **`components.schemas.KsefNumber` declares
|
|
2419
|
+
`minLength: 35, maxLength: 36`**, with the pattern
|
|
2420
|
+
|
|
2421
|
+
```
|
|
2422
|
+
…-([0-9A-F]{6})-?([0-9A-F]{6})-([0-9A-F]{2})$
|
|
2423
|
+
```
|
|
2424
|
+
|
|
2425
|
+
— an **optional hyphen splitting the technical part 6-6** — and says outright: *"Numer KSeF
|
|
2426
|
+
o długości 36 znaków jest akceptowany, by zachować kompatybilność wsteczną z KSeF 1.0. W
|
|
2427
|
+
KSeF 2.0 numery są generowane wyłącznie w formacie 35-znakowym."*
|
|
2428
|
+
|
|
2429
|
+
So both forms are accepted on input; only the 35-character form is ever *generated*. The
|
|
2430
|
+
prose this section was built from says "always exactly 35", and the contract outranks it
|
|
2431
|
+
(§0 rule 2). This was a real defect, not just drift: `KsefNumber.parse` enforced 35, and
|
|
2432
|
+
both `Invoices::Client#download` and `UPO::Client#for_ksef_number` parse before use — so a
|
|
2433
|
+
KSeF 1.0-era number could not be looked up at all, failing locally where the API would have
|
|
2434
|
+
answered 200.
|
|
2435
|
+
|
|
2436
|
+
**The CRC input rule for the 36-character form is unverified.** §13's rule is "the first 32
|
|
2437
|
+
characters, everything before the final hyphen", which is specific to the 35-character
|
|
2438
|
+
layout; for the longer form that span is 33 characters and includes an extra hyphen, and
|
|
2439
|
+
nothing upstream says whether the checksum covers the hyphenated or the de-hyphenated text.
|
|
2440
|
+
Two consequences, both implemented:
|
|
2441
|
+
|
|
2442
|
+
- `KsefNumber.parse` accepts both forms, so a lookup is possible either way.
|
|
2443
|
+
- It **verifies the checksum only on the 35-character form**, where the rule is known, and
|
|
2444
|
+
reports `#checksum_verified?` so a caller can tell a checked number from an accepted one.
|
|
2445
|
+
Guessing the legacy rule and rejecting on a mismatch would turn an unverified assumption
|
|
2446
|
+
into a hard failure on numbers the API accepts.
|
|
2447
|
+
|
|
2448
|
+
The pattern also carries facts §13 never recorded: the NIP part is *structural*
|
|
2449
|
+
(`[1-9](\d[1-9]|[1-9]\d)\d{7}` — first digit non-zero, positions 2–3 not both zero) and
|
|
2450
|
+
the date part constrains year, month and day ranges. Both are stricter than this gem's
|
|
2451
|
+
`\d{10}` and `\d{8}`, though `Date.strptime` already rejects impossible dates.
|
|
2452
|
+
|
|
2453
|
+
**Fourth time prose has lost to the contract in this project** — after the `480` auth status
|
|
2454
|
+
code (§4.8), the `formCode` table (§11.3) and the `100`/`150` polling rule (§12.1). The
|
|
2455
|
+
standing lesson, now stated once here rather than rediscovered a fifth time: **when a fact
|
|
2456
|
+
comes from upstream prose, check the OpenAPI schema for the same field before ledgering it.**
|
|
2457
|
+
The prose is a summary; the contract is the interface.
|
|
2458
|
+
|
|
2459
|
+
One business fact worth surfacing in the API: per `limity/limity-api.md`, **the invoice's
|
|
2460
|
+
official receipt date is the date its KSeF number was assigned**, not the date the client
|
|
2461
|
+
downloaded it.
|
|
2462
|
+
|
|
2463
|
+
---
|
|
2464
|
+
|
|
2465
|
+
## 14. Contradictions within upstream's own sources
|
|
2466
|
+
|
|
2467
|
+
Distinct from §7, which records divergences from *our* design document. These are places
|
|
2468
|
+
where upstream's prose, its OpenAPI contract and its reference clients disagree. Precedence
|
|
2469
|
+
per DESIGN.md §2: the pinned artifacts win.
|
|
2470
|
+
|
|
2471
|
+
### 14.1 The IV is *not* prefixed to the ciphertext
|
|
2472
|
+
|
|
2473
|
+
`sesja-interaktywna.md` describes the 128-bit IV as "dołączanego jako prefiks do
|
|
2474
|
+
szyfrogramu" — appended as a prefix to the ciphertext. **This is wrong for the online
|
|
2475
|
+
session invoice path**, and following the prose produces a payload KSeF cannot decrypt.
|
|
2476
|
+
|
|
2477
|
+
**`sesja-wsadowa.md` says it too, word for word** (pinned 2026-08-26, §16.3). So this is not
|
|
2478
|
+
one document's slip but a sentence copied across the session documentation, which makes the
|
|
2479
|
+
resolution below more valuable rather than less: it now covers both session types. Batch and
|
|
2480
|
+
online share a single `EncryptionInfo` schema whose `initializationVector` is a **required
|
|
2481
|
+
discrete field**, so the contract argument applies to batch unchanged — but the byte-count
|
|
2482
|
+
measurement in witness four is an interactive example, and batch has no measured equivalent
|
|
2483
|
+
until it is built.
|
|
2484
|
+
|
|
2485
|
+
Four sources agree against it:
|
|
2486
|
+
|
|
2487
|
+
1. **The pinned OpenAPI spec** — `EncryptionInfo` carries `initializationVector` as a
|
|
2488
|
+
discrete field of the session-open request, alongside `encryptedSymmetricKey` and
|
|
2489
|
+
`publicKeyId`. Highest precedence. (2026-08-22)
|
|
2490
|
+
2. **C#** — `EncryptBytesWithAES256` returns the encryptor's output directly, with no
|
|
2491
|
+
prepended bytes (`CryptographyService.cs:149–161`). (2026-08-22)
|
|
2492
|
+
3. **Java** — `cipher.doFinal(content)`, likewise bare
|
|
2493
|
+
(`DefaultCryptographyService.java`). (2026-08-22)
|
|
2494
|
+
4. **The contract's own worked example, arithmetically** (found 2026-08-23). The
|
|
2495
|
+
`POST /sessions/online/{ref}/invoices` example pairs `invoiceSize: 6480` with
|
|
2496
|
+
`encryptedInvoiceSize: 6496`. 6480 is 405 whole AES blocks, so PKCS#7 adds **exactly one
|
|
2497
|
+
block** of padding and lands on 6496. A prefixed 16-byte IV would have made it 6512.
|
|
2498
|
+
This is the sharpest of the four: it is a number upstream published about its own
|
|
2499
|
+
encryption, and it leaves no room for the IV. Asserted in
|
|
2500
|
+
`spec/openapi_contract_spec.rb`, and reproduced by `Encryptor` in its own spec.
|
|
2501
|
+
|
|
2502
|
+
**Resolution: send the IV once in the session-open request; the per-invoice ciphertext is
|
|
2503
|
+
bare.** The prose's "prefix" phrasing may describe the separate ECDH/AES-GCM path, which
|
|
2504
|
+
*does* concatenate — `subjectPublicKeyInfo || nonce || tag || ciphertext`
|
|
2505
|
+
(`CryptographyService.cs:446`) — but that path is not used for online-session invoices.
|
|
2506
|
+
|
|
2507
|
+
### 14.2 `downloadUrl` is a pre-signed link, not a path to join
|
|
2508
|
+
|
|
2509
|
+
**Corrected 2026-08-22.** An earlier revision of this section said the field carried a
|
|
2510
|
+
stray `/api/v2` prefix and concluded "treat `downloadUrl` as advisory and construct the
|
|
2511
|
+
path yourself". That conclusion was drawn from the *prose example* without checking the
|
|
2512
|
+
OpenAPI contract, which is the higher-precedence artifact and describes the field
|
|
2513
|
+
explicitly. The conclusion was wrong, and following it would have discarded something
|
|
2514
|
+
worth having.
|
|
2515
|
+
|
|
2516
|
+
`UpoPageResponse.downloadUrl` is `format: uri`, and the contract's own description says:
|
|
2517
|
+
|
|
2518
|
+
- the link is **generated on every status query**, so it is not a stable identifier;
|
|
2519
|
+
- access is by `HTTP GET` and the access token **must not be sent** ("*nie należy* wysyłać
|
|
2520
|
+
tokenu dostępowego");
|
|
2521
|
+
- it is **not subject to API rate limits**, and expires at `downloadUrlExpirationDate`;
|
|
2522
|
+
- the response carries **`x-ms-meta-hash`** — the SHA-256 of the UPO document, Base64.
|
|
2523
|
+
|
|
2524
|
+
The `x-ms-meta-hash` header is Azure Blob Storage's, so this is a pre-signed storage URL
|
|
2525
|
+
rather than an API route. That also explains the prefix that prompted the original
|
|
2526
|
+
misreading: the field is not meant to be concatenated with the base URL at all, so whether
|
|
2527
|
+
the documented example looks host-relative is beside the point.
|
|
2528
|
+
|
|
2529
|
+
Both reference clients implement it that way. `ksef-client-csharp` exposes two distinct
|
|
2530
|
+
paths — `GetSessionUpoAsync(sessionRef, upoRef, accessToken)` for the metered API route,
|
|
2531
|
+
and `GetUpoAsync(Uri)` / `GetUpoWithHashAsync(restClient, uri)` for the link, the latter
|
|
2532
|
+
passing **`token: null`** explicitly.
|
|
2533
|
+
|
|
2534
|
+
**Resolution: follow `downloadUrl` as an opaque absolute URI, without the bearer token,
|
|
2535
|
+
and verify `x-ms-meta-hash` against the bytes received.** Given how tight the session
|
|
2536
|
+
budgets are (§6.1 — `GET /sessions` allows 10/min), an unmetered path with a built-in
|
|
2537
|
+
integrity check is the better default; `GET /sessions/{ref}/upo/{upoRef}` remains the
|
|
2538
|
+
fallback when the link has expired.
|
|
2539
|
+
|
|
2540
|
+
Two consequences for the client design. The URL must never be logged or persisted as a
|
|
2541
|
+
durable reference — it expires, and it is credential-bearing. And the token-suppression is
|
|
2542
|
+
not optional politeness: sending a bearer token to third-party storage leaks it.
|
|
2543
|
+
|
|
2544
|
+
**How long it lives: exactly three days.** Measured 2026-09-03 from the committed cassettes —
|
|
2545
|
+
five links across three cassettes and two separate recording sessions, on both
|
|
2546
|
+
`downloadUrlExpirationDate` (session pages) and `upoDownloadUrlExpirationDate` (invoice status),
|
|
2547
|
+
each `recorded_at` + 72 h to within two seconds. The contract states the field but no duration,
|
|
2548
|
+
so this is observed rather than documented, and it is the kind of value that should be read from
|
|
2549
|
+
the response rather than assumed — `UpoPage#expires_at` does exactly that.
|
|
2550
|
+
|
|
2551
|
+
It is recorded because the *magnitude* matters to anything replaying a recorded response: three
|
|
2552
|
+
days is long enough for a cassette recorded and verified today to keep replaying correctly for
|
|
2553
|
+
days and then diverge, which is precisely what happened between 2026-08-26 and 2026-08-29
|
|
2554
|
+
(DESIGN.md §9.1). A credential that expires in fifteen minutes announces itself; one that expires
|
|
2555
|
+
in three days does not.
|
|
2556
|
+
|
|
2557
|
+
**Unverified:** whether the live API returns this field absolute or host-relative.
|
|
2558
|
+
`srodowiska.md` says only that a returned URL's *host* matches the environment called. Code
|
|
2559
|
+
should therefore resolve it against the environment's base host if it arrives relative, and
|
|
2560
|
+
use it as-is if absolute.
|
|
2561
|
+
|
|
2562
|
+
### 14.3 Every upstream UPO example fails upstream's own UPO schema
|
|
2563
|
+
|
|
2564
|
+
Measured locally 2026-08-22, with both artifacts pinned at `1c34fe27`: all **six** worked
|
|
2565
|
+
examples under `faktury/upo/przyklady/v4-3/` fail XSD validation against
|
|
2566
|
+
`upo-v4-3.xsd` — each with exactly one error, the same one.
|
|
2567
|
+
|
|
2568
|
+
`upo-v4-3.xsd:13` constrains the receiving party's name to a fixed value:
|
|
2569
|
+
|
|
2570
|
+
```xml
|
|
2571
|
+
<xsd:element name="NazwaPodmiotuPrzyjmujacego" fixed="Ministerstwo Finansów">
|
|
2572
|
+
```
|
|
2573
|
+
|
|
2574
|
+
while every example — all captured on TEST — carries:
|
|
2575
|
+
|
|
2576
|
+
```xml
|
|
2577
|
+
<NazwaPodmiotuPrzyjmujacego>Ministerstwo Finansów - środowisko testowe (TE)</NazwaPodmiotuPrzyjmujacego>
|
|
2578
|
+
```
|
|
2579
|
+
|
|
2580
|
+
Verified that this is the *only* discrepancy: with the `fixed` attribute removed, all six
|
|
2581
|
+
validate clean. So the schema is otherwise accurate, and the examples are otherwise
|
|
2582
|
+
well-formed UPOs.
|
|
2583
|
+
|
|
2584
|
+
**Consequence, and it is a real one:** the `fixed` value evidently describes PROD, while
|
|
2585
|
+
the non-production environments append an environment marker. A client that strictly
|
|
2586
|
+
XSD-validates a received UPO **will reject every UPO issued by TEST and, presumably,
|
|
2587
|
+
DEMO.** Given this gem already does offline XSD validation for FA(3), that is a trap it
|
|
2588
|
+
would otherwise have walked straight into.
|
|
2589
|
+
|
|
2590
|
+
**Resolution: do not hard-fail UPO validation on this element.** Either validate with the
|
|
2591
|
+
constraint relaxed, or treat a `NazwaPodmiotuPrzyjmujacego` mismatch as a warning carrying
|
|
2592
|
+
the observed value. Whichever is chosen, the UPO bytes are still archived verbatim (§12) —
|
|
2593
|
+
validation is a diagnostic here, never a gate on storing legal proof of receipt.
|
|
2594
|
+
|
|
2595
|
+
The pinned examples double as regression fixtures for exactly this behaviour: a correct
|
|
2596
|
+
implementation accepts all six.
|
|
2597
|
+
|
|
2598
|
+
Not treated as a §9 open item: the facts are measured and unambiguous. What is *not*
|
|
2599
|
+
verified is whether DEMO uses a third spelling and whether PROD matches the `fixed` value
|
|
2600
|
+
exactly — neither can be checked without access to those environments, and neither changes
|
|
2601
|
+
the resolution above.
|
|
2602
|
+
|
|
2603
|
+
### 14.4 Both auth schemas carry broken regular expressions
|
|
2604
|
+
|
|
2605
|
+
Measured 2026-08-22 against the pinned `schemat_auth_v2-{0,1}.xsd`. The root cause is the
|
|
2606
|
+
same in both: **XML Schema regular expressions are implicitly anchored, and `^` / `$` are
|
|
2607
|
+
*literal characters*, not anchors** (XSD Part 2, Appendix F — the metacharacters are
|
|
2608
|
+
`. \ ? * + { } ( ) [ ] |`). Patterns written as if they were Perl regexes therefore mean
|
|
2609
|
+
something quite different from what their author intended.
|
|
2610
|
+
|
|
2611
|
+
#### v2.0 does not compile at all
|
|
2612
|
+
|
|
2613
|
+
Its three IP patterns use `\b`, which XSD regex has no concept of. libxml2 rejects the
|
|
2614
|
+
whole file rather than just those facets:
|
|
2615
|
+
|
|
2616
|
+
```
|
|
2617
|
+
FATAL: failed to compile: Wrong escape sequence, misuse of character '\'
|
|
2618
|
+
ERROR: Element 'pattern': The value '^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$'
|
|
2619
|
+
of the facet 'pattern' is not a valid regular expression.
|
|
2620
|
+
```
|
|
2621
|
+
|
|
2622
|
+
So `schemat_auth_v2-0.xsd` cannot be used to validate anything — you get a schema
|
|
2623
|
+
compilation failure, not a validation result. (The same patterns also make dots optional
|
|
2624
|
+
via `\.?`, so even parsed loosely they would match `1111`.) **Resolution: validate against
|
|
2625
|
+
v2.1 only.** v2.1 rewrote all three patterns correctly.
|
|
2626
|
+
|
|
2627
|
+
#### Neither reference client validates locally, and both send 2.0
|
|
2628
|
+
|
|
2629
|
+
Checked 2026-08-22, and this is what settles how to respond to the defects below.
|
|
2630
|
+
|
|
2631
|
+
- **C#** serialises via `XmlSerializer` in `AuthenticationTokenRequestSerializer` with no
|
|
2632
|
+
schema attached, and writes the context value straight through
|
|
2633
|
+
(`writer.WriteString(Value)`).
|
|
2634
|
+
- **Java** marshals via JAXB without ever calling `marshaller.setSchema(...)`.
|
|
2635
|
+
|
|
2636
|
+
So the bundled XSD is a **codegen input, not a runtime check** in both clients, and the
|
|
2637
|
+
broken patterns below never fire for them: they emit the natural identifier and let the
|
|
2638
|
+
server decide. Notably the Java client ships its *own* edited copy of the 2.0 schema
|
|
2639
|
+
(`ksef-client/src/main/resources/xsd/AuthTokenRequest.xsd`) in which the IP patterns are
|
|
2640
|
+
repaired — just loosely, `([0-9]{1,3}\.){3}[0-9]{1,3}` would admit `999.999.999.999` —
|
|
2641
|
+
**but `TNipVatUE` and `TPeppolId` are left broken**, which is independent confirmation that
|
|
2642
|
+
those two are genuinely defective rather than misread.
|
|
2643
|
+
|
|
2644
|
+
This gem does the same thing for those two types (emit the real value, treat local
|
|
2645
|
+
validation as advisory) and additionally sends the 2.0 namespace both clients use.
|
|
2646
|
+
|
|
2647
|
+
#### Two of v2.1's four context identifiers cannot hold their real values
|
|
2648
|
+
|
|
2649
|
+
Because `^` and `$` are literal, `TPeppolId`'s pattern `^P[A-Z]{2}[0-9]{6}$` matches only
|
|
2650
|
+
a value that literally begins with `^` and ends with `$`; and `TNipVatUE`'s pattern ends
|
|
2651
|
+
with a stray `$`, so it demands a trailing dollar sign. Measured:
|
|
2652
|
+
|
|
2653
|
+
| Element | Value | Against v2.1 |
|
|
2654
|
+
|---|---|---|
|
|
2655
|
+
| `Nip` | `5265877635` | valid |
|
|
2656
|
+
| `InternalId` | `5265877635-12345` | valid |
|
|
2657
|
+
| `NipVatUe` | `5265877635-ATU12345678` — **upstream's own documented example** | **invalid** |
|
|
2658
|
+
| `NipVatUe` | `5265877635-ATU12345678$` | valid |
|
|
2659
|
+
| `PeppolId` | `PPL123456` | **invalid** |
|
|
2660
|
+
| `PeppolId` | `^PPL123456$` | valid |
|
|
2661
|
+
|
|
2662
|
+
That upstream's own example value for `NipVatUe` fails the schema that defines it is the
|
|
2663
|
+
clearest evidence this is an upstream defect and not a misreading.
|
|
2664
|
+
|
|
2665
|
+
**Resolution: emit the natural value and treat offline validation of those two context
|
|
2666
|
+
types as advisory.** Emitting `^PPL123456$` to satisfy a broken facet would be absurd and
|
|
2667
|
+
would certainly be rejected server-side, where the real identifier is what gets looked up.
|
|
2668
|
+
Nothing is lost in practice: a KSeF token can only be issued in a `Nip` or `InternalId`
|
|
2669
|
+
context anyway (§4.1), and both of those validate cleanly.
|
|
2670
|
+
|
|
2671
|
+
Whether the API enforces this XSD server-side is **unverified** and needs a live TEST call
|
|
2672
|
+
to settle. If it does, `NipVatUe` and `PeppolId` authentication are simply unusable until
|
|
2673
|
+
upstream fixes the patterns — which would be their bug to fix, not something a client can
|
|
2674
|
+
work around.
|
|
2675
|
+
|
|
2676
|
+
### 14.5 A signed `AuthTokenRequest` can never be schema-valid
|
|
2677
|
+
|
|
2678
|
+
Measured 2026-08-22. The API **requires** an enveloped XAdES signature on this document
|
|
2679
|
+
(§4.3: detached is rejected). The schema that defines the document declares a closed
|
|
2680
|
+
sequence — `Challenge`, `ContextIdentifier`, `SubjectIdentifierType`, optional
|
|
2681
|
+
`AuthorizationPolicy` — with **no `xsd:any`**. So the signature the API demands is, to the
|
|
2682
|
+
schema, an unexpected element:
|
|
2683
|
+
|
|
2684
|
+
```
|
|
2685
|
+
ERROR: Element '{http://www.w3.org/2000/09/xmldsig#}Signature': This element is not
|
|
2686
|
+
expected. Expected is ( {http://ksef.mf.gov.pl/auth/token/2.0}AuthorizationPolicy ).
|
|
2687
|
+
```
|
|
2688
|
+
|
|
2689
|
+
The same document validates cleanly before signing. This is not a defect a client can work
|
|
2690
|
+
around — both requirements come from upstream and they contradict each other.
|
|
2691
|
+
|
|
2692
|
+
**Resolution: validate before signing, never after.** `Signer#sign` does this by default,
|
|
2693
|
+
which also means a malformed document is caught before a signature is spent on it. Anyone
|
|
2694
|
+
reaching for "validate the thing we are about to send" will find it always fails; that is
|
|
2695
|
+
this, not a bug in the document.
|
|
2696
|
+
|
|
2697
|
+
Consistent with §14.4's finding that neither reference client validates locally at all —
|
|
2698
|
+
had they tried, they would have hit this immediately.
|
|
2699
|
+
|
|
2700
|
+
### 14.6 A session-open header both clients send and the contract never mentions
|
|
2701
|
+
|
|
2702
|
+
Found 2026-08-23, by reading the reference clients before designing the session layer.
|
|
2703
|
+
|
|
2704
|
+
`X-KSeF-Feature: upo-v4-3` is sent on `POST /sessions/online` (and on `POST /sessions/batch`)
|
|
2705
|
+
by **both** official clients. It selects the format of the UPO the session will eventually
|
|
2706
|
+
produce.
|
|
2707
|
+
|
|
2708
|
+
| Source | Evidence |
|
|
2709
|
+
|---|---|
|
|
2710
|
+
| `ksef-client-java` | `DefaultKsefClient.openOnlineSession` sets `headers.put(X_KSEF_FEATURE, upoVersion.value())`; the `UpoVersion` enum holds `upo-v4-2` and `upo-v4-3`, and `fromValue` falls back to `upo-v4-3` |
|
|
2711
|
+
| `ksef-client-csharp` | `OpenOnlineSessionAsync(..., string upoVersion = null)` adds `{ "X-KSeF-Feature", upoVersion }`; `IOnlineSessionClient` documents it as "Opcjonalna wersja formatu UPO. Dostępne wartości: `upo-v4-3`" |
|
|
2712
|
+
| **Pinned OpenAPI contract** | **`X-KSeF-Feature` does not appear anywhere in the document.** `POST /sessions/online` declares no `parameters` at all, and the string `upo-v4-` occurs zero times |
|
|
2713
|
+
|
|
2714
|
+
This is the inverse of §14.4's situation: not upstream contradicting itself, but the contract
|
|
2715
|
+
being *silent* about something both of its own reference implementations consider necessary.
|
|
2716
|
+
Measured, not inferred — grep counts of zero in the pinned spec.
|
|
2717
|
+
|
|
2718
|
+
**Resolution: send `X-KSeF-Feature: upo-v4-3` explicitly on session open.** Three reasons.
|
|
2719
|
+
This gem pins `upo-v4-3.xsd` and nothing else, so a session producing 4.2 would yield a
|
|
2720
|
+
document we cannot validate. Java's enum proves the server still understands `upo-v4-2`,
|
|
2721
|
+
so a default exists and is not ours to guess. And §14.3 already establishes that UPO
|
|
2722
|
+
validation is delicate enough — TEST's own examples fail upstream's own schema — without
|
|
2723
|
+
adding version drift to it.
|
|
2724
|
+
|
|
2725
|
+
Being contract-silent, this could not be checked offline, only observed — and it now has
|
|
2726
|
+
been. **Verified against live TEST on 2026-08-24** (nightly run `32692339217`): a session
|
|
2727
|
+
opened with the header returned a UPO in the `upo-v4-3` namespace, which is the one whose
|
|
2728
|
+
schema this gem bundles. The header does what both reference clients assume, and this section
|
|
2729
|
+
is no longer the least certain fact in it.
|
|
2730
|
+
|
|
2731
|
+
### 14.7 A real UPO fails upstream's UPO schema on its own signature
|
|
2732
|
+
|
|
2733
|
+
Found on the **first live nightly**, 2026-08-24 (run `32692339217`), and invisible to every
|
|
2734
|
+
offline test before it.
|
|
2735
|
+
|
|
2736
|
+
A UPO is *"an XML document XAdES-signed by the Ministry of Finance"* (§12) — the signature is
|
|
2737
|
+
what makes it proof of receipt. `upo-v4-3.xsd` **declares no `ds:Signature` element anywhere**,
|
|
2738
|
+
so a genuine UPO validated against it reports:
|
|
2739
|
+
|
|
2740
|
+
```
|
|
2741
|
+
Element '{http://www.w3.org/2000/09/xmldsig#}Signature': This element is not expected.
|
|
2742
|
+
```
|
|
2743
|
+
|
|
2744
|
+
Every UPO KSeF has ever issued fails upstream's own schema, for the same reason §14.3's six
|
|
2745
|
+
examples do — except this one is worse, because it applies to the real artifact rather than to
|
|
2746
|
+
the published samples.
|
|
2747
|
+
|
|
2748
|
+
**And the samples are exactly why nobody noticed.** All six pinned worked examples are
|
|
2749
|
+
**unsigned** — measured: zero `Signature` elements between them. Upstream publishes the
|
|
2750
|
+
business content without the envelope, so the offline corpus cannot exhibit the defect that
|
|
2751
|
+
every live document has. `spec/ksef/upo/validator_spec.rb` now signs one of them to close that
|
|
2752
|
+
gap, so the next occurrence is caught without a live run.
|
|
2753
|
+
|
|
2754
|
+
**Resolution: `UPO::Validator` removes the enveloped signature from a *copy* before validating.**
|
|
2755
|
+
Not leniency — an enveloped signature wraps the business document, and the schema describes the
|
|
2756
|
+
business document, so setting it aside is what makes the remaining errors mean anything. §14.3's
|
|
2757
|
+
environment-marker warning and every other violation still surface unchanged, and the caller's
|
|
2758
|
+
bytes are never touched, which §12 requires. `UPO::Validator.signed?` exposes whether the
|
|
2759
|
+
signature is there, since that distinguishes a real UPO from a published example.
|
|
2760
|
+
|
|
2761
|
+
**What this does *not* do is verify the signature.** Checking the Ministry's XAdES would need
|
|
2762
|
+
its certificate chain and the W3C/ETSI schemas, which §1.2 keeps out of `lib/` on licensing
|
|
2763
|
+
grounds. Validation here stays a diagnostic on content, exactly as the rest of §14.3 says.
|
|
2764
|
+
|
|
2765
|
+
---
|
|
2766
|
+
|
|
2767
|
+
## 15. Invoice verification — what KSeF checks on submission
|
|
2768
|
+
|
|
2769
|
+
Source: `faktury/weryfikacja-faktury.md` (dated 09.04.2026), pinned 2026-08-24 at the same
|
|
2770
|
+
`ksef-api` commit `1c34fe27` as everything else in §1.3.
|
|
2771
|
+
|
|
2772
|
+
**Read this first: it is not the document §9 said it was.** §9 named this file as the place
|
|
2773
|
+
to look for the **tier-3 business-rule catalogue** (DESIGN.md §7.7 — line sums against rate
|
|
2774
|
+
summaries against `P_15`, correction references for `KOR`). It contains none of that. It is
|
|
2775
|
+
a list of *technical admission* checks: XML shape, uniqueness, size, crypto, permissions.
|
|
2776
|
+
§9 has been corrected, and §15.6 records where tier 3's catalogue actually is and is not.
|
|
2777
|
+
|
|
2778
|
+
What it does deliver is unexpectedly valuable in a different place: **tier 1 was the tier
|
|
2779
|
+
with no first-tier source at all**, assembled from the schema plus judgement, and this
|
|
2780
|
+
document specifies it exactly.
|
|
2781
|
+
|
|
2782
|
+
### 15.1 XML admission rules — and not one of them is a schema check
|
|
2783
|
+
|
|
2784
|
+
An invoice must satisfy all six. The right-hand column is the point of the table:
|
|
2785
|
+
|
|
2786
|
+
| Rule | Can tier 2 catch it? |
|
|
2787
|
+
|---|---|
|
|
2788
|
+
| Well-formed XML per XML 1.0 | **Only since 2026-08-24** — see the note below |
|
|
2789
|
+
| UTF-8 **without BOM** (no leading `EF BB BF`) | **No** — a BOM parses fine |
|
|
2790
|
+
| Conforms to the schema declared at session open | Yes — this *is* tier 2 |
|
|
2791
|
+
| An XML prolog is optional, but if present must not declare a non-UTF-8 encoding | **No** |
|
|
2792
|
+
| **No processing instructions** | **No** — PIs are legal XML |
|
|
2793
|
+
| No discouraged Unicode characters: `[#x7F-#x84]`, `[#x86-#x9F]`, `[#xFDD0-#xFDEF]`, and `[#xNFFFE-#xNFFFF]` for every plane `N` = 1…10₁₆ | **No** |
|
|
2794
|
+
|
|
2795
|
+
Failing any one of them rejects the invoice.
|
|
2796
|
+
|
|
2797
|
+
**A correction to the first row, and it was a real defect.** The rule is not free: libxml2
|
|
2798
|
+
parses in *recovery* mode by default, so a document with an unclosed root or trailing text
|
|
2799
|
+
after it comes back as a usable tree — and that recovered tree validates clean against the
|
|
2800
|
+
schema. `Ksef::FA3::Validator` never consulted `document.errors`, so `valid?` returned **true
|
|
2801
|
+
for XML that is not XML**. Fixed 2026-08-24 by checking well-formedness first and reporting it
|
|
2802
|
+
separately; before that fix, *five* of the six rules were invisible to this gem's tier 2, and
|
|
2803
|
+
a tier-1 implementer reading this table would have skipped the one check the table promised was
|
|
2804
|
+
already covered.
|
|
2805
|
+
|
|
2806
|
+
**Four of the six are invisible to tier 2 even now**, which is the argument for tier 1
|
|
2807
|
+
existing at all — and it is not hypothetical. The C# client's own test corpus ships
|
|
2808
|
+
`invoice-template-fa-3-with-disallowed-unicode-characters.xml`, now pinned to
|
|
2809
|
+
`spec/fixtures/fa3/` (§1.4). Measured 2026-08-24:
|
|
2810
|
+
|
|
2811
|
+
- it is **XSD-valid** against our pinned FA(3) schema once its `#nip#` placeholder is
|
|
2812
|
+
substituted (§1.4) — `Ksef::FA3::Validator` returns zero errors, so tier 2 passes it;
|
|
2813
|
+
- it carries **U+0087** and **U+009B**, both inside the forbidden `[#x86-#x9F]` range.
|
|
2814
|
+
|
|
2815
|
+
**Implemented 2026-08-24** as `Ksef::FA3::DocumentValidator` (validator tier 1b, DESIGN.md
|
|
2816
|
+
§7.7). It runs on `#to_xml`'s output, before those bytes are hashed and encrypted — after that
|
|
2817
|
+
point a rejection costs a round trip and a session. All four otherwise-invisible rules are
|
|
2818
|
+
covered, plus the 1 000 000-byte ceiling of §15.5.
|
|
2819
|
+
|
|
2820
|
+
A schema-only validator ships that invoice, and the pinned rule above says KSeF rejects it.
|
|
2821
|
+
**That rejection is derived, not observed** — it follows from *"Niespełnienie któregokolwiek z
|
|
2822
|
+
powyższych wymagań spowoduje odrzucenie faktury"*, and nobody has submitted this fixture. (An
|
|
2823
|
+
earlier version of this sentence justified it with "no session has ever reached live KSeF",
|
|
2824
|
+
which §14.6 in this same document contradicts as of 2026-08-24: sessions reach it nightly. The
|
|
2825
|
+
conclusion stands on the narrower ground.) One fixture, and it settles whether tier 1 is worth building.
|
|
2826
|
+
|
|
2827
|
+
**Where those characters come from matters more than the rule.** The offending text reads
|
|
2828
|
+
`ilość` — that is `ilość` encoded as UTF-8 and then decoded as Latin-1. **Double-encoded
|
|
2829
|
+
UTF-8 is the practical source of forbidden C1 characters**, not deliberate abuse. Any ERP
|
|
2830
|
+
export that has been through one bad encoding conversion hits this, which makes it a
|
|
2831
|
+
likely real-world rejection rather than an exotic one.
|
|
2832
|
+
|
|
2833
|
+
### 15.2 Duplicate detection is a three-field key, held for ten years
|
|
2834
|
+
|
|
2835
|
+
KSeF detects duplicates **globally**, not per session, on the combination:
|
|
2836
|
+
|
|
2837
|
+
1. seller NIP — `Podmiot1/DaneIdentyfikacyjne/NIP`
|
|
2838
|
+
2. invoice kind — `RodzajFaktury`
|
|
2839
|
+
3. invoice number — `P_2`
|
|
2840
|
+
|
|
2841
|
+
A duplicate returns per-invoice code **440** (§12.1's table, confirmed here from prose).
|
|
2842
|
+
Uniqueness is retained for **ten full calendar years** counted from the end of the year of
|
|
2843
|
+
issue.
|
|
2844
|
+
|
|
2845
|
+
Two consequences worth stating, because both are easy to get wrong:
|
|
2846
|
+
|
|
2847
|
+
- **`RodzajFaktury` is part of the key.** The same `P_2` under `VAT` and under `KOR` is not
|
|
2848
|
+
a duplicate. A correction therefore does not need a distinct number from the invoice it
|
|
2849
|
+
corrects.
|
|
2850
|
+
- **The key is the seller's, not the issuer's.** Where branches, local-government units or
|
|
2851
|
+
authorised third parties invoice on behalf of one NIP, upstream says explicitly they must
|
|
2852
|
+
agree a numbering scheme between themselves. Nothing in the API prevents a collision;
|
|
2853
|
+
it surfaces as a 440 after submission.
|
|
2854
|
+
|
|
2855
|
+
### 15.3 NIP checksums are validated **in production only**
|
|
2856
|
+
|
|
2857
|
+
The sharpest fact in the document, and it is stated twice — once for
|
|
2858
|
+
`Podmiot1`/`Podmiot2`/`Podmiot3`/`PodmiotUpowazniony`, and once for the NIP embedded in
|
|
2859
|
+
`Podmiot3`'s `InternalId`. Both say *"Dotyczy tylko środowiska produkcyjnego"* — applies to
|
|
2860
|
+
the production environment only.
|
|
2861
|
+
|
|
2862
|
+
Three consequences:
|
|
2863
|
+
|
|
2864
|
+
- **It explains `rake auth:bootstrap`.** §6a.1 invents a NIP and the flow works on TEST.
|
|
2865
|
+
That is not tolerance of a malformed identifier — checksum validation simply is not
|
|
2866
|
+
running there. (The bootstrap generates checksum-valid values anyway, which is why this
|
|
2867
|
+
never surfaced as a puzzle.)
|
|
2868
|
+
- **A green TEST run proves nothing about NIP validity.** No integration spec can cover
|
|
2869
|
+
this rule, in either direction: TEST will not reject a bad NIP, and PROD is off limits
|
|
2870
|
+
from any test (a hard rule). It is verifiable by reasoning about the document and by unit
|
|
2871
|
+
tests of our own checksum, and by nothing else.
|
|
2872
|
+
- **Keep `Ksef::FA3::NIP.validate!` running in every environment.** It is deliberately
|
|
2873
|
+
*stricter* than TEST. Do not add an environment check to relax it: production is where
|
|
2874
|
+
the rejection would land, and a gem that lets a bad NIP through on TEST has simply moved
|
|
2875
|
+
the failure to the worst possible moment.
|
|
2876
|
+
|
|
2877
|
+
### 15.4 The issue date may not be in the future
|
|
2878
|
+
|
|
2879
|
+
`P_1` must not be later than the date KSeF accepts the document.
|
|
2880
|
+
|
|
2881
|
+
Offline this can only be approximated: the comparison is against *KSeF's* acceptance date,
|
|
2882
|
+
which is not knowable at build time, and a document issued today and submitted today sits
|
|
2883
|
+
exactly on the boundary. So tier 1's rule is the safe half — **reject an issue date in the
|
|
2884
|
+
future** — and the same-day boundary is left to the service. Do not implement this as an
|
|
2885
|
+
equality against `Date.today` in the local zone; a client in a western timezone would
|
|
2886
|
+
reject perfectly good invoices around midnight in Warsaw.
|
|
2887
|
+
|
|
2888
|
+
### 15.5 Facts this document corroborates — and one it establishes
|
|
2889
|
+
|
|
2890
|
+
Three of the four rows below were already in this ledger, resting on the contract or the
|
|
2891
|
+
reference clients alone, so an independent witness is worth having. **The fourth is new**: the
|
|
2892
|
+
batch-only attachment rule appears here for the first time, and the row says so rather than
|
|
2893
|
+
claiming a confirmation it cannot make.
|
|
2894
|
+
|
|
2895
|
+
| Fact | Where it lives | What this document adds |
|
|
2896
|
+
|---|---|---|
|
|
2897
|
+
| AES-256-CBC, 256-bit key, 128-bit IV, PKCS#7; symmetric key under RSAES-OAEP **SHA-256/MGF1** | §10.1 | A **fifth** witness, and the first from first-tier prose. Note it writes "SHA-256/MGF1" as one unit, pairing the two digests exactly as §10.1 requires |
|
|
2898
|
+
| Hash **and size** of both the plaintext and the encrypted invoice | §11.1 | Confirms all four values, and that the check is KSeF's, not decoration. Note upstream heads this rule *"Zgodność metadanych faktury **w sesji interaktywnej**"* — it is scoped to the interactive session; the batch flow of 0.2 will need its own reading |
|
|
2899
|
+
| 1 MB (1 000 000 bytes) without attachments, 3 MB with; 10 000 invoices per session | §6.2 | Identical, plus batch: 50 ZIP files, 100 MB each before encryption, 5 GB per package. **Three of these carry an asterisk and are defaults, not ceilings of the format:** *"Jeżeli w scenariuszach biznesowych organizacji dostępne limity są niewystarczające, prosimy o kontakt z działem wsparcia KSeF"* — and `limity.md` heads the same numbers *"Wartość domyślna"*, with `GET /limits/context` reporting the live values. The asterisk attaches to 1 MB, 3 MB and 10 000; **not** to the batch ZIP figures. `DocumentValidator` takes `max_bytes:` for that reason: hard-coding the default as absolute rejected invoices KSeF would accept |
|
|
2900
|
+
| Attachments are **batch-only** | *nothing — this is the first source* | DESIGN.md §7.4 only puts *operational* attachment constraints out of scope for 0.1; it never said batch-only, and no document here did. So this row is **new**, not a confirmation, and the section heading above overstates it. With one exception — an offline *technical correction* may use an interactive session — plus a requirement of prior opt-in in `e-Urząd Skarbowy`. Both are 0.2/0.3 concerns; recorded so the exception is not rediscovered |
|
|
2901
|
+
|
|
2902
|
+
### 15.6 Tier 3's catalogue is in none of the 77 files
|
|
2903
|
+
|
|
2904
|
+
Searched at commit `1c34fe27`: no document in `ksef-api` states a reconciliation rule.
|
|
2905
|
+
Nothing says `P_15` equals the sum of the rate buckets, nothing constrains a `KOR`
|
|
2906
|
+
document's references to the invoice it corrects, nothing gives an error code for an
|
|
2907
|
+
arithmetic mismatch. The closest thing to a rule catalogue is this section, and it is
|
|
2908
|
+
technical rather than semantic.
|
|
2909
|
+
|
|
2910
|
+
The FA(3) XSD does carry **683 `xsd:documentation` annotations** — every field described in
|
|
2911
|
+
Polish, with citations to the VAT Act. They are a genuine first-tier source for what a
|
|
2912
|
+
field *means* (that is where §8.1a's rate buckets came from). They state **almost** no
|
|
2913
|
+
equalities: `P_13_1` is documented as "the sum of net sales values under the basic rate" and
|
|
2914
|
+
`P_15` as "the total amount due", and the arithmetic relating those two is left to the reader.
|
|
2915
|
+
|
|
2916
|
+
**The one exception, found by audit 2026-08-26 — this sentence read "They state no equalities"
|
|
2917
|
+
and that was wrong.** `Rozliczenie/DoZaplaty` is annotated *"Kwota należności do zapłaty **równa
|
|
2918
|
+
polu P_15 powiększonemu o Obciazenia i pomniejszonemu o Odliczenia**"* — a literal arithmetic
|
|
2919
|
+
identity, and precisely the grounding-1 shape tier 3 wants. Measured over the corpus it holds
|
|
2920
|
+
**exactly**, with no tolerance and no guard, in all four samples that carry a `Rozliczenie`:
|
|
2921
|
+
Przykład 4 (`64279.92 + 0.00 − 1000.00 = 63279.92`), 24 and 25 (`53.63 + 0 − 0`), 26
|
|
2922
|
+
(`31.50 + 5.00 − 0.00 = 36.50`).
|
|
2923
|
+
|
|
2924
|
+
It is **not implemented**, because the model does not carry `Rozliczenie` at all — the whole
|
|
2925
|
+
group is in `#unmapped_elements` for those four samples. That makes it the best-grounded
|
|
2926
|
+
candidate rule this project knows of, and the argument for carrying `Rozliczenie` when tier 3
|
|
2927
|
+
is next extended (§17.4).
|
|
2928
|
+
|
|
2929
|
+
So tier 3 has three possible groundings, and they are not equal:
|
|
2930
|
+
|
|
2931
|
+
1. **Arithmetic that follows from the field definitions.** That `P_13_1` is a *sum* of the
|
|
2932
|
+
lines taxed at the basic rate is what the annotation says; checking it is not policy.
|
|
2933
|
+
This is the part that can be built now, and it needs no external catalogue.
|
|
2934
|
+
2. **The Ministry's FA(3) brochure** (`broszura informacyjna`), published on
|
|
2935
|
+
`podatki.gov.pl` and **not** in any CIRFMF repository. This is where the published
|
|
2936
|
+
business-rule list DESIGN.md §7.7 asks for would come from. Pinning it means taking an
|
|
2937
|
+
artifact from outside the repositories §1 covers, under a licence nobody here has read.
|
|
2938
|
+
3. **Observation against TEST.** Reliable, slow, and it only ever finds rules we thought to
|
|
2939
|
+
probe for.
|
|
2940
|
+
|
|
2941
|
+
**Why it does not exist yet, and where it will appear.** `CIRFMF/ksef-api` issue **#837**
|
|
2942
|
+
(author `kw-cirf`, a collaborator) announced the **first business validation KSeF ever proposed**:
|
|
2943
|
+
for `KodWaluty != PLN`, at least one `P_14_x` + `P_14_xW` pair must be filled, rejection status
|
|
2944
|
+
**`425` "Faktura nie spełnia wymagań dotyczących danych"**, scheduled TEST 2026-07-21 / PROD
|
|
2945
|
+
2026-07-28, *"to be added to `faktury/weryfikacja-faktury.md`"*. It was **withdrawn back to
|
|
2946
|
+
analysis** after the community showed it rejects legal foreign-currency invoices carrying only
|
|
2947
|
+
`np`/`zw`/`0%`/`oo`/margin rates. The live `weryfikacja-faktury.md` was diffed against our pinned
|
|
2948
|
+
copy on 2026-08-24: **identical** — the section never landed. Two things follow. The catalogue's
|
|
2949
|
+
future address is known (that file, plus status `425`), and the Ministry's own remark in the
|
|
2950
|
+
thread — that not one production invoice violated the proposed rule — is evidence **KSeF today
|
|
2951
|
+
enforces nothing beyond the schema**.
|
|
2952
|
+
|
|
2953
|
+
**And the error codes are not the catalogue either.** A natural hope is that a rejection code list
|
|
2954
|
+
is the rule list seen from the failure side. It is not: the reference clients' catalogues
|
|
2955
|
+
(`InvoiceInSessionStatusCodeResponse` and siblings) funnel *every* content violation into one
|
|
2956
|
+
code — **`450` "Błąd weryfikacji semantyki dokumentu faktury"** — with free-text details. There is
|
|
2957
|
+
no code for "totals do not reconcile" and none for a malformed correction reference.
|
|
2958
|
+
|
|
2959
|
+
**What the Ministry's samples add.** §1.5 records the measurement: `Σ P_13_* + Σ P_14_* == P_15`
|
|
2960
|
+
holds exactly in **22** of the 26, Przykład 1 misses by a grosz, and **three** state no buckets
|
|
2961
|
+
at all (5, 13 and 16). That is *empirical* grounding — grounding 3 — for a rule the text does not
|
|
2962
|
+
state, and it fixes the rule's shape: a tolerance of a grosz and a guard for absent buckets, or
|
|
2963
|
+
it rejects the Ministry's own first example.
|
|
2964
|
+
|
|
2965
|
+
**Corrected 2026-08-26, twice over.** This paragraph said "24 of 26" — which counts Przykład 5
|
|
2966
|
+
and 13, whose `0 == 0` the guard skips before comparing, so they witness nothing — and it named
|
|
2967
|
+
the cause of Przykład 1's grosz as bucket-level tax rounding, which §1.5 shows is arithmetically
|
|
2968
|
+
false: that mechanism moves the invoice by 0.0007. The real cause is nets computed back *w stu*
|
|
2969
|
+
from round gross prices and rounded down. Both errors were fixed in §1.5 and §17 and left
|
|
2970
|
+
standing here, in the higher-precedence document, which is the drift this ledger exists to
|
|
2971
|
+
prevent.
|
|
2972
|
+
|
|
2973
|
+
**Superseded 2026-08-26 — tier 3 shipped, and not on this recommendation.** This section
|
|
2974
|
+
advised building on **grounding 1**, where the rule is the field definition. That turned out
|
|
2975
|
+
not to be implementable here: `P_15`'s annotation says nothing about the buckets, and the
|
|
2976
|
+
definitional rule one would check instead — `P_13_1` against the rows — is falsified by ten of
|
|
2977
|
+
the fourteen modelled stated-summary samples, because a correction's buckets are deltas and an
|
|
2978
|
+
advance's are pre-payments. **Grounding 1 yields no rule at all on this model.** What shipped is
|
|
2979
|
+
grounding 3, empirical, advisory, and recorded in §17. The one genuine grounding-1 rule anyone
|
|
2980
|
+
has found is `Rozliczenie/DoZaplaty`, above — blocked only by the model not carrying it.
|
|
2981
|
+
Do not synthesise rules from Polish VAT law and record them as verified facts; that is
|
|
2982
|
+
inference wearing a citation. Grounding 2 is a licensing and scope question, not a coding
|
|
2983
|
+
one.
|
|
2984
|
+
|
|
2985
|
+
**§9's characterisation of this as "the next place to look" was reasonable and wrong.** The
|
|
2986
|
+
lesson is the same one §1.3 already carries, pointed the other way: listing the upstream
|
|
2987
|
+
tree found 73 unread files, and reading them resolved most of §9 — but it also means a
|
|
2988
|
+
remaining gap is now much more likely to be genuinely absent than merely unread.
|
|
2989
|
+
|
|
2990
|
+
---
|
|
2991
|
+
|
|
2992
|
+
## 16. Offline modes, technical corrections, batch and download
|
|
2993
|
+
|
|
2994
|
+
Recorded 2026-08-26, from the thirteen documents pinned that day (§1.3). Two of them define
|
|
2995
|
+
parameters this gem already accepts; the rest cover work that is unbuilt, and are recorded
|
|
2996
|
+
here so the next milestone starts from evidence rather than from the OpenAPI's one-line
|
|
2997
|
+
field descriptions.
|
|
2998
|
+
|
|
2999
|
+
### 16.1 KSeF can classify an invoice as offline **that the sender declared online**
|
|
3000
|
+
|
|
3001
|
+
The rule, from `offline/automatyczne-okreslanie-trybu-offline.md` (dated 04.10.2025):
|
|
3002
|
+
|
|
3003
|
+
> Dla faktur wysyłanych jako `offlineMode: false` system porównuje **datę wystawienia**
|
|
3004
|
+
> faktury (`issueDate`, np. `P_1` dla faktury zgodnej z FA(3)) [i] **datę przyjęcia**
|
|
3005
|
+
> faktury w systemie KSeF do dalszego przetwarzania (`invoicingDate`).
|
|
3006
|
+
>
|
|
3007
|
+
> - Jeśli dzień kalendarzowy z `issueDate` jest wcześniejszy niż dzień kalendarzowy z
|
|
3008
|
+
> `invoicingDate` (porównanie po dacie, nie po godzinie), system automatycznie oznacza
|
|
3009
|
+
> fakturę jako **offline**, nawet jeśli nie była tak zadeklarowana.
|
|
3010
|
+
|
|
3011
|
+
`invoicingDate` differs by session type, and this is the part with a consequence:
|
|
3012
|
+
|
|
3013
|
+
| Session | `invoicingDate` is |
|
|
3014
|
+
|---|---|
|
|
3015
|
+
| interactive | the moment the **invoice** was submitted |
|
|
3016
|
+
| batch | the moment the **session was opened** (the `dateCreated` of `GET /sessions/{ref}`) |
|
|
3017
|
+
|
|
3018
|
+
**This reaches ordinary use of this gem.** `Ksef::Client#send_invoice` opens a session and
|
|
3019
|
+
submits immediately, so `invoicingDate` is now. An invoice whose `P_1` is *yesterday* — an
|
|
3020
|
+
entirely normal thing, and something the gem neither prevents nor warns about — is marked
|
|
3021
|
+
offline by KSeF regardless of what was declared. The comparison is by **calendar day, not by
|
|
3022
|
+
elapsed time**: upstream's own example is an invoice issued 2025-10-03 and sent at 00:00:01
|
|
3023
|
+
on 2025-10-04, which is offline after one second.
|
|
3024
|
+
|
|
3025
|
+
Note the asymmetry this creates with batch, where the clock stops at session open: a batch
|
|
3026
|
+
opened at 23:59:59 keeps its invoices online no matter when the parts arrive.
|
|
3027
|
+
|
|
3028
|
+
Nothing is implemented for this and nothing should be — it is the service's classification,
|
|
3029
|
+
not a client-side rule, and `P_1` is the caller's to choose. It is documented because a
|
|
3030
|
+
caller who reads "offlineMode: false" as "this invoice is online" is wrong, and neither the
|
|
3031
|
+
OpenAPI's field description nor anything else in this gem would tell them.
|
|
3032
|
+
|
|
3033
|
+
### 16.2 A technical correction is two parameters, and only in an interactive session
|
|
3034
|
+
|
|
3035
|
+
From `offline/korekta-techniczna.md`. It applies to an invoice **issued offline and then
|
|
3036
|
+
rejected by KSeF for a technical reason** — schema mismatch, oversized file, duplicate, or
|
|
3037
|
+
any other validation failure that prevented a KSeF number being assigned. The corrected file
|
|
3038
|
+
has different bytes and therefore a different SHA-256, and `hashOfCorrectedInvoice` carries
|
|
3039
|
+
the hash of the **original rejected** one, so KSeF can link the two and redirect the first
|
|
3040
|
+
invoice's QR code to the accepted document.
|
|
3041
|
+
|
|
3042
|
+
Four constraints, none of them in the OpenAPI:
|
|
3043
|
+
|
|
3044
|
+
1. It is **not** for content: *"nie jest dozwolona korygowanie treści faktury"*. Technical
|
|
3045
|
+
problems only.
|
|
3046
|
+
2. It is **not** for permission failures — self-billing, JST or VAT-group relationship
|
|
3047
|
+
validation.
|
|
3048
|
+
3. It may be sent **only in an interactive session**, though it may concern an invoice
|
|
3049
|
+
rejected in either an interactive or a batch one.
|
|
3050
|
+
4. It is not allowed for an offline invoice that has already had a proper correction accepted.
|
|
3051
|
+
|
|
3052
|
+
And the shape: the document sends `offlineMode: true` **together with**
|
|
3053
|
+
`hashOfCorrectedInvoice`, in both the C# and Java examples it links.
|
|
3054
|
+
|
|
3055
|
+
**This gem does not enforce the pairing, deliberately.** `Sessions::Online#send_invoice`
|
|
3056
|
+
takes `offline_mode:` and `corrected_invoice_hash:` independently, because the contract makes
|
|
3057
|
+
both optional and says only *"Wymagany przy wysyłaniu korekty technicznej faktury"* — it does
|
|
3058
|
+
not state that one requires the other. Enforcing a constraint upstream describes in a
|
|
3059
|
+
procedure but does not impose in its schema would be inventing a rule, which is the same
|
|
3060
|
+
reasoning §8.4 uses for not requiring `PrzyczynaKorekty` on a `KOR`. The documentation on the
|
|
3061
|
+
method now says what a technical correction is, so a caller sending one knows to set both.
|
|
3062
|
+
|
|
3063
|
+
One related ordering rule, from `tryby-offline.md`: *"Fakturę korygującą przesyła się dopiero
|
|
3064
|
+
po nadaniu numeru KSeF dokumentowi pierwotnemu"* — a correction goes only after the invoice
|
|
3065
|
+
it corrects has been assigned a KSeF number. That is a sequencing rule for the caller, not a
|
|
3066
|
+
tier-1 check: `KOR` carries `NrKSeFFaKorygowanej`, and whether that number exists yet is not
|
|
3067
|
+
something the document can be asked.
|
|
3068
|
+
|
|
3069
|
+
### 16.3 Batch, for when it is built — and a second witness to the §14.1 error
|
|
3070
|
+
|
|
3071
|
+
`sesja-wsadowa.md` (10.07.2025), plus `OpenBatchSessionRequest` and `BatchFilePartInfo` in
|
|
3072
|
+
the contract:
|
|
3073
|
+
|
|
3074
|
+
- One ZIP holding every invoice, **split into parts no larger than 100 MB before
|
|
3075
|
+
encryption**; each part is encrypted and uploaded separately, and described in `fileParts`.
|
|
3076
|
+
- `BatchFilePartInfo` is `ordinalNumber` + `fileSize` + `fileHash`, and both measurements are
|
|
3077
|
+
of the **encrypted** part, not the plaintext.
|
|
3078
|
+
- `offlineMode` sits on the **session open** request, not per invoice — the opposite of the
|
|
3079
|
+
interactive session, where it is per `SendInvoiceRequest`.
|
|
3080
|
+
- Upstream recommends hashing each plaintext XML before packing and keeping a local map,
|
|
3081
|
+
because per-invoice status comes back keyed by `invoiceHash` and there is otherwise nothing
|
|
3082
|
+
to correlate it to.
|
|
3083
|
+
- `TarGz` was added as an alternative to `Zip` in API 2.6.0 and is *recommended* for packages
|
|
3084
|
+
of many similar XML documents; `Zip` remains the default for compatibility (§16.5).
|
|
3085
|
+
|
|
3086
|
+
**And it repeats the IV claim §14.1 refutes**: *"wektora inicjującego o długości 128 bitów
|
|
3087
|
+
(IV), **dołączanego jako prefiks do szyfrogramu**"*. This is the same sentence as
|
|
3088
|
+
`sesja-interaktywna.md`'s, and it is wrong for the same structural reason — batch and online
|
|
3089
|
+
share one `EncryptionInfo` schema, whose `initializationVector` is a **required discrete
|
|
3090
|
+
field**. An IV that is a mandatory request field is not also a ciphertext prefix.
|
|
3091
|
+
|
|
3092
|
+
Be precise about the strength of that, because §14.1's four witnesses are interactive ones:
|
|
3093
|
+
the *contract* argument covers batch exactly, since it is literally the same schema; the
|
|
3094
|
+
*measured* argument (6480 plaintext bytes against 6496 encrypted — one PKCS#7 block, no room
|
|
3095
|
+
for a 16-byte IV) was taken from an interactive example and has no batch equivalent yet.
|
|
3096
|
+
Treat batch as settled by the schema and confirm it on the first live run, the way the
|
|
3097
|
+
interactive path was confirmed on 2026-08-24.
|
|
3098
|
+
|
|
3099
|
+
### 16.4 Invoice download, and the High Water Mark
|
|
3100
|
+
|
|
3101
|
+
`pobieranie-faktur/`. Only the first of these is built (`Invoices::Client`).
|
|
3102
|
+
|
|
3103
|
+
| Endpoint | What it does |
|
|
3104
|
+
|---|---|
|
|
3105
|
+
| `GET /invoices/ksef/{ksefNumber}` | one invoice by KSeF number — **built** |
|
|
3106
|
+
| `POST /invoices/query/metadata` | paged `InvoiceMetadata` search, by date range and subject role |
|
|
3107
|
+
| `POST /invoices/exports` | asynchronous export to encrypted packages; `encryption` is **required** |
|
|
3108
|
+
|
|
3109
|
+
An export package carries a `_metadata.json` holding the same `InvoiceMetadata` array the
|
|
3110
|
+
metadata query returns, and invoices in the package are sorted ascending by whichever date
|
|
3111
|
+
type the `DateRange` named at initialisation.
|
|
3112
|
+
|
|
3113
|
+
**High Water Mark** (`hwm.md`, 25.11.2025) is the completeness guarantee, and it is the part
|
|
3114
|
+
worth knowing before designing anything incremental. KSeF publishes a moment `HWM` up to
|
|
3115
|
+
which every invoice is durably stored:
|
|
3116
|
+
|
|
3117
|
+
- for `PermanentStorage` ≤ `HWM`, the set is **closed and complete** — no new invoice will
|
|
3118
|
+
ever appear with a date in it;
|
|
3119
|
+
- for `(HWM, now]`, results are **potentially incomplete**, because storage is asynchronous
|
|
3120
|
+
and multi-threaded, so invoices can still appear in a window already queried.
|
|
3121
|
+
|
|
3122
|
+
Which gives two sync strategies, and upstream states the trade-off rather than picking for
|
|
3123
|
+
you: query only up to `HWM` — definitive, minimal duplicates, freshest invoices invisible
|
|
3124
|
+
until `HWM` advances, and *recommended for automatic incremental sync*; or query to `now` —
|
|
3125
|
+
freshest data immediately, but the `(HWM, now]` window must be re-queried next time and the
|
|
3126
|
+
caller must deduplicate, by KSeF number.
|
|
3127
|
+
|
|
3128
|
+
### 16.5 The API changelog exists, and it is version-by-version
|
|
3129
|
+
|
|
3130
|
+
`api-changelog.md` covers 2.0 through **2.7.0** with per-environment deployment dates for
|
|
3131
|
+
TEST, DEMO and PRD. It is the document to consult when the service behaves unlike the pinned
|
|
3132
|
+
contract, since the contract is a snapshot and this is the history.
|
|
3133
|
+
|
|
3134
|
+
Items in it that touch code already written, all of which this gem already handles — recorded
|
|
3135
|
+
so the next reader does not re-derive them:
|
|
3136
|
+
|
|
3137
|
+
| Version | Change | Status here |
|
|
3138
|
+
|---|---|---|
|
|
3139
|
+
| 2.5.0 | `publicKeyId` selector for key rotation | implemented, §10.2 |
|
|
3140
|
+
| 2.6.0 | `X-System-Warning` advisory response header | implemented, `HTTP::SystemWarning` |
|
|
3141
|
+
| 2.6.0 | `TarGz` compression for batch and export | batch is unbuilt |
|
|
3142
|
+
| — | `X-Error-Format: problem-details` for 400/429 | implemented, `HTTP::Connection` |
|
|
3143
|
+
| — | `downloadUrlExpirationDate` on UPO pages | implemented, `UpoPage#expires_at` |
|
|
3144
|
+
| — | UPO v4-3 default from 2025-12-22; `X-KSeF-Feature: upo-v4-3` before that | implemented, §14.6 |
|
|
3145
|
+
| — | NIP checksum verification **on production only** | implemented, §15.3 |
|
|
3146
|
+
| — | `429` documented with `Retry-After` and `TooManyRequestsResponse` | implemented, §6 |
|
|
3147
|
+
|
|
3148
|
+
That every one of these was already correct is the useful result: the 2026-08-22 pass caught
|
|
3149
|
+
the API-behaviour documents, and what it missed was the *procedural* ones — offline modes and
|
|
3150
|
+
technical corrections — which describe not how an endpoint responds but when a taxpayer is
|
|
3151
|
+
supposed to call it. Those have no OpenAPI counterpart to catch them.
|
|
3152
|
+
|
|
3153
|
+
One oddity, flagged rather than resolved: the 2.7.0 row is dated `21.07.2027` for TEST, a
|
|
3154
|
+
year after the commit that introduces it (authored 2026-07-21). Every other row is
|
|
3155
|
+
chronologically consistent. Most likely an upstream typo for 2026; do not build anything on
|
|
3156
|
+
that date.
|
|
3157
|
+
|
|
3158
|
+
---
|
|
3159
|
+
|
|
3160
|
+
## 17. Validator tier 3 — one advisory rule, and why it cannot be more
|
|
3161
|
+
|
|
3162
|
+
Built 2026-08-26, then **substantially redesigned the same day** after a five-lens audit found
|
|
3163
|
+
that the first version refused legal invoices. `Ksef::FA3::BusinessValidator`, reached through
|
|
3164
|
+
`Invoice#warnings` — deliberately **not** `#errors`, and so it can never block a send.
|
|
3165
|
+
|
|
3166
|
+
### 17.1 The summary-reconciliation rule
|
|
3167
|
+
|
|
3168
|
+
`Σ P_13_* + Σ P_14_* ≈ P_15`, over **figures the document states**, within one grosz, skipped
|
|
3169
|
+
when no buckets are stated.
|
|
3170
|
+
|
|
3171
|
+
**Its grounding is empirical — §15.6's grounding 3 — and not definitional.** The first version
|
|
3172
|
+
of this section claimed otherwise ("`P_13_1` is annotated as a sum; checking a sum is not
|
|
3173
|
+
policy") and that was wrong twice over. `P_15` is annotated *"Kwota należności ogółem"* and says
|
|
3174
|
+
nothing about the buckets; §15.6's own sentence is "Nothing says `P_15` equals the sum of the
|
|
3175
|
+
rate buckets." And the definitional rule one might reach for instead — check `P_13_1` against
|
|
3176
|
+
the rows — **is not implementable on this model**: ten of the fourteen modelled stated-summary
|
|
3177
|
+
samples falsify it, because a correction's buckets are deltas, an advance's are pre-payments
|
|
3178
|
+
and a settlement's are remainders (§8.4, §8.5). Grounding 1 yields no rule here at all.
|
|
3179
|
+
|
|
3180
|
+
The measurement, over the 26 pinned samples:
|
|
3181
|
+
|
|
3182
|
+
| | Samples | Note |
|
|
3183
|
+
|---|---|---|
|
|
3184
|
+
| buckets stated, reconcile exactly | 22 | including all six non-`VAT` types |
|
|
3185
|
+
| buckets stated, out by one grosz | 1 | Przykład 1 |
|
|
3186
|
+
| **no buckets stated** | **3** | Przykład 5 and 13 (`P_15` 0), Przykład 16 (`P_15` 450) |
|
|
3187
|
+
|
|
3188
|
+
So the rule evaluates 23 of 26, not 25. Przykład 5 and 13 are skipped by the guard, not passed
|
|
3189
|
+
by the rule, and counting them as witnesses inflates the evidence.
|
|
3190
|
+
|
|
3191
|
+
**Why one grosz, and why it is honestly arbitrary.** Przykład 1 is the only witness, and §1.5
|
|
3192
|
+
records what actually causes its gap: nets computed back *w stu* from round gross prices and
|
|
3193
|
+
rounded down, not per-bucket tax rounding, which moves that invoice by 0.0007. The error
|
|
3194
|
+
therefore scales with the **number of lines** and with the issuer's rounding convention — two
|
|
3195
|
+
such lines and it is two grosze, fifty and it is 38. **No tolerance sized from this corpus can
|
|
3196
|
+
be sound**, because the corpus never varies the dimension the error scales with. One grosz is
|
|
3197
|
+
what the single witness shows, and the rule warns rather than refuses precisely because that
|
|
3198
|
+
number cannot be defended as a bound.
|
|
3199
|
+
|
|
3200
|
+
**Why it is a warning.** An invoice priced from round gross prices — the commonest shape in
|
|
3201
|
+
Polish retail — fails this check while being entirely legal. Making it an error would repeat
|
|
3202
|
+
the mistake that got KSeF's own proposed business rule withdrawn (§15.6, issue #837: *it
|
|
3203
|
+
rejects legal invoices*). §14.3 is the precedent: the UPO receiving-party mismatch is a warning
|
|
3204
|
+
so that a schema opinion cannot stand between a legal document and its being filed.
|
|
3205
|
+
|
|
3206
|
+
**Why it reads the document rather than the model.** Both sides must come from the same source
|
|
3207
|
+
or the comparison measures this gem instead of the invoice. The first version compared the
|
|
3208
|
+
model's *derived* buckets against the document's total, and produced two false positives that
|
|
3209
|
+
`Ksef::Client#send_invoice` turned into refusals:
|
|
3210
|
+
|
|
3211
|
+
- **A rounding-regime artefact.** `summary_buckets` rounds each bucket; `gross_total`'s derived
|
|
3212
|
+
arm did not. Three ordinary lines priced the way DESIGN.md §8's snippet prices them gave a
|
|
3213
|
+
two-grosz "error" on an invoice this gem had just built. It also made the verdict depend on
|
|
3214
|
+
whether you had round-tripped: built accused, parsed clean.
|
|
3215
|
+
- **The model's own incompleteness.** A document using `P_13_5`/`P_14_5` (OSS) or `P_13_11`
|
|
3216
|
+
(margin scheme) states buckets no rate code reaches ({VatRate.unreachable_elements}), so the
|
|
3217
|
+
model's derivation is necessarily short and the invoice was accused of an arithmetic error
|
|
3218
|
+
that was really a gap in this gem.
|
|
3219
|
+
|
|
3220
|
+
Reading both sides from `raw_document` removes both. A built invoice states nothing
|
|
3221
|
+
independently and is therefore never compared — which is the honest answer, not a limitation.
|
|
3222
|
+
|
|
3223
|
+
**The `W` twins are excluded, and this is now the only place it is decisive.** `P_14_1W` is the
|
|
3224
|
+
PLN equivalent of `P_14_1` on a foreign-currency invoice, not a second tax; Przykład 20 states
|
|
3225
|
+
13560 + 3118.80 against a `P_15` of 16678.80, and counting its 14036.16 twin gives 30714.96.
|
|
3226
|
+
`Totals::ELEMENTS` is the single definition of "a bucket" and the rule reads it. Note that the
|
|
3227
|
+
earlier version cited Przykład 20 for this and could not have been testing it: 20 has a
|
|
3228
|
+
*derived* summary, where `VatRate::BUCKETS` governs and no `W` element can appear whatever
|
|
3229
|
+
`Totals::ELEMENTS` says. No Ministry sample pairs a stated summary with a `W` twin, so the
|
|
3230
|
+
guard is exercised by an injected one.
|
|
3231
|
+
|
|
3232
|
+
### 17.2 The defect grounding the rule uncovered: `P_15` was derived, not read
|
|
3233
|
+
|
|
3234
|
+
**A `VAT` invoice re-serialised the Ministry's Przykład 1 a grosz cheaper, and nothing said so.**
|
|
3235
|
+
|
|
3236
|
+
`P_15` is mandatory in `Fa`, so every document states one. The six types in
|
|
3237
|
+
`Invoice::STATED_TOTALS_TYPES` read it into `Totals#gross`; `VAT` derived it from its rows. For
|
|
3238
|
+
21 of the 22 modelled samples the two agree. For Przykład 1 they do not — 2051 stated, 2050.99
|
|
3239
|
+
derived — so parsing and re-serialising it produced an invoice asking a grosz less.
|
|
3240
|
+
|
|
3241
|
+
Invisible to everything: tier 2 passes, `#unmapped_elements` is silent (`P_15` is present either
|
|
3242
|
+
way — a path-difference diagnostic cannot see a changed *value*), and even the round-trip law
|
|
3243
|
+
held, because a parsed invoice is already a fixed point. The `P_9A` class from §8.6, found the
|
|
3244
|
+
same way, by measuring the corpus rather than reading the code.
|
|
3245
|
+
|
|
3246
|
+
`Invoice#stated_gross` carries the document's `P_15` **only when it differs** from what the rows
|
|
3247
|
+
derive — the `Line#row_number` idiom (§8.4). **Canonicalised in the constructor**, where
|
|
3248
|
+
`Invoice.positioned` does the equivalent job for `row_number`; the first version canonicalised
|
|
3249
|
+
in the parser only, so `Invoice.new(stated_gross:)` and `#with(stated_gross:)` — both public —
|
|
3250
|
+
bypassed it and broke DESIGN.md §7.6 with no diagnostic. It is nil when `totals` is present,
|
|
3251
|
+
because a stated summary already carries its own gross.
|
|
3252
|
+
|
|
3253
|
+
Measured after the fix: **`P_15` is numerically unchanged on re-serialisation for all 22
|
|
3254
|
+
modelled samples**, asserted by a spec. *Numerically*, not byte-for-byte — 17 of the 22 change
|
|
3255
|
+
their formatting (`2051` → `2051.00`, `31.5` → `31.50`), which §1.5 already warns about
|
|
3256
|
+
("amounts are unpadded, so any comparison must be numeric and never on strings"). An earlier
|
|
3257
|
+
draft of this section claimed byte-identity and was wrong.
|
|
3258
|
+
|
|
3259
|
+
The parser reads `P_15` **tolerantly**: an empty or unparseable one yields nil rather than
|
|
3260
|
+
raising, because the parser does not validate and a document being read to find out why KSeF
|
|
3261
|
+
rejected it may well be one whose `P_15` is the problem.
|
|
3262
|
+
|
|
3263
|
+
### 17.3 Two asymmetries of the same shape, still open
|
|
3264
|
+
|
|
3265
|
+
Both predate this work and neither is fixed here; recorded so they are not rediscovered.
|
|
3266
|
+
|
|
3267
|
+
- **A line built with a quantity and a price and no `net_amount`** serialises a derived `P_11`
|
|
3268
|
+
that parses back *into* `net_amount`, so the built invoice and the parsed one differ in a
|
|
3269
|
+
field the caller never set. DESIGN.md §8's own snippet does this.
|
|
3270
|
+
- **An invoice with no `issued_at`** gets one written at serialisation and reads it back.
|
|
3271
|
+
|
|
3272
|
+
Both are the same shape as §17.2 — a value the document must carry that the model derives
|
|
3273
|
+
rather than holds — and the same remedy would apply.
|
|
3274
|
+
|
|
3275
|
+
A third, bounding what tier 3 can ever detect: **`P_13_*`/`P_14_*` are still recomputed** on a
|
|
3276
|
+
derived invoice rather than carried. Rewrite Przykład 1's `P_13_1` to 1700.00 and the
|
|
3277
|
+
re-serialised document silently restores 1666.66, with `#unmapped_elements` empty and tier 3
|
|
3278
|
+
silent — the rule compares the document against itself, and the model's derivation is not part
|
|
3279
|
+
of that comparison. Catching it needs a value-level provenance diagnostic, which is the
|
|
3280
|
+
generalisation of §8.6's "a path-difference diagnostic cannot see a changed value".
|
|
3281
|
+
|
|
3282
|
+
### 17.4 What would ground the next rule
|
|
3283
|
+
|
|
3284
|
+
In order of strength:
|
|
3285
|
+
|
|
3286
|
+
1. **`Rozliczenie/DoZaplaty`** — the one arithmetic identity the XSD actually states
|
|
3287
|
+
(§15.6), holding exactly in all four corpus samples that carry it. Blocked only by the model
|
|
3288
|
+
not carrying `Rozliczenie`.
|
|
3289
|
+
2. **`P_15` less the sum of `P_15Z`** — the amount beyond pre-payments; stated in the Ministry's
|
|
3290
|
+
brochure, which is not a pinned artifact (§15.6 grounding 2).
|
|
3291
|
+
3. **Observation against TEST** — reliable, slow, and it only ever finds rules we thought to
|
|
3292
|
+
probe for.
|
|
3293
|
+
|
|
3294
|
+
**Do not synthesise rules from Polish VAT law and record them as verified facts** (§15.6). Every
|
|
3295
|
+
rule added to `RULES` needs a ledger entry saying what grounds it.
|
|
3296
|
+
|
|
3297
|
+
---
|
|
3298
|
+
|
|
3299
|
+
## 18. Schema shapes the codegen relies on
|
|
3300
|
+
|
|
3301
|
+
Recorded 2026-08-26. The first two were measured while generating `docs/field_mapping.md`
|
|
3302
|
+
(DESIGN.md §7.2); §18.2 was found while extending the extractor for `Zalacznik`. All are
|
|
3303
|
+
properties of the pinned FA(3) XSD that a generator relies on, so they belong here rather
|
|
3304
|
+
than only in its comments.
|
|
3305
|
+
|
|
3306
|
+
**FA(3) declares no unbounded element.** `maxOccurs="unbounded"` appears **zero** times; every
|
|
3307
|
+
repeat is capped — `FaWiersz` at 10 000, `DaneFaKorygowanej` at 50 000, `FakturaZaliczkowa` at
|
|
3308
|
+
100, `Podmiot2K` at 101. `Generated::Types` writes `max: nil` for an unbounded element and
|
|
3309
|
+
never has occasion to. The generator raises rather than rendering half a bound if that ever
|
|
3310
|
+
changes, because a dangling `0–` reads as a formatting slip rather than as a fact nobody has
|
|
3311
|
+
checked.
|
|
3312
|
+
|
|
3313
|
+
**Eleven element names carry different `xsd:documentation` in different places**, out of 273
|
|
3314
|
+
that carry any: `DaneIdentyfikacyjne` (5 variants), `Adres` (4), `NrEORI` (4), `AdresKoresp`
|
|
3315
|
+
(4), `DaneKontaktowe` (4), `Email` (3), `Telefon` (3), `KodKraju` (2), `NrKlienta` (2), `Kwota`
|
|
3316
|
+
(2), `Powod` (2). A description looked up by bare name is therefore ambiguous for those, and
|
|
3317
|
+
the first version of the field mapping dropped them — leaving `Adres` and `KodKraju` blank,
|
|
3318
|
+
which is where the Ministry states *when a buyer's address may be omitted*. Resolving the
|
|
3319
|
+
element by **path** removes the ambiguity entirely, and is what the generator does now.
|
|
3320
|
+
|
|
3321
|
+
### 18.1 `Generated::Types` is flattened, and cardinality must not be read from it
|
|
3322
|
+
|
|
3323
|
+
The generated metadata hoists the children of an `xsd:choice` and of a
|
|
3324
|
+
`<xsd:sequence minOccurs="0">` up to their parent, because the serializer needs element
|
|
3325
|
+
*order* and nothing else. Occurrence counts survive that hoisting unchanged, so an element
|
|
3326
|
+
inside an optional group reports `min: 1` and a choice branch reports `min: 1` — both
|
|
3327
|
+
"mandatory", both wrong.
|
|
3328
|
+
|
|
3329
|
+
Measured consequences, each of which the first field mapping printed as fact: the buyer's
|
|
3330
|
+
`NIP` is one branch of a four-way choice; the buyer's `Nazwa` sits in an optional sequence;
|
|
3331
|
+
`P_15ZK` sits in two nested optional sequences; `NrFaZaliczkowej` and `NrKSeFFaZaliczkowej`
|
|
3332
|
+
are the two branches of one choice and were both rendered "occurs exactly 1", which is not a
|
|
3333
|
+
document any issuer can produce.
|
|
3334
|
+
|
|
3335
|
+
**The buyer's name being optional is §8.2a's three-time bug**, and printing it as mandatory in
|
|
3336
|
+
a table aimed at auditors is the same error in a new medium. Anything that needs *effective*
|
|
3337
|
+
cardinality must walk the XSD; `Generated::Types` answers a different question.
|
|
3338
|
+
|
|
3339
|
+
### 18.2 An attribute belongs to the type that declares it, and the extractor said otherwise
|
|
3340
|
+
|
|
3341
|
+
The extractor read attributes down a **descendant** axis, `.//xsd:attribute[@name]`, so every
|
|
3342
|
+
complexType inherited the attributes of everything nested beneath it. Seven types claimed an
|
|
3343
|
+
attribute; two declare one. Measured by running the pre-fix extractor: `TNaglowek` claimed
|
|
3344
|
+
`kodSystemowy` and `wersjaSchemy`, which are declared on `KodFormularza`; and `Faktura`,
|
|
3345
|
+
`Zalacznik`, `BlokDanych`, `Tabela`, the attachment's own `TNaglowek` **and `Kol` itself** each
|
|
3346
|
+
reported `Kol`'s `Typ` — five inheriting it and one declaring it.
|
|
3347
|
+
|
|
3348
|
+
An earlier version of this paragraph listed `KodFormularza` among the claimants, which cannot be
|
|
3349
|
+
true and is contradicted by the next paragraph: `KodFormularza` had no key at all. It also
|
|
3350
|
+
omitted `Kol`, the one type that really did declare what it reported. Corrected 2026-08-26 after
|
|
3351
|
+
an audit re-ran the old extractor rather than reading the description of it.
|
|
3352
|
+
|
|
3353
|
+
**It survived because it produced a correct document.** `DocumentMapping#header` reads the two
|
|
3354
|
+
fixed attributes and writes them onto `KodFormularza` — the right element, found at the wrong
|
|
3355
|
+
level, because the leak surfaced them one step up. Nothing downstream could tell: generated
|
|
3356
|
+
metadata that reads plausibly and is wrong is worse than metadata that is missing.
|
|
3357
|
+
|
|
3358
|
+
A second defect hid the first. Anonymous complexTypes were collected only by descending from
|
|
3359
|
+
the `Faktura` element, and that descent stops at any element declared with a named `type`. FA(3)
|
|
3360
|
+
has exactly one anonymous type nested inside a named one — `KodFormularza`, inside `TNaglowek` —
|
|
3361
|
+
so the correct key did not exist, and reading `TNaglowek` was the only lookup available. Both are
|
|
3362
|
+
fixed: anonymous types nested in a named type are keyed from the type name
|
|
3363
|
+
(`"TNaglowek/KodFormularza"`), and attributes are read from the type's own children plus its
|
|
3364
|
+
`xsd:simpleContent`/`xsd:complexContent` extension, which are wrappers around its own definition
|
|
3365
|
+
rather than a descent.
|
|
3366
|
+
|
|
3367
|
+
**Attributes may also carry an inline enumeration, and `Enums` cannot see it.** `Enums` keys on
|
|
3368
|
+
`xsd:simpleType[@name]`; `Kol/@Typ` restricts an anonymous one. It is FA(3)'s only such
|
|
3369
|
+
attribute, and its six values — `date`, `datetime`, `dec`, `int`, `time`, `txt` — are the column
|
|
3370
|
+
types of an attachment table. They are now carried as `values:` on the attribute, because the
|
|
3371
|
+
alternative is to restate them in Ruby, which DESIGN.md §7.1 forbids.
|
|
3372
|
+
|
|
3373
|
+
**Two more the same audit found, both making the metadata a false statement about the schema.**
|
|
3374
|
+
|
|
3375
|
+
`compositor_of` looked for `xsd:sequence`/`xsd:choice` among a type's direct children, so a type
|
|
3376
|
+
whose model arrives through `xsd:complexContent/xsd:extension` reported **no content model at
|
|
3377
|
+
all**. FA(3) has exactly one, `Faktura/Podmiot1/AdresKoresp` (`<xsd:extension base="tns:TAdres"/>`),
|
|
3378
|
+
and the XSD really does permit `KodKraju`/`AdresL1`/`AdresL2`/`GLN` there — so `Serializer`
|
|
3379
|
+
refused those elements with a message naming an **empty** list of permitted ones. Latent only
|
|
3380
|
+
because no model carries `AdresKoresp`. The general form is worse: a non-empty extension also
|
|
3381
|
+
loses every anonymous type declared beneath it.
|
|
3382
|
+
|
|
3383
|
+
And `element_particle` kept an inline restriction's `base` while discarding its enumerations and
|
|
3384
|
+
any `fixed`. Three elements carry an inline enumeration — `WariantFormularza`, `JST`, `GV` — and
|
|
3385
|
+
one carries `fixed` (`PrefiksPodatnika`, `fixed="PL"`, which rendered identically to the
|
|
3386
|
+
declaration without it). The cost was immediate and visible: `DocumentMapping#header` hand-wrote
|
|
3387
|
+
`"WariantFormularza" => 3` six lines below a comment saying the fixed values are read from this
|
|
3388
|
+
metadata. They were not, because the metadata did not carry them. It does now, and the line reads
|
|
3389
|
+
the enumeration.
|
|
3390
|
+
|
|
3391
|
+
One more, latent: `Renderer::KEY_ORDER` did not list `use` or `fixed`, and `sorted_keys` maps
|
|
3392
|
+
every unlisted key to one shared rank. `sort_by` is not stable, so those two tied on every
|
|
3393
|
+
rendered attribute — the same determinism trap that reached CI from `tasks/field_mapping.rb`.
|
|
3394
|
+
Adding `values` would have made three. Every key a rendered Hash can hold is now listed.
|
|
3395
|
+
|
|
3396
|
+
## 19. Defects the 2026-08-26 audit round found
|
|
3397
|
+
|
|
3398
|
+
Numbered as their own chapter because they were appended to §17 while it was being written and
|
|
3399
|
+
ended up **inside chapter 18**, numbered as though they belonged to §17. One of them, `§17.4`,
|
|
3400
|
+
was a true duplicate colliding with the tier-3 section that DESIGN.md and CLAUDE.md both cite;
|
|
3401
|
+
`§17.5` and `§17.6` were merely misfiled, with no counterpart to collide with. Renumbered
|
|
3402
|
+
2026-08-26; `CHANGELOG.md` and `spec/ksef/fa3/correction_spec.rb` point here now.
|
|
3403
|
+
|
|
3404
|
+
### 19.1 `StanPrzed` rows are not summed
|
|
3405
|
+
|
|
3406
|
+
Recorded 2026-08-26. `Summaries#net_by_rate` and `#vat_by_rate` skip a line marked
|
|
3407
|
+
`state_before`, and that is a correctness fix rather than a tidy.
|
|
3408
|
+
|
|
3409
|
+
A `StanPrzed` row states a position **as it was** before the correction; a `KOR` shows it beside
|
|
3410
|
+
its replacement so a reader can see both. Adding the two together answers a question nobody
|
|
3411
|
+
asked. Measured on Przykład 2:
|
|
3412
|
+
|
|
3413
|
+
| | before the fix | after |
|
|
3414
|
+
|---|---|---|
|
|
3415
|
+
| `net_by_rate` | `{"23" => 3089.42}` — 1626.01 *plus* 1463.41 | `{"23" => 1463.41}` |
|
|
3416
|
+
| `net_total` | −162.60 | −162.60 |
|
|
3417
|
+
|
|
3418
|
+
A caller building a per-rate VAT report over downloaded invoices got a figure nineteen times
|
|
3419
|
+
the truth, with no error and a passing `#valid?`.
|
|
3420
|
+
|
|
3421
|
+
**Nothing that derives a summary is affected**, which is what makes the change safe: tier 1's
|
|
3422
|
+
`SummaryChecks::DERIVATION_BLOCKERS` already refuses a `state_before` row on an invoice that
|
|
3423
|
+
derives, so these rows only ever appear where the summary is stated and `net_by_rate` is not
|
|
3424
|
+
what produces the document.
|
|
3425
|
+
|
|
3426
|
+
Note what the fix does *not* claim. `net_by_rate` now answers the **after** state, not the
|
|
3427
|
+
delta — the delta is what `Totals` states and what `#net_total` returns. Two different
|
|
3428
|
+
questions, and §8.4 is why: a correction's buckets are deltas that its rows need not determine.
|
|
3429
|
+
|
|
3430
|
+
### 19.2 The parser's own document was never consulted
|
|
3431
|
+
|
|
3432
|
+
`Invoice#errors` runs tier 2 over `#to_xml` — bytes this gem has just produced, well-formed by
|
|
3433
|
+
construction — so **tier 2 is structurally incapable of seeing the input**. libxml2 recovers
|
|
3434
|
+
from broken XML by default, so a document with a mismatched closing tag parsed into a
|
|
3435
|
+
good-looking invoice and `#valid?` answered **true** for XML that is not XML.
|
|
3436
|
+
|
|
3437
|
+
That is the same defect §15.1 records as fixed on 2026-08-24, one level up: the fix then was to
|
|
3438
|
+
teach `Validator` to consult `document.errors`, and the parser's own retained document was never
|
|
3439
|
+
wired the same way. `Provenance#source_errors` now reads it, and `#errors` reports it first.
|
|
3440
|
+
|
|
3441
|
+
Recovery also substitutes silently — an invalid UTF-8 byte becomes U+FFFD — and this is the only
|
|
3442
|
+
record that it happened.
|
|
3443
|
+
|
|
3444
|
+
**It bounds a wider family.** These all parse, and none is legal FA(3): `+1500.00`, `0001500.00`,
|
|
3445
|
+
`1.5e3`, a duplicated `<P_11>`, and `<P_11>1500.4567</P_11>` stored and re-emitted as `1500.46`.
|
|
3446
|
+
The last is §8.6's `P_9A` class at a different element. Catching those needs the input validated
|
|
3447
|
+
against the schema rather than the output — *"validate the bytes you were given, not the bytes
|
|
3448
|
+
you would write"* — which is a larger change than this one and is not made here.
|
|
3449
|
+
|
|
3450
|
+
### 19.3 Two encodings, one predicate
|
|
3451
|
+
|
|
3452
|
+
`String#valid_encoding?` answers **true** for a string that is validly encoded in something that
|
|
3453
|
+
is not UTF-8. Every guard in this gem tested it, so a `Windows-1250` or `ISO-8859-2` name — what
|
|
3454
|
+
a Polish ERP emits — passed tier 1 and then raised `Encoding::CompatibilityError` out of
|
|
3455
|
+
`#errors`, `#to_xml` and `Ksef::Client#send_invoice`. DESIGN.md §7.7 promises `#errors` reports rather than
|
|
3456
|
+
raises "including for text that is tagged UTF-8 but is not"; that held for invalid *bytes* and
|
|
3457
|
+
failed for a valid non-UTF-8 tag.
|
|
3458
|
+
|
|
3459
|
+
`FieldChecks.utf8?` is now the one place that decides, and it distinguishes three cases:
|
|
3460
|
+
|
|
3461
|
+
| Encoding | Rule | Why |
|
|
3462
|
+
|---|---|---|
|
|
3463
|
+
| `UTF-8` | `valid_encoding?` | as before |
|
|
3464
|
+
| `ASCII-8BIT` | valid when the bytes *are* UTF-8 | binary asserts nothing, so reading unambiguous bytes is not a guess — this is what `File.binread` produces |
|
|
3465
|
+
| anything else | refused, naming the encoding | a declared encoding is a statement, and overriding it would be guessing |
|
|
3466
|
+
|
|
3467
|
+
`Windows-1250` "Łódź" begins `A3`, a UTF-8 continuation byte, so it fails the binary test too and
|
|
3468
|
+
could not slip through even if mislabelled.
|