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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1424 -0
- data/CONTRIBUTING.md +45 -6
- data/README.md +377 -25
- data/SECURITY.md +25 -2
- data/docs/REFERENCE.md +3099 -34
- data/docs/errors.md +33 -4
- data/docs/field_mapping.md +390 -0
- data/lib/ksef/auth/access_token.rb +150 -0
- data/lib/ksef/auth/authorization_policy.rb +88 -0
- data/lib/ksef/auth/challenge.rb +53 -0
- data/lib/ksef/auth/client.rb +143 -0
- data/lib/ksef/auth/initiation.rb +22 -0
- data/lib/ksef/auth/operation_status.rb +34 -0
- data/lib/ksef/auth/schema/schemat_auth_v2-0.xsd +109 -0
- data/lib/ksef/auth/schema/schemat_auth_v2-1.xsd +109 -0
- data/lib/ksef/auth/signature_template.rb +120 -0
- data/lib/ksef/auth/signer.rb +132 -0
- data/lib/ksef/auth/status.rb +69 -0
- data/lib/ksef/auth/token.rb +121 -0
- data/lib/ksef/auth/token_info.rb +27 -0
- data/lib/ksef/auth/token_request.rb +142 -0
- data/lib/ksef/auth/tokens.rb +24 -0
- data/lib/ksef/auth/validator.rb +84 -0
- data/lib/ksef/auth/xades.rb +40 -0
- data/lib/ksef/auth.rb +43 -0
- data/lib/ksef/client/receipt.rb +37 -0
- data/lib/ksef/client/session.rb +68 -0
- data/lib/ksef/client.rb +314 -0
- data/lib/ksef/crypto/certificate.rb +78 -0
- data/lib/ksef/crypto/digest.rb +25 -0
- data/lib/ksef/crypto/encryptor.rb +131 -0
- data/lib/ksef/crypto/public_keys.rb +146 -0
- data/lib/ksef/crypto.rb +66 -0
- data/lib/ksef/environments.rb +1 -1
- data/lib/ksef/errors.rb +35 -2
- data/lib/ksef/fa3/address.rb +64 -0
- data/lib/ksef/fa3/advance_checks.rb +55 -0
- data/lib/ksef/fa3/advance_invoice.rb +55 -0
- data/lib/ksef/fa3/advance_reader.rb +59 -0
- data/lib/ksef/fa3/attachment.rb +43 -0
- data/lib/ksef/fa3/attachment_checks.rb +121 -0
- data/lib/ksef/fa3/attachment_reader.rb +99 -0
- data/lib/ksef/fa3/attachment_table.rb +98 -0
- data/lib/ksef/fa3/builder/advances.rb +57 -0
- data/lib/ksef/fa3/builder/corrections.rb +46 -0
- data/lib/ksef/fa3/builder/subjects.rb +71 -0
- data/lib/ksef/fa3/builder.rb +149 -0
- data/lib/ksef/fa3/business_validator.rb +134 -0
- data/lib/ksef/fa3/canonical.rb +44 -0
- data/lib/ksef/fa3/corrected_invoice.rb +53 -0
- data/lib/ksef/fa3/correction.rb +151 -0
- data/lib/ksef/fa3/correction_checks.rb +106 -0
- data/lib/ksef/fa3/correction_reader.rb +88 -0
- data/lib/ksef/fa3/data_block.rb +75 -0
- data/lib/ksef/fa3/document_mapping.rb +142 -0
- data/lib/ksef/fa3/document_validator.rb +174 -0
- data/lib/ksef/fa3/element_tree.rb +49 -0
- data/lib/ksef/fa3/field_checks.rb +151 -0
- data/lib/ksef/fa3/formatting.rb +273 -0
- data/lib/ksef/fa3/generated/enums.rb +652 -0
- data/lib/ksef/fa3/generated/types.rb +3308 -0
- data/lib/ksef/fa3/invoice.rb +259 -0
- data/lib/ksef/fa3/issue.rb +32 -0
- data/lib/ksef/fa3/line.rb +162 -0
- data/lib/ksef/fa3/meta_entry.rb +45 -0
- data/lib/ksef/fa3/model_validator.rb +219 -0
- data/lib/ksef/fa3/nip.rb +59 -0
- data/lib/ksef/fa3/node_reader.rb +46 -0
- data/lib/ksef/fa3/order.rb +55 -0
- data/lib/ksef/fa3/order_line.rb +70 -0
- data/lib/ksef/fa3/parser.rb +208 -0
- data/lib/ksef/fa3/provenance.rb +137 -0
- data/lib/ksef/fa3/rounding_inference.rb +87 -0
- data/lib/ksef/fa3/row_reader.rb +66 -0
- data/lib/ksef/fa3/serializer.rb +140 -0
- data/lib/ksef/fa3/subject.rb +128 -0
- data/lib/ksef/fa3/subject_checks.rb +126 -0
- data/lib/ksef/fa3/subject_reader.rb +76 -0
- data/lib/ksef/fa3/summaries.rb +107 -0
- data/lib/ksef/fa3/summary_checks.rb +75 -0
- data/lib/ksef/fa3/table_column.rb +49 -0
- data/lib/ksef/fa3/totals.rb +119 -0
- data/lib/ksef/fa3/validator.rb +79 -0
- data/lib/ksef/fa3/vat_rate.rb +94 -0
- data/lib/ksef/fa3.rb +59 -0
- data/lib/ksef/http/connection.rb +47 -4
- data/lib/ksef/http/json_decoder.rb +53 -0
- data/lib/ksef/http/retry.rb +105 -0
- data/lib/ksef/invoices/client.rb +72 -0
- data/lib/ksef/ksef_number.rb +150 -0
- data/lib/ksef/sessions/invoice_codes.rb +70 -0
- data/lib/ksef/sessions/invoice_state.rb +68 -0
- data/lib/ksef/sessions/online.rb +169 -0
- data/lib/ksef/sessions/session_codes.rb +73 -0
- data/lib/ksef/sessions/session_state.rb +48 -0
- data/lib/ksef/sessions/status.rb +138 -0
- data/lib/ksef/sessions/upo_page.rb +36 -0
- data/lib/ksef/sessions.rb +91 -0
- data/lib/ksef/upo/client.rb +154 -0
- data/lib/ksef/upo/document.rb +74 -0
- data/lib/ksef/upo/schema/upo-v4-3.xsd +293 -0
- data/lib/ksef/upo/validation.rb +37 -0
- data/lib/ksef/upo/validator.rb +124 -0
- data/lib/ksef/upo.rb +55 -0
- data/lib/ksef/version.rb +1 -1
- data/lib/ksef.rb +18 -0
- 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
|
|
25
|
-
`CIRFMF/ksef-api`
|
|
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 (
|
|
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
|
|
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
|
+
[](https://github.com/tibortc/ksef_client/actions/workflows/test.yml)
|
|
4
|
+
[](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
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
95
|
+
Or track the branch, where fixes land first:
|
|
23
96
|
|
|
24
97
|
```ruby
|
|
25
|
-
|
|
98
|
+
gem "ksef_client", github: "tibortc/ksef_client"
|
|
26
99
|
```
|
|
27
100
|
|
|
28
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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-
|
|
49
|
-
upo = client.upo(result.reference) # signed UPO
|
|
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
|
|
120
|
-
|
|
121
|
-
|
|
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
|
|
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 |
|
|
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
|
-
-
|
|
43
|
-
|
|
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
|
|