ksef_client 0.1.0.rc1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1424 -0
  3. data/CONTRIBUTING.md +45 -6
  4. data/README.md +377 -25
  5. data/SECURITY.md +25 -2
  6. data/docs/REFERENCE.md +3099 -34
  7. data/docs/errors.md +33 -4
  8. data/docs/field_mapping.md +390 -0
  9. data/lib/ksef/auth/access_token.rb +150 -0
  10. data/lib/ksef/auth/authorization_policy.rb +88 -0
  11. data/lib/ksef/auth/challenge.rb +53 -0
  12. data/lib/ksef/auth/client.rb +143 -0
  13. data/lib/ksef/auth/initiation.rb +22 -0
  14. data/lib/ksef/auth/operation_status.rb +34 -0
  15. data/lib/ksef/auth/schema/schemat_auth_v2-0.xsd +109 -0
  16. data/lib/ksef/auth/schema/schemat_auth_v2-1.xsd +109 -0
  17. data/lib/ksef/auth/signature_template.rb +120 -0
  18. data/lib/ksef/auth/signer.rb +132 -0
  19. data/lib/ksef/auth/status.rb +69 -0
  20. data/lib/ksef/auth/token.rb +121 -0
  21. data/lib/ksef/auth/token_info.rb +27 -0
  22. data/lib/ksef/auth/token_request.rb +142 -0
  23. data/lib/ksef/auth/tokens.rb +24 -0
  24. data/lib/ksef/auth/validator.rb +84 -0
  25. data/lib/ksef/auth/xades.rb +40 -0
  26. data/lib/ksef/auth.rb +43 -0
  27. data/lib/ksef/client/receipt.rb +37 -0
  28. data/lib/ksef/client/session.rb +68 -0
  29. data/lib/ksef/client.rb +314 -0
  30. data/lib/ksef/crypto/certificate.rb +78 -0
  31. data/lib/ksef/crypto/digest.rb +25 -0
  32. data/lib/ksef/crypto/encryptor.rb +131 -0
  33. data/lib/ksef/crypto/public_keys.rb +146 -0
  34. data/lib/ksef/crypto.rb +66 -0
  35. data/lib/ksef/environments.rb +1 -1
  36. data/lib/ksef/errors.rb +35 -2
  37. data/lib/ksef/fa3/address.rb +64 -0
  38. data/lib/ksef/fa3/advance_checks.rb +55 -0
  39. data/lib/ksef/fa3/advance_invoice.rb +55 -0
  40. data/lib/ksef/fa3/advance_reader.rb +59 -0
  41. data/lib/ksef/fa3/attachment.rb +43 -0
  42. data/lib/ksef/fa3/attachment_checks.rb +121 -0
  43. data/lib/ksef/fa3/attachment_reader.rb +99 -0
  44. data/lib/ksef/fa3/attachment_table.rb +98 -0
  45. data/lib/ksef/fa3/builder/advances.rb +57 -0
  46. data/lib/ksef/fa3/builder/corrections.rb +46 -0
  47. data/lib/ksef/fa3/builder/subjects.rb +71 -0
  48. data/lib/ksef/fa3/builder.rb +149 -0
  49. data/lib/ksef/fa3/business_validator.rb +134 -0
  50. data/lib/ksef/fa3/canonical.rb +44 -0
  51. data/lib/ksef/fa3/corrected_invoice.rb +53 -0
  52. data/lib/ksef/fa3/correction.rb +151 -0
  53. data/lib/ksef/fa3/correction_checks.rb +106 -0
  54. data/lib/ksef/fa3/correction_reader.rb +88 -0
  55. data/lib/ksef/fa3/data_block.rb +75 -0
  56. data/lib/ksef/fa3/document_mapping.rb +142 -0
  57. data/lib/ksef/fa3/document_validator.rb +174 -0
  58. data/lib/ksef/fa3/element_tree.rb +49 -0
  59. data/lib/ksef/fa3/field_checks.rb +151 -0
  60. data/lib/ksef/fa3/formatting.rb +273 -0
  61. data/lib/ksef/fa3/generated/enums.rb +652 -0
  62. data/lib/ksef/fa3/generated/types.rb +3308 -0
  63. data/lib/ksef/fa3/invoice.rb +259 -0
  64. data/lib/ksef/fa3/issue.rb +32 -0
  65. data/lib/ksef/fa3/line.rb +162 -0
  66. data/lib/ksef/fa3/meta_entry.rb +45 -0
  67. data/lib/ksef/fa3/model_validator.rb +219 -0
  68. data/lib/ksef/fa3/nip.rb +59 -0
  69. data/lib/ksef/fa3/node_reader.rb +46 -0
  70. data/lib/ksef/fa3/order.rb +55 -0
  71. data/lib/ksef/fa3/order_line.rb +70 -0
  72. data/lib/ksef/fa3/parser.rb +208 -0
  73. data/lib/ksef/fa3/provenance.rb +137 -0
  74. data/lib/ksef/fa3/rounding_inference.rb +87 -0
  75. data/lib/ksef/fa3/row_reader.rb +66 -0
  76. data/lib/ksef/fa3/serializer.rb +140 -0
  77. data/lib/ksef/fa3/subject.rb +128 -0
  78. data/lib/ksef/fa3/subject_checks.rb +126 -0
  79. data/lib/ksef/fa3/subject_reader.rb +76 -0
  80. data/lib/ksef/fa3/summaries.rb +107 -0
  81. data/lib/ksef/fa3/summary_checks.rb +75 -0
  82. data/lib/ksef/fa3/table_column.rb +49 -0
  83. data/lib/ksef/fa3/totals.rb +119 -0
  84. data/lib/ksef/fa3/validator.rb +79 -0
  85. data/lib/ksef/fa3/vat_rate.rb +94 -0
  86. data/lib/ksef/fa3.rb +59 -0
  87. data/lib/ksef/http/connection.rb +47 -4
  88. data/lib/ksef/http/json_decoder.rb +53 -0
  89. data/lib/ksef/http/retry.rb +105 -0
  90. data/lib/ksef/invoices/client.rb +72 -0
  91. data/lib/ksef/ksef_number.rb +150 -0
  92. data/lib/ksef/sessions/invoice_codes.rb +70 -0
  93. data/lib/ksef/sessions/invoice_state.rb +68 -0
  94. data/lib/ksef/sessions/online.rb +169 -0
  95. data/lib/ksef/sessions/session_codes.rb +73 -0
  96. data/lib/ksef/sessions/session_state.rb +48 -0
  97. data/lib/ksef/sessions/status.rb +138 -0
  98. data/lib/ksef/sessions/upo_page.rb +36 -0
  99. data/lib/ksef/sessions.rb +91 -0
  100. data/lib/ksef/upo/client.rb +154 -0
  101. data/lib/ksef/upo/document.rb +74 -0
  102. data/lib/ksef/upo/schema/upo-v4-3.xsd +293 -0
  103. data/lib/ksef/upo/validation.rb +37 -0
  104. data/lib/ksef/upo/validator.rb +124 -0
  105. data/lib/ksef/upo.rb +55 -0
  106. data/lib/ksef/version.rb +1 -1
  107. data/lib/ksef.rb +18 -0
  108. metadata +105 -4
