ksef-client-rails 0.1.0 → 1.0.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.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +161 -0
  3. data/NOTICE +40 -0
  4. data/README.md +524 -6
  5. data/examples/fa3_full.xml +143 -0
  6. data/examples/fa3_minimal.xml +65 -0
  7. data/examples/fa3_typical.xml +76 -0
  8. data/lib/generators/ksef/install/install_generator.rb +20 -0
  9. data/lib/generators/ksef/install/templates/ksef.rb +25 -0
  10. data/lib/ksef/auth/coordinator.rb +164 -0
  11. data/lib/ksef/auth/ksef_token_encryptor.rb +36 -0
  12. data/lib/ksef/batch/package_builder.rb +125 -0
  13. data/lib/ksef/batch/splitter.rb +99 -0
  14. data/lib/ksef/batch/uploader.rb +114 -0
  15. data/lib/ksef/client.rb +135 -0
  16. data/lib/ksef/configuration.rb +206 -0
  17. data/lib/ksef/crypto/cryptography_service.rb +136 -0
  18. data/lib/ksef/crypto/csr.rb +106 -0
  19. data/lib/ksef/crypto/public_keys.rb +125 -0
  20. data/lib/ksef/environments.rb +61 -0
  21. data/lib/ksef/errors.rb +187 -0
  22. data/lib/ksef/http/connection.rb +432 -0
  23. data/lib/ksef/http/error_parser.rb +194 -0
  24. data/lib/ksef/http/pagination.rb +80 -0
  25. data/lib/ksef/invoices/builder/checks.rb +127 -0
  26. data/lib/ksef/invoices/builder/number.rb +67 -0
  27. data/lib/ksef/invoices/builder/xml.rb +256 -0
  28. data/lib/ksef/invoices/builder.rb +251 -0
  29. data/lib/ksef/invoices/export.rb +241 -0
  30. data/lib/ksef/invoices/sample.rb +78 -0
  31. data/lib/ksef/invoices/schema_outline.rb +120 -0
  32. data/lib/ksef/invoices/validator.rb +200 -0
  33. data/lib/ksef/models/form_code.rb +49 -0
  34. data/lib/ksef/models/ksef_number.rb +74 -0
  35. data/lib/ksef/models/nip.rb +62 -0
  36. data/lib/ksef/polish_time.rb +49 -0
  37. data/lib/ksef/polling.rb +65 -0
  38. data/lib/ksef/qr/code.rb +104 -0
  39. data/lib/ksef/qr/verification_link.rb +215 -0
  40. data/lib/ksef/railtie.rb +20 -0
  41. data/lib/ksef/resources/auth.rb +116 -0
  42. data/lib/ksef/resources/base.rb +120 -0
  43. data/lib/ksef/resources/batch_sessions.rb +43 -0
  44. data/lib/ksef/resources/certificates.rb +82 -0
  45. data/lib/ksef/resources/collective_identifiers.rb +70 -0
  46. data/lib/ksef/resources/exports.rb +47 -0
  47. data/lib/ksef/resources/invoices.rb +67 -0
  48. data/lib/ksef/resources/limits.rb +33 -0
  49. data/lib/ksef/resources/online_sessions.rb +62 -0
  50. data/lib/ksef/resources/peppol.rb +21 -0
  51. data/lib/ksef/resources/permissions.rb +180 -0
  52. data/lib/ksef/resources/security.rb +18 -0
  53. data/lib/ksef/resources/sessions.rb +125 -0
  54. data/lib/ksef/resources/test_data.rb +178 -0
  55. data/lib/ksef/resources/tokens.rb +62 -0
  56. data/lib/ksef/sessions/online.rb +168 -0
  57. data/lib/ksef/sync/incremental.rb +169 -0
  58. data/lib/ksef/testing.rb +153 -0
  59. data/lib/ksef/token_manager.rb +130 -0
  60. data/lib/ksef/version.rb +11 -0
  61. data/lib/ksef/xades/auth_token_request.rb +135 -0
  62. data/lib/ksef/xades/external_signer.rb +58 -0
  63. data/lib/ksef/xades/signer.rb +312 -0
  64. data/lib/ksef-client-rails.rb +3 -2
  65. data/lib/ksef.rb +116 -0
  66. data/schemas/FA/schemat_FA(2)_v1-0E.xsd +3661 -0
  67. data/schemas/FA/schemat_FA(3)_v1-0E.xsd +3950 -0
  68. data/schemas/README.md +53 -0
  69. data/schemas/RR/schemat_FA_RR(1)_v1-1E.xsd +2188 -0
  70. data/schemas/bazowe/ElementarneTypyDanych_v10-0E.xsd +1 -0
  71. data/schemas/bazowe/KodyKrajow_v10-0E.xsd +1283 -0
  72. data/schemas/bazowe/StrukturyDanych_v10-0E.xsd +1 -0
  73. metadata +291 -9
data/README.md CHANGED
@@ -1,9 +1,527 @@
1
- # ksef-client-rails — placeholder
1
+ # ksef — Ruby/Rails client for KSeF API 2.0
2
2
 
