ksef_client 0.1.0.rc1

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.
data/docs/REFERENCE.md ADDED
@@ -0,0 +1,403 @@
1
+ # Verification ledger
2
+
3
+ Every fact the implementation depends on, with its source and retrieval date, per
4
+ DESIGN.md §0.2 and §2. **Nothing about endpoint paths, XML element names, namespace
5
+ URIs or cryptographic parameters may enter the code unless it appears here.**
6
+
7
+ Entries are `value + source URL + date`. When this ledger and DESIGN.md disagree, the
8
+ ledger wins (DESIGN.md §2) — divergences are called out in §7 below.
9
+
10
+ ---
11
+
12
+ ## 1. Pinned artifacts
13
+
14
+ Upstream repository: **`CIRFMF/ksef-api`** — https://github.com/CIRFMF/ksef-api
15
+ Pinned at commit **`1c34fe2799387d517b83a2fb21e31e83d5f66247`** (authored 2026-07-21T15:11:33Z).
16
+ Retrieved **2026-08-21**.
17
+
18
+ Upstream licence: **MIT, © 2025 Ministerstwo Finansów** (`LICENSE.txt` at repo root,
19
+ retained here as `LICENSE.upstream.txt`).
20
+
21
+ | Local path | Upstream path | SHA-256 |
22
+ |---|---|---|
23
+ | `spec/fixtures/openapi/open-api.json` | `open-api.json` | `ef8afbbee719f2232f692d1553a7d13a7f4524945614c9c3df331149dd083651` |
24
+ | `lib/ksef/fa3/schema/schemat_FA(3)_v1-0E.xsd` | `faktury/schemy/FA/schemat_FA(3)_v1-0E.xsd` | `b646b6b525f51adf1bb2545f111fc8ca6e7aa6dd2f98948f1667d3695c06d958` |
25
+ | `lib/ksef/fa3/schema/bazowe/ElementarneTypyDanych_v10-0E.xsd` | `faktury/schemy/FA/bazowe/…` | `8daf4d3771de200b26b697294cc906a2add3de9acfbbd97f4b1bd4fc0e5ecb2f` |
26
+ | `lib/ksef/fa3/schema/bazowe/KodyKrajow_v10-0E.xsd` | `faktury/schemy/FA/bazowe/…` | `48be2a9f181d7ff80f185c62491ba12604c5cacbbe21af8e2aaaf2c585bbd214` |
27
+ | `lib/ksef/fa3/schema/bazowe/StrukturyDanych_v10-0E.xsd` | `faktury/schemy/FA/bazowe/…` | `cb08348374598e1e716e086c40d740390fb9e1bfa3aba1f4ec4cba0e1ef6d60f` |
28
+
29
+ `rake verify:artifacts` re-checks these digests; treat a mismatch as an upstream change,
30
+ not as a local bug.
31
+
32
+ ### 1.1 Mirror vs. live spec
33
+
34
+ The committed `open-api.json` was compared against the spec each environment serves live
35
+ (2026-08-21). The GitHub mirror and the live TEST spec are **semantically identical** —
36
+ same 78 paths, same 302 component schemas; the small byte difference is formatting only.
37
+ The mirror is therefore safe to treat as the contract of record.
38
+
39
+ ### 1.2 XSD redistribution — resolves DESIGN.md §12 open question 2
40
+
41
+ The schemas are published under the repository's MIT licence, which permits
42
+ redistribution. **Decision: bundle the XSD in the gem.** The first-run
43
+ fetch-and-cache fallback contemplated by DESIGN.md §7.7 tier 2 is not needed.
44
+
45
+ ---
46
+
47
+ ## 2. Environments — resolves DESIGN.md §6.1 [VERIFY]
48
+
49
+ Each base URL was read from **that environment's own OpenAPI document** (`servers[0].url`,
50
+ served at `https://<host>/docs/v2/openapi.json`), not inferred by analogy. Retrieved 2026-08-21.
51
+
52
+ | Env | Base URL | Spec title | Paths |
53
+ |---|---|---|---|
54
+ | `:test` | `https://api-test.ksef.mf.gov.pl/v2` | KSeF API TE | 78 |
55
+ | `:demo` | `https://api-demo.ksef.mf.gov.pl/v2` | KSeF API TR | 59 |
56
+ | `:prod` | `https://api.ksef.mf.gov.pl/v2` | KSeF API PR | 59 |
57
+
58
+ Corroborated by `srodowiska.md` (dated 16.03.2026), which lists the same three hosts.
59
+
60
+ **The base URL already contains `/v2`.** Endpoint paths are appended bare — the correct
61
+ challenge URL is `https://api-test.ksef.mf.gov.pl/v2/auth/challenge`. There is **no `/api`
62
+ segment** (see §7.2).
63
+
64
+ **Environment parity:** DEMO and PROD expose an identical path set. TEST adds 19 paths —
65
+ the `/testdata/*` helper API (15) and `/collective-identifiers*` (4). Code that targets
66
+ those paths must be guarded to TEST.
67
+
68
+ Format support (`srodowiska.md`): TEST accepts FA(2) and FA(3); DEMO and PROD accept
69
+ FA(3) only.
70
+
71
+ Operational note: the test environments undergo scheduled maintenance daily between
72
+ **16:00 and 18:00** local time; expect transient failures in nightly CI scheduled in that
73
+ window.
74
+
75
+ ---
76
+
77
+ ## 3. Endpoint paths (from the pinned spec)
78
+
79
+ Relative to the environment base URL above. Only the paths this gem targets are listed;
80
+ the spec carries 78 in total.
81
+
82
+ ### Auth
83
+ | Method | Path |
84
+ |---|---|
85
+ | POST | `/auth/challenge` |
86
+ | POST | `/auth/ksef-token` |
87
+ | POST | `/auth/xades-signature` |
88
+ | POST | `/auth/token/redeem` |
89
+ | POST | `/auth/token/refresh` |
90
+ | GET | `/auth/{referenceNumber}` |
91
+ | GET | `/auth/sessions` |
92
+ | DELETE | `/auth/sessions/current` |
93
+ | DELETE | `/auth/sessions/{referenceNumber}` |
94
+
95
+ ### Security / crypto
96
+ | Method | Path |
97
+ |---|---|
98
+ | GET | `/security/public-key-certificates` |
99
+
100
+ ### Sessions
101
+ | Method | Path |
102
+ |---|---|
103
+ | POST | `/sessions/online` |
104
+ | POST | `/sessions/online/{referenceNumber}/invoices` |
105
+ | POST | `/sessions/online/{referenceNumber}/close` |
106
+ | POST | `/sessions/batch` |
107
+ | POST | `/sessions/batch/{referenceNumber}/close` |
108
+ | GET | `/sessions` |
109
+ | GET | `/sessions/{referenceNumber}` |
110
+ | GET | `/sessions/{referenceNumber}/invoices` |
111
+ | GET | `/sessions/{referenceNumber}/invoices/failed` |
112
+ | GET | `/sessions/{referenceNumber}/invoices/{invoiceReferenceNumber}` |
113
+
114
+ ### UPO
115
+ | Method | Path |
116
+ |---|---|
117
+ | GET | `/sessions/{referenceNumber}/upo/{upoReferenceNumber}` |
118
+ | GET | `/sessions/{referenceNumber}/invoices/{invoiceReferenceNumber}/upo` |
119
+ | GET | `/sessions/{referenceNumber}/invoices/ksef/{ksefNumber}/upo` |
120
+
121
+ UPO is retrievable **both per session and per invoice** — resolves the DESIGN.md §2
122
+ [VERIFY] on UPO fetch granularity. Per-invoice UPO is addressable by either the invoice
123
+ reference number or the KSeF number.
124
+
125
+ ### Invoices
126
+ | Method | Path |
127
+ |---|---|
128
+ | GET | `/invoices/ksef/{ksefNumber}` |
129
+ | POST | `/invoices/query/metadata` |
130
+ | POST | `/invoices/exports` |
131
+ | GET | `/invoices/exports/{referenceNumber}` |
132
+
133
+ ### Limits
134
+ | Method | Path |
135
+ |---|---|
136
+ | GET | `/rate-limits` |
137
+ | GET | `/limits/context` |
138
+ | GET | `/limits/subject` |
139
+
140
+ ---
141
+
142
+ ## 4. Authentication
143
+
144
+ - Security scheme: a single HTTP **`Bearer`** scheme, `bearerFormat: JWT`
145
+ (`components.securitySchemes.Bearer`).
146
+ - `POST /auth/challenge` returns `AuthenticationChallengeResponse`, required fields:
147
+ `challenge`, `timestamp` (date-time), `timestampMs` (int64, Unix ms), `clientIp`.
148
+ - `clientIp` in the challenge response ties into the `ip-not-allowed` authorisation
149
+ failure (§5.3): the API pins the session to the IP seen at authentication.
150
+ - Challenge validity of 10 minutes is asserted by DESIGN.md §1 as verified; **not**
151
+ re-confirmed from the pinned spec — the spec does not encode it. Treat as
152
+ documentation-sourced, and do not build a hard timer on it without re-verification.
153
+
154
+ Token redemption and refresh exist as distinct endpoints (`/auth/token/redeem`,
155
+ `/auth/token/refresh`), which confirms the DESIGN.md §6.3 step 4 [VERIFY]: the API does
156
+ issue a refresh token alongside the access token.
157
+
158
+ ---
159
+
160
+ ## 5. Error model — resolves DESIGN.md §6.7 [VERIFY]
161
+
162
+ ### 5.1 Two envelopes; the *request* opts in
163
+
164
+ Error bodies come in two shapes. The modern one is **opt-in via a request header**:
165
+
166
+ ```
167
+ X-Error-Format: problem-details
168
+ ```
169
+
170
+ All **83** operations in the pinned spec document this header, with the wording
171
+ "ustawienie tego nagłówka powoduje zwracanie błędów w formacie Problem Details" —
172
+ *setting this header causes errors to be returned in Problem Details format*. Without it
173
+ the API returns the deprecated `application/json` shapes, which carry no `traceId`, no
174
+ structured `errors[]` codes on 400 and no `reasonCode` on 403. `Ksef::HTTP::Connection`
175
+ therefore sends it on every request.
176
+
177
+ The response `Content-Type` then reflects which envelope you got:
178
+
179
+ | Content type | 400 | 429 | Status |
180
+ |---|---|---|---|
181
+ | `application/problem+json` | `BadRequestProblemDetails` | `TooManyRequestsProblemDetails` | **current** |
182
+ | `application/json` | `ExceptionResponse` | `TooManyRequestsResponse` | **deprecated** (`deprecated: true` in the spec) |
183
+
184
+ 401, 403 and 410 are declared **only** as `application/problem+json`.
185
+
186
+ The parser must prefer the problem+json shape and fall back to the legacy shapes, which
187
+ nest differently — `ExceptionResponse` under `exception.exceptionDetailList[]`, and
188
+ `TooManyRequestsResponse` under `status.details[]`. `limity/limity-api.md` still documents
189
+ the legacy 429 body, so both are live in the wild.
190
+
191
+ ### 5.2 problem+json common fields
192
+
193
+ `title`, `status`, `detail`, `instance`, `timestamp` (UTC date-time), `traceId`.
194
+ `traceId` should be surfaced on every error — it is what the Ministry's support asks for.
195
+
196
+ 400 additionally carries `errors[]` of **`ApiError`**: `code` (int32), `description`,
197
+ `details[]` (nullable). This is the machine-readable error-code catalogue —
198
+ `code` maps to `Ksef::ApiError#code`. Observed examples: `21405` (input validation
199
+ failure), `21157` (invalid package part size).
200
+
201
+ ### 5.3 403 carries a structured reason
202
+
203
+ `ForbiddenProblemDetails` adds a required **`reasonCode`** plus a `reasonCode`-dependent
204
+ `security` object:
205
+
206
+ | `reasonCode` | `security` payload |
207
+ |---|---|
208
+ | `missing-permissions` | `requiredAnyOfPermissions: string[]`, `presentPermissions: string[]` |
209
+ | `ip-not-allowed` | `clientIp: string` |
210
+ | `insufficient-resource-access` | — |
211
+ | `auth-method-not-allowed` | `authenticationMethodCategory: string` |
212
+ | `security-service-blocked` | `incidentId: string`, `clientIp: string` |
213
+ | `context-type-not-allowed` | `contextIdentifierType: string` |
214
+
215
+ ### 5.4 Status codes actually declared
216
+
217
+ `400` (83 ops), `401` (68), `403` (68), `410` (4), `429` (80). Success: `200` (59),
218
+ `201` (4), `202` (14), `204` (6).
219
+
220
+ **No 5xx is declared anywhere in the spec.** A `ServerError` branch is still required
221
+ defensively — infrastructure returns 5xx regardless of contract — but it cannot be
222
+ mapped to a documented payload shape and must degrade to the raw body.
223
+
224
+ ### 5.5 Response headers
225
+
226
+ | Header | Where | Meaning |
227
+ |---|---|---|
228
+ | `Retry-After` | every one of the 80 `429` responses | **seconds** to wait; authoritative, honour it over any computed backoff |
229
+ | `X-System-Warning` | all `200`/`201`/`202`/`204` responses | advisory system notice; surface via the instrumentation hook |
230
+ | `x-ms-meta-hash` | 4 × `200` (export/download) | Azure blob content hash on downloaded artifacts |
231
+
232
+ `Retry-After` confirms the DESIGN.md §6.7 [VERIFY]. `X-System-Warning` is not mentioned
233
+ in DESIGN.md and is worth propagating — it is the Ministry's in-band deprecation channel.
234
+
235
+ ---
236
+
237
+ ## 6. Rate limits — DESIGN.md §6.7 retry policy input
238
+
239
+ Source: `limity/limity-api.md` (dated 22.11.2025), retrieved 2026-08-21.
240
+
241
+ - Limits are counted per **(context, client IP)** pair, where context is the
242
+ `ContextIdentifier` (`Nip`, `InternalId` or `NipVatUe`) presented at authentication.
243
+ The same context from two IPs gets two independent budgets.
244
+ - Enforcement uses a **sliding window**, not a fixed one: req/s over the trailing second,
245
+ req/min over the trailing 60 seconds, req/h over the trailing 60 minutes. Windows do
246
+ **not** reset on the minute or hour. All thresholds apply simultaneously; the first one
247
+ crossed triggers the block.
248
+ - On breach the API returns **429** and blocks further requests for a **dynamic** period
249
+ that lengthens with repeat offences. The exact duration is in `Retry-After`.
250
+ - Per-endpoint ceilings are documented per operation in the spec (e.g. `/auth/challenge`
251
+ is 60 req/s).
252
+ - Repeated breaches are recorded and analysed; the docs explicitly flag spreading one
253
+ context across many IPs as an abuse pattern. **The client must never work around a 429
254
+ by rotating connections.**
255
+
256
+ Live budgets are introspectable at runtime via `GET /rate-limits`, `GET /limits/context`
257
+ and `GET /limits/subject`.
258
+
259
+ ---
260
+
261
+ ## 6a. Provisioning TEST credentials (DESIGN.md §12.4)
262
+
263
+ Sources: `dane-testowe-scenariusze.md` (05.08.2025), `tokeny-ksef.md` (29.06.2025),
264
+ `srodowiska.md`, and the pinned spec. Retrieved 2026-08-22.
265
+
266
+ ### 6a.1 There is no NIP to "obtain" — you invent one
267
+
268
+ `srodowiska.md` is explicit: use **random** NIPs on TEST and avoid any real data. TEST
269
+ permits self-signed certificates, so many integrators authenticate in the same company
270
+ context and **TEST data is not isolated between them**.
271
+
272
+ A test NIP must still pass the standard checksum: digits 1–9 weighted by
273
+ `6,5,7,2,3,4,5,6,7`, summed, `mod 11`, which must equal digit 10 (and must not be 10).
274
+ Verified against every NIP appearing in the upstream docs — `7762811692`, `7980332920`,
275
+ `3755747347` — and against the two in DESIGN.md §8, `9999999999` and `1111111111`. All
276
+ six are checksum-valid, which independently confirms the §7.2 algorithm.
277
+
278
+ You then register the NIP on TEST:
279
+
280
+ | Endpoint | Use |
281
+ |---|---|
282
+ | `POST /testdata/subject` | Legal entities. Body: `subjectNip`, `subjectType`, `description` (5–256 chars), optional `subunits`. Supports VAT-group and JST hierarchies. |
283
+ | `POST /testdata/person` | Natural persons. Body: `nip`, `pesel`, `description`, `isBailiff`. Grants **Owner** (plus `EnforcementOperations` when `isBailiff: true`). |
284
+
285
+ **These `/testdata/*` endpoints require no authentication.** The spec declares no global
286
+ `security` and these operations declare none of their own — so bootstrapping a context
287
+ needs no prior credentials. (They exist on TEST only; see §2.)
288
+
289
+ `createdDate` caveat: when re-creating test data under the same identifier, the date must
290
+ be **later** than the previous one — not equal, not earlier.
291
+
292
+ ### 6a.2 The token needs a one-time XAdES authentication — this blocks §12.4 for 0.1
293
+
294
+ `tokeny-ksef.md`: *"Wygenerowanie tokena KSeF jest możliwe wyłącznie po jednorazowym
295
+ uwierzytelnieniu się podpisem elektronicznym (XAdES)."* — a KSeF token can be generated
296
+ **only** after a one-time authentication with a qualified electronic signature.
297
+
298
+ The pinned spec corroborates it: `POST /tokens` declares `security: [{Bearer: []}]`, so it
299
+ needs an existing session, while `/auth/xades-signature` needs none. There is no
300
+ unauthenticated path to a first token.
301
+
302
+ **Consequence for this gem:** 0.1 implements KSeF-token auth only; XAdES is roadmapped for
303
+ 0.3. So `KSEF_TEST_TOKEN` cannot be minted by this gem at its current stage. The token must
304
+ be obtained out of band once — via the official `ksef-client-csharp`, which has a working
305
+ XAdES flow, using a self-signed certificate (permitted on TEST; see
306
+ `auth/testowe-certyfikaty-i-podpisy-xades.md`). After that one-time bootstrap the token is
307
+ long-lived and this gem's token auth works normally.
308
+
309
+ Tokens are minted in a `Nip` or `InternalId` context with a fixed permission set chosen at
310
+ creation — changing permissions requires a new token. For this gem's integration suite,
311
+ `InvoiceRead` and `InvoiceWrite` are the relevant ones. Treat the token as a confidential
312
+ secret (`tokeny-ksef.md` says so explicitly).
313
+
314
+ ---
315
+
316
+ ## 7. Divergences from DESIGN.md
317
+
318
+ Recorded per DESIGN.md §2 ("when the pinned artifacts and this document disagree, the
319
+ artifacts win"). These need noting in the PR description.
320
+
321
+ 1. **`ksef-docs` does not exist.** DESIGN.md §2 names `CIRFMF/ksef-docs` as the developer
322
+ compendium. The `CIRFMF` org contains `ksef-api`, `ksef-client-csharp`,
323
+ `ksef-client-java`, `ksef-pdf-generator`, `ksef-latarnia` and `ksef-schematy` — no
324
+ `ksef-docs`. All prose documentation **and** the OpenAPI spec **and** the FA(3)
325
+ schemas live in **`ksef-api`**. (`ksef-schematy` is a discussion repo — README only.)
326
+
327
+ 2. **No `/api` path prefix.** DESIGN.md §2 gives approximate shapes
328
+ `POST /api/v2/auth/challenge` and `POST /api/v2/auth/xades-signature`. The real
329
+ contract is base URL `…/v2` + `/auth/challenge`. Building URLs with `/api/v2` would
330
+ 404 every call.
331
+
332
+ 3. **`KodFormularza` value is `FA`, not `FA (3)`.** See §8 — `FA (3)` is the value of the
333
+ `kodSystemowy` attribute, not the element's text content. Easy to conflate.
334
+
335
+ 4. **DESIGN.md §6.7's hierarchy omits 403 and 410**, both of which the API returns. 403 in
336
+ particular carries structured, actionable data (§5.3) that deserves its own error
337
+ class rather than collapsing into a generic `ApiError`.
338
+
339
+ 5. **DESIGN.md §6.7 anticipates a 5xx branch** that the spec never declares (§5.4). Keep
340
+ the branch, but do not expect a documented payload.
341
+
342
+ ---
343
+
344
+ ## 8. FA(3) schema facts
345
+
346
+ Source: pinned `schemat_FA(3)_v1-0E.xsd` (§1).
347
+
348
+ | Fact | Value |
349
+ |---|---|
350
+ | Target namespace | `http://crd.gov.pl/wzor/2025/06/25/13775/` |
351
+ | Imported namespace | `http://crd.gov.pl/xml/schematy/dziedzinowe/mf/2022/01/05/eD/DefinicjeTypy/` |
352
+ | `elementFormDefault` | `qualified` |
353
+ | `attributeFormDefault` | `unqualified` |
354
+ | `KodFormularza` element value | `FA` (only member of `TKodFormularza`) |
355
+ | `KodFormularza/@kodSystemowy` | `FA (3)` — required, fixed. Note the space before `(` |
356
+ | `KodFormularza/@wersjaSchemy` | `1-0E` — required, fixed |
357
+ | `WariantFormularza` | `3` (xsd:byte, single enumeration) |
358
+ | `DataWytworzeniaFa` range | `2025-09-01T00:00:00Z` … `2050-01-01T23:59:59Z` |
359
+
360
+ `elementFormDefault="qualified"` means **every** element must be namespace-qualified in
361
+ the instance document — the serializer cannot emit unprefixed children.
362
+
363
+ ### 8.1 Import chain and offline validation
364
+
365
+ ```
366
+ schemat_FA(3)_v1-0E.xsd
367
+ └─ import http://crd.gov.pl/…/StrukturyDanych_v10-0E.xsd ← absolute remote URL
368
+ └─ include ElementarneTypyDanych_v10-0E.xsd ← relative
369
+ └─ include KodyKrajow_v10-0E.xsd ← relative
370
+ ```
371
+
372
+ Only the top-level import is an absolute `http://crd.gov.pl/…` URL; the two nested
373
+ includes are relative and resolve inside `bazowe/`.
374
+
375
+ **Consequence:** compiling this XSD as-is makes Nokogiri attempt a network fetch of
376
+ `crd.gov.pl` at validation time — unacceptable for an offline, deterministic test suite.
377
+ The validator must rewrite that one `schemaLocation` to the local `bazowe/` copy **in
378
+ memory**, parsing the XSD into a document, editing the attribute, and compiling with a
379
+ base URI pointing at the schema directory. The pinned file on disk must stay byte-for-byte
380
+ identical so the §1 digests keep verifying.
381
+
382
+ ---
383
+
384
+ ## 9. Still unverified
385
+
386
+ Carried forward; must be resolved before the code that depends on them is written
387
+ (DESIGN.md §0.2).
388
+
389
+ - **Crypto parameters** (DESIGN.md §6.4): symmetric cipher mode/padding/IV convention,
390
+ RSA-OAEP digest and MGF1 parameters for both key wrapping and token encryption, and
391
+ which published certificate serves which purpose. Sources to mine:
392
+ `bezpieczenstwo/klucze-publiczne-do-szyfrowania.md`, `tokeny-ksef.md`, and the
393
+ `ksef-client-csharp` reference implementation for golden vectors.
394
+ - **Session semantics**: whether one online session may carry multiple invoices, and
395
+ session lifetime. Source: `sesja-interaktywna.md`.
396
+ - **JWT lifetime and refresh mechanics**. Source: `uwierzytelnianie.md` plus the
397
+ `/auth/token/refresh` response model.
398
+ - **Challenge 10-minute validity** — asserted in DESIGN.md, not found in the spec (§4).
399
+ - **P_13_x / P_14_x rate-bucket ↔ VAT-rate mapping** (DESIGN.md §7.3). Source: the pinned
400
+ XSD plus `faktury/` guidance.
401
+ - **Business-rule catalogue** for validation tier 3 (DESIGN.md §7.7).
402
+ - **UPO document format** — schema pinned upstream at `faktury/upo/schemy/upo-v4-3.xsd`
403
+ with worked examples under `faktury/upo/przyklady/v4-3/`; not yet pulled in.
data/docs/errors.md ADDED
@@ -0,0 +1,128 @@
1
+ # Error handling
2
+
3
+ ## The hierarchy
4
+
5
+ ```
6
+ Ksef::Error #problem → Ksef::ProblemDetails or nil
7
+ ├── Ksef::ConfigurationError raised locally, before any request
8
+ ├── Ksef::AuthenticationError challenge / token / JWT problems, and HTTP 401
9
+ ├── Ksef::ValidationError raised locally by the FA(3) validator
10
+ ├── Ksef::ApiError #status #code #details #trace_id #raw
11
+ │ ├── Ksef::InvoiceRejectedError schema or business rejection by KSeF
12
+ │ ├── Ksef::SessionError session could not be opened, used or closed
13
+ │ ├── Ksef::AuthorizationError 403 — #reason_code, #security
14
+ │ ├── Ksef::ResourceGoneError 410
15
+ │ ├── Ksef::RateLimitedError 429 — #retry_after
16
+ │ └── Ksef::ServerError 5xx
17
+ ├── Ksef::TimeoutError open or read timeout
18
+ └── Ksef::ConnectionError connection or TLS failure
19
+ ```
20
+
21
+ Everything descends from `StandardError`, so a bare `rescue Ksef::Error` catches all of
22
+ it. `#problem` is `nil` for locally raised errors and populated for anything derived from
23
+ a response.
24
+
25
+ ## Status mapping
26
+
27
+ | Status | Class | Notes |
28
+ |---|---|---|
29
+ | 400 | `Ksef::ApiError` | Carries `errors[]`; `#code` is the KSeF error code |
30
+ | 401 | `Ksef::AuthenticationError` | Refresh and replay once, for idempotent requests only |
31
+ | 403 | `Ksef::AuthorizationError` | Check `#reason_code` before retrying anything |
32
+ | 410 | `Ksef::ResourceGoneError` | The resource existed but is gone |
33
+ | 429 | `Ksef::RateLimitedError` | Honour `#retry_after` |
34
+ | 5xx | `Ksef::ServerError` | Not declared in the contract; body shape unknown |
35
+
36
+ ## The two error envelopes
37
+
38
+ KSeF serves errors in two shapes and the response `Content-Type` decides which:
39
+
40
+ - `application/problem+json` — current. Fields: `title`, `status`, `detail`, `instance`,
41
+ `timestamp`, `traceId`, plus `errors[]` on 400 and `reasonCode`/`security` on 403.
42
+ - `application/json` — deprecated but still live. `ExceptionResponse` nests under
43
+ `exception.exceptionDetailList[]`; the 429 variant nests under `status.details[]`, where
44
+ `status` is an **object**, not an integer.
45
+
46
+ `Ksef::ProblemDetails` normalises all of these, so callers only ever see the flat
47
+ accessors. A body that is neither — an HTML block page from the WAF in front of the API,
48
+ say — degrades to `#raw` with an empty `#entries`.
49
+
50
+ ## `#trace_id`
51
+
52
+ Every problem+json error carries a `traceId`. Log it. It is what the Ministry's support
53
+ process asks for, and it is the only way to correlate a failure with their side. For the
54
+ deprecated envelope, `#trace_id` falls back to `referenceNumber`, its closest analogue.
55
+
56
+ ## Rate limiting
57
+
58
+ ```ruby
59
+ begin
60
+ client.invoice(ksef_number)
61
+ rescue Ksef::RateLimitedError => e
62
+ sleep e.retry_after if e.retry_after
63
+ retry
64
+ end
65
+ ```
66
+
67
+ `#retry_after` comes from the `Retry-After` header, in seconds, and is present on every
68
+ declared 429. It is authoritative: the block period is **dynamic** and lengthens with
69
+ repeat breaches, so waiting less than instructed makes the next block longer.
70
+
71
+ Limits are counted per **(context, client IP)** pair over a **sliding window** — req/s
72
+ over the trailing second, req/min over the trailing 60 seconds, req/h over the trailing
73
+ 60 minutes. Windows do not reset on the minute or hour. All thresholds apply at once and
74
+ the first crossed triggers the block.
75
+
76
+ KSeF records breaches and explicitly treats spreading one context across many IPs as an
77
+ abuse pattern that can escalate to protective action. **Never work around a 429 by
78
+ rotating connections.** Live budgets are introspectable via `GET /rate-limits`,
79
+ `GET /limits/context` and `GET /limits/subject`.
80
+
81
+ ## Retries
82
+
83
+ `Ksef::RetryPolicy` retries only **idempotent** methods (GET, HEAD), on 429, 5xx, or a
84
+ transport failure. Capped exponential backoff: 1s, 2s, 4s … 30s.
85
+
86
+ **Invoice submission is never auto-retried.** A duplicate invoice in KSeF is a real tax
87
+ problem, so a failed POST surfaces to you rather than being replayed. This is deliberate
88
+ and applies even to a 429, which the server rejected before processing.
89
+
90
+ `Retry-After` is honoured **unclamped** — retrying sooner than instructed is worse than
91
+ waiting. If the server demands longer than `max_retry_after` (60s by default), the client
92
+ declines to retry at all and raises, rather than waiting a period it was not asked to
93
+ wait.
94
+
95
+ ## `X-System-Warning`
96
+
97
+ The API sets this advisory header on successful responses. It is the Ministry's in-band
98
+ channel for notices such as forthcoming contract changes. `Ksef::HTTP::SystemWarning`
99
+ logs it at warn level when a logger is configured; watch for it in production, since it
100
+ is the earliest signal that something upstream is about to change.
101
+
102
+ ## Error code catalogue
103
+
104
+ 400 responses carry `errors[]`, each with a numeric `code`, a `description`, and optional
105
+ `details[]`. Codes observed so far:
106
+
107
+ | Code | Meaning |
108
+ |---|---|
109
+ | 21405 | Input validation failure (e.g. unsupported form code) |
110
+ | 21157 | Invalid package part size |
111
+
112
+ This catalogue grows as codes are encountered against the live API; the full published
113
+ list is still to be mined from the spec and upstream docs (see `docs/REFERENCE.md` §9).
114
+
115
+ ## 403 reason codes
116
+
117
+ | `reasonCode` | `security` payload |
118
+ |---|---|
119
+ | `missing-permissions` | `requiredAnyOfPermissions`, `presentPermissions` |
120
+ | `ip-not-allowed` | `clientIp` |
121
+ | `insufficient-resource-access` | — |
122
+ | `auth-method-not-allowed` | `authenticationMethodCategory` |
123
+ | `security-service-blocked` | `incidentId`, `clientIp` |
124
+ | `context-type-not-allowed` | `contextIdentifierType` |
125
+
126
+ `ip-not-allowed` is worth calling out: the API pins a session to the IP seen at
127
+ authentication, so a client behind a rotating egress address will hit this. Retrying will
128
+ not help — re-authenticate from a stable address.
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ksef
4
+ # Immutable client configuration.
5
+ #
6
+ # Frozen at construction so a single {Ksef::Client} can be shared across threads
7
+ # (DESIGN.md §5.2); all mutable per-operation state lives in session and flow objects.
8
+ #
9
+ # `#inspect` is redacted: credentials must never reach a log or an exception backtrace
10
+ # (DESIGN.md §4.5).
11
+ class Configuration
12
+ DEFAULT_TIMEOUT = { open: 10, read: 60 }.freeze
13
+ DEFAULT_ADAPTER = :net_http
14
+
15
+ attr_reader :environment, :auth, :logger, :timeout, :retry_policy, :adapter, :proxy, :user_agent
16
+
17
+ # @param env [Symbol, Ksef::Environments::Environment] `:test`, `:demo`, `:prod`, or
18
+ # the result of {Ksef::Environments.custom}
19
+ # @param auth [Object, nil] a credential object, e.g. {Ksef::Auth::Token}
20
+ # @param logger [Object, nil] any object responding to `#debug`/`#info`/`#warn`.
21
+ # Deliberately duck-typed — `logger` is a bundled gem as of Ruby 4.0 and this gem
22
+ # does not require it (DESIGN.md §4.3).
23
+ # @param timeout [Hash] `{open:, read:}` in seconds
24
+ # @param adapter [Symbol] Faraday adapter; kept swappable
25
+ # @param options [Hash] accepts `retry:` (DESIGN.md §6.1) or `retry_policy:`,
26
+ # plus `proxy:` and `user_agent:`
27
+ def initialize(env: :test, auth: nil, logger: nil, timeout: DEFAULT_TIMEOUT, adapter: DEFAULT_ADAPTER, **options)
28
+ apply_options(options)
29
+
30
+ @environment = Environments.fetch(env)
31
+ @auth = auth
32
+ @adapter = adapter
33
+ @logger = validate_logger(logger)
34
+ @timeout = normalize_timeout(timeout)
35
+
36
+ validate_retry_policy!
37
+ freeze
38
+ end
39
+
40
+ # @return [String] the environment base URL, already including `/v2`
41
+ def base_url = environment.base_url
42
+
43
+ def production? = environment.production?
44
+
45
+ # @return [Integer] seconds
46
+ def open_timeout = timeout.fetch(:open)
47
+
48
+ # @return [Integer] seconds
49
+ def read_timeout = timeout.fetch(:read)
50
+
51
+ # Redacted — never expose `auth` (DESIGN.md §4.5).
52
+ def inspect
53
+ "#<#{self.class.name} env=#{environment.name.inspect} base_url=#{base_url.inspect} " \
54
+ "auth=#{auth ? "[REDACTED]" : "nil"} adapter=#{adapter.inspect} timeout=#{timeout.inspect}>"
55
+ end
56
+ alias to_s inspect
57
+
58
+ private
59
+
60
+ # `retry` cannot be read as a method parameter name — it parses, but the body can only
61
+ # reach it via binding tricks. DESIGN.md §6.1 spells the option `retry:`, so it is
62
+ # accepted here through **options along with its clearer alias.
63
+ def apply_options(options)
64
+ options = options.dup
65
+ @retry_policy = options.delete(:retry) || options.delete(:retry_policy) || RetryPolicy.default
66
+ @proxy = options.delete(:proxy)
67
+ @user_agent = options.delete(:user_agent) || default_user_agent
68
+
69
+ return if options.empty?
70
+
71
+ raise ConfigurationError, "Unknown configuration option(s): #{options.keys.map(&:inspect).join(", ")}"
72
+ end
73
+
74
+ def default_user_agent
75
+ "ksef_client/#{Ksef::VERSION} (Ruby #{RUBY_VERSION})"
76
+ end
77
+
78
+ def validate_logger(logger)
79
+ return if logger.nil?
80
+
81
+ missing = %i[debug info warn error].reject { |m| logger.respond_to?(m) }
82
+ unless missing.empty?
83
+ raise ConfigurationError,
84
+ "logger must respond to #{missing.map(&:inspect).join(", ")}; got #{logger.class}"
85
+ end
86
+
87
+ logger
88
+ end
89
+
90
+ def normalize_timeout(timeout)
91
+ case timeout
92
+ when Numeric then { open: timeout, read: timeout }.freeze
93
+ when Hash then normalize_timeout_hash(timeout)
94
+ else
95
+ raise ConfigurationError, "timeout must be a Numeric or a Hash of {open:, read:}, got #{timeout.class}"
96
+ end
97
+ end
98
+
99
+ def normalize_timeout_hash(timeout)
100
+ normalized = DEFAULT_TIMEOUT.dup
101
+
102
+ timeout.each do |key, value|
103
+ sym = key.to_sym
104
+ raise ConfigurationError, "Unknown timeout key #{key.inspect}; expected :open or :read" unless
105
+ normalized.key?(sym)
106
+ raise ConfigurationError, "timeout[#{sym.inspect}] must be a positive number, got #{value.inspect}" unless
107
+ value.is_a?(Numeric) && value.positive?
108
+
109
+ normalized[sym] = value
110
+ end
111
+
112
+ normalized.freeze
113
+ end
114
+
115
+ def validate_retry_policy!
116
+ return if @retry_policy.respond_to?(:retryable?) && @retry_policy.respond_to?(:interval_for)
117
+
118
+ raise ConfigurationError,
119
+ "retry policy must respond to #retryable? and #interval_for; got #{@retry_policy.class}"
120
+ end
121
+ end
122
+ end