data/CONTRIBUTING.md CHANGED
@@ -21,8 +21,12 @@ produces rejected invoices, or worse, silently malformed ones. So:
21
21
 
22
22
  1. Every externally sourced fact goes in [`docs/REFERENCE.md`](docs/REFERENCE.md) with its
23
23
  value, source URL and retrieval date, *before* the code depending on it is written.
24
- 2. The authoritative sources, in order: the integrator documentation portal, the
25
- `CIRFMF/ksef-api` repository, the pinned OpenAPI spec, the pinned FA(3) XSD.
24
+ 2. The authoritative sources, **in precedence order**: the pinned OpenAPI spec, the pinned
25
+ FA(3) XSD, then `CIRFMF/ksef-api`'s prose, then the integrator documentation portal. This
26
+ order is the opposite of what an earlier version of this list said, and it is not a
27
+ preference — the ledger records four occasions where upstream prose contradicted the
28
+ contract and the contract was right (`docs/REFERENCE.md` §4.8, §11.3, §12.1, §13.1). Read
29
+ the schema for a field before trusting a sentence about it.
26
30
  3. If a detail is not in the ledger and not verifiable from those sources, **stop and ask**
27
31
  rather than guessing.
28
32
  4. When the pinned artifacts and the design document disagree, the artifacts win. Update
@@ -51,6 +55,10 @@ Please raise these for discussion rather than changing them in a PR:
51
55
  - **Invoice submission is never auto-retried.**
52
56
  - **No `VERIFY_NONE`**, no way to disable TLS verification.
53
57
  - `lib/ksef/fa3/generated/` is codegen output — regenerate with `rake fa3:generate`, never
58
+
59
+ `docs/field_mapping.md` is the second generated file — `rake fa3:field_mapping` writes it
60
+ from the declaration in `tasks/field_mapping.rb` plus the pinned XSD. Same rule: edit the
61
+ declaration, never the output. `rake fa3:verify` fails if the committed file is stale.
54
62
  hand-edit.
55
63
 
56
64
  ## Style
@@ -67,11 +75,24 @@ Please raise these for discussion rather than changing them in a PR:
67
75
 
68
76
  | Tier | Runs |
69
77
  |---|---|
70
- | Unit + recorded (WebMock/VCR) | every push, full Ruby matrix |
78
+ | Unit (WebMock stubs) + recorded (VCR cassettes; re-record with `rake vcr:record`) | every push, full Ruby matrix |
71
79
  | Golden files, round-trip, crypto vectors | every push |
72
80
  | Live TEST integration | nightly and pre-release only, never per-PR |
73
81
 
74
- Coverage gate is 90% lines, excluding `generated/`. Live integration specs are tagged
82
+ Coverage is gated on three criteria, excluding `generated/`: **line 99, branch 98,
83
+ method 100**. `spec/spec_helper.rb` is the single source of truth for these numbers —
84
+ if this paragraph and that file ever disagree, the file wins. Branch coverage is the one that finds real gaps — the suite once sat at 99%
85
+ line coverage with 83% branch coverage, meaning plenty of conditional paths were untested
86
+ behind covered lines. Method coverage at 100 means a method nothing exercises fails the
87
+ build.
88
+
89
+ The floors only ever move up, and they move **at phase boundaries** — set with
90
+ deliberate margin under the then-current actuals, never pinned to them. Don't lower one to make
91
+ a change pass. If you hit the method floor, the usual cause is a new method reachable only
92
+ from an untested branch. Filtered runs (a single file, or `--tag`) skip the gate, since
93
+ they legitimately cover less.
94
+
95
+ Live integration specs are tagged
75
96
  `:integration` and read credentials only from `KSEF_TEST_NIP` / `KSEF_TEST_TOKEN` with
76
97
  `KSEF_ENV=test`.
77
98
 