3
- This version (0.1.0) only reserves the gem name. It contains no functionality.
3
+ Pure-Ruby client for the Polish National e-Invoice System
4
+ ([KSeF API 2.0](https://github.com/CIRFMF/ksef-api)), covering every operation of the **API 2.8.1**
5
+ contract: resources mapped 1:1 to the endpoints, plus high-level flows — XAdES and token
6
+ authentication, interactive and batch sessions, export, incremental sync, QR codes. Works in plain
7
+ Ruby; the Rails integration loads only under Rails.
4
8
 
5
- `ksef-client-rails` will be a pure-Ruby client for the Polish KSeF (Krajowy System e-Faktur)
6
- API 2.0, with optional Rails integration. The full client will be released as **1.0.0** — use
7
- `gem "ksef-client-rails", ">= 1.0"` once it is out.
9
+ > **Status:** every request is checked against the API 2.8.1 OpenAPI document on each test run, and
10
+ > every flow passes on live KSeF TEST ([Verification](#verification)). Not exercised live: 5xx retries,
11
+ > `SessionUnavailable` and a token refresh refused with 401.
8
12
 
9
- MIT licence.
13
+ ## Installation
14
+
15
+ ```ruby
16
+ gem "ksef-client-rails", "~> 1.0"
17
+ gem "rqrcode" # optional: QR rendering
18
+ gem "minitar" # optional: tar.gz packages
19
+ ```
20
+
21
+ In Rails, `bin/rails g ksef:install` creates `config/initializers/ksef.rb`.
22
+
23
+ ## Quick start
24
+
25
+ On TEST: sign in, fetch your invoices, issue one. The same code runs on DEMO and PROD (where invoices
26
+ are legally binding).
27
+
28
+ ```ruby
29
+ require "ksef"
30
+
31
+ Ksef.configure { |c| c.environment = :test } # :test | :demo | :prod
32
+ client = Ksef::Client.new
33
+ nip = "5555555555" # the company you act for (the context)
34
+ ```
35
+
36
+ **1. Sign in** — with a KSeF token (generated in the taxpayer app) or with a certificate / company seal:
37
+
38
+ ```ruby
39
+ auth = Ksef::Auth::Coordinator.new(client)
40
+
41
+ # a) KSeF token
42
+ result = auth.authenticate_with_token(context: { nip: nip }, token: ENV.fetch("KSEF_TOKEN"))
43
+
44
+ # b) certificate (XAdES signature) — PEM/DER files, or PKCS#12: OpenSSL::PKCS12.new(File.binread("seal.p12"), password)
45
+ cert = OpenSSL::X509::Certificate.new(File.binread("ksef.cert"))
46
+ key = OpenSSL::PKey.read(File.binread("ksef.key"), ENV.fetch("KSEF_CERT_PASSWORD", ""))
47
+ result = auth.authenticate(context: { nip: nip }, signer: Ksef::Xades::Signer.new(cert: cert, key: key))
48
+
49
+ client = client.with_access_token(result.access_token) # valid 15 min — see Refreshing the access token
50
+ ```
51
+
52
+ **2. Fetch invoices** — received (you are the buyer, `Subject2`) or issued (`Subject1`):
53
+
54
+ ```ruby
55
+ filters = { "subjectType" => "Subject2",
56
+ "dateRange" => { "dateType" => "Invoicing", "from" => (Time.now - (30 * 86_400)).utc.iso8601 } }
57
+ client.invoices.query_metadata(filters: filters, sort_order: "Desc").first(10).each do |meta|
58
+ puts "#{meta['invoiceNumber']} from #{meta.dig('seller', 'nip')}: #{meta['grossAmount']} #{meta['currency']}"
59
+ File.write("#{meta['ksefNumber']}.xml", client.invoices.get_by_ksef_number(meta["ksefNumber"]))
60
+ end
61
+ ```
62
+
63
+ **3. Issue an invoice** — FA(3) XML, from a file or built with [`Ksef::Invoices::Builder`](#invoice-xml),
64
+ with the context's NIP (or one it may invoice for) in `Podmiot1`:
65
+
66
+ ```ruby
67
+ invoice_xml = Ksef::Invoices::Sample.minimal(seller_nip: nip, invoice_number: "FV/2026/10/1").to_xml # or File.read
68
+ Ksef::Invoices::Validator.new.validate!(invoice_xml) # the XSD and KSeF's XML rules, checked locally
69
+ session = Ksef::Sessions::Online.open(client)
70
+ sent = session.send_invoice(invoice_xml) # encrypted and hashed by the gem
71
+ session.close
72
+ upo = session.wait_for_upo(sent.reference_number) # waits until accepted; the UPO XML
73
+ ksef_number = client.sessions.invoice(session.reference_number, sent.reference_number)["ksefNumber"]
74
+
75
+ link = Ksef::Qr::VerificationLink.from_client(client).online(seller_nip: nip, invoice_xml: invoice_xml)
76
+ File.write("qr.svg", Ksef::Qr::Code.new(link, label: ksef_number).as_svg) # the code printed on the invoice (rqrcode)
77
+ ```
78
+
79
+ A rejected invoice raises `Ksef::InvoiceRejected` from `wait_for_upo` ([Interactive session](#interactive-session)).
80
+ For many invoices, use a [batch session](#batch-session), an [export or the incremental sync](#download-metadata-and-export).
81
+
82
+ ## Configuration
83
+
84
+ ```ruby
85
+ Ksef.configure do |c|
86
+ c.environment = :demo
87
+ c.timeout = 30
88
+ c.logger = Logger.new($stdout)
89
+ end
90
+ ```
91
+
92
+ | Option | Default | Meaning |
93
+ |---|---|---|
94
+ | `environment` | `:test` | `:test`, `:demo`, `:prod` |
95
+ | `timeout` / `open_timeout` | `60` / `15` | seconds |
96
+ | `max_retries` | `2` | a 429 is retried for any call; 500/502/503/504 and connection failures (not TLS) only for idempotent ones (GET, presigned transfers), with backoff; a failure before anything was sent (a connect timeout, a busy connection pool) for any call |
97
+ | `max_retry_after` | `60` | longest `Retry-After` slept through; a longer one raises `Ksef::RateLimited`; `nil` — no limit |
98
+ | `adapter` | `:net_http_persistent` (keep-alive) | Faraday adapter, e.g. `[:net_http_persistent, { pool_size: 5 }]` — at least the number of threads sharing a client, as a thread waits 0.5 s for a free connection; `:net_http` turns keep-alive off |
99
+ | `proxy` | `ENV["KSEF_PROXY"]` | HTTP proxy |
100
+ | `logger` | `nil` (Rails: `Rails.logger`) | every request at `debug` (method, path, status, time), retries, `X-System-Warning`, polling — never secrets |
101
+ | `ksef_features` | `[]` | `X-KSeF-Feature` flags, e.g. `subject-identifier-validation` |
102
+ | `default_headers`, `prefer_problem_details`, `user_agent`, `api_base_url_override`, `qr_base_url_override` | | extra headers, error format, mock hosts |
103
+
104
+ - Per client: `Ksef::Client.new(config: Ksef.config.with(environment: :demo))` — a copy that keeps the
105
+ other options (in Rails, what `config.ksef.*` and the initializer set); `Ksef::Configuration.new`
106
+ starts from the defaults. Configure before building clients: a client copies the configuration it is
107
+ built with. A value of the wrong type (`timeout: "60"`) raises `Ksef::ConfigurationError` when set.
108
+ - Rails: `config.ksef.<option>` takes the same keys (an unknown key stops the boot). The generated
109
+ initializer runs after it and sets only the environment, from `KSEF_ENV` when present — never
110
+ derive the environment from `Rails.env`, staging often runs as production.
111
+ - A proxy password with percent-encoded characters needs `adapter: :net_http` — the keep-alive adapter
112
+ (faraday-net_http_persistent 2.3) fails on it with `URI::InvalidComponentError`.
113
+ - Keep one client per environment and process — it holds the keep-alive connection, the Ministry key
114
+ cache and the CA store, and is thread-safe — and derive per-context clients with
115
+ `client.with_access_token(token)` rather than assigning `client.access_token =` on a shared one.
116
+ `client.close` closes its keep-alive connections; the next request opens new ones.
117
+ - Build clients after a `fork`. A client used both before and after one shares its keep-alive
118
+ connection with the other process, so its next request, GET or POST, can fail with
119
+ `Ksef::NetworkError` for about 5 s (a POST may or may not have been processed); a client kept across a
120
+ fork needs `adapter: :net_http`.
121
+ - Each HTTP attempt is published as `request.ksef` (ActiveSupport::Notifications: `method`, `path`, `status`).
122
+ - Tokens and keys are redacted in `#inspect` and `pp`; presigned transfers refuse non-`https` URLs.
123
+ - `enforce_xades_compliance!` is not needed: KSeF validates XAdES strictly on every environment.
124
+
125
+ ## Authentication
126
+
127
+ ### XAdES signature
128
+
129
+ ```ruby
130
+ cert = OpenSSL::X509::Certificate.new(File.binread("ksef.cert")) # PEM or DER
131
+ key = OpenSSL::PKey.read(File.binread("ksef.key"), ENV.fetch("KSEF_CERT_PASSWORD", ""))
132
+ # or from PKCS#12: p12 = OpenSSL::PKCS12.new(File.binread("seal.p12"), password); cert, key = p12.certificate, p12.key
133
+
134
+ result = Ksef::Auth::Coordinator.new(client).authenticate(
135
+ context: { nip: "5555555555" }, # or internal_id:, nip_vat_ue:, peppol_id:
136
+ signer: Ksef::Xades::Signer.new(cert: cert, key: key), # RSA or EC P-256
137
+ subject_identifier_type: :certificate_subject, # or :certificate_fingerprint
138
+ verify_certificate_chain: false, # true adds an OCSP/CRL check
139
+ allowed_ips: { addresses: ["203.0.113.7"] } # optional; also ranges:, masks:
140
+ )
141
+ result.access_token # and result.refresh_token
142
+ ```
143
+
144
+ - Pass `""` when there is no password, never `nil`: if the key turns out to be encrypted, `nil` makes
145
+ OpenSSL prompt on the terminal. A key that does not match the certificate, or one KSeF refuses (RSA
146
+ under 2048 bits, EC under 256), raises `Ksef::CryptoError` up front instead of KSeF's opaque `9105`.
147
+ - `:certificate_subject` needs `organizationIdentifier` `VATPL-<NIP>` (a seal) or `serialNumber`
148
+ `TINPL-<NIP>`/`PNOPL-<PESEL>` (a person); other certificates use `:certificate_fingerprint` once
149
+ permissions are granted to the fingerprint. Self-signed certificates work on TEST only.
150
+ - Any object responding to `#call(xml)` or `#sign(xml)` can replace the signer (HSM, qualified
151
+ signature): `signer: ->(xml) { MyHsm.sign_xades(xml) }`. It must return the signed XML; a key alone
152
+ (`OpenSSL::PKey`) is refused — wrap it in `Ksef::Xades::Signer`.
153
+ - A NIP context (and `seller_nip:` of the QR links) may carry spaces, dashes or a `PL` prefix: it is
154
+ sent as its 10 digits. An invalid context, subject type or `allowed_ips` (at most 10 of each kind)
155
+ raises `Ksef::ConfigurationError` before any request.
156
+
157
+ ### Trying TEST without credentials
158
+
159
+ TEST accepts a self-signed seal (`organizationIdentifier` `VATPL-<NIP>`) as the owner of any valid NIP:
160
+
161
+ ```ruby
162
+ nip = loop do # a random NIP with a valid check digit
163
+ digits = [rand(1..9), *Array.new(8) { rand(10) }]
164
+ check = Ksef::Nip::WEIGHTS.each_with_index.sum { |weight, i| weight * digits[i] } % 11
165
+ candidate = (digits + [check]).join
166
+ break candidate if check < 10 && Ksef::Nip.new(candidate).valid?
167
+ end
168
+ key = OpenSSL::PKey::RSA.new(2048) # or OpenSSL::PKey::EC.generate("prime256v1")
169
+ cert = OpenSSL::X509::Certificate.new
170
+ cert.version = 2
171
+ cert.serial = OpenSSL::BN.rand(64)
172
+ cert.subject = cert.issuer = OpenSSL::X509::Name.new(
173
+ [["CN", "Test seal"], ["O", "Test seal"], ["2.5.4.97", "VATPL-#{nip}", OpenSSL::ASN1::UTF8STRING], ["C", "PL"]]
174
+ )
175
+ cert.public_key = OpenSSL::PKey.read(key.public_to_der)
176
+ cert.not_before = Time.now - 300
177
+ cert.not_after = Time.now + (365 * 86_400)
178
+ cert.sign(key, OpenSSL::Digest.new("SHA256"))
179
+
180
+ client = Ksef::Client.new(config: Ksef.config.with(environment: :test))
181
+ result = Ksef::Auth::Coordinator.new(client).authenticate(context: { nip: nip },
182
+ signer: Ksef::Xades::Signer.new(cert: cert, key: key))
183
+ ```
184
+
185
+ ### Refreshing the access token
186
+
187
+ ```ruby
188
+ manager = Ksef::TokenManager.new(client, access_token: result.access_token, refresh_token: result.refresh_token,
189
+ access_token_valid_until: result.access_token_valid_until,
190
+ on_refresh: ->(token, valid_until) { store(token, valid_until) }) # optional
191
+ client = client.with_access_token(-> { manager.access_token }) # read on every request, refreshed 60 s early
192
+ ```
193
+
194
+ `on_refresh` lets you persist the new token between requests or jobs; it runs outside the manager's
195
+ lock, and one that raises runs again on the next `access_token`. When the refresh token expires or is
196
+ revoked (KSeF answers 401), or the authentication no longer allows a refresh (`21301`, `21304`), the
197
+ refresh raises `Ksef::ReauthenticationRequired`: authenticate again. `21308` (the credentials of a
198
+ deceased person) is a plain `Ksef::BadRequest` — signing in again does not help.
199
+
200
+ ## Invoices
201
+
202
+ ### Invoice XML
203
+
204
+ `Ksef::Invoices::Builder` writes FA(3) XML from Ruby — parties, lines with any of the ten rates (the
205
+ summary is computed), payment, annotations, corrections, footer:
206
+
207
+ ```ruby
208
+ today = Ksef::PolishTime.today # P_1 is the calendar day in Poland
209
+ invoice = Ksef::Invoices::Builder.new(
210
+ number: "FV/2026/10/1", issue_date: today,
211
+ seller: { nip: nip, name: "Seller Ltd.", address: "1 Test Street, 00-001 Warsaw" },
212
+ buyer: { nip: "1111111111", name: "Buyer Inc.", address: "2 Example Street, 00-002 Warsaw" },
213
+ lines: [{ name: "Service", unit: "szt.", quantity: "1", unit_price: "100.00", rate: "23" }],
214
+ payment_method: :transfer, due_date: today + 14, bank_account: "17100000000000000000000001"
215
+ )
216
+ invoice_xml = invoice.to_xml
217
+ ```
218
+
219
+ - `issue_date` (`P_1`) is the Polish calendar day: KSeF refuses a later one, and marks an invoice dated
220
+ earlier than the day it receives it as issued offline. `Date.today` on a UTC server is the day before
221
+ for the first hour or two after midnight in Poland.
222
+ - Quantities and prices are Integers, BigDecimals or strings with a comma or a dot (`"1 234,56"` too);
223
+ prices and discounts take at most 2 decimal places, quantities 6. Anything else raises
224
+ `Ksef::ConfigurationError` naming the line — the builder never writes a guessed amount — and so do
225
+ unknown codes, missing required values and text KSeF refuses. `to_xml` validates its own output
226
+ against the schema (`Ksef::SchemaValidationError` for, say, a value over its length limit).
227
+ - What the builder cannot express is refused: a currency other than PLN with a 23/8/5% line (the VAT
228
+ would also have to be stated in PLN), and a `zw` line without `exemption_reason` (the legal basis,
229
+ `P_19A`). An `oo` or `np II` line sets the reverse-charge annotation (`P_18`); a buyer identified by an
230
+ EU VAT number or another tax ID needs `country`.
231
+ - A correction (`invoice_type: "KOR"`, with `corrected_date:` and `corrected_number:`) carries the
232
+ change: its totals are summed from its lines, so give difference rows (quantity `"2"` at `"-10.00"` for
233
+ a 20.00 reduction) or the original line negated plus the corrected one.
234
+
235
+ `Ksef::Invoices::Sample.minimal`, `.typical` and `.full` (`seller_nip:`, `invoice_number:`, `issue_date:`
236
+ — the Polish day by default) return builders for three invoices accepted on KSeF TEST; the full one
237
+ carries most fields the builder supports, but no payment made, no buyer identified other than by NIP,
238
+ no correction or annotation, and five of the ten rates. Their XML ships with the gem in
239
+ [`examples/`](examples). For a field the builder does not cover, start from the XML; every field of a
240
+ form, with its description from the schema:
241
+
242
+ ```ruby
243
+ puts Ksef::Invoices::Validator.new.outline("Faktura/Fa/FaWiersz", descriptions: true)
244
+ ```
245
+
246
+ The Ministry's SDKs carry more templates (attachments, `Podmiot3`, FA_RR, PEF):
247
+ [C#](https://github.com/CIRFMF/ksef-client-csharp/tree/main/KSeF.Client.Tests.Core/Templates),
248
+ [Java](https://github.com/CIRFMF/ksef-client-java/tree/main/demo-web-app/src/integrationTest/resources/xml/invoices/sample).
249
+
250
+ ### Validation
251
+
252
+ ```ruby
253
+ validator = Ksef::Invoices::Validator.new # FA(3); or form_code: Ksef::FormCode::FA_RR1 / FA2
254
+ validator.validate(xml) # => ["...Podmiot2: Missing child element(s)..."], [] when valid
255
+ validator.errors(xml) # => [#<Issue path="Faktura/Podmiot2/DaneIdentyfikacyjne/NIP", line=21, message="...">]
256
+ validator.validate!(xml) # raises Ksef::SchemaValidationError (#messages, #errors)
257
+ ```
258
+
259
+ `errors` names the element of each violation, e.g. to show it next to a form field.
260
+
261
+ The FA(2), FA(3) and FA_RR(1) schemas ship with the gem, so validation works offline; malformed XML
262
+ is reported, never repaired; PEF is not supported. Besides the XSD it checks KSeF's XML rules: UTF-8
263
+ without a BOM, no other declared encoding, XML 1.0, no processing instructions and none of the
264
+ characters KSeF refuses (C1 controls, Unicode noncharacters). What fails here fails on KSeF too; the
265
+ messages are libxml2's, not KSeF's.
266
+
267
+ KSeF also checks what a schema cannot: `KodFormularza` must match the session's form code, the context
268
+ must be allowed to invoice for the `Podmiot1` NIP (usually it is that NIP; under self-billing or a tax
269
+ representative it is the other party's), the seller NIP, invoice type and `P_2` must not repeat an
270
+ earlier invoice, `P_1` must not be later than the day KSeF receives it, and on PROD the NIPs must
271
+ have valid check digits.
272
+
273
+ ### Interactive session
274
+
275
+ The [Quick start](#quick-start) shows the flow. A session takes up to 10,000 invoices and closes by
276
+ itself 12 hours after it was opened (`session.valid_until`). `send_invoice` sends the bytes as given,
277
+ which must be UTF-8 without a BOM: a BOM or invalid UTF-8 raises `Ksef::ConfigurationError` before
278
+ anything is sent.
279
+
280
+ A rejected invoice makes `wait_for_upo` raise `Ksef::InvoiceRejected` (an `ApiError`) with `#codes`
281
+ (`450` schema/semantics, `440` duplicate) and the failed rule in `e.body["status"]["details"]`;
282
+ `client.sessions.failed_invoices(ref)` lists every rejection. Any other error from `wait_for_upo` does
283
+ not mean a rejection — the invoice may already be accepted, so wait again. These cases need care:
284
+
285
+ - `Ksef::SessionUnavailable` (`21184`) — the session is under maintenance: continue in a new session.
286
+ - A `Ksef::BadRequest` with `21180` (the session is closed or expired) or `21155` (it holds 10,000
287
+ invoices) — continue in a new session.
288
+ - `Ksef::NetworkError` (`RequestTimeout` included) on `send_invoice` after the request went out — the
289
+ outcome is unknown: check `client.sessions.invoices(ref)` before sending again. A refused connection,
290
+ a DNS failure or a connect timeout means nothing was sent.
291
+
292
+ A technical correction — an offline invoice resent after a technical rejection — goes with
293
+ `send_invoice(xml, hash_of_corrected_invoice: hash)`, in offline mode (`offline_mode: false` with it
294
+ raises). A rotated Ministry key (`21470`) is retried automatically — here and in batch, export and
295
+ token authentication.
296
+
297
+ In background jobs, use one client per worker process, built after it forks, and derive the job's
298
+ client from it: `client = KSEF_CLIENT.with_access_token(-> { manager.access_token })` (clients and
299
+ sessions do not serialize). A client built in every job holds its own keep-alive socket and CA store
300
+ until garbage collection. One session per invoice is capped by the session rate limit
301
+ (`client.limits.rate_limits`); after a restart,
302
+ `Ksef::Sessions::Online.new(client, reference_number: ref, encryption: nil).wait_for_upo(invoice_ref)`
303
+ resumes waiting (without the key such a session can be closed and polled, not sent to).
304
+
305
+ ### Batch session
306
+
307
+ ```ruby
308
+ result = Ksef::Batch::Uploader.new(client).call( # Uploader.new(client, part_size: …) — up to 100 MB, the default
309
+ invoices: { "invoice-1.xml" => xml1, "invoice-2.xml" => xml2 },
310
+ compression: :zip # or :tar_gz
311
+ )
312
+ ref = result.reference_number
313
+ status = Ksef::Polling.run(description: "batch #{ref}", timeout: 1800) do
314
+ current = client.sessions.status(ref)
315
+ current.dig("status", "code") < 200 ? Ksef::Polling.pending : current # 100 started · 150 processing
316
+ end
317
+ raise "batch #{ref} failed: #{status['status']}" if status.dig("status", "code") >= 400
318
+ client.sessions.invoices(ref).each do |invoice|
319
+ puts "#{invoice['invoiceFileName']}: #{invoice['ksefNumber'] || invoice.dig('status', 'description')}"
320
+ end
321
+ client.sessions.failed_invoices(ref).to_a if status["failedInvoiceCount"].to_i.positive?
322
+ ```
323
+
324
+ - `200` means the session was processed and at least one invoice accepted: check
325
+ `failedInvoiceCount` and `client.sessions.failed_invoices(ref)` for the rejected ones. A code of
326
+ 400 or more is final — `405` (the package failed verification), `415` (the key could not be
327
+ decrypted), `420` (too many invoices), `430`/`435` (the archive could not be decompressed/decrypted),
328
+ `440` (cancelled), `445` (no valid invoice), `500` — so a wait for `200` alone would never end.
329
+ - The package is split into encrypted parts, uploaded and the session closed; the package stays in
330
+ memory, its parts are encrypted one at a time. No invoices, two names that are the same in the
331
+ package (`x.xml` and `:"x.xml"`, `a\b.xml` and `a/b.xml`), an absolute or `..` name, a tar.gz name
332
+ over 100 bytes (`ż` takes 2), or a package over 5 GB or 50 parts raise `Ksef::ConfigurationError`
333
+ before anything is sent. `Result#files` is keyed by the name in the package.
334
+ - A failed part upload or close raises `Ksef::BatchUploadError` with the session's `#reference_number`
335
+ (and the part's `#ordinal_number`) — check that session before uploading again.
336
+ - Session UPO pages: `status.dig("upo", "pages")` (nil until processed) → `client.sessions.session_upo`.
337
+
338
+ ### Download, metadata and export
339
+
340
+ ```ruby
341
+ xml = client.invoices.get_by_ksef_number("5555555555-20250826-0100001AF629-72")
342
+
343
+ filters = { # camelCase string keys, sent verbatim
344
+ "subjectType" => "Subject1", # Subject1 seller · Subject2 buyer · Subject3 · SubjectAuthorized
345
+ "dateRange" => { "dateType" => "Issue", # Issue | Invoicing | PermanentStorage
346
+ "from" => "2026-05-01T00:00:00+02:00", "to" => "2026-07-31T23:59:59+02:00" },
347
+ "sellerNip" => "5555555555", "invoiceTypes" => %w[Vat Kor] # optional, see below
348
+ }
349
+ client.invoices.query_metadata(filters: filters, sort_order: "Desc").first(50) # lazy; page_size 10–250 (250)
350
+
351
+ result = Ksef::Invoices::Export.new(client).call(filters: filters, compression_type: "Zip") # or "TarGz"
352
+ result.metadata # the InvoiceMetadata entries of _metadata.json
353
+ result.each_entry { |name, bytes| store(name, bytes) } # every file — "{ksefNumber}.xml" and _metadata.json
354
+ ```
355
+
356
+ - `dateRange` spans at most 100 days (UTC); give dates with `Z` or an offset.
357
+ `restrictToPermanentStorageHwmDate` goes **inside** `dateRange`.
358
+ - Optional filters: `ksefNumber`, `invoiceNumber`, `sellerNip`, `buyerIdentifier`
359
+ (`{"type" => "Nip"|"VatUe"|"Other"|"None", "value"}`), `amount` (`{"type" => "Brutto"|"Netto"|"Vat",
360
+ "from", "to"}`), `currencyCodes`, `invoicingMode`, `isSelfInvoicing`, `formType` (`FA`, `PEF`,
361
+ `FA_RR`), `invoiceTypes` (`Vat`, `Zal`, `Kor`, `Roz`, `Upr`, `KorZal`, `KorRoz`, `VatPef`,
362
+ `VatPefSp`, `KorPef`, `VatRr`, `KorVatRr`), `hasAttachment`.
363
+ - A filter set yields at most 10,000 records: iterating past them raises `Ksef::ResultTruncated`
364
+ (`#last_item`). Query again from page 0 with `dateRange.from` (ascending) or `dateRange.to`
365
+ (`sort_order: "Desc"`) set to that record's date — the boundary record repeats, dedupe by
366
+ `ksefNumber` — or use `Sync::Incremental`. The endpoint allows 20 requests an hour, hence 250
367
+ records per page by default.
368
+ - An export package holds up to 10,000 invoices or 1 GB and is decrypted in memory; every part is
369
+ hash-checked before decryption; an expired package is error `210`. Entry names are never absolute
370
+ or climbing (`..`), and a file over 100 MB unpacked raises.
371
+ - An invoice or UPO download is checked against the SHA-256 KSeF sends with it (`x-ms-meta-hash`); a 2xx
372
+ that is not an XML document (a proxy's HTML page, an empty body) raises `Ksef::ApiError`.
373
+
374
+ ### Incremental sync
375
+
376
+ ```ruby
377
+ sync = Ksef::Sync::Incremental.new(client, from: last_hwm || (Time.now - (30 * 86_400)), subject_type: "Subject2")
378
+ begin
379
+ sync.each_invoice { |xml, meta| upsert(meta["ksefNumber"], xml) } # stores each invoice before returning
380
+ ensure
381
+ save(sync.hwm) # the next run starts here, after an error too
382
+ end
383
+ ```
384
+
385
+ - It walks windows by permanent-storage date from the HWM until it has caught up; a start more than
386
+ 90 days back goes in closed 90-day windows (`dateRange` allows 100). `from:` is a `Time`, a `Date`
387
+ (midnight in Poland) or an ISO 8601 string with `Z` or an offset; one the HWM has not reached yet
388
+ yields nothing (status 420). The first window of a run may repeat the previous run's last invoices,
389
+ so store them by KSeF number.
390
+ - Each window is an export, and KSeF allows 20 exports an hour, shared by every sync of the context
391
+ (one per subject type) — so run it no more often than every 15 minutes, and size `page_limit:` (the
392
+ windows per call, 1000 by default) to that budget. A spent budget raises `Ksef::RateLimited` with
393
+ `sync.hwm` at the window it could not export: the `ensure` above saves the progress, and the next
394
+ run resumes there. `poll:` sets the export wait (300 s by default).
395
+ - Save `sync.hwm` after an error only when the block stores each invoice before it returns. A consumer
396
+ that buffers (`sync.each_invoice.each_slice(500)`) has not stored the last slice yet: save the HWM
397
+ only after `each_invoice` finished, or after each stored slice.
398
+
399
+ ## Certificates and QR codes
400
+
401
+ ```ruby
402
+ csr = Ksef::Crypto::Csr.generate_ecdsa(client.certificates.enrollment_data) # needs an XAdES session
403
+ enrollment = client.certificates.enroll(certificate_name: "Server", certificate_type: "Offline", # or "Authentication"
404
+ csr: csr.csr_der_base64)
405
+ client.certificates.enrollment_status(enrollment["referenceNumber"]) # status.code 200 → certificateSerialNumber
406
+ client.certificates.retrieve(certificate_serial_numbers: [serial]) # Base64 DER; keep csr.private_key_pem
407
+
408
+ link = Ksef::Qr::VerificationLink.from_client(client)
409
+ code1 = link.online(seller_nip: "5555555555", invoice_xml: xml) # the date is the invoice's P_1
410
+ code2 = link.offline(context_type: "Nip", context_value: "5555555555", seller_nip: "5555555555",
411
+ certificate_serial: serial, private_key: csr.private_key, invoice_xml: xml)
412
+ Ksef::Qr::Code.new(code1, label: ksef_number).as_svg # label: the KSeF number or "OFFLINE"; "CERTYFIKAT" for CODE II
413
+ ```
414
+
415
+ The SVG scales with CSS and keeps the 4-module quiet zone; `as_png` renders the bare code and
416
+ `as_ascii` an ANSI terminal preview. `invoice_hash:` plus `issue_date:` (the invoice's `P_1`) instead
417
+ of `invoice_xml:` take the `invoiceHash` KSeF returns, Base64 or Base64URL. `certificate_serial:` is the
418
+ 16 hex digits of `certificateSerialNumber` (or `certificate.serial`); `context_type:` is `"Nip"`,
419
+ `"InternalId"`, `"NipVatUe"` or `"PeppolId"` (or `:nip`, … as in authentication). CODE II signs with
420
+ the Offline certificate's key: RSA of at least 2048 bits or EC P-256.
421
+
422
+ ## Other resources
423
+
424
+ `client.tokens`, `client.permissions`, `client.limits`, `client.collective_identifiers`,
425
+ `client.peppol`, `client.security` and `client.test_data` (refused on PROD) map 1:1 to the API; lists
426
+ are lazy enumerators:
427
+
428
+ ```ruby
429
+ client.sessions.list(session_type: "Online", statuses: %w[Succeeded]).first(20) # plus date_*_from/_to: filters
430
+ client.tokens.list(status: %w[Active]).to_a
431
+ client.collective_identifiers.generate(invoices: [{ "ksefNumber" => k1 }, { "ksefNumber" => k2 }])
432
+ ```
433
+
434
+ `Ksef::Nip#valid?` (the contract pattern and mod 11; `#value` gives the 10 digits without separators or
435
+ `PL`) and `Ksef::KsefNumber#valid?` (the contract pattern and CRC-8) check identifiers locally.
436
+
437
+ ## Calling KSeF from a web request
438
+
439
+ The defaults suit jobs: a request may wait 60 s, a GET is retried twice, and a 429 is slept through for
440
+ up to 60 s per retry. In a controller, use a client that fails fast:
441
+
442
+ ```ruby
443
+ WEB_KSEF = Ksef::Client.new(config: Ksef.config.with(timeout: 10, open_timeout: 5, max_retries: 0,
444
+ max_retry_after: 0))
445
+ ```
446
+
447
+ ## Testing your app
448
+
449
+ `require "ksef/testing"` (needs `webmock`, enabled — `require "webmock/rspec"`, `"webmock/minitest"` or
450
+ `WebMock.enable!`; otherwise the stubs are bypassed and the requests reach KSeF) adds WebMock stubs that
451
+ answer as KSeF does:
452
+
453
+ ```ruby
454
+ require "webmock/rspec"
455
+ require "ksef/testing"
456
+
457
+ include Ksef::Testing
458
+ stub_ksef_authentication # token or XAdES sign-in and refresh
459
+ stub_ksef_online_session(ksef_number: Ksef::Testing.ksef_number) # every invoice accepted, with a UPO
460
+ ```
461
+
462
+ Each takes `config:` for another environment; other endpoints are stubbed with WebMock directly. The
463
+ stubbed Ministry certificate is valid from 2000 to 2099, so tests that travel in time (`travel_to`,
464
+ Timecop) work in any order.
465
+
466
+ ## Errors
467
+
468
+ The gem's own errors, network failures included, inherit from `Ksef::Error`:
469
+
470
+ | Class | When |
471
+ |---|---|
472
+ | `ApiError` → `BadRequest`, `Unauthorized`, `Forbidden`, `NotFound`, `Conflict`, `Gone`, `RateLimited` (`#retry_after`), `ServerError` | an HTTP error; `#status`, `#codes` (business codes), `#trace_id`, `#body`. Also an export `status.code` failure (`#status` nil, the code in `#codes`), and a 2xx body that is not what the endpoint returns (not a JSON object, a required field missing, not an XML document) |
473
+ | `InvoiceRejected` (an `ApiError`) | KSeF rejected an invoice (`status.code` 400 or more, e.g. `450`, `440` duplicate); `#status` nil, `#body` the invoice status |
474
+ | `SessionUnavailable` (a `BadRequest`) | `21184` — the session is under maintenance |
475
+ | `ReauthenticationRequired` (an `ApiError`) | the authentication can no longer be used: `21301` (it does not allow it, e.g. its KSeF token was revoked), `21304` (it does not exist — also from the auth status), or a 401 from the refresh: sign in again |
476
+ | `NetworkError`, `RequestTimeout` | a transport failure after the retries; `#cause` is the original error. On a call that is not idempotent, any of them after the request went out (a timeout, a reset, a cut-short response) leaves the outcome unknown |
477
+ | `AuthenticationFailed` | an authentication `status.code` (e.g. `450` — not an HTTP code) |
478
+ | `PollingTimeout`, `SchemaValidationError`, `BatchUploadError`, `ResultTruncated`, `CryptoError`, `ConfigurationError` | too long a wait, invalid XML, a failed batch upload or close, the 10,000-record query limit, a crypto problem, invalid input |
479
+
480
+ Safe calls are retried automatically (`max_retries`); a POST only after a 429, which KSeF answers
481
+ before processing it. Both API error formats (legacy and RFC 7807) are parsed.
482
+
483
+ ## Verification
484
+
485
+ - **Contract**, on every `rspec` run: `spec/ksef/contract_spec.rb` checks each request — operation,
486
+ path, query (every value of a repeated parameter) and header parameters, tokens, JSON body — against
487
+ the 2.8.1 OpenAPI document in `spec/fixtures/openapi/`, fails when an operation or parameter goes
488
+ unused, walks every list to its second page, and checks the response fields and enums the gem relies
489
+ on. `rake contract:drift` compares that document with what TEST, DEMO and PROD publish.
490
+ - **Live**: `rake e2e:test` runs every flow on KSeF TEST with a throwaway self-signed seal for a
491
+ random NIP — no credentials needed — and revokes what it creates.
492
+ - **Cryptography**: `rake oracle:all` checks with an independent Python implementation the AES/RSA-OAEP
493
+ output, the KSeF-token plaintext, the CSRs (RDN order and ASN.1 types) and the XAdES signatures
494
+ (digests, SignatureValue, SigningCertificate).
495
+
496
+ ### Checking for KSeF API changes
497
+
498
+ The contract suite checks the gem against the fixture, not the servers. To see whether KSeF changed
499
+ (it ships to TEST first):
500
+
501
+ ```bash
502
+ bundle exec rake contract:drift # TEST, DEMO, PROD vs. the fixture
503
+ bundle exec rake e2e:test # every flow on live TEST
504
+ ```
505
+
506
+ On a difference, follow [Moving to a new API version](spec/fixtures/openapi/README.md#moving-to-a-new-api-version).
507
+ New invoice schemas are updated by hand ([schemas/README.md](schemas/README.md)).
508
+
509
+ ## Demo application
510
+
511
+ [`examples/ksef_demo`](examples/ksef_demo) is a Rails 8 test bench for every API area on TEST, DEMO
512
+ and PROD (`bundle install && bin/rails server` in its directory). Run it locally only; on PROD the
513
+ invoices it sends are legally binding. Details: [its README](examples/ksef_demo/README.md).
514
+
515
+ ## Development
516
+
517
+ ```bash
518
+ bundle exec rake # rspec (incl. the contract) + rubocop + XSD check of the sample invoice
519
+ bundle exec rake "xsd:outline[Faktura/Fa]" # a form's field tree from the packaged schema
520
+ bundle exec rake contract:drift e2e:test oracle:all # need network
521
+ ```
522
+
523
+ ## License
524
+
525
+ MIT — see [`LICENSE.txt`](LICENSE.txt); the bundled XSD schemas and the OpenAPI fixture come from the
526
+ Ministry of Finance under MIT ([`NOTICE`](NOTICE)). Provided "as is", without warranty — check results
527
+ against KSeF before relying on them for tax obligations. Not affiliated with the Ministry of Finance.