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/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,1426 @@ gem version for which API state".
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.1.0] — 2026-09-14
|
|
14
|
+
|
|
15
|
+
**Targets:** KSeF API 2.0 · FA(3) `1-0E` · upstream `CIRFMF/ksef-api@1c34fe27`,
|
|
16
|
+
`CIRFMF/ksef-client-csharp@406904d6`, `CIRFMF/ksef-pdf-generator@2b7c1dae` (sample corpus,
|
|
17
|
+
`docs/REFERENCE.md` §1.4)
|
|
18
|
+
|
|
19
|
+
> **What "works" means in this section.** As of 2026-08-24 the certificate/XAdES flow **and**
|
|
20
|
+
> the full send path have run against the live KSeF TEST service: a session opened, an invoice
|
|
21
|
+
> built by this gem encrypted, submitted and accepted, a KSeF number assigned, and the signed
|
|
22
|
+
> UPO retrieved and hash-verified. The **KSeF-token auth call and the crypto module went live
|
|
23
|
+
> the same day** — a stale `timestampMs` refused by TEST, and `certificateId`/`publicKeyId`
|
|
24
|
+
> recomputed against real certificates (run `32704511675`, `docs/REFERENCE.md` §9). **Token
|
|
25
|
+
> refresh and invoice download have since run against TEST too** (2026-08-26), each with a
|
|
26
|
+
> committed cassette, so nothing in the shipped surface is WebMock-only any more. Batch has no
|
|
27
|
+
> code at all yet, so it is absent rather than stubbed.
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
|
|
31
|
+
- **`Ksef::Client#wait_for_session`** — waits until KSeF has finished processing a whole
|
|
32
|
+
session, rather than just closing it.
|
|
33
|
+
|
|
34
|
+
**Closing and finishing are two different clocks.** `#session` closes on the way out, which
|
|
35
|
+
*starts* asynchronous generation of the collective UPO; the session then sits at `170` until
|
|
36
|
+
that completes at `200`. `#wait_until_accepted` does not cover it — an accepted invoice says
|
|
37
|
+
nothing about the session's own progress — so a caller that sends, waits for the invoice and
|
|
38
|
+
then asks for `#collective_upo` is racing.
|
|
39
|
+
|
|
40
|
+
The wait already existed on `Sessions::Status` and simply was not reachable through the
|
|
41
|
+
facade, which left `#collective_upo`'s own documentation instructing callers to poll
|
|
42
|
+
`#session_status` by hand. Two places in this repository did exactly that, and the live one
|
|
43
|
+
got it wrong — see below.
|
|
44
|
+
|
|
45
|
+
- **A recorded flow for the two retrieval paths nothing had ever run**:
|
|
46
|
+
`GET /invoices/ksef/{ksefNumber}` and the pre-signed **storage** leg. All 31 interactions in
|
|
47
|
+
the first three cassettes were on the API host, and none touched `/invoices/ksef/` — so
|
|
48
|
+
`Invoices::Client#download`, `HTTP::Connection.storage` and `x-ms-meta-hash` verification *on
|
|
49
|
+
that route* were carried, documented and WebMock-verified without ever having run. DESIGN.md
|
|
50
|
+
§9.1 asserted the storage request "is part of the cassette too", which was never true:
|
|
51
|
+
`Client#upo` deliberately uses the metered per-invoice route, so the unmetered link is only
|
|
52
|
+
reached by `#collective_upo`. The `uri_without_param` matcher existed the whole time for a
|
|
53
|
+
request nothing made. One invoice covers both paths.
|
|
54
|
+
|
|
55
|
+
Scrubbing that request needed two changes. The **request URI** is now redacted, not just
|
|
56
|
+
bodies — the storage leg puts the signature in the request line, where nothing was looking.
|
|
57
|
+
And the placeholder stays a query *parameter* (`sig=<REDACTED>`), because a bare marker leaves
|
|
58
|
+
a query the matcher cannot strip, so a request replayed from the scrubbed body would not match
|
|
59
|
+
the recorded one. All twelve SAS parameters are ignored for matching now, not just the six
|
|
60
|
+
that look secret.
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
- **A golden file for the attachment** (`spec/fixtures/fa3/golden/vat_attachment.xml`), carrying
|
|
64
|
+
two blocks. A mutation audit found eight of thirty-eight mutations surviving a suite with
|
|
65
|
+
100% line, branch and method coverage of this code — coverage was measuring reachability, not
|
|
66
|
+
constraint. Two facts had nothing pinning them: attribute-versus-child write order and
|
|
67
|
+
`<WKom/>` versus `<WKom></WKom>` are invisible to the XSD *and* to the round-trip law, because
|
|
68
|
+
parsing collapses both; and every corpus sample has exactly one `BlokDanych`, so taking
|
|
69
|
+
`blocks.first` anywhere dropped the rest in silence.
|
|
70
|
+
|
|
71
|
+
- **`Zalacznik`, the FA(3) attachment**, at build and parse level — the last implementation item
|
|
72
|
+
in Phase 3's scope (DESIGN.md §7.4). `Ksef::FA3::Attachment`, `DataBlock`, `MetaEntry`,
|
|
73
|
+
`AttachmentTable` and `TableColumn`, read by `AttachmentReader` and built through
|
|
74
|
+
`Ksef::FA3.build`'s `f.attachment`. The Ministry's two attachment samples listed 17 attachment
|
|
75
|
+
paths in `#unmapped_elements`; that set is now empty, and both round-trip.
|
|
76
|
+
|
|
77
|
+
An FA(3) attachment carries no bytes and no MIME type: it is a structured document of
|
|
78
|
+
headings, key/value metadata, paragraphs and tables, sitting beside `Fa` rather than inside
|
|
79
|
+
it, so it touches no summary. Three schema facts shape the model — **rows are ragged** (`Kol`
|
|
80
|
+
and `WKom` each repeat 1..20 and are related nowhere, and the corpus has one-cell rows heading
|
|
81
|
+
nine-cell ones), **an empty cell is legal and distinct from an absent one** (`TZnakowy2` has
|
|
82
|
+
`minLength="0"`), and **`MetaDane` is the only mandatory child of a block**. Operational
|
|
83
|
+
constraints on *sending* one stay out of 0.1 scope. (`docs/REFERENCE.md` §8.7.)
|
|
84
|
+
|
|
85
|
+
- **The release workflow now creates a GitHub release**, with the version's `CHANGELOG.md`
|
|
86
|
+
section as its body (`rake 'release:notes[X.Y.Z]'` prints what will be published). It is a
|
|
87
|
+
second job, running **after** the gem is pushed and with its own `contents: write`, so the job
|
|
88
|
+
that runs this gem's own code during publishing never holds write access to the repository —
|
|
89
|
+
and a release can never announce a publish that then failed. Prerelease status comes from
|
|
90
|
+
`Gem::Version#prerelease?`, so `v0.1.0.rc1` is flagged without the workflow keeping its own
|
|
91
|
+
opinion about version strings. Tagging while the entries are still under `[Unreleased]` fails
|
|
92
|
+
the job rather than publishing empty notes.
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
- **`spec/tasks/fa3_codegen_spec.rb`** — the codegen had no unit spec and was outside SimpleCov
|
|
96
|
+
entirely, so `method: 100` never applied to it. Its only checks were `rake fa3:verify`, which
|
|
97
|
+
proves determinism rather than correctness, and assertions about the one schema it reads. Three
|
|
98
|
+
defects survived that arrangement. It is now driven against a synthetic schema written to hold
|
|
99
|
+
the constructs — a `complexContent` extension, an anonymous type nested in a named one, an
|
|
100
|
+
attribute on a nested type, inline enumerations on both an element and an attribute — because
|
|
101
|
+
testing only against FA(3) is what let the extension defect through: FA(3) has exactly one
|
|
102
|
+
extension and it is empty.
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
- **`POST /auth/token/refresh` has now run against live TEST**, closing the last of §4.2's six
|
|
106
|
+
auth calls to have never been exercised — and settling three assumptions the implementation
|
|
107
|
+
was making. The response carries `accessToken` **and nothing else**, so reading
|
|
108
|
+
`body["accessToken"]` is right and `renew!` is right to keep the refresh token. A renewed
|
|
109
|
+
token gets a **full fresh fifteen minutes**, not the remainder of the old one, so a long
|
|
110
|
+
session renews indefinitely within the refresh token's seven days — which are exactly seven
|
|
111
|
+
days on TEST, to the second. (`docs/REFERENCE.md` §4.2a.)
|
|
112
|
+
|
|
113
|
+
- **A recorded cassette for `POST /auth/token/refresh`** — the one auth call whose real response
|
|
114
|
+
had never been seen (`docs/REFERENCE.md` §4.2: the 2026-08-23 live run covered five of six).
|
|
115
|
+
`Auth::Client#refresh` reads `body["accessToken"]` on the OpenAPI contract's word alone, and a
|
|
116
|
+
wrong envelope would not raise — it yields a credential holding no token. The example advances
|
|
117
|
+
its clock to 90% of the token's observed lifetime, past the 80% refresh threshold and short of
|
|
118
|
+
expiry, so it exercises the *proactive* renewal rather than an expiry. It costs an
|
|
119
|
+
authentication and no invoice. (DESIGN.md §9.1.)
|
|
120
|
+
|
|
121
|
+
- **`rake vcr:record` takes an optional target**, and so does the dispatch workflow:
|
|
122
|
+
`rake 'vcr:record[spec/recorded/auth_refresh_spec.rb]'`. `record: :all` re-records everything
|
|
123
|
+
it runs and each session-flow example submits an unwithdrawable invoice, so recording one new
|
|
124
|
+
flow used to cost two permanent TEST invoices for flows that had not changed. The replay step
|
|
125
|
+
still runs the whole tier, because a recording that replays only what it wrote cannot see what
|
|
126
|
+
it broke.
|
|
127
|
+
|
|
128
|
+
- **Attribute enumerations declared inline are now captured by the codegen** as `values:` on the
|
|
129
|
+
attribute. FA(3) has exactly one — `Kol/@Typ`, the six column types of an attachment table
|
|
130
|
+
(`date`, `datetime`, `dec`, `int`, `time`, `txt`) — and `Generated::Enums` cannot see it,
|
|
131
|
+
because it keys on named `xsd:simpleType`. Without this the only way to enforce the six is to
|
|
132
|
+
restate them in Ruby, which DESIGN.md §7.1 forbids. Groundwork for `Zalacznik`.
|
|
133
|
+
(`docs/REFERENCE.md` §18.2.)
|
|
134
|
+
|
|
135
|
+
- **The recorded test tier** — three VCR cassettes, 31 interactions, replaying in about 1.4
|
|
136
|
+
seconds with **no credentials present**: authenticate, open a session, encrypt and submit an
|
|
137
|
+
invoice, poll to acceptance, fetch the UPO, and renew an access token. They pin two things no stub can give —
|
|
138
|
+
a KSeF number whose CRC-8 agrees with ours, and a UPO that is XAdES-signed although upstream's
|
|
139
|
+
own UPO schema declares no `ds:Signature` (`docs/REFERENCE.md` §14.7).
|
|
140
|
+
|
|
141
|
+
- **Its harness** (`spec/support/vcr.rb`, `rake vcr:record`) — VCR wired to
|
|
142
|
+
the same WebMock the rest of the suite uses, scrubbing for every secret
|
|
143
|
+
`spec/cassette_hygiene_spec.rb` scans for, and a `:recorded` tag excluded until a cassette
|
|
144
|
+
exists. **Recording stays a deliberate human-run step** rather than part of `rake`: it needs
|
|
145
|
+
TEST credentials, creates a permanent TEST invoice and burns rate-limited quota. A
|
|
146
|
+
`:release_check` gate refuses 0.1.0 while `spec/cassettes/` is empty, so an emptied tier
|
|
147
|
+
cannot be forgotten. (DESIGN.md §9.1.)
|
|
148
|
+
|
|
149
|
+
Two decisions bind. **Requests are never matched on the body** — `Encryptor.generate` draws a
|
|
150
|
+
random key and IV and RSA-OAEP padding is randomised, so a recorded body cannot be reproduced
|
|
151
|
+
and a body matcher would present as a flaky test rather than an impossible one. And
|
|
152
|
+
**`record: :none` unless `KSEF_VCR_RECORD=1`**, so a deleted cassette fails loudly instead of
|
|
153
|
+
silently reaching TEST and recording a fresh invoice.
|
|
154
|
+
|
|
155
|
+
- **`Ksef::Client#session(encryptor:)` and `#send_invoice(encryptor:)`** — a seam for the
|
|
156
|
+
recorded tier alone. A session's symmetric key is per-session by design
|
|
157
|
+
(`docs/REFERENCE.md` §11.2a) and callers should keep letting it be generated; a replay has to
|
|
158
|
+
supply the key its recording used, and without this the tier would bypass the facade and stop
|
|
159
|
+
testing the path users call.
|
|
160
|
+
|
|
161
|
+
- **`docs/field_mapping.md`** — the English↔Polish field table, listing every attribute this
|
|
162
|
+
model carries against the FA(3) element it reads and writes, with the element's XSD type,
|
|
163
|
+
**effective** cardinality and the Ministry's own description in full. **Generated** by `rake fa3:field_mapping`
|
|
164
|
+
from a declared mapping plus the pinned schema, and `rake fa3:verify` fails if the committed
|
|
165
|
+
file is stale — the same gate `lib/ksef/fa3/generated/` gets.
|
|
166
|
+
|
|
167
|
+
Three guards make drift loud rather than silent: an element path that does not resolve
|
|
168
|
+
against the schema aborts the run, an attribute that is not a member of its model aborts, and
|
|
169
|
+
a model member that is neither mapped nor given a reason aborts. Adding a field to a model
|
|
170
|
+
without saying where it goes fails the build.
|
|
171
|
+
|
|
172
|
+
- **Validator tier 3** — `Ksef::FA3::BusinessValidator`, reached through **`Invoice#warnings`**. It holds **one rule**, and the size is the finding rather than a shortfall: no
|
|
173
|
+
file in `CIRFMF/ksef-api` states a reconciliation rule anywhere, and the only business
|
|
174
|
+
validation KSeF ever proposed was withdrawn after it turned out to reject legal invoices. So
|
|
175
|
+
the tier is built on the one grounding that needs no *catalogue*: measurement over the
|
|
176
|
+
Ministry's 26 worked examples. It is **empirical, not definitional** — `P_15`'s annotation says
|
|
177
|
+
nothing about the buckets, and checking `P_13_1` against the rows instead is falsified by ten
|
|
178
|
+
of the fourteen modelled stated-summary samples, because a correction's buckets are deltas.
|
|
179
|
+
|
|
180
|
+
**It is advisory and never makes an invoice invalid**, which is the whole design. The rule is
|
|
181
|
+
`Σ P_13_* + Σ P_14_* ≈ P_15` compared over figures the *document* states — never against the
|
|
182
|
+
model's own derivation — with a one-grosz tolerance and a bucket-presence guard. A Polish
|
|
183
|
+
invoice whose nets are computed back from round gross prices misses it by roughly a grosz per
|
|
184
|
+
line and is entirely legal; the Ministry's own Przykład 1 is one. Making that an error would
|
|
185
|
+
refuse legal documents through `Client#send_invoice`, which is exactly what got KSeF's own
|
|
186
|
+
proposed business rule withdrawn. The `W` twins are excluded — `P_14_1W` is a PLN equivalent,
|
|
187
|
+
not a second tax. (`docs/REFERENCE.md` §17.)
|
|
188
|
+
|
|
189
|
+
- **`Invoice#warnings`**, the advisory tier — empty unless a document's own figures disagree.
|
|
190
|
+
|
|
191
|
+
- **`Invoice#stated_gross`** and **`Invoice#summary_buckets`**, both public. The first carries
|
|
192
|
+
the document's `P_15` when it differs from the derived figure; the second is the summary as
|
|
193
|
+
the document will carry it, rounded per bucket.
|
|
194
|
+
|
|
195
|
+
### Fixed
|
|
196
|
+
|
|
197
|
+
- **The live integration suite asserted a terminal session code without waiting for one**, so
|
|
198
|
+
the nightly of 2026-09-13 went red on a commit that passed on 09-12 and 09-14. It read
|
|
199
|
+
`session_status` once, immediately after `wait_until_accepted`, and required `code >= 200` —
|
|
200
|
+
but `170` means closed with the collective UPO still generating, exactly as
|
|
201
|
+
`SessionCodes`' own documentation says. A race, not a flake: roughly one night in ten on
|
|
202
|
+
this sample, which is enough to reset a three-consecutive-night release gate. It now uses
|
|
203
|
+
the new `Client#wait_for_session`.
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
- **`json` 3.0.0 broke every API response, for users as well as CI.** json 3 dropped the second
|
|
207
|
+
*positional* argument to `JSON.parse`, and `Faraday::Response::Json#parse` still passes one —
|
|
208
|
+
so every JSON response from KSeF raised
|
|
209
|
+
`Faraday::ParsingError: wrong number of arguments (given 2, expected 1)`. Since the gemspec
|
|
210
|
+
requires `faraday "~> 2.0"`, faraday declares `json >= 0`, and **faraday 2.14.3 is the latest
|
|
211
|
+
release**, a plain `gem install ksef_client` produced a client that could not read any
|
|
212
|
+
response. Measured in a clone bundled against json 3.0.0: **1580 examples, 163 failures**, one
|
|
213
|
+
cause; with the fix, **0 failures** with the coverage gates enforced.
|
|
214
|
+
|
|
215
|
+
It arrived with no commit to this repository. `Gemfile.lock` is gitignored by library
|
|
216
|
+
convention, so CI resolves fresh — and rubocop 1.90.0 relaxed its own `json ~> 2.3` pin to
|
|
217
|
+
`>= 2.3`, which let json 3.0.0 in. A transitive development pin had been shielding the build
|
|
218
|
+
by accident.
|
|
219
|
+
|
|
220
|
+
Faraday's response middleware takes a caller-supplied decoder, so `Ksef::HTTP::JsonDecoder`
|
|
221
|
+
now provides one and only the failing call changes. Everything else the middleware does is
|
|
222
|
+
retained — the content-type match with its `;` split, the `to_str` guard, blank body to `nil`,
|
|
223
|
+
and `Faraday::ParsingError` wrapping — which also leaves the spec pinning the middleware
|
|
224
|
+
ordering by class working untouched. `docs/REFERENCE.md` §4.9 records three measured traps
|
|
225
|
+
(a frozen options hash raises; a shared one silently reverts; `decoder: JSON` returns nil for
|
|
226
|
+
a valid body) and a **removal trigger**: faraday merged json 3 support in PR #1687 on
|
|
227
|
+
2026-08-12 but has not released it, and the shim goes when it does.
|
|
228
|
+
|
|
229
|
+
No new dependency, and no version pin — pinning `json` would have fought faraday's own
|
|
230
|
+
`json >= 0` and rubocop's `json >= 2.3`, making the gem uninstallable next to them.
|
|
231
|
+
|
|
232
|
+
- **The cassette-recording workflow discarded the recordings it had just paid for.**
|
|
233
|
+
`record-cassettes.yml` has never succeeded: four dispatches, four failures. The two that got
|
|
234
|
+
furthest recorded real cassettes, passed the credential scan, then failed the *replay* step —
|
|
235
|
+
and `upload-artifact` carried no `if:`, so its implicit `success()` skipped it. Each of those
|
|
236
|
+
runs created a permanent, unwithdrawable TEST invoice and produced nothing retrievable. It is
|
|
237
|
+
why every cassette in this repository was recorded locally, against DESIGN.md §9.1's own
|
|
238
|
+
decision that recording belongs in CI.
|
|
239
|
+
|
|
240
|
+
Now `if: always() && steps.hygiene.outcome == 'success'`. Deliberately **not** `if: always()`,
|
|
241
|
+
which would publish after a failed credential scan — exactly when a cassette may hold a live
|
|
242
|
+
credential, to an artifact anyone with the run id can fetch. Verification that runs after an
|
|
243
|
+
irreversible step must not be able to destroy its output: fail the job, keep the artifact.
|
|
244
|
+
|
|
245
|
+
- **The "refuse to run against production" guard could not fire.** Both credentialed workflows
|
|
246
|
+
set `KSEF_ENV: test` in the guard step's own `env:` and then asked whether it equalled `prod`
|
|
247
|
+
— a literal against a different literal, in the step whose whole purpose is to be able to
|
|
248
|
+
fail. `KSEF_ENV` now sits on the job, so the guard validates the one setting every step
|
|
249
|
+
inherits, and it is an allow-list (`!= "test"`) rather than a `prod` denylist, matching
|
|
250
|
+
`rake vcr:record`'s own guard.
|
|
251
|
+
|
|
252
|
+
- **The rules the workflows must obey are asserted now, not reviewed.**
|
|
253
|
+
`spec/workflows_spec.rb` reads `.github/workflows/*.yml` and fails on an expression
|
|
254
|
+
interpolated into a `run:` script (a hard rule that had already been violated once, enforced
|
|
255
|
+
only by comments), an unpinned action, a `KSEF_ENV` that is not `test`, a guard that sets the
|
|
256
|
+
value it checks, and an upload not gated on the credential scan. Three defects in three
|
|
257
|
+
workflows — this, the guard above, and the release-announce bug below — were all in code no
|
|
258
|
+
spec could reach.
|
|
259
|
+
|
|
260
|
+
Development-only; nothing shipped changes.
|
|
261
|
+
|
|
262
|
+
- **The GitHub-release job would have failed on every release, after the gem was published.**
|
|
263
|
+
`release.yml` passed `github.ref_name` — the *tag*, `v0.1.0` — to `ReleaseNotes.for`, which is
|
|
264
|
+
keyed on the CHANGELOG heading, `0.1.0`. One character, and it raises. The next step in the
|
|
265
|
+
same job strips the `v` correctly, so the two disagreed and the first one lost. `announce`
|
|
266
|
+
runs `needs: publish`, so the failure lands after the push to RubyGems: the irreversible half
|
|
267
|
+
succeeds and the recoverable half breaks.
|
|
268
|
+
|
|
269
|
+
Never caught because the spec and the workflow never met. Eight examples exercised
|
|
270
|
+
`ReleaseNotes.for`, all passing a bare version; the workflow was the only caller that exists,
|
|
271
|
+
and it passed a tag. The mapping is now `ReleaseNotes.version_from_tag`, in one place with a
|
|
272
|
+
test, and `spec/tasks/release_notes_spec.rb` reads `release.yml` and asserts the call — not
|
|
273
|
+
just the function.
|
|
274
|
+
|
|
275
|
+
Two things about the guard, since they look like bugs and are not: `Gem::Version.correct?("")`
|
|
276
|
+
is **true** (its pattern is entirely optional), which is what the tag `v` reduces to — so the
|
|
277
|
+
check requires a leading digit. And `0.1.O` is a *legal prerelease*, not a typo, so this
|
|
278
|
+
refuses tags that are not versions and deliberately does not try to catch mistyping.
|
|
279
|
+
|
|
280
|
+
Development-only; `tasks/` is not packaged.
|
|
281
|
+
|
|
282
|
+
- **Live integration specs could not reach the network at all**, so the nightly ran red for six
|
|
283
|
+
consecutive nights (2026-08-28 onward: 27 examples, 27 failures, one error class). The
|
|
284
|
+
recorded tier's `hook_into :webmock` installs a *global* WebMock stub, and
|
|
285
|
+
`StubRegistry#response_for_request` consults global stubs **before** WebMock asks whether a
|
|
286
|
+
real connection is allowed — so `WebMock.allow_net_connect!`, the live tier's opt-in since
|
|
287
|
+
long before VCR arrived here, stopped being read. VCR also aliases
|
|
288
|
+
`WebMock.net_connect_allowed?` to answer `true` while it is turned on. With no cassette in use
|
|
289
|
+
and `record: :none`, VCR refused every request with `UnhandledHTTPRequestError`.
|
|
290
|
+
|
|
291
|
+
The seam is now `spec/support/live_network.rb`, which turns VCR off for the example as well.
|
|
292
|
+
Nothing in the suite could have caught this — the live tier is the only tier that opens the
|
|
293
|
+
seam, so neither per-push CI nor `rake` exercises the hook — so `spec/live_network_spec.rb`
|
|
294
|
+
now asks `StubRegistry#response_for_request`, the exact call `Net::HTTP#request` makes, what
|
|
295
|
+
is handling a request with the seam open and closed. It needs no socket and no credential, and
|
|
296
|
+
its first example is the negative control that keeps the rest meaningful.
|
|
297
|
+
|
|
298
|
+
Test-only; no shipped behaviour changed.
|
|
299
|
+
|
|
300
|
+
- **`UPO::Client#fetch` judged a pre-signed link's expiry against the wall clock**, which made it
|
|
301
|
+
the one route decision in the library that `Ksef::Client.new(clock:)` did not reach. KSeF signs
|
|
302
|
+
a UPO `downloadUrl` for **exactly three days** (measured across three cassettes and two
|
|
303
|
+
recording sessions; `docs/REFERENCE.md` §14.2), so the retrieval cassette replayed correctly
|
|
304
|
+
for three days and then began taking the metered fallback — a request no recording contained.
|
|
305
|
+
The recorded tier went red on 2026-08-29 and stayed unseen until 2026-09-03, because nothing
|
|
306
|
+
pushed in between.
|
|
307
|
+
|
|
308
|
+
`UPO::Client` now takes a `clock:`, threaded from the facade. **Both branches had been covered
|
|
309
|
+
since the method was written**, using `Time.now + 600` and `Time.now - 60` — built from the
|
|
310
|
+
very clock the code read, so the two agreed by construction and no coverage criterion could
|
|
311
|
+
distinguish an injected clock from a wall-clock one. The regression examples pin a clock that
|
|
312
|
+
disagrees with the wall clock in both directions.
|
|
313
|
+
|
|
314
|
+
This affects a caller who holds a `UPO::Client` open across the link's three-day lifetime;
|
|
315
|
+
everything reached through `Ksef::Client` behaves as before.
|
|
316
|
+
|
|
317
|
+
- **The VCR record hooks raised on any body VCR delivered as `ASCII-8BIT`.** A UTF-8 pattern
|
|
318
|
+
matched against a binary string raises as soon as it holds a non-ASCII byte, and KSeF's bodies
|
|
319
|
+
are full of Polish. The first three cassettes never hit it because their bodies arrived tagged
|
|
320
|
+
UTF-8; the first `application/xml` responses did not, and a real recording died after creating
|
|
321
|
+
a permanent TEST invoice. `before_record` does not run on replay, so no local check exercised
|
|
322
|
+
it. Every pattern now matches in binary — all of them are pure ASCII, as is everything they
|
|
323
|
+
match — with the body's encoding restored afterwards, and `spec/recorded_tier_spec.rb` calls
|
|
324
|
+
the hooks with bodies encoded as VCR delivers them.
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
- **`#errors` raised instead of reporting for two attachment shapes.** `Invoice#attachment` was
|
|
328
|
+
a public constructor field tier 1a never inspected, so a value that was not an `Attachment`
|
|
329
|
+
gave `NoMethodError`, and a U+0000 in any of the attachment's nine text fields gave a bare
|
|
330
|
+
`ArgumentError` from libxml2 — outside this gem's error hierarchy, which the docs tell callers
|
|
331
|
+
to rescue. `FieldChecks` had the guard and predicted this exact failure in its own comments;
|
|
332
|
+
the attachment never reached it. `AttachmentChecks` now walks the node and reports with a
|
|
333
|
+
field path like `attachment.blocks[0].tables[0].rows[1][0]`, and `Attachment.wrap` moved into
|
|
334
|
+
the constructor so `Invoice.new` and `#with` cannot walk around it.
|
|
335
|
+
|
|
336
|
+
- **A legal attachment invoice over 1 MB was refused.** `DocumentValidator` hard-coded the
|
|
337
|
+
no-attachment ceiling with a comment saying attachments "are batch-only, so 0.1 has no reason
|
|
338
|
+
to carry the larger figure" — true exactly as long as the model could not carry one. The
|
|
339
|
+
ceiling now follows the document (3 MB with an attachment, `docs/REFERENCE.md` §6.2). This is
|
|
340
|
+
the "refuses legal invoices" failure §15.6 and §14.3 exist to prevent, introduced by the
|
|
341
|
+
change that made it reachable.
|
|
342
|
+
|
|
343
|
+
- **`DataBlock` and `AttachmentTable` froze the caller's array in place**, so building two
|
|
344
|
+
blocks from one accumulator raised `FrozenError` far from its cause. The `dup` that
|
|
345
|
+
`Correction` documents three files away was missing from two of five sites.
|
|
346
|
+
|
|
347
|
+
- **`docs/field_mapping.md` printed `paragraphs` and `totals` as required**, contradicting §8.7
|
|
348
|
+
in the same commit: both sit inside optional parents (`Tekst`, `Suma`). The cardinality walker
|
|
349
|
+
only considered wrappers inside an element's own complexType, and no declared path had crossed
|
|
350
|
+
an optional *element* before. It now reports cardinality relative to each model's own element
|
|
351
|
+
— "given a `Tabela`, is there a `Suma`?" — which is the question that section's reader is
|
|
352
|
+
asking. `rows` also showed `WKom`'s 1–20 where the attribute holds rows (1–1000).
|
|
353
|
+
|
|
354
|
+
- **`AttachmentReader`'s documented contract was the opposite of its behaviour**, claiming
|
|
355
|
+
nothing refuses a document and naming a reporter that did not exist. Eight structurally
|
|
356
|
+
invalid shapes raise; the comment now says which, why that is consistent with the rest of the
|
|
357
|
+
parser, and what the alternative would cost.
|
|
358
|
+
|
|
359
|
+
- Corrections found by the same audit: `docs/REFERENCE.md` §8.7 was physically inside chapter 18
|
|
360
|
+
— the mistake chapter 19's own introduction describes being repaired the same day — and cited
|
|
361
|
+
"§16" for a rule that is in §15.5; `serializer.rb` still said the model does not carry
|
|
362
|
+
`Zalacznik` three lines above the member added for it; the ragged-row description said
|
|
363
|
+
"heading a group of nine-cell ones" where the corpus alternates and one table is six wide; and
|
|
364
|
+
`CLAUDE.md`'s verification line carried the old branch figure in the sentence telling you to
|
|
365
|
+
keep it current.
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
- **A live pre-signed URL was committed in two cassettes.** KSeF hands out the UPO as an Azure
|
|
369
|
+
user-delegation SAS, where the query string *is* the authorisation — and both session cassettes
|
|
370
|
+
carried one, read-only and valid for three days. DESIGN.md §9.1 requirement 1 names this exact
|
|
371
|
+
value alongside tokens and key material; the requirement's URI-matching half shipped and its
|
|
372
|
+
scrubbing half did not. All four hygiene checks passed over it, because a SAS `sig` is neither
|
|
373
|
+
`Bearer`-prefixed, nor JWT-shaped, nor a value the scanning machine holds. The query is now
|
|
374
|
+
stripped on record, the committed cassettes are scrubbed, and the scanner has a fifth check.
|
|
375
|
+
History was deliberately not rewritten: the capability is read-only, scoped to one synthetic
|
|
376
|
+
TEST document, and expires 2026-08-29 by itself (DESIGN.md §9.1).
|
|
377
|
+
|
|
378
|
+
- **Command injection in the one workflow holding a live credential.** `${{ inputs.target }}` was
|
|
379
|
+
interpolated into a `run:` script that also exports `KSEF_TEST_TOKEN` — as was
|
|
380
|
+
`${{ inputs.confirm }}`, inside the guard enforcing the typed confirmation, on a step that runs
|
|
381
|
+
before checkout. Inputs now reach the shell through `env:`; `rake vcr:record` passes its target
|
|
382
|
+
as an argv entry rather than a command fragment, and its guard is anchored rather than a
|
|
383
|
+
`start_with?` that accepted `spec/recorded; …`. Both credential-bearing workflows declare
|
|
384
|
+
`permissions: contents: read`, and the cassette artifact expires after a day.
|
|
385
|
+
|
|
386
|
+
- **The cassettes would have stopped replaying on 2027-09-29.** `Client#public_keys` built
|
|
387
|
+
`Crypto::PublicKeys` without the clock, and `#for_usage` filters published certificates on
|
|
388
|
+
`valid_at?` — so the recorded certificate list expiring in 2027 would have taken the whole tier
|
|
389
|
+
with it. The same defect as the fifteen-minute one, in the second wall-clock consumer of the
|
|
390
|
+
same path.
|
|
391
|
+
|
|
392
|
+
- **`compositor_of` missed a `complexContent` extension**, so `Faktura/Podmiot1/AdresKoresp`
|
|
393
|
+
reported no content model and `Serializer` refused its children while naming an **empty** list
|
|
394
|
+
of permitted ones. A false statement about the schema, sourced from metadata.
|
|
395
|
+
(`docs/REFERENCE.md` §18.2.)
|
|
396
|
+
|
|
397
|
+
- **The codegen dropped element-level `fixed` and inline enumerations**, which is why
|
|
398
|
+
`DocumentMapping#header` hand-wrote `"WariantFormularza" => 3` six lines below a comment saying
|
|
399
|
+
the fixed values come from the metadata. Three elements carry an inline enumeration and one
|
|
400
|
+
carries `fixed`; all are captured now, and the header reads the enumeration.
|
|
401
|
+
|
|
402
|
+
- **`spec/cassette_hygiene_spec.rb`'s anti-vacuity guard was itself vacuous** —
|
|
403
|
+
`expect(cassettes.size).to be >= 0` — in the file written to expose vacuous passes. It now
|
|
404
|
+
asserts the scanner found every committed cassette. The env-secret check also failed *open* on
|
|
405
|
+
a body with invalid bytes, since `String#include?` answers false rather than raising.
|
|
406
|
+
|
|
407
|
+
- **A cassette held an interaction from an earlier, aborted recording**, on a different auth
|
|
408
|
+
reference number and 56 minutes older than the flow around it. It never replayed, but
|
|
409
|
+
`RecordedClock` seeds from the first interaction, so that cassette's pinned "now" was 56
|
|
410
|
+
minutes before the flow — making §9.1's claim about pinning false for one of three cassettes.
|
|
411
|
+
|
|
412
|
+
- **`ModelValidator`'s leaf test** asked whether the metadata had an entry, which stopped meaning
|
|
413
|
+
"takes a text value" the moment the codegen began emitting `simpleContent` types. It now asks
|
|
414
|
+
about `:content`.
|
|
415
|
+
|
|
416
|
+
- **The cassette hygiene scan read the file, and a cassette is not entirely text.** Psych stores
|
|
417
|
+
a body it cannot write as a plain scalar as `!binary`, and one non-ASCII byte is enough — five
|
|
418
|
+
of the tier's bodies are stored that way. A JWT inside one matches no regex over the file, and
|
|
419
|
+
`include?` of a known secret fails too. Since `Łódź` in a seller name is routine in Polish
|
|
420
|
+
e-invoicing, and the redeem response — the one that has actually leaked — carries both tokens
|
|
421
|
+
in a JSON body, this was the guard added after that leak being blind to the same leak. It now
|
|
422
|
+
decodes every body and header first, with a planted-secret example proving it.
|
|
423
|
+
(DESIGN.md §9.1.)
|
|
424
|
+
|
|
425
|
+
- **The recorded tier spent eight of its nine seconds asleep.** Authentication is asynchronous,
|
|
426
|
+
so `Auth::Client#wait_until_complete` sleeps between status polls — including on replay, where
|
|
427
|
+
every answer is already on disk. `Ksef::Client.new` now takes `sleeper:`, the same seam
|
|
428
|
+
`Sessions::Status#poll` has always had, and the tier replays in 1.6 s again.
|
|
429
|
+
|
|
430
|
+
- **`SimpleCov.add_filter` is deprecated in SimpleCov 1.1** and printed two lines on every run,
|
|
431
|
+
including inside `rake`, where a real coverage failure has to be spotted among them. Replaced
|
|
432
|
+
with `skip`.
|
|
433
|
+
|
|
434
|
+
- **The recorded cassettes stopped replaying about twelve minutes after they were recorded.**
|
|
435
|
+
A KSeF access token is valid for fifteen minutes and `Auth::AccessToken` refreshes at 80% of
|
|
436
|
+
that, so a replay against the real clock found the recorded credential stale and issued a
|
|
437
|
+
`POST /auth/token/refresh` the cassette has no interaction for. The suite went red overnight
|
|
438
|
+
with nothing changed; the verification that declared the tier green had run inside the window.
|
|
439
|
+
`Ksef::Client.new` now takes `clock:`, and the recorded specs pin it to the cassette's own
|
|
440
|
+
`recorded_at` — the response is never rewritten, because the tier's claim is that these are
|
|
441
|
+
the bytes KSeF sent. (DESIGN.md §9.1.)
|
|
442
|
+
|
|
443
|
+
- **The codegen attributed an XML attribute to every type above the one declaring it.** The
|
|
444
|
+
extractor used a descendant axis, so seven complexTypes claimed an attribute where only two
|
|
445
|
+
declare one. It survived because it produced a *correct document*: `DocumentMapping#header`
|
|
446
|
+
found `kodSystemowy`/`wersjaSchemy` on `TNaglowek`, one level above where `KodFormularza`
|
|
447
|
+
declares them,
|
|
448
|
+
and a second defect made that the only lookup available — anonymous types nested inside a
|
|
449
|
+
**named** type were never collected, so `TNaglowek/KodFormularza` did not exist. Both fixed,
|
|
450
|
+
and `Generated::Types` gained one entry. (`docs/REFERENCE.md` §18.2.)
|
|
451
|
+
|
|
452
|
+
- **`Renderer::KEY_ORDER` omitted two keys it renders**, and `sorted_keys` gives every unlisted
|
|
453
|
+
key the same rank while `sort_by` is unstable — so `use` and `fixed` tied on every rendered
|
|
454
|
+
attribute. The same trap produced a macOS/Linux difference in `tasks/field_mapping.rb`. Every
|
|
455
|
+
key a rendered Hash can hold is now listed. (§18.2.)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
- **A `VAT` invoice derived `P_15` instead of reading it**, so re-serialising the Ministry's
|
|
459
|
+
Przykład 1 produced an invoice a grosz cheaper — 2050.99 against a stated 2051 — with tier 2
|
|
460
|
+
clean, `#errors` empty and `#unmapped_elements` silent, because `P_15` is present either way
|
|
461
|
+
and a path-difference diagnostic cannot see a changed value. Even the round-trip law held: a
|
|
462
|
+
parsed invoice is already a fixed point. The same class as the `P_9A` rounding defect, found
|
|
463
|
+
the same way, by measuring the corpus. `P_15` is now **numerically unchanged** on
|
|
464
|
+
re-serialisation for all 22 modelled samples, and a spec asserts it — numerically, not
|
|
465
|
+
byte-for-byte, since 17 of the 22 reformat (`2051` → `2051.00`). (`docs/REFERENCE.md` §17.2.)
|
|
466
|
+
|
|
467
|
+
### Added
|
|
468
|
+
|
|
469
|
+
- **Every markdown file in `CIRFMF/ksef-api` is now pinned** — thirteen more, taking the
|
|
470
|
+
mirror under `docs/upstream/` from 19 of 32 to 32 of 32, all verified byte-for-byte against
|
|
471
|
+
their upstream blob SHAs at the same commit `1c34fe27`. They had been left out deliberately,
|
|
472
|
+
on the rule that only documents a milestone derives facts from belong in the manifest; the
|
|
473
|
+
rule was right and its application was wrong, because two of them define `offlineMode` and
|
|
474
|
+
`hashOfCorrectedInvoice` — parameters `Sessions::Online#send_invoice` has accepted since
|
|
475
|
+
Phase 2. New ledger section `docs/REFERENCE.md` §16.
|
|
476
|
+
|
|
477
|
+
The fact that reaches callers: **declaring `offlineMode: false` does not mean KSeF treats
|
|
478
|
+
the invoice as online.** It compares `P_1`'s calendar day against the moment it accepts the
|
|
479
|
+
document, and marks the invoice offline if `P_1` is earlier — an invoice dated yesterday and
|
|
480
|
+
sent one second after midnight is offline (§16.1). Nothing is enforced for this, because it
|
|
481
|
+
is the service's classification and `P_1` is the caller's choice; `#send_invoice` now
|
|
482
|
+
documents it, along with what a *technical correction* actually is (§16.2).
|
|
483
|
+
|
|
484
|
+
### Fixed
|
|
485
|
+
|
|
486
|
+
- **`#valid?` answered `true` for XML that is not XML.** `Invoice#errors` ran the schema tier
|
|
487
|
+
over `#to_xml` — bytes this gem had just produced, well-formed by construction — so it could
|
|
488
|
+
not see the input at all, while libxml2's recovery made the parsed tree look fine.
|
|
489
|
+
`Invoice#source_errors` now reports what libxml2 said about the document it was given, and
|
|
490
|
+
`#errors` reports it first. (`docs/REFERENCE.md` §19.2.)
|
|
491
|
+
|
|
492
|
+
- **Text that is validly encoded but is not UTF-8 crashed the tier meant to report it.**
|
|
493
|
+
`String#valid_encoding?` answers true for `Windows-1250` and `ISO-8859-2` — what a Polish ERP
|
|
494
|
+
emits — so such a name passed every guard and then raised `Encoding::CompatibilityError` out
|
|
495
|
+
of `#errors`, `#to_xml` and `Ksef::Client#send_invoice`. It is now reported, naming the
|
|
496
|
+
encoding. Bytes tagged `ASCII-8BIT` are accepted when they *are* UTF-8, which is what
|
|
497
|
+
`File.binread` produces. (§19.3.)
|
|
498
|
+
|
|
499
|
+
- **`net_by_rate` and `vat_by_rate` summed a correction's before-state into its after-state.**
|
|
500
|
+
On the Ministry's Przykład 2 that gave 3089.42 against a `net_total` of −162.60 — a per-rate
|
|
501
|
+
VAT report nineteen times the truth, with no error and a passing `#valid?`. Rows marked
|
|
502
|
+
`StanPrzed` are now skipped. (§19.1.)
|
|
503
|
+
|
|
504
|
+
- **`NaN` and `Infinity` passed the money gate.** `BigDecimal("NaN")` succeeds where
|
|
505
|
+
`BigDecimal("abc")` raises, so a document stating `<P_11>NaN</P_11>` reached the model and
|
|
506
|
+
serialised as `NaN.00` — and broke the `==`/`hash` contract on the way, since `NaN != NaN`.
|
|
507
|
+
Refused now, for the same reason a `Float` is.
|
|
508
|
+
|
|
509
|
+
- **`Invoice#to_h` handed out the entire source document.** `#inspect` redacts `raw_document`
|
|
510
|
+
so `p invoice` stays readable; `to_h` is a `Data` freebie and did not, so
|
|
511
|
+
`JSON.dump(invoice.to_h)` embedded the whole XML. Both `to_h` and `deconstruct_keys` now
|
|
512
|
+
redact it, as `#==` already ignored it.
|
|
513
|
+
|
|
514
|
+
- **A parsed invoice could lose the `P_15` it was given.** Settling the rounding strategy by
|
|
515
|
+
copying the invoice meant the copy's constructor re-ran with `stated_gross` already dropped,
|
|
516
|
+
so a `:per_summary` document was re-serialised with a derived total. The strategy is now
|
|
517
|
+
decided from the lines *before* the invoice is built. Every one of the 26 pinned samples
|
|
518
|
+
infers `:per_line`, so the corpus could not have caught it.
|
|
519
|
+
|
|
520
|
+
- **The advisory tier raised.** `Invoice#warnings` read `P_15` and the buckets off the retained
|
|
521
|
+
document with no tolerance for an empty or unparseable one — the very case
|
|
522
|
+
`Parser#readable_gross` had just been taught to survive. It also stripped namespaces from
|
|
523
|
+
that document in place, mutating the value object's retained source from a read-only query.
|
|
524
|
+
|
|
525
|
+
- **A unit price was silently rounded from eight decimal places to two.** `P_9A` — and `P_9AZ`
|
|
526
|
+
on an order position — is `TKwotowy2`, *"22 znaki max, w tym 8 znaków po przecinku"*, not the
|
|
527
|
+
two-place `TKwotowy` of the amounts it produces. A row priced at `1626.0125` is schema-valid;
|
|
528
|
+
this model read it, stored `1626.01` and re-emitted `1626.01`, with `#errors` empty and
|
|
529
|
+
`#unmapped_elements` empty because the element path never changed. A **stated amount altered
|
|
530
|
+
in silence**, which is the most serious defect this model can carry. `Formatting.unit_price`
|
|
531
|
+
keeps up to eight places and still pads `150` to `150.00`, and `TKwotowy2`'s pattern allows
|
|
532
|
+
one to eight, so every existing document is byte-identical under the fix.
|
|
533
|
+
(`docs/REFERENCE.md` §8.6.)
|
|
534
|
+
|
|
535
|
+
- **`Correction#exchange_rate_before` held a rate the document cannot carry.** `KursWalutyZK`
|
|
536
|
+
is `TIlosci`, whose six decimal places are a ceiling and not a preference. Stored unrounded,
|
|
537
|
+
a correction built with `4.12345678` emitted `4.123457` and then failed the round-trip law
|
|
538
|
+
against itself — with tier 2 silent, because what reached the document was valid. It was the
|
|
539
|
+
only field in the model not rounded to its element's scale (§8.2b).
|
|
540
|
+
|
|
541
|
+
- **`Line#vat` answered `0` for a row whose tax is unknown.** `#net` and `#gross` already
|
|
542
|
+
answered `nil` for a row that states no amount; `#vat` claimed the tax was nothing, so
|
|
543
|
+
`lines.sum(&:vat)` under-reported in silence while `sum(&:net)` raised. It now answers `nil`
|
|
544
|
+
for that row and keeps `0` for the case that really is zero — a rate code such as `zw`, `oo`
|
|
545
|
+
or `np I`, where the amount is stated and carries no tax.
|
|
546
|
+
|
|
547
|
+
- **Tier 1 addresses a rateless row to the field that fixes it**, `lines[0].vat_rate` rather
|
|
548
|
+
than `lines[0]`. Supplying `P_12` is the whole remedy, so the issue points at it. A row that
|
|
549
|
+
states no amount stays addressed to the row, because its remedies are several.
|
|
550
|
+
|
|
551
|
+
- **VAT rate code `np II` reported into the wrong summary bucket** — the third bug of this
|
|
552
|
+
exact class, after the shared-bucket accumulation bug and rate code `3`. `np II` is
|
|
553
|
+
*"świadczenie usług o których mowa w art. 100 ust. 1 pkt 4 ustawy"*, and `P_13_9` is *"suma
|
|
554
|
+
wartości świadczenia usług, o których mowa w art. 100 ust. 1 pkt 4 ustawy"* — the same
|
|
555
|
+
statutory scope, word for word. `P_13_8`, where both `np` codes went, reads *"z wyłączeniem
|
|
556
|
+
kwot wykazanych w polach P_13_5 i **P_13_9**"*: the one bucket `np II` may not use. The
|
|
557
|
+
effect was an intra-EU services figure declared in the wrong category on an XSD-valid
|
|
558
|
+
document that tier 1 passed. `VatRate.unreachable_elements` now also names `P_13_11` (the
|
|
559
|
+
margin scheme, declared through `Adnotacje/PMarzy` rather than a rate), and a spec asserts
|
|
560
|
+
every *other* net bucket is reachable — an incomplete list there is what made `P_13_9` look
|
|
561
|
+
like a deliberate gap (`docs/REFERENCE.md` §8.1a).
|
|
562
|
+
|
|
563
|
+
- **Two more falsifications of the tier 1a contract** ("what this passes, `#to_xml` can
|
|
564
|
+
serialise"):
|
|
565
|
+
- A Hash nested under a **leaf** annotation — `annotations: {"P_16" => {"X" => 1}}` — passed
|
|
566
|
+
the model tier and then made `#to_xml` raise. The recursion added in the previous release
|
|
567
|
+
resolved each child's type the way the serializer does, but read a leaf's empty element
|
|
568
|
+
list as "the codegen changed" rather than "this element takes text".
|
|
569
|
+
- `address_errors` still checked `respond_to?(:line1)` and then read `line2` and `country`,
|
|
570
|
+
so an object answering only the first made `#errors` raise `NoMethodError` — the symptom
|
|
571
|
+
the surrounding guards had already been changed to `is_a?` to prevent.
|
|
572
|
+
|
|
573
|
+
- **A correction built without stated totals derived its summary from the rows, counting
|
|
574
|
+
`StanPrzed` rows as sales.** `docs/REFERENCE.md` §8.4 says a correction's summaries are read
|
|
575
|
+
and never computed; that held in the parser and not in the serializer, which fell back to
|
|
576
|
+
the line-derived buckets whenever no `Totals` was given. A `StanPrzed` row is the position
|
|
577
|
+
*as it was before the correction* — already invoiced on the original document — so summing
|
|
578
|
+
it counts the amount twice with the wrong sign. The Ministry's Przykład 2, built through
|
|
579
|
+
this gem's own DSL without `f.totals`, declared `P_15 = 3799.98` for a correction worth
|
|
580
|
+
`-200.00`: a refund emitted as a charge, XSD-valid, with `invoice.errors` empty and
|
|
581
|
+
`#unmapped_elements` showing nothing. **Tier 1 now requires stated totals whenever a line is
|
|
582
|
+
marked `state_before`** — scoped to the marker rather than to the invoice type, because a
|
|
583
|
+
correction whose rows already *are* the deltas computes correctly, and Przykład 3 is exactly
|
|
584
|
+
that shape.
|
|
585
|
+
|
|
586
|
+
- **`NrWierszaFa` was read as octal.** `Formatting.integer` called `Integer(value)` with no
|
|
587
|
+
base, so Ruby honoured a leading zero. `<NrWierszaFa>010</NrWierszaFa>` is schema-valid and
|
|
588
|
+
means ten; it parsed as **eight** and re-serialised as `8` — in the one field that pairs a
|
|
589
|
+
correction's before/after rows once `UU_ID` is dropped. `"08"` failed the other way, raising
|
|
590
|
+
on a document the schema accepts. A `Float` is now refused rather than truncated.
|
|
591
|
+
|
|
592
|
+
- **Tier 1 rejected KSeF numbers the FA(3) schema allows.** It judged a referenced
|
|
593
|
+
`NrKSeFFaKorygowanej` with `Ksef::KsefNumber::FORMAT`, which comes from the **OpenAPI
|
|
594
|
+
contract** and admits only the NIP issuer form; the **XSD** additionally admits `M\d{9}` and
|
|
595
|
+
`[A-Z]{3}\d{7}`. The two artifacts are both right about their own domain — the contract
|
|
596
|
+
governs lookup URLs, the XSD governs documents — so tier 1 no longer checks the format and
|
|
597
|
+
tier 2 owns it (`docs/REFERENCE.md` §8.4b).
|
|
598
|
+
|
|
599
|
+
- **Silent drops and silent rewrites**, each found by the same audit:
|
|
600
|
+
- A **symbol-keyed** element passed the serializer's own unknown-key check, which compares
|
|
601
|
+
`keys.map(&:to_s)`, and was then dropped by a write loop asking `key?(name)` for a String.
|
|
602
|
+
- An **absent `Adnotacje`** was read as the defaults, emitting eight affirmative tax
|
|
603
|
+
declarations the document never made. It now reads as "nothing declared".
|
|
604
|
+
- A **`DaneFaKorygowanej` stating neither branch** of the schema's choice was re-serialised
|
|
605
|
+
with `NrKSeFN`, asserting the corrected invoice had been issued outside KSeF. Refused.
|
|
606
|
+
- `Ksef::FA3::Totals` **dropped a bucket** given under two spellings (`:P_13_1` and
|
|
607
|
+
`"P_13_1"`); it now refuses the collision.
|
|
608
|
+
- The `Adnotacje` key check reached one level deep while the serializer recurses, so a bad
|
|
609
|
+
nested key passed the model and raised on the way out.
|
|
610
|
+
|
|
611
|
+
- **Round-trip equality** held for fewer invoices than documented. `Ksef::FA3.parse(x.to_xml)
|
|
612
|
+
== x` now survives `number: 123`, `vat: 23`, a buyer flag given as `"1"`, and a
|
|
613
|
+
`row_number` that merely repeats its position — all of which describe exactly the document
|
|
614
|
+
their canonical spellings do. Text bound for an `xsd:token` element is canonicalised on the
|
|
615
|
+
way in, which also closed a case where `vat_rate: " 23 "` passed tier 1 and then made
|
|
616
|
+
`#to_xml` raise.
|
|
617
|
+
|
|
618
|
+
- **Errors that escaped this gem's hierarchy.** `Invoice#errors` is documented to answer
|
|
619
|
+
rather than raise; it raised for a mojibake NIP, for a mojibake KSeF number, and for a
|
|
620
|
+
`totals:` or `correction:` of the wrong class (the duck-typed guards accepted this gem's
|
|
621
|
+
*other* value objects, then called methods they do not have). `Formatting.date_time` raised
|
|
622
|
+
`NoMethodError` for a non-time, `Builder#totals` for `net: nil`, and `Correction.new` mangled
|
|
623
|
+
a Hash into pairs. All now `Ksef::ValidationError` — except the Hash, which no longer
|
|
624
|
+
becomes pairs at all: it is carried as a single entry and reported by `#errors` as
|
|
625
|
+
`correction.corrected[0]: is not a Ksef::FA3::CorrectedInvoice`, the addressed error the
|
|
626
|
+
rest of this bullet is about.
|
|
627
|
+
|
|
628
|
+
- **`Formatting.to_date` guessed.** `Date.parse("junk")` answers the first of June, and
|
|
629
|
+
`"12"` answers the twelfth of the current month — a value that changes with the clock.
|
|
630
|
+
A String must now be ISO-8601, which is the only form an FA(3) document can carry.
|
|
631
|
+
|
|
632
|
+
- **`BigDecimal("-0.00")` broke the `==`/`hash` contract**, comparing equal to `0.00` while
|
|
633
|
+
hashing differently, so a line whose net arrived as `"-0.00"` failed as a Hash key. Negative
|
|
634
|
+
zero is normalised.
|
|
635
|
+
|
|
636
|
+
- **Frozen state.** `Ksef::FA3::Correction` no longer freezes the caller's own array, and
|
|
637
|
+
`Totals#buckets` and `Invoice#lines` are frozen, so an invariant cannot be edited around
|
|
638
|
+
after construction.
|
|
639
|
+
|
|
640
|
+
### Added
|
|
641
|
+
|
|
642
|
+
- **`UPR`, `KOR_ZAL` and `KOR_ROZ` — the last three types. All seven now build, parse,
|
|
643
|
+
round-trip and validate** (DESIGN.md §7.4, `docs/REFERENCE.md` §8.6). Twenty-two of the
|
|
644
|
+
Ministry's twenty-six worked examples go through end to end; the four that do not are
|
|
645
|
+
refused for a **construct** rather than for their type — two priced gross, two identifying
|
|
646
|
+
their buyer by something other than a NIP.
|
|
647
|
+
|
|
648
|
+
`KOR_ZAL` and `KOR_ROZ` needed one element between them. **`P_15ZK` means two different
|
|
649
|
+
things**, and the invoice type decides which: the amount *paid* before the correction on a
|
|
650
|
+
`KOR_ZAL`, the amount *left to pay* before it on a `KOR_ROZ`. `Correction#paid_before`
|
|
651
|
+
carries the figure and does not name it more precisely than the schema does.
|
|
652
|
+
|
|
653
|
+
`UPR` needed the real change: **a row that states no amount at all.**
|
|
654
|
+
|
|
655
|
+
```ruby
|
|
656
|
+
f.invoice_type "UPR"
|
|
657
|
+
f.line name: "wiertarka Wiertex mk5" # and nothing else — this is the whole row
|
|
658
|
+
f.totals gross: "450", net: { "23" => "365.85" }, vat: { "23" => "84.15" }
|
|
659
|
+
```
|
|
660
|
+
|
|
661
|
+
Every child of `FaWiersz` but `NrWierszaFa` is `minOccurs="0"`, so an amount-less row is
|
|
662
|
+
the schema's own default and this model was the strict one. `Ksef::FA3::Line#net` now
|
|
663
|
+
answers **nil rather than raising**, and nil is not zero: the row states nothing, rather
|
|
664
|
+
than stating that it is worth nothing. The same change unblocked **Przykład 7**, the one
|
|
665
|
+
Ministry correction this model could not read — its single row names goods, a `CN` code and
|
|
666
|
+
a quantity, with no amount anywhere — so all five corrections now go through.
|
|
667
|
+
|
|
668
|
+
**The parser stopped refusing two shapes, and tier 1 took them over.** An unpriced row, and
|
|
669
|
+
a row with an amount but no `P_12`, are both legal. But on an invoice that *derives* its
|
|
670
|
+
summary from its rows the amount is simply absent from the tax base, and neither tier can
|
|
671
|
+
see that — the XSD is blind to arithmetic and `#unmapped_elements` to values. So the check
|
|
672
|
+
is line-addressed and fires only where the summary is derived:
|
|
673
|
+
|
|
674
|
+
```
|
|
675
|
+
lines[0]: states no amount, and this invoice derives its summary from its rows…
|
|
676
|
+
lines[1]: states an amount but no P_12 rate code, so there is no bucket to put it in…
|
|
677
|
+
```
|
|
678
|
+
|
|
679
|
+
**Gross pricing is still refused at parse time**, and the distinction is deliberate: a
|
|
680
|
+
gross-priced row carries a number this model has nowhere to put, so reading it would drop a
|
|
681
|
+
real amount. An unpriced row has no number to drop.
|
|
682
|
+
|
|
683
|
+
- **`ZAL` and `ROZ` — the advance invoice and the settlement invoice that closes it out**
|
|
684
|
+
(DESIGN.md §7.4, `docs/REFERENCE.md` §8.5). Four of the seven types now build, parse,
|
|
685
|
+
round-trip and validate, and fifteen of the Ministry's twenty-six worked examples go through
|
|
686
|
+
end to end.
|
|
687
|
+
|
|
688
|
+
A `ZAL` documents money received before delivery, and carries **no invoice rows at all** —
|
|
689
|
+
the order or contract of art. 106f ust. 1 pkt 4 takes their place:
|
|
690
|
+
|
|
691
|
+
```ruby
|
|
692
|
+
f.invoice_type "ZAL"
|
|
693
|
+
f.order total: "375150" # the whole order, including tax
|
|
694
|
+
f.order_line name: "mieszkanie 50m^2", qty: 1, unit: "szt.",
|
|
695
|
+
net_unit_price: "300000", net_amount: "300000",
|
|
696
|
+
vat_amount: "69000", vat: "23"
|
|
697
|
+
f.totals gross: "20000", net: { "23" => "16260.16" }, vat: { "23" => "3739.84" }
|
|
698
|
+
```
|
|
699
|
+
|
|
700
|
+
`f.order`'s `total:` is `WartoscZamowienia`, the whole order **including tax** — 375 150
|
|
701
|
+
against 20 000 actually received. They are different numbers, and conflating them would be a
|
|
702
|
+
large error. An order position states its own tax through `vat_amount:`, because FA(3) gives
|
|
703
|
+
it a field (`P_11VatZ`) that an invoice row does not have; nothing about an order is derived.
|
|
704
|
+
|
|
705
|
+
The `ROZ` issued once the goods are delivered names each advance invoice it settles, in
|
|
706
|
+
either of the two forms the schema allows:
|
|
707
|
+
|
|
708
|
+
```ruby
|
|
709
|
+
f.settles ksef_number: "5265877635-20250826-0100001AF629-AF" # issued through KSeF
|
|
710
|
+
f.settles number: "FZ/2026/02/150" # issued outside it
|
|
711
|
+
```
|
|
712
|
+
|
|
713
|
+
**The choice here is inverted from a correction's**: `NrKSeFZN` marks an advance invoice
|
|
714
|
+
issued *outside* KSeF and pairs with the plain number, while the KSeF branch is the number
|
|
715
|
+
alone. The two branches name different fields, so `Ksef::FA3::AdvanceInvoice` carries both
|
|
716
|
+
and requires exactly one — a single nil-able field, which was enough for `CorrectedInvoice`,
|
|
717
|
+
would assert the wrong provenance here.
|
|
718
|
+
|
|
719
|
+
**Both types state their tax summary rather than deriving it**, and that is measured rather
|
|
720
|
+
than assumed: across every sample of the family the stated buckets never equal the row
|
|
721
|
+
totals. A `ZAL` has no rows to derive from; a `ROZ` describes the goods in its rows and
|
|
722
|
+
states what is left to pay **after** the advance — an amount this document does not contain
|
|
723
|
+
enough to compute. Tier 1 now requires a stated summary on three structural triggers: a
|
|
724
|
+
`state_before` row, an order, or a settled advance invoice.
|
|
725
|
+
|
|
726
|
+
Not modelled, and visible through `#unmapped_elements` if a parsed document carries them:
|
|
727
|
+
`ZaliczkaCzesciowa` (in none of the twenty-six samples), `P_15ZK` (scoped to the `KOR_`
|
|
728
|
+
combinations, which are the remaining work), `Platnosc` and `DodatkowyOpis`.
|
|
729
|
+
|
|
730
|
+
- **`KOR`, the correction — building, parsing and validating** (DESIGN.md §7.4,
|
|
731
|
+
`docs/REFERENCE.md` §8.4). A correction says what it corrects, why, and when it takes effect:
|
|
732
|
+
|
|
733
|
+
```ruby
|
|
734
|
+
f.invoice_type "KOR"
|
|
735
|
+
f.correction reason: "obniżka ceny o 200 zł", effect: 3
|
|
736
|
+
f.corrects number: "FV/2026/02/150", issue_date: "2026-02-15", ksef_number: "…"
|
|
737
|
+
f.totals gross: "-200.00", net: { "23" => "-162.60" }, vat: { "23" => "-37.40" }
|
|
738
|
+
```
|
|
739
|
+
|
|
740
|
+
`corrects` may be called up to fifty thousand times — art. 106j ust. 3 lets one correction
|
|
741
|
+
carry a discount across a whole period — and takes no `ksef_number` when the corrected invoice
|
|
742
|
+
was issued outside KSeF, which writes the `NrKSeFN` marker instead. `Ksef::FA3::Correction`
|
|
743
|
+
also carries `period`, `corrected_number`, and the previous state of either party
|
|
744
|
+
(`Podmiot1K`/`Podmiot2K`), linked to the live record by the `buyer_id` the schema calls
|
|
745
|
+
`IDNabywcy`. A row can be marked `state_before: true` to show a position as it was, and
|
|
746
|
+
`row_number:` gives the before/after pair the shared number that ties them together.
|
|
747
|
+
|
|
748
|
+
**A correction's tax summary is stated, not computed**, through `f.totals` — and that is the
|
|
749
|
+
decision worth knowing about. Its buckets are *deltas*, and FA(3) does not require the rows to
|
|
750
|
+
determine them: of the Ministry's five worked corrections, two carry no `FaWiersz` at all and
|
|
751
|
+
a third has a row stating no amount anywhere. Deriving the figures would invent a tax base the
|
|
752
|
+
document already states. `Invoice#lines` may therefore be empty, but **only** when totals are
|
|
753
|
+
stated; without either there is nothing to declare tax from, and the constructor says so.
|
|
754
|
+
|
|
755
|
+
Four of the Ministry's five corrections now parse, re-serialise and validate; the fifth is
|
|
756
|
+
refused for a row that states no price at all, with a message naming the construct.
|
|
757
|
+
`Ksef::FA3::Totals` is keyed by summary-element name rather than rate code, because that
|
|
758
|
+
mapping is not invertible — `"23"` and `"22"` share `P_13_1`.
|
|
759
|
+
|
|
760
|
+
One thing validation deliberately does **not** do: check the CRC-8 of a referenced KSeF
|
|
761
|
+
number. All six such numbers in the Ministry's own worked corrections fail it — they are
|
|
762
|
+
well-formed placeholders — so the check would refuse the Ministry's own documents. The shape
|
|
763
|
+
is checked; the checksum is not (`docs/REFERENCE.md` §8.4a).
|
|
764
|
+
|
|
765
|
+
- **`Ksef::FA3.parse` — reading an FA(3) document back into the model** (DESIGN.md §7.6).
|
|
766
|
+
The round-trip law runs green over a pinned corpus of the Ministry's own sample invoices,
|
|
767
|
+
which turned out not to live in `ksef-api` at all: they come from `CIRFMF/ksef-pdf-generator`
|
|
768
|
+
and `CIRFMF/ksef-client-csharp`, each pinned at its own commit (`docs/REFERENCE.md` §1.4).
|
|
769
|
+
|
|
770
|
+
Three things are worth knowing before using it. **Parsing is not validating** — a document
|
|
771
|
+
KSeF rejected still parses, because inspecting one is usually *why* you are parsing.
|
|
772
|
+
**`#raw_document` is always retained**, and `#unmapped_elements` names what re-serialising
|
|
773
|
+
would drop, computed by difference against the serializer so it cannot drift from what is
|
|
774
|
+
actually written; FA(3) is much larger than this model, so re-serialising a document you did
|
|
775
|
+
not write is lossy. And it **refuses what it cannot represent faithfully** rather than
|
|
776
|
+
guessing: an invoice type the model does not carry, a row with no `P_12` rate code, a row
|
|
777
|
+
priced gross, a row that states no price at all, and a buyer identified by anything but a
|
|
778
|
+
NIP. Those messages say the document is
|
|
779
|
+
fine and the model is the limit, and name the construct.
|
|
780
|
+
|
|
781
|
+
- **Validator tier 1, in two halves** (DESIGN.md §7.7, amended). `Ksef::FA3::ModelValidator`
|
|
782
|
+
checks the invoice object — required fields, enum membership read from the generated schema
|
|
783
|
+
metadata, NIP checksums, string lengths against the schema's own facets, and an issue date
|
|
784
|
+
that is not in the future. `Ksef::FA3::DocumentValidator` checks the serialized bytes for the
|
|
785
|
+
four admission rules of `docs/REFERENCE.md` §15.1 that **neither a model tier nor tier 2 can
|
|
786
|
+
see** — a byte-order mark, a prolog declaring anything but UTF-8, processing instructions, and
|
|
787
|
+
the Unicode characters KSeF refuses — plus that the bytes are UTF-8 at all, and the
|
|
788
|
+
million-byte ceiling, which `max_bytes:` overrides because upstream marks it a negotiable
|
|
789
|
+
default rather than a limit of the format.
|
|
790
|
+
|
|
791
|
+
`Invoice#errors` runs model → document → schema and returns `Issue` values carrying a field
|
|
792
|
+
path (`lines[2].vat_rate`), so a caller learns which value to fix rather than reading a
|
|
793
|
+
libxml2 message about a facet. `#validate!` lists every problem instead of the first.
|
|
794
|
+
|
|
795
|
+
The model tier short-circuits deliberately: serialisation *raises* on a bad NIP, a nameless
|
|
796
|
+
seller or a line with no derivable net, so running it after a model failure would collapse a
|
|
797
|
+
list of addressed errors into a single exception. Its aim is the stronger statement — *what
|
|
798
|
+
the model tier passes, `#to_xml` can serialise* — held as an aim rather than a proof, since a
|
|
799
|
+
review falsified it twice before release. And `#errors` reports rather than raises, including
|
|
800
|
+
for text tagged UTF-8 that is not: a method asked what is wrong should answer.
|
|
801
|
+
|
|
802
|
+
- **`Ksef::FA3::Invoice#annotations`** — the `Adnotacje` block is carried, not defaulted, so
|
|
803
|
+
re-serialising cannot deny a declaration the document made (cash accounting, reverse charge,
|
|
804
|
+
split payment, a real VAT exemption).
|
|
805
|
+
|
|
806
|
+
- **Pinned the Ministry's 26 worked FA(3) examples** as test fixtures
|
|
807
|
+
(`spec/fixtures/fa3/mf-samples/`, `docs/REFERENCE.md` §1.5) — all seven `RodzajFaktury` values,
|
|
808
|
+
and the only corpus of non-`VAT` invoice types in existence: two independent sweeps of every
|
|
809
|
+
CIRFMF repository, all branches and full history, found every FA(3) fixture there to be `VAT`.
|
|
810
|
+
All 26 validate against the pinned XSD. They are not packaged in the gem.
|
|
811
|
+
|
|
812
|
+
This is the first artifact pinned from outside the CIRFMF organisation, so §1.2's MIT reasoning
|
|
813
|
+
does not reach it; the redistribution rests on `podatki.gov.pl`'s site-wide statement that use
|
|
814
|
+
requires no consent, with the caveat — recorded rather than glossed — that the files sit on a
|
|
815
|
+
subdomain carrying no licence statement of its own.
|
|
816
|
+
|
|
817
|
+
- **Pinned `faktury/weryfikacja-faktury.md`** (`docs/REFERENCE.md` §15), the invoice-admission
|
|
818
|
+
rules KSeF applies on submission. It settles two open questions: validator tier 3's
|
|
819
|
+
business-rule catalogue is **absent from upstream**, not merely unpinned (§15.6), while
|
|
820
|
+
tier 1 — which had no first-tier source at all — is now specified exactly. NIP checksums are
|
|
821
|
+
validated **in production only** (§15.3), so no TEST run can ever exercise that rule.
|
|
822
|
+
|
|
823
|
+
- **`Ksef::Client` — the facade, and DESIGN.md §8's snippet now runs.** A spec drives that
|
|
824
|
+
snippet as written, from `Ksef::FA3.build` through `send_invoice`, `wait_until_accepted`
|
|
825
|
+
and `upo`, so the README's headline example can no longer drift from the code.
|
|
826
|
+
|
|
827
|
+
**`Receipt#reference` returns `self`,** and that is not a trick. §8 reads
|
|
828
|
+
`client.wait_until_accepted(result.reference)`, which looks like it wants a string — but
|
|
829
|
+
every status and UPO endpoint is keyed on **both** the session and the invoice, so an
|
|
830
|
+
invoice reference alone looks nothing up. Rather than bend §8 into two arguments, or cache
|
|
831
|
+
the session on a client that has to stay thread-safe, the pair *is* the reference.
|
|
832
|
+
|
|
833
|
+
**`#upo` uses the metered per-invoice route**, against §14.2's stated preference —
|
|
834
|
+
deliberately, and only there. Obtaining the unmetered pre-signed link costs a metered
|
|
835
|
+
status call first, so for one invoice the direct route is one request rather than two. The
|
|
836
|
+
link earns its keep on collective UPOs, which is what `#collective_upo` uses it for.
|
|
837
|
+
|
|
838
|
+
Thread-safe by construction rather than by promise (DESIGN.md §5.2): frozen configuration,
|
|
839
|
+
stateless connections, and the only mutable state — the memoised credential — behind one
|
|
840
|
+
mutex. **No session is ever held on the client**, which was the deciding argument for
|
|
841
|
+
opening a fresh one per send: a cached session would be mutable state two threads could
|
|
842
|
+
submit into at once. A spec runs six concurrent sends and asserts one authentication.
|
|
843
|
+
|
|
844
|
+
Authentication is lazy: constructing a client performs no I/O, and the first call needing
|
|
845
|
+
a credential runs the whole KSeF-token flow.
|
|
846
|
+
- **`Ksef::Invoices::Client`** — `GET /invoices/ksef/{ksefNumber}`, returning the invoice
|
|
847
|
+
verbatim. 8 req/s but only **64 req/h**, one of the tightest ceilings in the API: it is a
|
|
848
|
+
per-document fetch, so a month of invoices exhausts the hourly allowance long before the
|
|
849
|
+
per-second limit bites, and the 0.2 package export is the bulk route. The number is
|
|
850
|
+
CRC-checked locally rather than spending one of those 64 requests on a 404.
|
|
851
|
+
- **`Ksef::UPO` — retrieval, over a connection that has no credential.** A UPO is the legal
|
|
852
|
+
proof that KSeF received an invoice, fetched over an unauthenticated storage link, and
|
|
853
|
+
three properties follow from that.
|
|
854
|
+
|
|
855
|
+
**The access token is never sent to a `downloadUrl`.** Those links are pre-signed Azure
|
|
856
|
+
Blob URIs carrying their own authorisation in the query string, and the contract says
|
|
857
|
+
outright not to send the token — it would hand a live KSeF credential to third-party
|
|
858
|
+
storage. Rather than remembering that per call site, `Ksef::HTTP::Connection.storage`
|
|
859
|
+
builds a second connection with **no bearer and no base URL**, so no code path can leak
|
|
860
|
+
one. It also omits JSON encoding and parsing, since a UPO is XML that must survive as the
|
|
861
|
+
exact bytes received.
|
|
862
|
+
|
|
863
|
+
**The bytes are archived verbatim.** The Ministry's XAdES signature covers octets, not an
|
|
864
|
+
abstract tree, so even a lossless XML round-trip can produce a document that no longer
|
|
865
|
+
verifies. `UPO::Document` holds the received string and offers no parsed form, no
|
|
866
|
+
`#to_xml` and no re-encode; `#write` uses `binwrite` so a newline translation cannot
|
|
867
|
+
corrupt an archive.
|
|
868
|
+
|
|
869
|
+
**`x-ms-meta-hash` is verified** — the only integrity check available on bytes fetched
|
|
870
|
+
outside the API. `#fetch` prefers the unmetered, hash-verified link and falls back to the
|
|
871
|
+
metered route when it expires, which is §14.2's resolution after an earlier reading had it
|
|
872
|
+
backwards. `for_ksef_number` parses the number first, so a mistyped one fails on its CRC-8
|
|
873
|
+
locally rather than as an opaque 404.
|
|
874
|
+
- **`Ksef::UPO::Validator`** — offline schema validation for a received UPO, and the place
|
|
875
|
+
§14.3's trap would otherwise have caught us. `upo-v4-3.xsd` fixes the receiving party's
|
|
876
|
+
name to `"Ministerstwo Finansów"` while every non-production environment appends a marker,
|
|
877
|
+
so **a strict validator rejects every UPO that TEST and DEMO issue** — measured: all six of
|
|
878
|
+
upstream's own worked examples fail upstream's own schema, each with exactly one error,
|
|
879
|
+
always that element.
|
|
880
|
+
|
|
881
|
+
The mismatch is reported as a **warning** rather than relaxing the constraint, which keeps
|
|
882
|
+
strictly more: in production the fixed value is presumably right, so a mismatch there is a
|
|
883
|
+
real anomaly a relaxed schema would never mention. The observed value is read from the
|
|
884
|
+
document by XPath, and doubles as a way to tell which environment issued a UPO.
|
|
885
|
+
|
|
886
|
+
**There is deliberately no `validate!`.** A UPO is legal proof that an invoice was
|
|
887
|
+
received; whether it satisfies a schema is never a reason to discard the bytes or fail an
|
|
888
|
+
operation that already succeeded at the far end. Offering a raising method would make
|
|
889
|
+
gating the path of least resistance, so a spec asserts its absence.
|
|
890
|
+
- **`Ksef::IntegrityError`** — raised when downloaded bytes do not match the published hash.
|
|
891
|
+
Its own class because the right response is unlike every other error here: *fetch it
|
|
892
|
+
again*. Nothing is wrong with the request, the credentials or the document — the transfer
|
|
893
|
+
was corrupted. Silently archiving corrupt bytes as proof of receipt is the one outcome
|
|
894
|
+
worth refusing loudly.
|
|
895
|
+
- **`Ksef::Sessions::Status`** — single-shot session and per-invoice reads, plus
|
|
896
|
+
deadline-bounded waits. Capped exponential backoff (1s, 2s, 4s … 30s, five-minute
|
|
897
|
+
deadline) rather than the reference clients' fixed 1s × 60: at 1/s a single wait spends 60
|
|
898
|
+
of the 1200 requests an hour a context gets, and a 60-second ceiling is far too short for
|
|
899
|
+
a large session. A timeout says the operation has **not failed but outlasted the wait**,
|
|
900
|
+
because conflating the two invites a needless resend.
|
|
901
|
+
|
|
902
|
+
**It exposes no list-sessions call at all.** `GET /sessions` is 10 req/min — the tightest
|
|
903
|
+
budget in the API — against 1200/h for the two endpoints polling should use. Omitting the
|
|
904
|
+
method is the simplest way to make that mistake impossible rather than merely discouraged.
|
|
905
|
+
|
|
906
|
+
`InvoiceState` surfaces what the endpoint already returns instead of making callers fetch
|
|
907
|
+
it twice: the per-invoice UPO link, and on a duplicate the **original** submission's KSeF
|
|
908
|
+
number and session reference, which is what makes a resend reconcilable. `UpoPage` keeps
|
|
909
|
+
its pre-signed URL out of `#inspect` and `#to_s` — the link carries its own authorisation
|
|
910
|
+
in the query string, so it is a credential — while still exposing it to a caller that asks.
|
|
911
|
+
- **Corrected: both `100` and `150` mean "keep polling"**, not `150` alone. The earlier
|
|
912
|
+
reading came from `OnlineSessionUtils.cs`, whose poller returns as soon as the code is
|
|
913
|
+
anything but `150`, and it is wrong on the contract's own wording — `100` is *"przyjęta do
|
|
914
|
+
dalszego przetwarzania"*, accepted for **further** processing, so an invoice sitting there
|
|
915
|
+
is undecided and has no KSeF number. A poller that stops at `100` reports a pending
|
|
916
|
+
invoice as though it were settled.
|
|
917
|
+
|
|
918
|
+
The rule is now `code < 200 is in progress` rather than a list of intermediate codes, so a
|
|
919
|
+
code upstream adds later behaves correctly by default. This is deliberately the inverse of
|
|
920
|
+
the treat-the-unknown-as-terminal rule used for *authentication* status, and the asymmetry
|
|
921
|
+
is justified in both places: auth polls without a deadline so it must not loop for ever,
|
|
922
|
+
while session polling is bounded — and "unresolved" is a more honest answer about an
|
|
923
|
+
invoice than "prematurely final". The same rule makes `170` → `200` work for sessions,
|
|
924
|
+
where closing starts asynchronous UPO generation and "closed" is one step short of done.
|
|
925
|
+
- **`Ksef::Sessions::Online` — open, send, close.** Stateless and thin, like
|
|
926
|
+
`Auth::Client`: it maps requests and responses and holds no session of its own. Both
|
|
927
|
+
official clients do the same, threading the reference through as a parameter.
|
|
928
|
+
|
|
929
|
+
**The `Encryptor` is bound to the `Session`, not passed per send**, and that is the
|
|
930
|
+
decision worth knowing about. The symmetric key is agreed once, at open, and every invoice
|
|
931
|
+
in the session is encrypted under it — so an invoice encrypted with any other key is
|
|
932
|
+
undecryptable at the far end, and the only symptom is per-invoice status `435` arriving
|
|
933
|
+
**asynchronously**, long after the send returned `202`. There is no synchronous error to
|
|
934
|
+
catch. Binding the key to the session makes the mistake unrepresentable rather than merely
|
|
935
|
+
documented, the same move as `Encryptor#seal` returning both digests together.
|
|
936
|
+
|
|
937
|
+
Sends `X-KSeF-Feature: upo-v4-3` on open — a header in neither the contract nor any
|
|
938
|
+
upstream prose, but sent by both official clients, which selects the UPO format the session
|
|
939
|
+
produces. This gem bundles `upo-v4-3.xsd` and nothing else, so silence would mean accepting
|
|
940
|
+
a version it might not be able to validate (`docs/REFERENCE.md` §14.6).
|
|
941
|
+
|
|
942
|
+
`formCode` comes from the contract rather than from `srodowiska.md`, whose prose misspells
|
|
943
|
+
the PEF system codes and omits `FA_RR (1)` entirely. Reference numbers are shape-checked
|
|
944
|
+
before reaching a URL path.
|
|
945
|
+
- **`send_invoice` opens a fresh session per call**, with `client.session { |s| ... }` for
|
|
946
|
+
deliberate batching. Resolves the session-reuse `[VERIFY]` in DESIGN.md §6.5, which the
|
|
947
|
+
facts left open: sessions last 12 hours and take 10 000 invoices, and neither official
|
|
948
|
+
client offers a composite to copy. A reused session is mutable state on a client that must
|
|
949
|
+
be thread-safe; an opened-but-unused session is cancelled with status `440`; and the API
|
|
950
|
+
returning the *original's* KSeF number on a duplicate suggests resends are an expected
|
|
951
|
+
hazard rather than one to make likelier by hiding session state.
|
|
952
|
+
- **`Ksef::KsefNumber`** — parses and validates the identifier KSeF assigns to
|
|
953
|
+
an accepted invoice. CRC-8 with polynomial `0x07`, verified against the Ministry's own
|
|
954
|
+
documented example, which doubles as the golden vector. Checking the checksum locally is
|
|
955
|
+
the point: these numbers get copied between systems and read down telephones, and a CRC-8
|
|
956
|
+
catches exactly those slips, turning a silent lookup failure into a specific error naming
|
|
957
|
+
both the carried and the computed value. `assigned_on` is a `Date` because it is not
|
|
958
|
+
metadata — it is the invoice's **official receipt date**.
|
|
959
|
+
- **`Ksef::Auth::AccessToken`** — tracks expiry from the response's `validUntil`, never by
|
|
960
|
+
decoding the JWT: §4.3 excludes the `jwt` dependency and the contract carries `validUntil`
|
|
961
|
+
precisely so no decoding is needed. Refreshes at ~80% of the observed lifetime rather than
|
|
962
|
+
on expiry, so a request never carries a credential that dies mid-flight — on an invoice
|
|
963
|
+
submission, a failure that *might* have been delivered is the situation this gem works
|
|
964
|
+
hardest to avoid. Thread-safe with the staleness check re-run inside the lock, so a burst
|
|
965
|
+
of threads yields one refresh rather than a stampede.
|
|
966
|
+
- **`Ksef::Crypto` — the encryption layer.** AES-256-CBC with PKCS#7 for payloads,
|
|
967
|
+
RSA-OAEP with SHA-256 *and* MGF1-SHA-256 for wrapping, and the Ministry's published
|
|
968
|
+
certificates fetched, cached and selected by declared usage. Every parameter is ledgered
|
|
969
|
+
at `docs/REFERENCE.md` §10 from first-tier documentation, not inferred from client
|
|
970
|
+
behaviour.
|
|
971
|
+
|
|
972
|
+
**The IV is not prefixed to the ciphertext**, whatever `sesja-interaktywna.md` says. It
|
|
973
|
+
travels once as a discrete field of the session-open request, and each invoice ciphertext
|
|
974
|
+
is bare. There is now a fourth witness against the prose, and it is the sharpest:
|
|
975
|
+
upstream's own worked example pairs a 6480-byte invoice with a 6496-byte ciphertext, which
|
|
976
|
+
is exactly one block of PKCS#7 padding and no room at all for a 16-byte IV (§14.1).
|
|
977
|
+
|
|
978
|
+
DESIGN.md §6.4 asked for golden vectors ported from the C# client. **There are none to
|
|
979
|
+
port** — neither reference client commits plaintext/ciphertext pairs. The primitives are
|
|
980
|
+
pinned to their standards instead: **NIST SP 800-38A F.2.5** for AES-256-CBC, byte for
|
|
981
|
+
byte, and **FIPS 180-4** for SHA-256. The two parameters that genuinely could have gone
|
|
982
|
+
wrong are pinned behaviourally: the longest accepted OAEP plaintext is 190 bytes, which
|
|
983
|
+
fixes the digest at SHA-256 without trusting an option name, and a ciphertext made with
|
|
984
|
+
MGF1-SHA-256 provably fails to decrypt under MGF1-SHA-1. That last one matters —
|
|
985
|
+
OpenSSL's MGF1 digest defaults to SHA-1, so naming only `rsa_oaep_md` yields a different
|
|
986
|
+
scheme that fails at the far end and nowhere else.
|
|
987
|
+
- **`Ksef::Crypto::PublicKeys`** — `GET /security/public-key-certificates`, cached for an
|
|
988
|
+
hour behind a mutex, with the documented selection rule: filter by usage, require validity
|
|
989
|
+
*at the moment of use*, and prefer the latest `validFrom` where several qualify. The
|
|
990
|
+
endpoint is unauthenticated, so keys can be fetched before any credential exists.
|
|
991
|
+
|
|
992
|
+
Two rotation modes must not be conflated. Re-certification keeps the key pair, so
|
|
993
|
+
`publicKeyId` is unchanged; key rotation changes it, and an **emergency** rotation drops
|
|
994
|
+
the old certificate from the list immediately. `#with_key_rotation` implements §10.2's
|
|
995
|
+
recovery for that window — on a `400`/`21470` it re-fetches and re-runs the operation.
|
|
996
|
+
That is remediation and not a blind replay: a 21470 means the request was declined
|
|
997
|
+
outright, and the second attempt carries a *different* key identifier, so DESIGN.md
|
|
998
|
+
§6.7's never-auto-retry-a-POST rule is intact. Any other API error surfaces untouched.
|
|
999
|
+
- **`Ksef::Crypto::Encryptor#seal`** returns the ciphertext together with the hash *and*
|
|
1000
|
+
size of both the plaintext and the ciphertext. `POST /sessions/online/{ref}/invoices`
|
|
1001
|
+
requires all four (§11.1) and hashing the wrong artifact is a silent error only the server
|
|
1002
|
+
can catch, so the two digests are produced together rather than left to a caller to pair
|
|
1003
|
+
up. Sizes are byte counts, not character counts — the distinction is not academic when
|
|
1004
|
+
every KSeF document is full of Polish characters.
|
|
1005
|
+
- **`Ksef::Auth::Token` — the KSeF-token credential of DESIGN.md §8**, and
|
|
1006
|
+
`Auth::Client#submit_ksef_token`. **Both authentication methods now exist.** The token is
|
|
1007
|
+
never sent as a bearer: it is RSA-OAEP-encrypted alongside the challenge's own
|
|
1008
|
+
`timestampMs`, which the docs describe as a replay nonce — so the method takes a
|
|
1009
|
+
`Challenge` object rather than its string, and refuses the string outright. Inventing the
|
|
1010
|
+
timestamp locally would fail with nothing to point at.
|
|
1011
|
+
|
|
1012
|
+
Only `Nip` and `InternalId` contexts are offered, though the contract's enum has four: a
|
|
1013
|
+
token can only be *issued* in those two, so a token for the other two cannot exist to be
|
|
1014
|
+
presented (§4.1). `#to_s` and `#inspect` are redacted; the token is reachable only by
|
|
1015
|
+
building the request.
|
|
1016
|
+
- **`Ksef::CryptoError`** — a new branch of the DESIGN.md §6.7 hierarchy, for "no published
|
|
1017
|
+
key is valid for this usage" and for malformed key material. Neither an `ApiError` (the
|
|
1018
|
+
request succeeded; the list has nothing usable) nor a `ConfigurationError` (documented as
|
|
1019
|
+
local and pre-request). Reasoning recorded at `docs/REFERENCE.md` §10.3, alongside the
|
|
1020
|
+
other decisions upstream does not state.
|
|
1021
|
+
- **Live integration for the crypto module** (`spec/integration/crypto_spec.rb`). Two things
|
|
1022
|
+
no offline test can reach: that `certificateId` and `publicKeyId` are derived as §10.2
|
|
1023
|
+
claims — recomputed from the real certificates, which matters because the library only
|
|
1024
|
+
ever echoes the server's value back — and that KSeF **decrypts** what this gem wrapped,
|
|
1025
|
+
since an access token is issued only if the RSA-OAEP ciphertext unwrapped to the right
|
|
1026
|
+
`token|timestampMs`. Unlike the XAdES suite this needs no PESEL, so it reuses the stored
|
|
1027
|
+
`KSEF_TEST_NIP`/`KSEF_TEST_TOKEN` rather than provisioning a test person.
|
|
1028
|
+
|
|
1029
|
+
**Superseded 2026-08-24 — the nightly ran and all three specs went green.** `crypto_spec`
|
|
1030
|
+
resolved both of its open questions against live TEST (run `32704511675`,
|
|
1031
|
+
`docs/REFERENCE.md` §9), so the crypto module and the KSeF-token auth call *are*
|
|
1032
|
+
live-verified. As written at the time: *"These specs have only ever run against stubs. They
|
|
1033
|
+
are written, not yet exercised; the nightly is their first real run. Nothing in the crypto
|
|
1034
|
+
module is live-verified."*
|
|
1035
|
+
- `rake fa3:generate` — codegen producing committed `lib/ksef/fa3/generated/`: 59 content
|
|
1036
|
+
models and 21 enumerations read from the pinned FA(3) XSD. Hand-written models consume
|
|
1037
|
+
this for element ordering, occurrence rules and enum membership.
|
|
1038
|
+
- `rake fa3:verify` — regenerates and fails on any byte difference, so a non-deterministic
|
|
1039
|
+
generator or a stale `generated/` breaks the build. Runs in the default task and on every
|
|
1040
|
+
CI matrix leg.
|
|
1041
|
+
- Pinned `AuthTokenRequest` schemas (auth v2-0 and v2-1) ahead of the certificate auth flow.
|
|
1042
|
+
- **FA(3) models and serializer.** A plain `VAT` invoice can be described with English
|
|
1043
|
+
keyword arguments and serialised to schema-valid XML. `Ksef::FA3::Invoice`,
|
|
1044
|
+
`Subject`, `Line`, `Address`, plus `NIP` checksum validation, `VatRate` bucket mapping and
|
|
1045
|
+
centralised `Formatting`. Both rounding strategies from DESIGN.md §7.3 are implemented.
|
|
1046
|
+
- **`Ksef::FA3::Validator`** — offline XSD validation against the bundled schema. The
|
|
1047
|
+
schema's one remote `xsd:import` is redirected in memory, so validation needs no network
|
|
1048
|
+
and the pinned file stays byte-identical.
|
|
1049
|
+
- The serializer reads element order from the generated metadata rather than hand-listing
|
|
1050
|
+
it, and raises on element names the schema does not define at that position instead of
|
|
1051
|
+
dropping them silently.
|
|
1052
|
+
- **Live integration specs, and the nightly schedule enabled.**
|
|
1053
|
+
`spec/integration/auth_flow_spec.rb` exercises the real authentication flow against TEST.
|
|
1054
|
+
Opt-in twice over — tagged `:integration` *and* excluded unless `KSEF_INTEGRATION=1`,
|
|
1055
|
+
because RSpec ANDs exclusion filters with CLI inclusions, so a tag alone would either
|
|
1056
|
+
always run or never run. WebMock is re-enabled around each example rather than globally,
|
|
1057
|
+
so a failure cannot leave the network open for whatever runs next.
|
|
1058
|
+
|
|
1059
|
+
**DESIGN.md §12 item 4 is resolved.** Running the bootstrap against TEST established what no
|
|
1060
|
+
offline test could: KSeF accepts the XAdES-BES signature this gem produces, the 2.0
|
|
1061
|
+
namespace is correct, `/testdata/person` really is unauthenticated, and a self-signed
|
|
1062
|
+
certificate is accepted on TEST. Recorded at `docs/REFERENCE.md` §6a.4.
|
|
1063
|
+
- **`rake auth:bootstrap`** — provisions a TEST credential end to end, retiring the
|
|
1064
|
+
workaround `docs/REFERENCE.md` §6a.2 used to describe (a one-time out-of-band mint via
|
|
1065
|
+
the official C# client). It invents a checksum-valid NIP and PESEL, registers them
|
|
1066
|
+
through the **unauthenticated** `/testdata/person` endpoint — the only reason the chain
|
|
1067
|
+
is not circular, since `POST /tokens` needs a session — authenticates by XAdES with a
|
|
1068
|
+
self-signed certificate, and mints the token. A real qualified certificate can be
|
|
1069
|
+
supplied instead.
|
|
1070
|
+
|
|
1071
|
+
It lives in `tasks/`, so it is never packaged, but it is **not** an untested script:
|
|
1072
|
+
every method is covered against stubs. A checksum bug would otherwise surface as an
|
|
1073
|
+
opaque rejection from a remote server. It refuses any environment whose `test_data_api?`
|
|
1074
|
+
capability is false, so DEMO is refused as well as PROD, and the guard is on the
|
|
1075
|
+
capability rather than the name so a `custom` environment cannot slip past.
|
|
1076
|
+
- **`Ksef::Auth::Client`** — the six HTTP calls of the authentication flow, with typed
|
|
1077
|
+
responses (`Challenge`, `Initiation`, `OperationStatus`, `Tokens`, `TokenInfo`) and a
|
|
1078
|
+
poller. Deliberately thin: it maps requests and responses and nothing else. Only
|
|
1079
|
+
`wait_until_complete` has policy, and it has **no timeout by default** — on DEMO and PROD
|
|
1080
|
+
the operation legitimately stays "in progress" while the certificate's status is checked
|
|
1081
|
+
with its issuer over OCSP/CRL, so a fixed deadline would report failure for
|
|
1082
|
+
authentications that were about to succeed.
|
|
1083
|
+
- **`Ksef::Auth::Status`** — the twelve authentication status codes. These are not HTTP
|
|
1084
|
+
statuses; they arrive inside a 200 response. An unrecognised code is treated as
|
|
1085
|
+
**terminal**, because assuming otherwise polls a dead operation for ever, and elapsed
|
|
1086
|
+
time cannot distinguish "still legitimately 100" from "stuck".
|
|
1087
|
+
- `TokenInfo` redacts **both `#inspect` and `#to_s`**. Redacting only `#inspect` leaves the
|
|
1088
|
+
leak that actually happens — interpolating a token into a log line. Extracting the value
|
|
1089
|
+
is an explicit `#token` call, and `Auth::Client` does that when setting the header.
|
|
1090
|
+
- **`Ksef::Auth::Signer`** — the XAdES-BES enveloped signature for
|
|
1091
|
+
`POST /auth/xades-signature`, built on Nokogiri and stdlib `openssl` with no new
|
|
1092
|
+
dependency. Every algorithm is from the Ministry's published allow-list, and the shape is
|
|
1093
|
+
corroborated against both official clients. Its specs recompute each digest and verify
|
|
1094
|
+
the `SignatureValue` **without reusing any of the signer's own code**, so a test cannot
|
|
1095
|
+
pass by agreeing with a bug; they also assert that tampering with the payload, the
|
|
1096
|
+
signing time, or the signature itself is detected, and that the result validates against
|
|
1097
|
+
the pinned xmldsig schema.
|
|
1098
|
+
|
|
1099
|
+
It signs a String and returns a String on purpose. A digest over "the document" has to
|
|
1100
|
+
match what the verifier computes after parsing the bytes sent, and Nokogiri pretty-prints
|
|
1101
|
+
on output without adding text nodes to the tree — so an in-memory tree and its serialised
|
|
1102
|
+
form can canonicalise differently. Output is emitted with `FORMAT` off; re-indenting after
|
|
1103
|
+
signing would invalidate every signature while leaving it internally consistent.
|
|
1104
|
+
- **`Ksef::Auth::SignatureTemplate`** and **`Ksef::Auth::Xades`** — the signature XML and
|
|
1105
|
+
the algorithm vocabulary, split out so that rendering and cryptography are separable.
|
|
1106
|
+
- **`Ksef::Auth::TokenRequest`** — the `AuthTokenRequest` document, step 2 of the
|
|
1107
|
+
authentication flow. Sends the **2.0** namespace by default, validates the challenge
|
|
1108
|
+
format locally
|
|
1109
|
+
before a signature is spent on it, and emits the `ContextIdentifier` choice and the
|
|
1110
|
+
optional `AuthorizationPolicy` in schema order regardless of the caller's argument
|
|
1111
|
+
order. A spec compares the generated document against upstream's own pinned example,
|
|
1112
|
+
canonicalised, so the implementation is tied to an artifact rather than to a reading of
|
|
1113
|
+
one.
|
|
1114
|
+
- **`Ksef::Auth::AuthorizationPolicy`** — the client-IP whitelist as its own value object,
|
|
1115
|
+
since the schema treats it as a distinct structure with its own rules (three list kinds,
|
|
1116
|
+
each capped at ten, fixed order, mandatory `AllowedIps` wrapper). IP *values* are left
|
|
1117
|
+
to the schema rather than re-validated in Ruby, which would mean maintaining a second
|
|
1118
|
+
and divergent source of truth.
|
|
1119
|
+
- Pinned the **W3C xmldsig and ETSI XAdES v1.3.2/v1.4.1 schemas** that upstream
|
|
1120
|
+
redistributes in its PEF bundle, so the signature namespaces come from an artifact
|
|
1121
|
+
rather than from memory. All three compile offline — their imports are relative, so
|
|
1122
|
+
unlike the FA(3) schema they need no `schemaLocation` rewrite — which means the signer
|
|
1123
|
+
will get real structural validation. Kept under `spec/fixtures/`, not `lib/`: validating
|
|
1124
|
+
a signature is a test-time concern, and these are W3C/ETSI documents whose terms are not
|
|
1125
|
+
the repository's MIT licence that §1.2 relied on for bundling the FA schemas.
|
|
1126
|
+
- **`Ksef::Auth::Validator`** — offline XSD validation for auth documents, mirroring
|
|
1127
|
+
`Ksef::FA3::Validator`. Validates a document in either namespace, taking the rules from
|
|
1128
|
+
v2.1's file with its target namespace rewritten in memory, because v2.0's file cannot be
|
|
1129
|
+
compiled at all. A namespace that is not a known schema version is refused rather than
|
|
1130
|
+
validated against itself.
|
|
1131
|
+
- **`Ksef::FA3.build` — the keyword DSL of DESIGN.md §8.** That section's snippet now runs
|
|
1132
|
+
verbatim and produces schema-valid FA(3) XML, so the README's headline example is no
|
|
1133
|
+
longer aspirational. The DSL accepts the English shorthand from the spec (`qty:`,
|
|
1134
|
+
`vat:`) alongside the canonical names, and coerces an address given as a Hash or as an
|
|
1135
|
+
already-formatted String. It is a thin front end over the existing value objects —
|
|
1136
|
+
every computation and every schema default stays in `Invoice`, so there is one
|
|
1137
|
+
implementation of each rule rather than two.
|
|
1138
|
+
|
|
1139
|
+
Unknown or misspelled keys **raise**, naming what was permitted, on the same reasoning
|
|
1140
|
+
as the serializer's treatment of unknown element names: the alternative is an invoice
|
|
1141
|
+
that is silently missing a field. Passing both a shorthand and its canonical name
|
|
1142
|
+
(`qty:` and `quantity:`) is an error rather than a silent last-one-wins. Single-value
|
|
1143
|
+
fields set twice do take the later value, which is what a builder should do.
|
|
1144
|
+
- **Pinned the normative subset of upstream's prose documentation** (`docs/upstream/`, 19
|
|
1145
|
+
files) plus the UPO schema and its six worked examples, all at the same
|
|
1146
|
+
`CIRFMF/ksef-api@1c34fe27` already used for the OpenAPI contract and FA schemas. The
|
|
1147
|
+
repository holds 77 files and only 4 had been pinned; most of what the ledger listed as
|
|
1148
|
+
"unverified" turned out to be documented prose nobody had read. Newly ledgered from
|
|
1149
|
+
first-tier sources: crypto parameters, XAdES signature requirements, online session
|
|
1150
|
+
semantics, the UPO format, per-endpoint rate limits, size limits, and the KSeF number
|
|
1151
|
+
structure. Four of the five open items in `docs/REFERENCE.md` §9 are now closed,
|
|
1152
|
+
including both that were marked as blocking.
|
|
1153
|
+
- `docs/REFERENCE.md` §14 — a new section for **contradictions within upstream's own
|
|
1154
|
+
sources**, kept separate from §7's divergences from this project's design document. Four
|
|
1155
|
+
are recorded, each with the resolution and the evidence for it.
|
|
1156
|
+
- `spec/openapi_contract_spec.rb` — asserts the ledger's claims against the pinned
|
|
1157
|
+
contract, rather than only that the contract has not changed. It covers the two findings
|
|
1158
|
+
that had **no code when they were ledgered** — §14.1's discrete IV field, since
|
|
1159
|
+
implemented by `Ksef::Crypto`, and §14.2's pre-signed link, since implemented by `Ksef::UPO::Client` — because
|
|
1160
|
+
those are the ones that would otherwise rot unnoticed until someone implemented crypto
|
|
1161
|
+
or session handling from a stale conclusion.
|
|
1162
|
+
- Coverage is now gated on three criteria rather than one, at **line 99, branch 95,
|
|
1163
|
+
method 100**. Branch coverage was 83% behind 99% line coverage, so seventeen conditional
|
|
1164
|
+
paths were untested; closing the real gaps brought it to 97%, and line coverage to 100%. Fixes uncovered on the way:
|
|
1165
|
+
proxy configuration was entirely unexercised, and the `Retry-After` parser's past-date
|
|
1166
|
+
and unparseable-value fallbacks had no tests. (Branch was ratcheted to 96 and then 97 on 2026-08-24; `spec/spec_helper.rb` is the gate that decides.)
|
|
1167
|
+
|
|
1168
|
+
### Changed
|
|
1169
|
+
|
|
1170
|
+
- The challenge format check moved from `Auth::TokenRequest::CHALLENGE_FORMAT` to
|
|
1171
|
+
**`Auth::Challenge::FORMAT`**, with `Challenge.validate_format!` alongside it. Both
|
|
1172
|
+
authentication methods consume the same challenge, so the XAdES document and the
|
|
1173
|
+
KSeF-token JSON body would otherwise have carried their own copies of one rule.
|
|
1174
|
+
- **`bigdecimal` constraint widened from `~> 3.1` to `>= 3.1, < 5`.** The old constraint
|
|
1175
|
+
made this gem uninstallable alongside bigdecimal 4, which has been out since 2026-03.
|
|
1176
|
+
A library should not force that choice on its users over an arithmetic dependency.
|
|
1177
|
+
- **Certificate/XAdES authentication moved from 0.3 into 0.1.** A KSeF token can only be
|
|
1178
|
+
issued after a one-time XAdES authentication, so a token-only client cannot bootstrap a
|
|
1179
|
+
credential from nothing.
|
|
1180
|
+
- The prior-art claim in DESIGN.md §1 ("there is no Ruby SDK") is withdrawn: `ksef-rb`
|
|
1181
|
+
exists and is a working KSeF 2.0 client. This gem's distinction is FA(3) authoring and
|
|
1182
|
+
validation rather than transport alone.
|
|
1183
|
+
|
|
1184
|
+
### Fixed
|
|
1185
|
+
|
|
1186
|
+
- **VAT rate code `3` reported into the wrong summary bucket.** Buckets pair a current rate with
|
|
1187
|
+
the one it replaced — 23/22, 8/7, 4/3 — and bucket 5 is not a rate bucket at all: `P_14_5` is
|
|
1188
|
+
*"kwota podatku od wartości dodanej"*, foreign VAT under the OSS procedure, whose per-line rate
|
|
1189
|
+
lives in `P_12_XII`. Mapping `3` there meant a domestic 3% sale was declared as OSS foreign VAT,
|
|
1190
|
+
on a document the XSD accepts. Found by comparing against `ksef-pdf-generator`, the only
|
|
1191
|
+
official code that renders these buckets (`docs/REFERENCE.md` §8.1a).
|
|
1192
|
+
|
|
1193
|
+
- **`Ksef::FA3.parse` refused a valid document for the wrong reason.** Keyword arguments evaluate
|
|
1194
|
+
in source order, so the row reader ran before the invoice-type check — and the Ministry's
|
|
1195
|
+
collective corrections carry no `FaWiersz` at all, so a `KOR` was rejected with "Invoice has no
|
|
1196
|
+
FaWiersz rows". True, and the wrong diagnosis. The type is now checked first, and a row priced
|
|
1197
|
+
gross (`P_9B`/`P_11A` under art. 106e ust. 7-8) is named as such rather than reported as
|
|
1198
|
+
missing a net value.
|
|
1199
|
+
|
|
1200
|
+
- **Two failures from the first live nightly** (2026-08-24), both ours rather than the
|
|
1201
|
+
service's. An integration spec asserted `be_success` on a deliberately duplicated invoice —
|
|
1202
|
+
a contradiction, since `440` is a terminal rejection; KSeF behaved exactly as
|
|
1203
|
+
`docs/REFERENCE.md` §12.1 describes, returning the original's `originalKsefNumber`. And
|
|
1204
|
+
**`Ksef::UPO::Validator` reported an error on every real UPO**: a genuine one is
|
|
1205
|
+
XAdES-signed by the Ministry, `upo-v4-3.xsd` declares no `ds:Signature`, and **none of
|
|
1206
|
+
upstream's six published examples is signed** — so nothing offline could reveal it. The
|
|
1207
|
+
signature is now removed from a copy before the schema runs, which leaves §14.3's warning
|
|
1208
|
+
and every other violation intact, and `UPO::Validator.signed?` tells the two kinds of
|
|
1209
|
+
document apart (§14.7).
|
|
1210
|
+
|
|
1211
|
+
- **Summary buckets are accumulated, not overwritten.** Several VAT rate codes report into one
|
|
1212
|
+
bucket — `"23"` and `"22"` both into `P_13_1`/`P_14_1`, `"np I"` and `"np II"` into `P_13_8`
|
|
1213
|
+
(`docs/REFERENCE.md` §8.1a) — and assigning per rate code let the last one win. An invoice
|
|
1214
|
+
with a 23% line of 100 and a 22% line of 200 emitted `P_13_1=200.00` and `P_14_1=44.00`
|
|
1215
|
+
while `P_15` carried the correct `367.00`: the tax base understated by a third,
|
|
1216
|
+
`P_13_1 + P_14_1 ≠ P_15`, and **XSD-valid**, because a schema cannot see an arithmetic
|
|
1217
|
+
inconsistency. This affected documents the builder produced, not only parsed ones.
|
|
1218
|
+
|
|
1219
|
+
- **`Ksef::FA3::Validator` no longer passes XML that is not well-formed.** libxml2 parses in
|
|
1220
|
+
recovery mode, so an unclosed root or trailing text after it yielded a usable tree that
|
|
1221
|
+
validated clean; `document.errors` was never consulted. Well-formedness is KSeF's first
|
|
1222
|
+
admission rule (§15.1).
|
|
1223
|
+
|
|
1224
|
+
- **`Data#with` now re-runs the constructor** (`Ksef::FA3::Canonical`). On Ruby 3.2 — this
|
|
1225
|
+
gem's declared floor — `Data#with` does *not* call a custom `initialize`, though it does on
|
|
1226
|
+
4.0, so every "canonicalise on the way in" invariant was bypassable through a public method
|
|
1227
|
+
there: `line.with(quantity: 0.1)` stored a `Float` in a monetary field, straight through the
|
|
1228
|
+
no-`Float` rule. RuboCop cannot see this; only running on 3.2 finds it.
|
|
1229
|
+
|
|
1230
|
+
- **Amounts and quantities are rounded to the scale their element permits**, at construction.
|
|
1231
|
+
`TKwotowy` is `fractionDigits="2"` and `TIlosci` is `"6"`, so a unit price of `150.125` is
|
|
1232
|
+
not a value FA(3) can express — and a quantity finer than six places made the document
|
|
1233
|
+
schema-invalid outright. Rounding on the way in means the model reports the figure the
|
|
1234
|
+
document will carry, and a line's net agrees with the price shown on the invoice.
|
|
1235
|
+
|
|
1236
|
+
- **`Formatting.decimal` no longer truncates a large `Rational`.** `BigDecimal`'s second
|
|
1237
|
+
argument is *significant digits*, not decimal places; the old `AMOUNT_SCALE + 10` turned
|
|
1238
|
+
`12345678901234.56` into `12345678901200.0` — the silent-rounding failure the `Float` ban
|
|
1239
|
+
exists to prevent, in the one type admitted as safe.
|
|
1240
|
+
|
|
1241
|
+
- **Malformed dates and numbers raise `Ksef::ValidationError`** instead of `Date::Error` and
|
|
1242
|
+
`ArgumentError`, so rescuing this gem's own hierarchy — which its docs tell you to do —
|
|
1243
|
+
actually catches the empty and malformed field text a rejected document contains.
|
|
1244
|
+
|
|
1245
|
+
- **`P_7` and `Podmiot2/Adres` are optional**, as the XSD says (`minOccurs="0"`; the address is
|
|
1246
|
+
*"opcjonalne dla przypadków określonych w art. 106e ust. 5 pkt 3"*, the simplified invoice).
|
|
1247
|
+
Requiring them refused valid FA(3) while reporting it as malformed. Absent optional row
|
|
1248
|
+
fields are now omitted rather than written as empty elements, which failed `TZnakowy512`.
|
|
1249
|
+
|
|
1250
|
+
- **`nokogiri` is required centrally.** Every file that uses it required it, but Zeitwerk defers
|
|
1251
|
+
loading those files, so the constant did not exist after `require "ksef_client"` until
|
|
1252
|
+
something touched the serializer — making one spec pass or fail on the RSpec seed.
|
|
1253
|
+
|
|
1254
|
+
- **`bundle exec rake` was never enforcing the coverage floors.** `rake spec` invokes
|
|
1255
|
+
`rspec --pattern spec/**{,/*/**}/*_spec.rb`, and that pattern *value* starts with `spec/` —
|
|
1256
|
+
which `spec_helper`'s filtered-run check read as "the user narrowed the run", so it skipped
|
|
1257
|
+
`minimum_coverage` on every single `rake`. CI calls `bundle exec rspec` directly and has
|
|
1258
|
+
always been strict, so nothing shipped uncovered; but CLAUDE.md described `rake` as
|
|
1259
|
+
mirroring CI, and on the one criterion that matters most it was strictly weaker.
|
|
1260
|
+
|
|
1261
|
+
Fixed by stripping `--pattern` and its value before looking for selectors, and verified
|
|
1262
|
+
both ways: `rake` now fails on a shortfall, and a filtered run stays exempt — which is what
|
|
1263
|
+
keeps the nightly's `rspec --tag integration` from failing on coverage rather than on tests.
|
|
1264
|
+
|
|
1265
|
+
It hid because a coverage failure prints among the RuboCop lines, so reading output instead
|
|
1266
|
+
of exit codes conceals it. CLAUDE.md now says to check `rake`'s exit code.
|
|
1267
|
+
- **Authentication status code `480` was missing.** "Uwierzytelnienie zablokowane" — the
|
|
1268
|
+
authentication is blocked on suspicion of a security incident, and the user must contact
|
|
1269
|
+
the Ministry. `Ksef::Auth::Status` now names it and says so; before, it fell through to
|
|
1270
|
+
"unrecognised status code 480".
|
|
1271
|
+
|
|
1272
|
+
The cause is worth recording, because it is a precedence mistake rather than an oversight.
|
|
1273
|
+
`docs/REFERENCE.md` §4.8 had sourced the whole table from `ksef-client-csharp`'s enum, on
|
|
1274
|
+
the strength of upstream *prose* saying the full list "will be available in the endpoint's
|
|
1275
|
+
technical documentation". The pinned OpenAPI contract — a **first-tier** artifact — states
|
|
1276
|
+
the table in full, and carries a code the C# enum does not. Two smaller corrections came
|
|
1277
|
+
out of the same reading: `450` collapses **eight** distinct causes rather than four, and
|
|
1278
|
+
`400`/`401` are C#-only and not contract codes at all. Now asserted in
|
|
1279
|
+
`spec/openapi_contract_spec.rb` so the provenance cannot regress silently.
|
|
1280
|
+
|
|
1281
|
+
The general lesson, now in §4.8: when upstream prose says a fact is undocumented, read the
|
|
1282
|
+
OpenAPI descriptions before reaching for a reference implementation.
|
|
1283
|
+
- **A documentation-consistency pass over the whole repo**, which found no defect that would
|
|
1284
|
+
fail a live request but a good deal of drift worth correcting. The notable ones:
|
|
1285
|
+
`rake auth:bootstrap` told the operator to store a live KSeF credential as a *repository*
|
|
1286
|
+
secret, which is precisely what §6a.3 argues against; the coverage floors existed in three
|
|
1287
|
+
places with three different values, one of which would have told a contributor they passed
|
|
1288
|
+
when CI fails them; SECURITY.md promised a cassette-scanning spec that did not exist;
|
|
1289
|
+
DESIGN.md still instructed the reader to send the 2.1 auth namespace and to take token
|
|
1290
|
+
expiry from the JWT `exp` claim, both since ruled against; and `docs/errors.md` was missing
|
|
1291
|
+
`Ksef::CryptoError` entirely.
|
|
1292
|
+
|
|
1293
|
+
Also corrected: several documents claimed the certificate flow's live verification covered
|
|
1294
|
+
more endpoints than it did. Only four have ever reached KSeF — challenge,
|
|
1295
|
+
`xades-signature`, `GET /auth/{ref}` and `redeem`. **`refresh` is implemented but has never run
|
|
1296
|
+
live** (`ksef-token` has, as of 2026-08-24), and the ledger now says so where it previously
|
|
1297
|
+
claimed "the whole §4.2 flow works as ledgered".
|
|
1298
|
+
- **`spec/cassette_hygiene_spec.rb`** — scans every committed VCR cassette for `Bearer `
|
|
1299
|
+
headers and for any value held in `KSEF_TEST_TOKEN` / `KSEF_TEST_NIP`, finding them by
|
|
1300
|
+
content rather than by path so one saved somewhere unconventional is still caught. No
|
|
1301
|
+
cassette exists yet, so it passes vacuously — which is the point of adding it now, since
|
|
1302
|
+
SECURITY.md was already promising users that this check ran.
|
|
1303
|
+
- `spec.files` globbed only the FA(3) schema directory, so schemas added elsewhere under
|
|
1304
|
+
`lib/` would not have shipped — a failure that would appear only in the packaged gem.
|
|
1305
|
+
|
|
1306
|
+
### Notes
|
|
1307
|
+
|
|
1308
|
+
Two further upstream defects found while implementing authentication, both recorded with
|
|
1309
|
+
evidence in `docs/REFERENCE.md` §14.4. The root cause is shared: **XML Schema regular
|
|
1310
|
+
expressions are implicitly anchored, and `^` / `$` are literal characters, not anchors.**
|
|
1311
|
+
|
|
1312
|
+
- **Neither official client validates the request locally, and both send the 2.0
|
|
1313
|
+
namespace.** C# serialises with `XmlSerializer` and no schema; Java marshals with JAXB
|
|
1314
|
+
and never calls `setSchema`. The bundled XSD is a codegen input, not a runtime check, so
|
|
1315
|
+
the defects below never fire for them — they emit the real identifier and let the server
|
|
1316
|
+
decide. This gem now does the same, and sends 2.0. The Java client even ships its own
|
|
1317
|
+
edited copy of the 2.0 schema with the IP patterns repaired but `TNipVatUE` and
|
|
1318
|
+
`TPeppolId` left broken, which independently confirms those two are defective.
|
|
1319
|
+
- **PESEL is a structured identifier and KSeF enforces it** (§6a.3) — the first fact in the
|
|
1320
|
+
ledger whose source is the API's own behaviour rather than a document or a reference
|
|
1321
|
+
client. A checksum-perfect PESEL was rejected with `400 [21405] Invalid PESEL format`;
|
|
1322
|
+
the first six digits encode a birth date with the century folded into the month field.
|
|
1323
|
+
Nothing upstream states this, and no amount of offline verification would have found it.
|
|
1324
|
+
- **A missing OpenSSL CA bundle looks like a broken server certificate** (§6a.5). Recorded
|
|
1325
|
+
because the error names the wrong thing entirely and the tempting fix — disabling
|
|
1326
|
+
verification — is a hard-rule violation.
|
|
1327
|
+
- **The PESEL checksum is now recorded** at §6a.3 — needed to invent a test person, and
|
|
1328
|
+
confirmed the way §6a.1 confirmed the NIP algorithm: it validates every PESEL the
|
|
1329
|
+
upstream documentation ships and rejects those values with the check digit altered.
|
|
1330
|
+
§6a.3 also states the NIP check digit precisely, because it is easy to get backwards —
|
|
1331
|
+
it *is* the weighted sum `mod 11`, not `11 - (sum mod 11)`.
|
|
1332
|
+
- **The authentication status codes are now recorded** at `docs/REFERENCE.md` §4.8, from
|
|
1333
|
+
the reference implementation — the Ministry's prose names only two of the twelve and says
|
|
1334
|
+
the rest "will be available in the endpoint's technical documentation". This narrows, but
|
|
1335
|
+
does not close, the open error-code catalogue in §9.
|
|
1336
|
+
- **A signed `AuthTokenRequest` can never be schema-valid.** The API requires an enveloped
|
|
1337
|
+
signature; the schema defining the document declares a closed sequence with no
|
|
1338
|
+
`xsd:any`, so that signature is an unexpected element. Both requirements are upstream's
|
|
1339
|
+
and they contradict each other. `Signer#sign` therefore validates *before* signing by
|
|
1340
|
+
default, which also catches a malformed document before a signature is spent on it.
|
|
1341
|
+
- **`schemat_auth_v2-0.xsd` does not compile as a schema at all.** Its IP patterns use
|
|
1342
|
+
`\b`, which XSD regex has no concept of, so libxml2 rejects the whole file rather than
|
|
1343
|
+
those facets. Anyone validating against v2.0 gets a compilation failure, not a
|
|
1344
|
+
validation result. Only v2.1 is usable, and it rewrote all three patterns correctly.
|
|
1345
|
+
- **Two of v2.1's four context identifiers cannot hold their real values.** `TPeppolId`'s
|
|
1346
|
+
pattern is written `^P[A-Z]{2}[0-9]{6}$`, so it matches only a value that literally
|
|
1347
|
+
starts with `^` and ends with `$`; `TNipVatUE`'s ends with a stray `$`. Upstream's own
|
|
1348
|
+
documented example value for `NipVatUe` fails the schema that defines it. The natural
|
|
1349
|
+
value is emitted regardless and local validation is reported as advisory for those two
|
|
1350
|
+
types — a KSeF token can only be issued in a `Nip` or `InternalId` context anyway, and
|
|
1351
|
+
both of those validate cleanly.
|
|
1352
|
+
|
|
1353
|
+
Three upstream inconsistencies found while pinning the documentation, each of which would
|
|
1354
|
+
have produced a working-looking client that fails in practice. All are recorded with
|
|
1355
|
+
evidence in `docs/REFERENCE.md` §14.
|
|
1356
|
+
|
|
1357
|
+
- **The AES initialisation vector is not prefixed to the ciphertext**, despite
|
|
1358
|
+
`sesja-interaktywna.md` saying it is. The pinned OpenAPI contract carries the IV as a
|
|
1359
|
+
discrete `EncryptionInfo.initializationVector` field, and both the C# and Java reference
|
|
1360
|
+
clients emit bare ciphertext. Following the prose yields payloads KSeF cannot decrypt.
|
|
1361
|
+
- **All six of upstream's UPO examples fail upstream's own UPO schema**, each with the same
|
|
1362
|
+
single error: `NazwaPodmiotuPrzyjmujacego` is `fixed="Ministerstwo Finansów"` in the XSD,
|
|
1363
|
+
but TEST issues `"Ministerstwo Finansów - środowisko testowe (TE)"`. A client that
|
|
1364
|
+
strictly validates a received UPO would reject every UPO that TEST issues.
|
|
1365
|
+
- **`upo.pages[].downloadUrl` is a pre-signed storage link, not a path to join.** The
|
|
1366
|
+
contract declares it `format: uri`, generated per status query, expiring at
|
|
1367
|
+
`downloadUrlExpirationDate`, **exempt from API rate limits**, and served with an
|
|
1368
|
+
`x-ms-meta-hash` SHA-256 integrity header — and says the access token must *not* be sent
|
|
1369
|
+
to it. Both reference clients implement it that way; C# passes `token: null` explicitly.
|
|
1370
|
+
Given how tight the session budgets are, this unmetered path is the better default, with
|
|
1371
|
+
`GET /sessions/{ref}/upo/{upoRef}` as the fallback once a link expires.
|
|
1372
|
+
|
|
1373
|
+
## [0.1.0.rc2] — 2026-09-03
|
|
1374
|
+
|
|
1375
|
+
**Targets:** KSeF API 2.0 · FA(3) `1-0E` · upstream `CIRFMF/ksef-api@1c34fe27`,
|
|
1376
|
+
`CIRFMF/ksef-client-csharp@406904d6`, `CIRFMF/ksef-pdf-generator@2b7c1dae` (sample corpus,
|
|
1377
|
+
`docs/REFERENCE.md` §1.4)
|
|
1378
|
+
|
|
1379
|
+
> **The release candidate for 0.1.0.** Unlike `0.1.0.rc1`, this is a working client: it
|
|
1380
|
+
> authenticates, builds and validates FA(3), sends, polls, and retrieves the UPO — all verified
|
|
1381
|
+
> against the live KSeF **TEST** environment. It is a candidate rather than a release because
|
|
1382
|
+
> 0.1.0's gates are not yet met (below), and because this API has not yet been used by anyone
|
|
1383
|
+
> but its author.
|
|
1384
|
+
>
|
|
1385
|
+
> Prereleases are not installed by `gem install ksef_client`; ask for this version explicitly.
|
|
1386
|
+
> **Do not point it at production.**
|
|
1387
|
+
|
|
1388
|
+
### Why this release exists
|
|
1389
|
+
|
|
1390
|
+
To run the publishing pipeline end to end before `0.1.0` does.
|
|
1391
|
+
|
|
1392
|
+
`0.1.0.rc1` proved trusted publishing — OIDC, no long-lived API key anywhere. It did not prove
|
|
1393
|
+
the rest: the job that creates the GitHub release from this file landed four days *after* that
|
|
1394
|
+
tag, and rc1's release entry was written by hand. That job had never executed, and when it was
|
|
1395
|
+
finally read it turned out to be broken — it passed the git tag where a version was expected, so
|
|
1396
|
+
it would have failed on any tag at all. It runs `needs: publish`, so the failure would have
|
|
1397
|
+
landed *after* the gem was irreversibly on RubyGems.
|
|
1398
|
+
|
|
1399
|
+
That is the shape of thing a rehearsal is for, and it is why this version exists rather than
|
|
1400
|
+
going straight to `0.1.0`.
|
|
1401
|
+
|
|
1402
|
+
### What is in it
|
|
1403
|
+
|
|
1404
|
+
The 0.1.0 development line as it stood on 2026-09-03. The entries are kept under the 0.1.0
|
|
1405
|
+
heading rather than duplicated here — at the time of tagging that heading is `[Unreleased]`.
|
|
1406
|
+
|
|
1407
|
+
Since `0.1.0.rc1`, in summary:
|
|
1408
|
+
|
|
1409
|
+
- **Authentication**, both methods the Ministry documents: XAdES-BES with a qualified
|
|
1410
|
+
certificate, and the KSeF-token flow. Both have minted a token against live TEST.
|
|
1411
|
+
- **The FA(3) layer** — a builder DSL, a parser with a round-trip law green over the Ministry's
|
|
1412
|
+
own sample corpus, all seven invoice types (`VAT`, `KOR`, `ZAL`, `ROZ`, `UPR`, `KOR_ZAL`,
|
|
1413
|
+
`KOR_ROZ`), the attachment node, and a generated `docs/field_mapping.md`.
|
|
1414
|
+
- **Four validator tiers**, split by what each can actually see: the model, the serialised
|
|
1415
|
+
bytes, the XSD, and business checks. The last are advisory — `Invoice#warnings`, never
|
|
1416
|
+
`#errors` — because a Polish invoice priced from round gross prices legitimately misses the
|
|
1417
|
+
arithmetic by a grosz per line, and refusing those is what got KSeF's own proposed rule
|
|
1418
|
+
withdrawn.
|
|
1419
|
+
- **Online sessions** — open, send, poll, close — with UPO retrieval and invoice download, both
|
|
1420
|
+
integrity-checked against the hash KSeF publishes.
|
|
1421
|
+
- **`Ksef::Client`**, the facade, thread-safe by construction rather than by convention.
|
|
1422
|
+
|
|
1423
|
+
### Known gaps
|
|
1424
|
+
|
|
1425
|
+
- **Batch sessions are absent**, not stubbed. 0.2.
|
|
1426
|
+
- `Rozliczenie`, `Platnosc`, `Stopka` and other optional FA(3) nodes are not carried.
|
|
1427
|
+
`Invoice#unmapped_elements` names exactly what a parsed document would lose on the way back
|
|
1428
|
+
out, so this is visible rather than silent. 0.2.
|
|
1429
|
+
- The error-code catalogue in `docs/errors.md` is incomplete. 0.2.
|
|
1430
|
+
- **This is a release candidate and the API may still change.** SemVer's promises begin at 1.0;
|
|
1431
|
+
until then see the support policy in the README.
|
|
1432
|
+
|
|
13
1433
|
## [0.1.0.rc1] — 2026-08-22
|
|
14
1434
|
|
|
15
1435
|
**Targets:** KSeF API 2.0 · FA(3) `1-0E` (`kodSystemowy` `FA (3)`, variant 3)
|
|
@@ -43,6 +1463,10 @@ gem version for which API state".
|
|
|
43
1463
|
always on, and error mapping.
|
|
44
1464
|
- `Ksef::HTTP::SystemWarning` — surfaces the `X-System-Warning` advisory header the API
|
|
45
1465
|
sets on successful responses.
|
|
1466
|
+
- Requests send `X-Error-Format: problem-details`, opting into RFC7807 error bodies.
|
|
1467
|
+
This is opt-in per request, not selected by content negotiation: without the header the
|
|
1468
|
+
API returns the deprecated envelopes, which carry no `traceId`, no structured `errors[]`
|
|
1469
|
+
codes on 400 and no `reasonCode` on 403.
|
|
46
1470
|
- CI: test matrix across Ruby 3.2/3.3/3.4/4.0/head, nightly TEST integration, and a
|
|
47
1471
|
tag-triggered release workflow using RubyGems Trusted Publishing.
|
|
48
1472
|
|