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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +59 -0
- data/CONTRIBUTING.md +103 -0
- data/LICENSE +27 -0
- data/README.md +150 -0
- data/SECURITY.md +69 -0
- data/docs/REFERENCE.md +403 -0
- data/docs/errors.md +128 -0
- data/lib/ksef/configuration.rb +122 -0
- data/lib/ksef/environments.rb +85 -0
- data/lib/ksef/errors.rb +92 -0
- data/lib/ksef/fa3/schema/LICENSE.upstream.txt +21 -0
- data/lib/ksef/fa3/schema/bazowe/ElementarneTypyDanych_v10-0E.xsd +1 -0
- data/lib/ksef/fa3/schema/bazowe/KodyKrajow_v10-0E.xsd +1283 -0
- data/lib/ksef/fa3/schema/bazowe/StrukturyDanych_v10-0E.xsd +1 -0
- data/lib/ksef/fa3/schema/schemat_FA(3)_v1-0E.xsd +3950 -0
- data/lib/ksef/http/connection.rb +62 -0
- data/lib/ksef/http/error_handler.rb +83 -0
- data/lib/ksef/http/system_warning.rb +29 -0
- data/lib/ksef/problem_details.rb +143 -0
- data/lib/ksef/retry_policy.rb +77 -0
- data/lib/ksef/version.rb +5 -0
- data/lib/ksef.rb +32 -0
- data/lib/ksef_client.rb +5 -0
- metadata +130 -0
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
|