@@ -83,7 +104,17 @@ Cassettes must be scrubbed of tokens, JWTs and keys before committing.
83
104
 
84
105
  1. Confirm no metadata placeholders were reintroduced: `grep -r UNRESOLVED-DESIGN-12-1`.
85
106
  The release gate checks this too, but it is cheaper to notice here.
86
- 2. Update `CHANGELOG.md`, including the KSeF API version and FA schema revision targeted.
107
+ 2. Update `CHANGELOG.md`, including the KSeF API version and FA schema revision targeted, and
108
+ **move the `[Unreleased]` entries under a `## [X.Y.Z] — DATE` heading**. This is not
109
+ bookkeeping: the GitHub release body is that section, verbatim. Check what will be published
110
+ before you tag:
111
+
112
+ ```bash
113
+ bundle exec rake 'release:notes[X.Y.Z]'
114
+ ```
115
+
116
+ It fails if the section is missing or empty, and the release workflow fails the same way —
117
+ after the gem has been pushed, which is recoverable but annoying.
87
118
  3. Confirm a **green nightly on the release commit**.
88
119
  4. Do one deliberate local full-suite run on **Ruby 3.2** — the floor contract deserves a
89
120
  look, not just a matrix tick. RuboCop cannot substitute for this: `TargetRubyVersion`
@@ -100,4 +131,12 @@ Cassettes must be scrubbed of tokens, JWTs and keys before committing.
100
131
  The separate `BUNDLE_PATH` leaves your development gem set alone, and resolving without
101
132
  the lockfile mirrors CI. Bundler on 3.2 cannot read a lockfile written by Bundler 4.
102
133
  5. Tag `vX.Y.Z`. The release workflow runs the suite with `KSEF_RELEASE_CHECK=1`, checks
103
- the tag against `Ksef::VERSION`, and publishes via Trusted Publishing.
134
+ the tag against `Ksef::VERSION`, and publishes via Trusted Publishing. A second job then
135
+ creates the GitHub release from the CHANGELOG section, marking it a prerelease when
136
+ `Gem::Version` says the version is one — so `v0.1.0.rc1` is flagged without the workflow
137
+ holding an opinion about version strings.
138
+
139
+ The two jobs are separate so their permissions can be: publishing gets an OIDC token and
140
+ read-only `contents`, announcing gets `contents: write` and no token. Announcing runs
141
+ **after** the push — a release pointing at a gem nobody can install would be worse than a
142
+ gem that is briefly unannounced.
data/README.md CHANGED
@@ -1,17 +1,90 @@
1
1
  # ksef_client
2
2
 
