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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +161 -0
- data/NOTICE +40 -0
- data/README.md +524 -6
- data/examples/fa3_full.xml +143 -0
- data/examples/fa3_minimal.xml +65 -0
- data/examples/fa3_typical.xml +76 -0
- data/lib/generators/ksef/install/install_generator.rb +20 -0
- data/lib/generators/ksef/install/templates/ksef.rb +25 -0
- data/lib/ksef/auth/coordinator.rb +164 -0
- data/lib/ksef/auth/ksef_token_encryptor.rb +36 -0
- data/lib/ksef/batch/package_builder.rb +125 -0
- data/lib/ksef/batch/splitter.rb +99 -0
- data/lib/ksef/batch/uploader.rb +114 -0
- data/lib/ksef/client.rb +135 -0
- data/lib/ksef/configuration.rb +206 -0
- data/lib/ksef/crypto/cryptography_service.rb +136 -0
- data/lib/ksef/crypto/csr.rb +106 -0
- data/lib/ksef/crypto/public_keys.rb +125 -0
- data/lib/ksef/environments.rb +61 -0
- data/lib/ksef/errors.rb +187 -0
- data/lib/ksef/http/connection.rb +432 -0
- data/lib/ksef/http/error_parser.rb +194 -0
- data/lib/ksef/http/pagination.rb +80 -0
- data/lib/ksef/invoices/builder/checks.rb +127 -0
- data/lib/ksef/invoices/builder/number.rb +67 -0
- data/lib/ksef/invoices/builder/xml.rb +256 -0
- data/lib/ksef/invoices/builder.rb +251 -0
- data/lib/ksef/invoices/export.rb +241 -0
- data/lib/ksef/invoices/sample.rb +78 -0
- data/lib/ksef/invoices/schema_outline.rb +120 -0
- data/lib/ksef/invoices/validator.rb +200 -0
- data/lib/ksef/models/form_code.rb +49 -0
- data/lib/ksef/models/ksef_number.rb +74 -0
- data/lib/ksef/models/nip.rb +62 -0
- data/lib/ksef/polish_time.rb +49 -0
- data/lib/ksef/polling.rb +65 -0
- data/lib/ksef/qr/code.rb +104 -0
- data/lib/ksef/qr/verification_link.rb +215 -0
- data/lib/ksef/railtie.rb +20 -0
- data/lib/ksef/resources/auth.rb +116 -0
- data/lib/ksef/resources/base.rb +120 -0
- data/lib/ksef/resources/batch_sessions.rb +43 -0
- data/lib/ksef/resources/certificates.rb +82 -0
- data/lib/ksef/resources/collective_identifiers.rb +70 -0
- data/lib/ksef/resources/exports.rb +47 -0
- data/lib/ksef/resources/invoices.rb +67 -0
- data/lib/ksef/resources/limits.rb +33 -0
- data/lib/ksef/resources/online_sessions.rb +62 -0
- data/lib/ksef/resources/peppol.rb +21 -0
- data/lib/ksef/resources/permissions.rb +180 -0
- data/lib/ksef/resources/security.rb +18 -0
- data/lib/ksef/resources/sessions.rb +125 -0
- data/lib/ksef/resources/test_data.rb +178 -0
- data/lib/ksef/resources/tokens.rb +62 -0
- data/lib/ksef/sessions/online.rb +168 -0
- data/lib/ksef/sync/incremental.rb +169 -0
- data/lib/ksef/testing.rb +153 -0
- data/lib/ksef/token_manager.rb +130 -0
- data/lib/ksef/version.rb +11 -0
- data/lib/ksef/xades/auth_token_request.rb +135 -0
- data/lib/ksef/xades/external_signer.rb +58 -0
- data/lib/ksef/xades/signer.rb +312 -0
- data/lib/ksef-client-rails.rb +3 -2
- data/lib/ksef.rb +116 -0
- data/schemas/FA/schemat_FA(2)_v1-0E.xsd +3661 -0
- data/schemas/FA/schemat_FA(3)_v1-0E.xsd +3950 -0
- data/schemas/README.md +53 -0
- data/schemas/RR/schemat_FA_RR(1)_v1-1E.xsd +2188 -0
- data/schemas/bazowe/ElementarneTypyDanych_v10-0E.xsd +1 -0
- data/schemas/bazowe/KodyKrajow_v10-0E.xsd +1283 -0
- data/schemas/bazowe/StrukturyDanych_v10-0E.xsd +1 -0
- metadata +291 -9
data/README.md
CHANGED
|
@@ -1,9 +1,527 @@
|
|
|
1
|
-
# ksef
|
|
1
|
+
# ksef — Ruby/Rails client for KSeF API 2.0
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
`
|
|
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
|
-
|
|
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.
|