3
+ [![test](https://github.com/tibortc/ksef_client/actions/workflows/test.yml/badge.svg)](https://github.com/tibortc/ksef_client/actions/workflows/test.yml)
4
+ [![Coverage Status](https://coveralls.io/repos/github/tibortc/ksef_client/badge.svg?branch=main)](https://coveralls.io/github/tibortc/ksef_client?branch=main)
5
+
3
6
  Ruby client for **KSeF 2.0**, Poland's national e-invoicing system (Krajowy System
4
7
  e-Faktur), with a standalone **FA(3)** invoice builder.
5
8
 
6
9
  Invoicing through KSeF has been a legal obligation since 2026-02-01 for taxpayers with
7
10
  2024 gross sales above 200M PLN, and since 2026-04-01 for essentially everyone else. The
8
- Ministry of Finance publishes official SDKs in C# and Java; this gem fills the Ruby gap.
9
-
10
- > **Status: pre-release, under active development.** The transport foundations
11
- > (configuration, environments, error model, HTTP layer) are in place. Authentication,
12
- > encryption, sessions and the FA(3) builder are not implemented yet — see
13
- > [Roadmap](#roadmap). The quickstart below is the **target** API for 0.1.0 and does not
14
- > run today.
11
+ Ministry of Finance publishes official SDKs in C# and Java, but none for Ruby.
12
+
13
+ **How this differs from [`ksef-rb`](https://github.com/skycocker/ksef-rb).** That gem is a
14
+ working KSeF 2.0 client and is further along on transport; if you already generate FA(3)
15
+ XML yourself, it may be all you need. `ksef_client` aims at the other half of the problem:
16
+ **authoring and validating** the invoice document — a schema-backed builder for all seven
17
+ invoice types, the FA(3) XSD bundled, and three tiers of validation before anything is
18
+ submitted.
19
+
20
+ > **Status: pre-release, under active development.**
21
+ >
22
+ > **Working:** the transport foundations (configuration, environments, error model, HTTP
23
+ > layer), the FA(3) schema metadata, offline XSD validation, and building a plain `VAT`
24
+ > invoice — via the `Ksef::FA3.build` DSL or the value objects directly — to schema-valid
25
+ > XML.
26
+ >
27
+ > **Certificate authentication works, against the real TEST environment.** The
28
+ > `AuthTokenRequest` document, its XAdES-BES signature and the five HTTP calls that flow
29
+ > needs are implemented, and this gem has minted a KSeF token end to end with no external client.
30
+ >
31
+ > **Both authentication methods are now implemented**, along with the encryption layer they
32
+ > and the session layer share: `Ksef::Crypto` fetches and selects the Ministry's published
33
+ > keys, wraps a symmetric key with RSA-OAEP, and encrypts payloads with AES-256-CBC.
34
+ >
35
+ > **The session layer works** too: `Ksef::Sessions::Online` opens a session, encrypts and
36
+ > submits invoices, and closes it; `Ksef::Sessions::Status` reads and waits on session and
37
+ > per-invoice status.
38
+ >
39
+ > **UPO retrieval works** as well, over a connection deliberately built without a
40
+ > credential: the download links are third-party storage URIs, so the access token must
41
+ > never reach them.
42
+ >
43
+ > **`Ksef::Client` ties it together, and the quickstart below is now real code** — a spec
44
+ > drives that exact snippet end to end, from `Ksef::FA3.build` through `send_invoice`,
45
+ > `wait_until_accepted` and `upo`.
46
+ >
47
+ > **It has now run against the real thing.** On 2026-08-24 the nightly opened a session on
48
+ > KSeF TEST, encrypted and submitted an invoice this gem built, had it **accepted**, got a
49
+ > KSeF number whose checksum our own code agrees with, and retrieved the signed UPO with its
50
+ > bytes matching the hash the server published.
51
+ >
52
+ > **The honest caveat, narrowed:** token refresh and invoice download are implemented but
53
+ > verified against stubs only — still "believed correct" rather than proven. Batch has no code at all yet, so it
54
+ > is absent rather than stubbed. The KSeF-token auth call and the crypto module went live on
55
+ > 2026-08-24, so they are no longer on this list.
56
+ >
57
+ > **Reading invoices back works** — `Ksef::FA3.parse` turns FA(3) XML into the same model the
58
+ > builder produces, tested against the Ministry's own published sample invoices. It refuses
59
+ > what it cannot represent faithfully rather than guessing.
60
+ >
61
+ > **Validation runs in three stages now**, not one: the model (required fields, enum
62
+ > membership, NIP checksums, string lengths, date sanity), the serialized bytes (valid UTF-8,
63
+ > no BOM, no processing instructions, UTF-8 prolog, no characters KSeF refuses, size), then the
64
+ > XSD. Those are validator tiers 1a, 1b and 2. Tier 3, the reconciliation rules, reports
65
+ > through `#warnings` and never makes an invoice invalid.
66
+ > `invoice.errors` reports the model problems together, each addressed to the field that
67
+ > caused it (`lines[2].vat_rate`); the byte and schema checks run once the model is sound, and
68
+ > report against `document` and `schema`.
69
+ >
70
+ > **Corrections build and parse.** `KOR` carries what it corrects — one invoice or fifty
71
+ > thousand — the reason, the effect date, the previous state of a party, and rows marked
72
+ > before/after. Its tax summary is *stated* rather than derived, because a correction's
73
+ > buckets are deltas its rows need not determine: three of the Ministry's five worked
74
+ > corrections have no usable rows at all.
75
+ >
76
+ > **Advance invoices work too.** A `ZAL` carries the order or contract it collects against
77
+ > (`Zamowienie`) and has no invoice rows at all; a `ROZ` names every advance invoice it settles
78
+ > and states what is left to pay.
79
+ >
80
+ > **All seven invoice types build and parse**, as of 2026-08-26 — `VAT`, `KOR`, `ZAL`, `ROZ`,
81
+ > `UPR` and the two `KOR_` combinations. Twenty-two of the Ministry's twenty-six worked
82
+ > examples go through end to end; the four that do not are refused for a *construct* — two
83
+ > priced gross, two identifying their buyer by something other than a NIP — rather than for
84
+ > their type.
85
+ >
86
+ > **Advisory:** `invoice.warnings` is tier 3 — it reconciles figures a *document* states
87
+ > independently, and never blocks a send. See [Roadmap](#roadmap).
15
88
 
16
89
  ## Installation
17
90
 
@@ -19,20 +92,38 @@ Ministry of Finance publishes official SDKs in C# and Java; this gem fills the R
19
92
  gem "ksef_client"
20
93
  ```
21
94
 
22
- The gem is named `ksef_client`; the namespace is `Ksef`.
95
+ Or track the branch, where fixes land first:
23
96
 
24
97
  ```ruby
25
- require "ksef_client" # defines Ksef, Ksef::Client, Ksef::FA3, ...
98
+ gem "ksef_client", github: "tibortc/ksef_client"
26
99
  ```
27
100
 
28
- ## Quickstart (target API for 0.1.0 — not yet functional)
101
+ **Not the `0.1.0.rc1` prerelease**, if you go looking. It predates this API — a name-claiming
102
+ placeholder holding the transport layer and nothing else, with no authentication, no FA(3) and
103
+ no sessions. It is still on RubyGems because a published version cannot be withdrawn and
104
+ replaced; the line above will not select it, since Bundler skips prereleases for an
105
+ unconstrained requirement.
106
+
107
+ The gem is named `ksef_client`; the namespace is `Ksef`.
29
108
 
30
109
  ```ruby
31
- client = Ksef::Client.new(
32
- env: :test,
33
- auth: Ksef::Auth::Token.new(context_nip: "9999999999", token: ENV["KSEF_TOKEN"])
34
- )
110
+ require "ksef_client" # defines Ksef, Ksef::FA3, Ksef::Auth, Ksef::Crypto, ...
111
+ ```
112
+
113
+ ## Quickstart
114
+
115
+ All of this runs. `spec/ksef/client_spec.rb` drives the same sequence against stubbed HTTP, so
116
+ an API change here breaks a test — though the snippet is mirrored by hand rather than
117
+ extracted, so the *prose* can still drift from the spec. And see the status note above on what
118
+ stubs do and do not prove.
119
+
120
+ **Everything down to `to_xml` is offline and needs no credential** — building, validating and
121
+ serialising an invoice never touches the network. From `send_invoice` on you need a KSeF token
122
+ in `KSEF_TOKEN`; `Ksef::Auth::Token.new` raises immediately if it is nil, rather than failing
123
+ later against the API. A token is issued by `POST /tokens` after a one-time XAdES
124
+ authentication, so getting the first one is a separate exercise.
35
125
 
126
+ ```ruby
36
127
  invoice = Ksef::FA3.build do |f|
37
128
  f.seller nip: "9999999999", name: "ACME sp. z o.o.",
38
129
  address: { street: "Prosta 1", city: "Warszawa", postal_code: "00-001", country: "PL" }
@@ -43,12 +134,49 @@ invoice = Ksef::FA3.build do |f|
43
134
  f.line name: "Consulting", qty: 10, unit: "godz.", net_unit_price: 150, vat: "23"
44
135
  end
45
136
 
137
+ invoice.validate! # offline: model, bytes, then the XSD
138
+ invoice.to_xml
139
+
140
+ client = Ksef::Client.new( # from here on, a credential is required
141
+ env: :test,
142
+ auth: Ksef::Auth::Token.new(context_nip: "9999999999", token: ENV["KSEF_TOKEN"])
143
+ )
144
+
46
145
  result = client.send_invoice(invoice) # validate! → encrypt → session → submit
47
146
  status = client.wait_until_accepted(result.reference)
48
- status.ksef_number # => "9999999999-2026…"
49
- upo = client.upo(result.reference) # signed UPO XML — archive this verbatim
147
+ status.ksef_number # => "9999999999-20260823-…-3F"
148
+ upo = client.upo(result.reference) # signed UPO — archive the bytes verbatim
149
+ Dir.mkdir("upo") unless Dir.exist?("upo") # #write does not create directories
150
+ upo.write("upo/#{status.ksef_number}.xml") # binwrite: the MF signature covers octets
50
151
  ```
51
152
 
153
+ `send_invoice` opens a session of its own, submits, and closes it. Sending many invoices?
154
+ Use a block, so they share one session instead of opening thirty:
155
+
156
+ ```ruby
157
+ receipts = client.session do |batch|
158
+ invoices.map { |invoice| batch.send_invoice(invoice) }
159
+ end # ← closed here, which starts the collective UPO
160
+ ```
161
+
162
+ `result.reference` is the pair of references — session and invoice — because every status
163
+ and UPO endpoint is keyed on both. That is why it can be passed straight to
164
+ `wait_until_accepted` and `upo`.
165
+
166
+ The DSL accepts English shorthand (`qty:`, `vat:`) and coerces a plain Hash into an
167
+ address. A misspelled key raises and tells you what was permitted, rather than quietly
168
+ producing an invoice with a field missing:
169
+
170
+ ```ruby
171
+ Ksef::FA3.build { |f| f.line name: "X", price: 1 }
172
+ # => Ksef::ValidationError: Unknown line option(s) :price. Permitted: name, quantity,
173
+ # unit, net_unit_price, vat_rate, net_amount, row_number, state_before.
174
+ # Shorthand: qty for quantity, vat for vat_rate
175
+ ```
176
+
177
+ `address:` takes an `Address`, a Hash of its fields, or an already-formatted string —
178
+ FA(3) stores an address as free text, so all three end up in the same place.
179
+
52
180
  ## What works today
53
181
 
54
182
  ```ruby
@@ -60,7 +188,9 @@ conn = Ksef::HTTP::Connection.build(config)
60
188
  conn.get("rate-limits")
61
189
  ```
62
190
 
63
- Errors from the API arrive as a typed hierarchy carrying the Ministry's own diagnostics:
191
+ Errors from the API arrive as a typed hierarchy carrying the Ministry's own diagnostics.
192
+ [`docs/errors.md`](docs/errors.md) is the full reference — the class hierarchy, the status
193
+ mapping, the 403 reason codes, and what the rate limits actually are:
64
194
 
65
195
  ```ruby
66
196
  begin
@@ -78,6 +208,218 @@ rescue Ksef::ApiError => e
78
208
  end
79
209
  ```
80
210
 
211
+ ### Building an invoice without the DSL
212
+
213
+ The DSL is a thin front end over plain value objects, and they are public API too. Reach
214
+ for these when you are mapping from your own domain objects and the keyword block would
215
+ just be indirection.
216
+
217
+ ```ruby
218
+ require "ksef_client"
219
+
220
+ seller = Ksef::FA3::Subject.new(
221
+ nip: "9999999999", name: "ACME sp. z o.o.",
222
+ address: Ksef::FA3::Address.new(street: "Prosta 1", city: "Warszawa", postal_code: "00-001")
223
+ )
224
+ buyer = Ksef::FA3::Subject.new(
225
+ nip: "1111111111", name: "Klient S.A.",
226
+ address: Ksef::FA3::Address.new(street: "Długa 2", city: "Kraków", postal_code: "30-001")
227
+ )
228
+
229
+ invoice = Ksef::FA3::Invoice.new(
230
+ seller: seller, buyer: buyer,
231
+ number: "FV/2026/08/001",
232
+ issue_date: Date.new(2026, 8, 22),
233
+ lines: [
234
+ Ksef::FA3::Line.new(name: "Consulting", quantity: 10, unit: "godz.",
235
+ net_unit_price: BigDecimal("150"), vat_rate: "23")
236
+ ]
237
+ )
238
+
239
+ invoice.net_total # => 1500 (a BigDecimal; #inspect shows 0.15e4)
240
+ invoice.vat_total # => 345
241
+ invoice.gross_total # => 1845 — use .to_s("F") for a plain decimal string
242
+
243
+ invoice.validate! # model, bytes, then the bundled XSD — no network
244
+ invoice.to_xml # => "<?xml version=\"1.0\" encoding=\"UTF-8\"?>..."
245
+ ```
246
+
247
+ Three things it does for you that the schema requires but you would not think to supply:
248
+ the buyer's mandatory `JST` and `GV` flags, the five mandatory `Adnotacje` flags, and one
249
+ branch of each mandatory choice wrapper. Omitting any of them is a schema error.
250
+
251
+ Rounding is explicit, because Polish VAT law permits two approaches and they can differ by
252
+ a grosz — pass `rounding: :per_line` (the default) or `:per_summary`.
253
+
254
+ ### Issuing a correction
255
+
256
+ A `KOR` says what it corrects and by how much. `corrects` names one corrected invoice — call
257
+ it again for each of them, up to fifty thousand, which is how a period discount under
258
+ art. 106j ust. 3 is issued.
259
+
260
+ ```ruby
261
+ correction = Ksef::FA3.build do |f|
262
+ f.seller nip: "9999999999", name: "ACME sp. z o.o.", address: "Prosta 1, 00-001 Warszawa"
263
+ f.buyer nip: "1111111111", name: "Klient S.A.", address: "Długa 2, 30-001 Kraków"
264
+ f.number "FK/2026/08/001"
265
+ f.issue_date Date.new(2026, 8, 22)
266
+ f.invoice_type "KOR"
267
+
268
+ # `effect` is TypKorekty: when the correction takes effect in the VAT register — 1 at the
269
+ # corrected invoice's date, 2 at this one's, 3 at some other date.
270
+ f.correction reason: "obniżka ceny o 200 zł", effect: 3
271
+ f.corrects number: "FV/2026/02/150", issue_date: "2026-02-15",
272
+ ksef_number: "5265877635-20250826-0100001AF629-AF"
273
+
274
+ # The position as it was, then as it now is — two rows sharing one number.
275
+ f.line name: "lodówka", qty: 1, unit: "szt.", net_unit_price: "1626.01",
276
+ net_amount: "1626.01", vat: "23", state_before: true
277
+ f.line name: "lodówka", qty: 1, unit: "szt.", net_unit_price: "1463.41",
278
+ net_amount: "1463.41", vat: "23", row_number: 1
279
+
280
+ f.totals gross: "-200.00", net: { "23" => "-162.60" }, vat: { "23" => "-37.40" }
281
+ end
282
+ ```
283
+
284
+ **The totals are stated, not computed**, and that is deliberate. A correction's summary
285
+ buckets are deltas, and FA(3) does not require its rows to determine them — three of the
286
+ Ministry's five worked corrections have no usable rows at all, and **two of those carry no
287
+ `FaWiersz` whatsoever**. Deriving the figures would invent a tax base the document already
288
+ states, so `f.totals` is how a correction says what it does. It takes rate codes, like
289
+ `f.line`, and maps them to summary buckets for you.
290
+
291
+ `f.totals` is **required** once a row is marked `state_before`: such a row shows the position
292
+ as it was, so adding it up counts an amount that was already invoiced. `invoice.errors` says
293
+ so rather than letting the document out.
294
+
295
+ Omit `ksef_number` when the invoice being corrected was issued outside KSeF; the document then
296
+ carries the `NrKSeFN` marker instead. If the buyer's details are what changed, pass the old
297
+ ones as `previous_buyers:` on `f.correction` and give both the old and new record the same
298
+ `buyer_id`, which is what links them.
299
+
300
+ ### Collecting an advance, then settling it
301
+
302
+ A `ZAL` documents money received before the goods are delivered. It has **no invoice rows** —
303
+ the order or contract takes their place, per art. 106f ust. 1 pkt 4:
304
+
305
+ ```ruby
306
+ Ksef::FA3.build do |f|
307
+ # …seller, buyer, number, issue_date…
308
+ f.invoice_type "ZAL"
309
+
310
+ f.order total: "375150" # the whole order, including tax
311
+ f.order_line name: "mieszkanie 50m^2", qty: 1, unit: "szt.",
312
+ net_unit_price: "300000", net_amount: "300000",
313
+ vat_amount: "69000", vat: "23"
314
+
315
+ f.totals gross: "20000", net: { "23" => "16260.16" }, vat: { "23" => "3739.84" }
316
+ end
317
+ ```
318
+
319
+ `f.order`'s `total:` is the **whole order including tax** — 375 150 here — while `f.totals`
320
+ states the 20 000 actually received. They are different numbers on purpose, and an order
321
+ position states its own tax (`vat_amount`) rather than having it computed, because FA(3)
322
+ gives it a field of its own.
323
+
324
+ The `ROZ` issued once the goods are delivered names the advance invoices it settles:
325
+
326
+ ```ruby
327
+ f.invoice_type "ROZ"
328
+ f.settles ksef_number: "5265877635-20250826-0100001AF629-AF" # issued through KSeF
329
+ f.settles number: "FZ/2026/02/150" # issued outside it
330
+ ```
331
+
332
+ Its rows describe the goods while `f.totals` states what is **left** to pay, so those two do
333
+ not add up to each other — which is why the summary is stated here as well.
334
+
335
+ ### Reading an invoice back
336
+
337
+ `Ksef::FA3.parse` turns FA(3) XML into the same model the builder produces — useful for an
338
+ invoice you fetched from KSeF, or for inspecting one KSeF rejected.
339
+
340
+ ```ruby
341
+ invoice = Ksef::FA3.parse(File.read("faktura.xml", encoding: "UTF-8"))
342
+ invoice.number # => "FA/2026/08/001"
343
+ invoice.gross_total # => BigDecimal("1845")
344
+ invoice.lines.size # => 3
345
+ ```
346
+
347
+ Two things to know, because they matter more than the happy path.
348
+
349
+ **Parsing is not validating.** A document KSeF refused still parses — that is the point, since
350
+ you usually parse one in order to find out what was wrong with it. Run `invoice.validate!`
351
+ yourself if you want the schema checked.
352
+
353
+ **FA(3) is much larger than this model, so re-serialising a document you did not write loses
354
+ whatever it does not cover.** Nothing is thrown away — the whole document stays on
355
+ `#raw_document` — but check before you round-trip:
356
+
357
+ ```ruby
358
+ invoice.fully_mapped? # => false
359
+ invoice.unmapped_elements # => ["Faktura/Fa/DodatkowyOpis", "Faktura/Podmiot3", ...]
360
+ invoice.raw_document # the complete Nokogiri document, always
361
+ ```
362
+
363
+ **The first two answer by re-serialising, so they raise when the document cannot be** — which
364
+ is the case the paragraph above sends you here for. A rejected invoice with a malformed NIP
365
+ parses happily and then fails `#unmapped_elements` with
366
+ `This invoice cannot be re-serialised, so there is nothing to say about what re-serialising it
367
+ would drop: …`. That is deliberate: what a document would lose on the way out is not a question
368
+ about a document that cannot get out. `#raw_document` is the one that always answers, which is
369
+ why it says *always*.
370
+
371
+ For an invoice this gem built, the XML always round-trips byte for byte, and
372
+ `Ksef::FA3.parse(invoice.to_xml) == invoice` holds whenever the invoice states everything the
373
+ document will carry — in practice, set `issued_at` and a `net_amount` per line. Leave them out
374
+ and serialisation supplies them (a generation timestamp, and each line's net from quantity ×
375
+ price), so the parsed invoice knows two things the original did not.
376
+
377
+ One field is inherently exempt: **`rounding` is not recorded in an FA(3) document at all**. It
378
+ is inferred by asking which strategy reproduces the tax summaries, so a `:per_summary` invoice
379
+ whose summaries happen to match `:per_line`'s — the common case — comes back as `:per_line`.
380
+
381
+ Parsing also refuses what it cannot represent faithfully, rather than guessing: a row priced
382
+ gross (`P_9B`/`P_11A`), a buyer identified by anything other than a NIP, and any
383
+ `RodzajFaktury` the schema does not define. The message says that the document is fine and the
384
+ model is the limit, and names the construct.
385
+
386
+ A row that simply states *no* amount is **not** in that list — it is legal FA(3), it is what a
387
+ simplified `UPR` invoice carries, and `Line#net` answers nil for it. What such a row costs is a
388
+ summary bucket, so `Invoice#errors` objects to one only on an invoice that derives its summary
389
+ from its rows.
390
+
391
+ ### Querying the schema
392
+
393
+ The FA(3) schema metadata is generated from the bundled XSD, so element ordering and
394
+ enum membership are queryable without parsing anything yourself:
395
+
396
+ ```ruby
397
+ E = Ksef::FA3::Generated::Enums
398
+ E.values_for("TRodzajFaktury") # => ["VAT", "KOR", "ZAL", "ROZ", "UPR", "KOR_ZAL", "KOR_ROZ"]
399
+ E.valid?("TStawkaPodatku", "23") # => true
400
+
401
+ T = Ksef::FA3::Generated::Types
402
+ T.ordered_elements("Faktura").map { |e| e[:name] }
403
+ # => ["Naglowek", "Podmiot1", "Podmiot2", ...] — the order KSeF requires
404
+ ```
405
+
406
+ Note VAT rate codes are **strings**, not numbers: half of the fourteen are codes like
407
+ `"0 WDT"`, `"zw"` and `"np I"`.
408
+
409
+ ### Which English name is which Polish element
410
+
411
+ [`docs/field_mapping.md`](docs/field_mapping.md) is the table: every attribute this model
412
+ carries, the FA(3) element it reads and writes, its XSD type and cardinality, and the
413
+ Ministry's own description of it.
414
+
415
+ It is **generated** — from a declared mapping plus the pinned XSD — and `rake fa3:verify`
416
+ fails if it is stale, so it cannot quietly fall behind either the code or the schema. Adding a
417
+ field to a model without saying where it goes fails the build.
418
+
419
+ The model does not carry all of FA(3), and the table says which attributes reach no element at
420
+ all. For a specific document, `parse(xml).unmapped_elements` reports exactly what `#to_xml`
421
+ would drop.
422
+
81
423
  ## Environments
82
424
 
83
425
  | Env | Base URL |
@@ -98,8 +440,9 @@ integrators.
98
440
 
99
441
  - **MRI >= 3.2**, with **no upper bound, ever.** `required_ruby_version` resolves at
100
442
  install time, so an upper bound strands users on each new Ruby release.
101
- - CI covers 3.2, 3.3, 3.4, 4.0 and `head`, and the full suite is run on 3.2 at every
102
- milestone — the floor is verified, not just declared.
443
+ - CI covers 3.2, 3.3, 3.4, 4.0 and `head`, and the full suite is run locally on 3.2 at every
444
+ milestone — the floor is verified, not just declared. Last such run: 2026-08-26, 1460
445
+ examples green.
103
446
  - **The 3.2 floor is a commitment, not a default.** Ruby 3.2 is EOL upstream, and this gem
104
447
  still supports it deliberately: Polish tax-compliance software upgrades slowly, and 3.2
105
448
  is the Rails 8.0 floor. There is no plan to raise it.
@@ -116,9 +459,14 @@ integrators.
116
459
  string.
117
460
  - **`BigDecimal` everywhere for money.** `Float` is forbidden in any monetary path.
118
461
  - **Invoice submission is never auto-retried.** A duplicate invoice in KSeF is a real tax
119
- problem. Idempotent GETs retry with backoff; a failed submission surfaces to you.
120
- - **Thread-safe.** A single client is shareable across threads (Sidekiq is the expected
121
- habitat); configuration is frozen at construction.
462
+ problem. Idempotent GETs retry with capped exponential backoff, honouring `Retry-After`
463
+ unclamped; every POST surfaces its error to you, so re-sending is always your decision.
464
+ - **Thread-safe by requirement.** Configuration is frozen at construction, and a single
465
+ client **is** shareable across threads (Sidekiq is the expected habitat), and that is
466
+ shipped rather than aspirational: the configuration is frozen at construction, the
467
+ connections are stateless, the only mutable state is a memoised credential behind one
468
+ mutex, and no session is ever held on the client — which is why `send_invoice` opens a
469
+ fresh one. A spec drives six concurrent sends and asserts a single authentication.
122
470
  - **Secrets never logged.** Tokens, JWTs, symmetric keys and IVs are redacted from
123
471
  `#inspect` output.
124
472
 
@@ -126,16 +474,20 @@ integrators.
126
474
 
127
475
  | Version | Scope |
128
476
  |---|---|
129
- | 0.1.0 | KSeF-token auth, crypto, online sessions, send/status/UPO/download, full FA(3) builder for all seven invoice types |
477
+ | 0.1.0 | **Both auth methods** — certificate/XAdES *and* KSeF token — crypto, online sessions, send/status/UPO/download, **three-tier validation** (model *and its byte-level half*, XSD, business), full FA(3) builder for all seven invoice types |
130
478
  | 0.2 | Batch sessions, invoice query/search, package export, hardened error catalogue |
131
- | 0.3 | XAdES authentication, certificate lifecycle, permissions API, offline QR codes |
479
+ | 0.3 | KSeF certificate lifecycle endpoints, permissions API, offline QR codes |
132
480
  | 1.0 | After sustained production use; API stability promise begins |
133
481
 
482
+ Certificate authentication is in 0.1 rather than deferred, because a KSeF token can only
483
+ be issued *after* a one-time authentication with a qualified signature — so a token-only
484
+ client cannot get you started from nothing.
485
+
134
486
  ## Development
135
487
 
136
488
  ```bash
137
489
  bin/setup # or: bundle install
138
- bundle exec rake # verify pinned artifacts, specs, RuboCop
490
+ bundle exec rake # verify pinned artifacts, reproduce the codegen, specs, RuboCop
139
491
  ```
140
492
 
141
493
  Every externally sourced fact — endpoint paths, XML element names, namespace URIs,
data/SECURITY.md CHANGED
@@ -39,8 +39,31 @@ fixtures:
39
39
  Enforced by:
40
40
 
41
41
  - A redacting `#inspect` on configuration and authentication objects.
42
- - VCR cassette scrubbing, plus a spec that scans committed cassettes for `Bearer ` and
43
- known secret environment values.
42
+ - `spec/cassette_hygiene_spec.rb`, which scans every committed VCR cassette for `Bearer `
43
+ headers and for any value this machine holds in `KSEF_TEST_TOKEN`. **Not the NIP**: a tax
44
+ identifier is printed on every invoice and is embedded in the KSeF number itself, so
45
+ redacting it corrupts the very documents a cassette exists to hold — and costs the two
46
+ checks only a real response can support, the KSeF number's checksum and KSeF's own
47
+ integrity header. The token is the credential.
48
+
49
+ It also scans for anything shaped like a **JSON Web Token**, added 2026-08-26 after a
50
+ recording carried a live refresh token past both of the other checks (caught before it was
51
+ committed; the cassettes now in the repository were recorded after the fix and are scanned
52
+ on every run): it was in a JSON
53
+ response body, so it was neither a `Bearer ` header nor a value this machine held in its
54
+ environment. Scanning for the shape of a credential catches the ones you did not predict. It
55
+ finds cassettes by content, not by path, so one saved somewhere unconventional is still
56
+ scanned. **Three cassettes are committed** as of 2026-08-26, so the check is live rather than
57
+ vacuous. What it scans is the *decoded* content — bodies, including the ones YAML stores as
58
+ `!binary`, and every header value — because a scan of the file misses a secret in a body that
59
+ carries one non-ASCII byte, and Polish text in this domain routinely does.
60
+
61
+ It looks for four shapes: a `Bearer` value, anything JWT-shaped, any value this machine holds
62
+ in `KSEF_TEST_TOKEN`, and **a pre-signed URL that still carries its signature**. That last one
63
+ was added after two cassettes reached git holding a live Azure SAS for a TEST UPO — read-only,
64
+ three-day, and invisible to the other three checks because a SAS `sig` is none of them. The scrubbing hooks it depends on are in
65
+ `spec/support/vcr.rb`, written before the first recording rather than after, because a
66
+ cassette committed without them is a leak `git` remembers.
44
67
  - Integration tests reading credentials only from environment variables
45
68
  (`KSEF_TEST_NIP`, `KSEF_TEST_TOKEN`, `KSEF_ENV=test`).
46
69