ksef-rb 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 +7 -0
- data/CHANGELOG.md +35 -0
- data/LICENSE.txt +21 -0
- data/README.md +135 -0
- data/lib/ksef/client.rb +47 -0
- data/lib/ksef/configuration.rb +40 -0
- data/lib/ksef/credentials/certificate.rb +18 -0
- data/lib/ksef/credentials/token.rb +29 -0
- data/lib/ksef/errors.rb +47 -0
- data/lib/ksef/internal/connection.rb +160 -0
- data/lib/ksef/internal/token_encryptor.rb +48 -0
- data/lib/ksef/invoice_header.rb +74 -0
- data/lib/ksef/invoices.rb +154 -0
- data/lib/ksef/session.rb +41 -0
- data/lib/ksef/sessions.rb +189 -0
- data/lib/ksef/version.rb +5 -0
- data/lib/ksef.rb +46 -0
- metadata +133 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: eaa5f61dd0f2d3e8f852cb504016268c73b798da5fe1e896a821115ea01fd2a5
|
|
4
|
+
data.tar.gz: 23df18df8297859a7fdf10fe0546dc03dc49e43b7617236de86837f7d2dbbe43
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: a15c7b19048d06f3328c5e4cb4cbc0a85ce3d7acf4daea6af2c24ec9dc3aee09c83c391071949665fa2a9b81d6f9ac2e9a8285a9e0b582f6e8c45c1d3d764422
|
|
7
|
+
data.tar.gz: f2b4956065c21bd7ac5dde726ca129664aa04e30efea9c7ffc746cc32900b0d76d523202f63aa74c1a03105108c58b627e0d96abff8e2c54d208f3d41d90fd5b
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2025-05-18
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- `Ksef.configure` / `Ksef::Configuration` for environment selection
|
|
14
|
+
(`:test`, `:demo`, `:production`) and request defaults.
|
|
15
|
+
- `Ksef::Credentials::Token` wrapping the long-lived KSeF integration token.
|
|
16
|
+
- `Ksef::Client` as the main entry point.
|
|
17
|
+
- `Ksef::Sessions` implementing the interactive-session lifecycle —
|
|
18
|
+
`/auth/challenge` → RSA-OAEP/SHA-256 token encryption → `/auth/ksef-token`
|
|
19
|
+
→ status polling → `/auth/token/redeem` → `DELETE /auth/sessions/current`.
|
|
20
|
+
Public API: `#with_interactive`, `#open`, `#terminate`.
|
|
21
|
+
- `Ksef::Invoices` for inbound retrieval — `#query` (metadata) and
|
|
22
|
+
`#fetch_xml` (raw FA(3) XML).
|
|
23
|
+
- `Ksef::InvoiceHeader` value object exposing the business-meaningful slice
|
|
24
|
+
of the `InvoiceMetadata` payload.
|
|
25
|
+
- Typed errors: `Ksef::AuthError`, `Ksef::NotFoundError`,
|
|
26
|
+
`Ksef::RateLimitError` (with `retry_after`), `Ksef::ServerError`,
|
|
27
|
+
`Ksef::ClientError`, `Ksef::ConfigurationError`, plus a base `Ksef::Error`.
|
|
28
|
+
- Stubs (`NotImplementedError`) for `Ksef::Credentials::Certificate`,
|
|
29
|
+
`Ksef::Invoices#fetch_visualisation`, `Ksef::Invoices#fetch_upo`.
|
|
30
|
+
- RSpec suite (63 examples, ~99% line coverage) backed by WebMock plus a
|
|
31
|
+
hand-crafted VCR cassette synthesised from the OpenAPI 3.0.4 spec and the
|
|
32
|
+
CIRFMF reference clients. Re-recording against the live sandbox is gated
|
|
33
|
+
by `KSEF_RECORD=true` and a `KSEF_TOKEN`.
|
|
34
|
+
|
|
35
|
+
[0.1.0]: https://github.com/skycocker/ksef-rb/releases/tag/v0.1.0
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Michał Siwek
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# ksef-rb
|
|
2
|
+
|
|
3
|
+
Ruby client for the Polish [KSeF 2.0](https://ksef.podatki.gov.pl/) (Krajowy
|
|
4
|
+
System e-Faktur) National e-Invoicing System.
|
|
5
|
+
|
|
6
|
+
Targets the FA(3) schema (mandatory since February 2026). Built against the
|
|
7
|
+
official OpenAPI spec at `https://api-test.ksef.mf.gov.pl/docs/v2/openapi.json`
|
|
8
|
+
and the [CIRFMF reference clients](https://github.com/CIRFMF) for C# and Java.
|
|
9
|
+
|
|
10
|
+
## Status
|
|
11
|
+
|
|
12
|
+
Pre-1.0. The public API is small on purpose and stable across the v0.1 line,
|
|
13
|
+
but additions are expected as more KSeF features land.
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
gem "ksef-rb", require: "ksef"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Quick start
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
require "ksef"
|
|
25
|
+
|
|
26
|
+
Ksef.configure do |c|
|
|
27
|
+
c.environment = :test # :test, :demo, or :production
|
|
28
|
+
c.user_agent = "MyApp / ksef-rb #{Ksef::VERSION}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
client = Ksef::Client.new(
|
|
32
|
+
nip: "1234567890",
|
|
33
|
+
credentials: Ksef::Credentials::Token.new(ENV.fetch("KSEF_TOKEN"))
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
client.sessions.with_interactive do |_session|
|
|
37
|
+
headers = client.invoices.query(
|
|
38
|
+
subject_type: :recipient,
|
|
39
|
+
date_from: Time.now.utc - (7 * 24 * 3600),
|
|
40
|
+
date_to: Time.now.utc
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
headers.each do |h|
|
|
44
|
+
puts "#{h.ksef_reference_number} #{h.issuer_nip} #{h.gross_amount} #{h.currency}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
xml = client.invoices.fetch_xml(headers.first.ksef_reference_number)
|
|
48
|
+
File.write("invoice.xml", xml)
|
|
49
|
+
end
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`Ksef::InvoiceHeader` exposes (among others):
|
|
53
|
+
`ksef_reference_number`, `invoice_number`, `issuer_nip`, `issuer_name`,
|
|
54
|
+
`recipient_nip`, `recipient_name`, `issued_on`, `gross_amount`, `net_amount`,
|
|
55
|
+
`vat_amount`, `currency`, `invoicing_mode`, `invoice_type`, `form_code`,
|
|
56
|
+
`form_schema_version`, `permanently_stored_at`, `has_attachment?`,
|
|
57
|
+
`self_invoicing?`, and the original payload via `raw`.
|
|
58
|
+
|
|
59
|
+
## Authentication
|
|
60
|
+
|
|
61
|
+
v0.1 ships with token-based auth using the long-lived integration tokens minted
|
|
62
|
+
in the KSeF portal after a Profil Zaufany / qualified-seal login.
|
|
63
|
+
|
|
64
|
+
The full handshake — `/auth/challenge`, `/auth/ksef-token`, status polling at
|
|
65
|
+
`/auth/{ref}`, and `/auth/token/redeem` — is performed automatically by
|
|
66
|
+
`Ksef::Sessions#with_interactive`. The integration token is encrypted with
|
|
67
|
+
RSA-OAEP (SHA-256) using the public key returned by
|
|
68
|
+
`/security/public-key-certificates`.
|
|
69
|
+
|
|
70
|
+
`with_interactive` always tears the session down by calling
|
|
71
|
+
`DELETE /auth/sessions/current`, even when the block raises.
|
|
72
|
+
|
|
73
|
+
## Errors
|
|
74
|
+
|
|
75
|
+
All KSeF-specific errors inherit from `Ksef::Error`:
|
|
76
|
+
|
|
77
|
+
| Class | When |
|
|
78
|
+
|------------------------|----------------------------------------------|
|
|
79
|
+
| `Ksef::AuthError` | 401, 403, or auth-status failure (`code: 450`, etc.) |
|
|
80
|
+
| `Ksef::NotFoundError` | 404 |
|
|
81
|
+
| `Ksef::RateLimitError` | 429 (exposes `#retry_after` in seconds when sent) |
|
|
82
|
+
| `Ksef::ServerError` | 5xx |
|
|
83
|
+
| `Ksef::ClientError` | other 4xx |
|
|
84
|
+
| `Ksef::ConfigurationError` | bad config |
|
|
85
|
+
|
|
86
|
+
Every error captures `status`, `body`, and the KSeF-supplied `code`.
|
|
87
|
+
|
|
88
|
+
## What's not in v0.1.0
|
|
89
|
+
|
|
90
|
+
| Feature | Status |
|
|
91
|
+
|-----------------------------------------|--------|
|
|
92
|
+
| Token-based auth | shipped |
|
|
93
|
+
| Interactive sessions | shipped |
|
|
94
|
+
| Inbound invoice metadata query | shipped |
|
|
95
|
+
| Inbound invoice XML fetch | shipped |
|
|
96
|
+
| Inbound invoice PDF visualisation | **stubbed** — KSeF 2.0 has no server-side PDF endpoint; render client-side from the XML using the official XSLT (`wizualizacja-faktury_v3-0.xsl`) |
|
|
97
|
+
| Certificate-based auth (qualified seal) | **stubbed** (`Ksef::Credentials::Certificate`) |
|
|
98
|
+
| Batch sessions | not yet |
|
|
99
|
+
| Outbound invoice issuance | not yet |
|
|
100
|
+
| UPO download | **stubbed** (`Ksef::Invoices#fetch_upo`) |
|
|
101
|
+
| Offline / QR-code modes | not yet |
|
|
102
|
+
|
|
103
|
+
`NotImplementedError` is raised from the stubs.
|
|
104
|
+
|
|
105
|
+
## Configuration reference
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
Ksef.configure do |c|
|
|
109
|
+
c.environment = :test # :test, :demo, :production
|
|
110
|
+
c.user_agent = "..." # appended to every request
|
|
111
|
+
c.timeout = 30 # seconds
|
|
112
|
+
c.open_timeout = 10 # seconds
|
|
113
|
+
c.api_version = "v2" # path segment; defaults to v2
|
|
114
|
+
c.base_url = nil # override entirely (useful for tests)
|
|
115
|
+
c.logger = Logger.new($stdout) # wires Faraday's logger middleware
|
|
116
|
+
end
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`Ksef::Client.new(configuration:)` accepts a per-client `Configuration`,
|
|
120
|
+
which is the duplicated global configuration by default.
|
|
121
|
+
|
|
122
|
+
## Development
|
|
123
|
+
|
|
124
|
+
```sh
|
|
125
|
+
bundle install
|
|
126
|
+
bundle exec rspec
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
The suite uses VCR (opt-in, via the `:vcr` metadata tag) and WebMock. Live
|
|
130
|
+
re-recording against the sandbox is gated behind `KSEF_RECORD=true` and a
|
|
131
|
+
real token in `KSEF_TOKEN`.
|
|
132
|
+
|
|
133
|
+
## License
|
|
134
|
+
|
|
135
|
+
MIT — see [LICENSE.txt](LICENSE.txt).
|
data/lib/ksef/client.rb
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ksef
|
|
4
|
+
# Main entry point for the KSeF API.
|
|
5
|
+
#
|
|
6
|
+
# @example
|
|
7
|
+
# client = Ksef::Client.new(
|
|
8
|
+
# nip: "1234567890",
|
|
9
|
+
# credentials: Ksef::Credentials::Token.new(ENV.fetch("KSEF_TOKEN"))
|
|
10
|
+
# )
|
|
11
|
+
#
|
|
12
|
+
# client.sessions.with_interactive do |session|
|
|
13
|
+
# headers = client.invoices.query(
|
|
14
|
+
# subject_type: :recipient,
|
|
15
|
+
# date_from: Time.now.utc - (7 * 24 * 3600),
|
|
16
|
+
# date_to: Time.now.utc
|
|
17
|
+
# )
|
|
18
|
+
# xml = client.invoices.fetch_xml(headers.first.ksef_reference_number)
|
|
19
|
+
# end
|
|
20
|
+
class Client
|
|
21
|
+
attr_reader :nip, :credentials, :configuration
|
|
22
|
+
attr_accessor :current_session
|
|
23
|
+
|
|
24
|
+
def initialize(nip:, credentials:, configuration: nil)
|
|
25
|
+
raise ConfigurationError, "nip cannot be blank" if nip.nil? || nip.to_s.empty?
|
|
26
|
+
|
|
27
|
+
@nip = nip.to_s
|
|
28
|
+
@credentials = credentials
|
|
29
|
+
@configuration = configuration || Ksef.configuration.dup
|
|
30
|
+
@current_session = nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Lazily-built HTTP connection. Public so the resource classes can share
|
|
34
|
+
# it; not part of the supported public surface — treat as internal.
|
|
35
|
+
def connection
|
|
36
|
+
@connection ||= Internal::Connection.new(@configuration)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def sessions
|
|
40
|
+
@sessions ||= Sessions.new(self)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def invoices
|
|
44
|
+
@invoices ||= Invoices.new(self)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ksef
|
|
4
|
+
# Global gem configuration. Set via `Ksef.configure { |c| ... }`.
|
|
5
|
+
#
|
|
6
|
+
# Mutable defaults are exposed so callers can build a per-client
|
|
7
|
+
# `Ksef::Client` override without touching the global state.
|
|
8
|
+
class Configuration
|
|
9
|
+
# Maps environment symbol → base URL.
|
|
10
|
+
ENVIRONMENTS = {
|
|
11
|
+
test: "https://api-test.ksef.mf.gov.pl",
|
|
12
|
+
demo: "https://api-demo.ksef.mf.gov.pl",
|
|
13
|
+
production: "https://api.ksef.mf.gov.pl"
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
DEFAULT_API_VERSION = "v2"
|
|
17
|
+
|
|
18
|
+
attr_accessor :environment, :user_agent, :timeout, :open_timeout,
|
|
19
|
+
:api_version, :base_url, :logger
|
|
20
|
+
|
|
21
|
+
def initialize
|
|
22
|
+
@environment = :test
|
|
23
|
+
@user_agent = "ksef-rb/#{Ksef::VERSION}"
|
|
24
|
+
@timeout = 30
|
|
25
|
+
@open_timeout = 10
|
|
26
|
+
@api_version = DEFAULT_API_VERSION
|
|
27
|
+
@base_url = nil
|
|
28
|
+
@logger = nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Returns the effective base URL for the configured environment, including
|
|
32
|
+
# the `/v2` (or whatever) API version path.
|
|
33
|
+
def resolved_base_url
|
|
34
|
+
root = @base_url || ENVIRONMENTS.fetch(@environment) do
|
|
35
|
+
raise ConfigurationError, "Unknown KSeF environment: #{@environment.inspect}"
|
|
36
|
+
end
|
|
37
|
+
"#{root.chomp("/")}/#{@api_version}"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ksef
|
|
4
|
+
module Credentials
|
|
5
|
+
# Certificate-based credential (qualified seal / XAdES signature flow).
|
|
6
|
+
#
|
|
7
|
+
# @note Stub for v0.1.0 — interactive sessions backed by a qualified seal
|
|
8
|
+
# require XAdES-signed XML which is out of scope for this release. The
|
|
9
|
+
# class is here so the public API shape doesn't shift when we land it.
|
|
10
|
+
class Certificate
|
|
11
|
+
def initialize(*)
|
|
12
|
+
raise NotImplementedError,
|
|
13
|
+
"Certificate-based authentication is not implemented in ksef-rb v#{Ksef::VERSION}. " \
|
|
14
|
+
"Use Ksef::Credentials::Token for now; tracked for a future release."
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ksef
|
|
4
|
+
module Credentials
|
|
5
|
+
# A long-lived KSeF integration token, minted in the KSeF portal after
|
|
6
|
+
# Profil Zaufany / qualified-seal login.
|
|
7
|
+
#
|
|
8
|
+
# The raw token value is treated as opaque; it is encrypted with the KSeF
|
|
9
|
+
# public RSA key during the {Ksef::Sessions} init flow.
|
|
10
|
+
class Token
|
|
11
|
+
attr_reader :value
|
|
12
|
+
|
|
13
|
+
def initialize(value)
|
|
14
|
+
raise ConfigurationError, "Token value cannot be blank" if value.nil? || value.to_s.empty?
|
|
15
|
+
|
|
16
|
+
@value = value.to_s
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def type
|
|
20
|
+
:token
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def to_s
|
|
24
|
+
"#<Ksef::Credentials::Token value=[REDACTED]>"
|
|
25
|
+
end
|
|
26
|
+
alias inspect to_s
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
data/lib/ksef/errors.rb
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ksef
|
|
4
|
+
# Base class for all KSeF gem errors.
|
|
5
|
+
class Error < StandardError
|
|
6
|
+
# The underlying HTTP status code, when applicable.
|
|
7
|
+
attr_reader :status
|
|
8
|
+
|
|
9
|
+
# The parsed error body returned by the API, when applicable.
|
|
10
|
+
attr_reader :body
|
|
11
|
+
|
|
12
|
+
# The KSeF-specific exception code, when surfaced in the body.
|
|
13
|
+
attr_reader :code
|
|
14
|
+
|
|
15
|
+
def initialize(message = nil, status: nil, body: nil, code: nil)
|
|
16
|
+
super(message)
|
|
17
|
+
@status = status
|
|
18
|
+
@body = body
|
|
19
|
+
@code = code
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Raised when the API rejects authentication (401 / 403 or auth-status failure).
|
|
24
|
+
class AuthError < Error; end
|
|
25
|
+
|
|
26
|
+
# Raised when an upstream resource cannot be located (404 / invoice-not-found).
|
|
27
|
+
class NotFoundError < Error; end
|
|
28
|
+
|
|
29
|
+
# Raised on 4xx responses we don't otherwise classify.
|
|
30
|
+
class ClientError < Error; end
|
|
31
|
+
|
|
32
|
+
# Raised when the API returns 5xx.
|
|
33
|
+
class ServerError < Error; end
|
|
34
|
+
|
|
35
|
+
# Raised when the API returns 429. `retry_after` is in seconds when known.
|
|
36
|
+
class RateLimitError < Error
|
|
37
|
+
attr_reader :retry_after
|
|
38
|
+
|
|
39
|
+
def initialize(message = nil, status: nil, body: nil, code: nil, retry_after: nil)
|
|
40
|
+
super(message, status: status, body: body, code: code)
|
|
41
|
+
@retry_after = retry_after
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Raised when configuration is missing or invalid.
|
|
46
|
+
class ConfigurationError < Error; end
|
|
47
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "faraday/retry"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Ksef
|
|
8
|
+
module Internal
|
|
9
|
+
# Thin Faraday wrapper around the KSeF HTTP API.
|
|
10
|
+
#
|
|
11
|
+
# All response-shape parsing and error classification lives here so the
|
|
12
|
+
# higher-level resource classes can treat the API as a typed boundary.
|
|
13
|
+
class Connection
|
|
14
|
+
RETRY_OPTIONS = {
|
|
15
|
+
max: 2,
|
|
16
|
+
interval: 0.4,
|
|
17
|
+
interval_randomness: 0.2,
|
|
18
|
+
backoff_factor: 2,
|
|
19
|
+
retry_statuses: [502, 503, 504],
|
|
20
|
+
methods: %i[get head post delete put patch],
|
|
21
|
+
exceptions: [
|
|
22
|
+
Errno::ETIMEDOUT,
|
|
23
|
+
Faraday::TimeoutError,
|
|
24
|
+
Faraday::ConnectionFailed
|
|
25
|
+
]
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
JSON_CONTENT_TYPE = "application/json"
|
|
29
|
+
|
|
30
|
+
attr_reader :configuration
|
|
31
|
+
|
|
32
|
+
def initialize(configuration)
|
|
33
|
+
@configuration = configuration
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Issues an HTTP request and returns a `Faraday::Response`.
|
|
37
|
+
#
|
|
38
|
+
# @param method [Symbol] :get, :post, :delete, etc.
|
|
39
|
+
# @param path [String] path relative to `configuration.resolved_base_url`
|
|
40
|
+
# @param body [Object, nil] hash → JSON, String → raw body
|
|
41
|
+
# @param headers [Hash]
|
|
42
|
+
# @param query [Hash]
|
|
43
|
+
# @param bearer_token [String, nil] sent as `Authorization: Bearer ...`
|
|
44
|
+
# @return [Faraday::Response]
|
|
45
|
+
def request(method, path, body: nil, headers: {}, query: {}, bearer_token: nil)
|
|
46
|
+
response = http.run_request(method, expand_path(path), nil,
|
|
47
|
+
build_headers(headers, bearer_token)) do |req|
|
|
48
|
+
req.params.update(query) unless query.empty?
|
|
49
|
+
assign_body(req, body)
|
|
50
|
+
end
|
|
51
|
+
check!(response)
|
|
52
|
+
response
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Parses a JSON response body, returning `{}` on empty bodies.
|
|
56
|
+
def self.parse_json(response)
|
|
57
|
+
return {} if response.body.nil? || response.body.to_s.empty?
|
|
58
|
+
|
|
59
|
+
JSON.parse(response.body)
|
|
60
|
+
rescue JSON::ParserError
|
|
61
|
+
{}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
# Resolves API-relative paths against the configured base URL. Faraday
|
|
67
|
+
# treats `/auth/...` as absolute and would strip the `/v2` prefix; we
|
|
68
|
+
# always pass a fully-qualified URL to avoid that pitfall.
|
|
69
|
+
def expand_path(path)
|
|
70
|
+
"#{configuration.resolved_base_url}#{path.start_with?("/") ? path : "/#{path}"}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def http
|
|
74
|
+
@http ||= Faraday.new do |conn|
|
|
75
|
+
conn.request :retry, RETRY_OPTIONS
|
|
76
|
+
conn.options.timeout = configuration.timeout
|
|
77
|
+
conn.options.open_timeout = configuration.open_timeout
|
|
78
|
+
conn.headers["User-Agent"] = configuration.user_agent
|
|
79
|
+
conn.headers["Accept"] = JSON_CONTENT_TYPE
|
|
80
|
+
if configuration.logger
|
|
81
|
+
conn.response :logger, configuration.logger, headers: false, bodies: false
|
|
82
|
+
end
|
|
83
|
+
conn.adapter Faraday.default_adapter
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def build_headers(extra, bearer_token)
|
|
88
|
+
headers = {}
|
|
89
|
+
headers["Authorization"] = "Bearer #{bearer_token}" if bearer_token
|
|
90
|
+
headers.merge(extra)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def assign_body(req, body)
|
|
94
|
+
case body
|
|
95
|
+
when nil
|
|
96
|
+
# no body
|
|
97
|
+
when String
|
|
98
|
+
req.body = body
|
|
99
|
+
else
|
|
100
|
+
req.headers["Content-Type"] ||= JSON_CONTENT_TYPE
|
|
101
|
+
req.body = JSON.dump(body)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def check!(response)
|
|
106
|
+
return if response.status.between?(200, 299)
|
|
107
|
+
|
|
108
|
+
parsed = Connection.parse_json(response)
|
|
109
|
+
code, message = extract_error_metadata(parsed)
|
|
110
|
+
klass = error_class_for(response.status)
|
|
111
|
+
|
|
112
|
+
raise build_error(klass, response, message, code, parsed)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def error_class_for(status)
|
|
116
|
+
case status
|
|
117
|
+
when 401, 403 then AuthError
|
|
118
|
+
when 404 then NotFoundError
|
|
119
|
+
when 429 then RateLimitError
|
|
120
|
+
when 400..499 then ClientError
|
|
121
|
+
when 500..599 then ServerError
|
|
122
|
+
else Error
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def build_error(klass, response, message, code, body)
|
|
127
|
+
text = message || "KSeF API error (HTTP #{response.status})"
|
|
128
|
+
if klass == RateLimitError
|
|
129
|
+
retry_after = parse_retry_after(response.headers["Retry-After"])
|
|
130
|
+
klass.new(text, status: response.status, body: body, code: code, retry_after: retry_after)
|
|
131
|
+
else
|
|
132
|
+
klass.new(text, status: response.status, body: body, code: code)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def parse_retry_after(value)
|
|
137
|
+
return nil if value.nil?
|
|
138
|
+
|
|
139
|
+
Integer(value)
|
|
140
|
+
rescue ArgumentError, TypeError
|
|
141
|
+
nil
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Handles both the legacy `ExceptionResponse` shape and the newer
|
|
145
|
+
# RFC 7807 `application/problem+json` payload.
|
|
146
|
+
def extract_error_metadata(parsed)
|
|
147
|
+
return [nil, nil] unless parsed.is_a?(Hash)
|
|
148
|
+
|
|
149
|
+
if parsed["exception"].is_a?(Hash)
|
|
150
|
+
details = Array(parsed["exception"]["exceptionDetailList"]).first || {}
|
|
151
|
+
[details["exceptionCode"], details["exceptionDescription"]]
|
|
152
|
+
elsif parsed["title"] || parsed["detail"]
|
|
153
|
+
[parsed["status"], parsed["detail"] || parsed["title"]]
|
|
154
|
+
else
|
|
155
|
+
[nil, nil]
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "base64"
|
|
5
|
+
|
|
6
|
+
module Ksef
|
|
7
|
+
module Internal
|
|
8
|
+
# Encrypts a KSeF integration token for the `/auth/ksef-token` endpoint.
|
|
9
|
+
#
|
|
10
|
+
# Per the KSeF 2.0 docs:
|
|
11
|
+
# - The payload is `"{token}|{timestampMs}"`, where `timestampMs` comes
|
|
12
|
+
# from the `/auth/challenge` response.
|
|
13
|
+
# - Encryption is RSA-OAEP with SHA-256 (and MGF1-SHA-256).
|
|
14
|
+
# - The ciphertext is Base64-encoded for transport.
|
|
15
|
+
#
|
|
16
|
+
# The PEM-encoded RSA public key is supplied by the caller (typically
|
|
17
|
+
# fetched from `/security/public-key-certificates`).
|
|
18
|
+
module TokenEncryptor
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# @param token [String] raw KSeF integration token
|
|
22
|
+
# @param timestamp_ms [Integer] timestamp from the challenge response
|
|
23
|
+
# @param public_key_pem [String] PEM-encoded RSA public key
|
|
24
|
+
# @return [String] Base64-encoded ciphertext
|
|
25
|
+
def encrypt(token:, timestamp_ms:, public_key_pem:)
|
|
26
|
+
plaintext = "#{token}|#{timestamp_ms}"
|
|
27
|
+
key = OpenSSL::PKey::RSA.new(public_key_pem)
|
|
28
|
+
ciphertext = key.encrypt(
|
|
29
|
+
plaintext,
|
|
30
|
+
rsa_padding_mode: "oaep",
|
|
31
|
+
rsa_oaep_md: "sha256",
|
|
32
|
+
rsa_mgf1_md: "sha256"
|
|
33
|
+
)
|
|
34
|
+
Base64.strict_encode64(ciphertext)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Convenience wrapper that handles raw DER-encoded (base64) keys returned
|
|
38
|
+
# by the public-key endpoint, falling back to PEM if the input already
|
|
39
|
+
# contains BEGIN markers.
|
|
40
|
+
def normalize_public_key(raw)
|
|
41
|
+
return raw if raw.to_s.include?("BEGIN")
|
|
42
|
+
|
|
43
|
+
der = Base64.decode64(raw.to_s)
|
|
44
|
+
OpenSSL::PKey::RSA.new(der).to_pem
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module Ksef
|
|
6
|
+
# Lightweight value object describing an invoice as returned by
|
|
7
|
+
# `POST /invoices/query/metadata`. Built from a single element of the
|
|
8
|
+
# `invoices` array in the API response.
|
|
9
|
+
#
|
|
10
|
+
# The object intentionally exposes a small, business-meaningful slice of the
|
|
11
|
+
# full payload. The raw hash is preserved on {#raw} for advanced callers.
|
|
12
|
+
class InvoiceHeader
|
|
13
|
+
attr_reader :ksef_reference_number,
|
|
14
|
+
:invoice_number,
|
|
15
|
+
:issuer_nip, :issuer_name,
|
|
16
|
+
:recipient_nip, :recipient_name,
|
|
17
|
+
:issued_on, :acquired_at, :permanently_stored_at,
|
|
18
|
+
:net_amount, :gross_amount, :vat_amount, :currency,
|
|
19
|
+
:invoicing_mode, :invoice_type,
|
|
20
|
+
:form_code, :form_schema_version,
|
|
21
|
+
:self_invoicing, :has_attachment,
|
|
22
|
+
:invoice_hash, :raw
|
|
23
|
+
|
|
24
|
+
def initialize(raw)
|
|
25
|
+
@raw = raw
|
|
26
|
+
@ksef_reference_number = raw["ksefNumber"]
|
|
27
|
+
@invoice_number = raw["invoiceNumber"]
|
|
28
|
+
@issuer_nip = raw.dig("seller", "nip")
|
|
29
|
+
@issuer_name = raw.dig("seller", "name")
|
|
30
|
+
@recipient_nip = raw.dig("buyer", "identifier", "value")
|
|
31
|
+
@recipient_name = raw.dig("buyer", "name")
|
|
32
|
+
@issued_on = parse_date(raw["issueDate"])
|
|
33
|
+
@acquired_at = parse_time(raw["acquisitionDate"])
|
|
34
|
+
@permanently_stored_at = parse_time(raw["permanentStorageDate"])
|
|
35
|
+
@net_amount = raw["netAmount"]
|
|
36
|
+
@gross_amount = raw["grossAmount"]
|
|
37
|
+
@vat_amount = raw["vatAmount"]
|
|
38
|
+
@currency = raw["currency"]
|
|
39
|
+
@invoicing_mode = raw["invoicingMode"]
|
|
40
|
+
@invoice_type = raw["invoiceType"]
|
|
41
|
+
@form_code = raw.dig("formCode", "value")
|
|
42
|
+
@form_schema_version = raw.dig("formCode", "schemaVersion")
|
|
43
|
+
@self_invoicing = raw["isSelfInvoicing"]
|
|
44
|
+
@has_attachment = raw["hasAttachment"]
|
|
45
|
+
@invoice_hash = raw["invoiceHash"]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self_invoicing?
|
|
49
|
+
@self_invoicing == true
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def has_attachment?
|
|
53
|
+
@has_attachment == true
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def parse_date(value)
|
|
59
|
+
return nil if value.nil? || value.empty?
|
|
60
|
+
|
|
61
|
+
Date.iso8601(value)
|
|
62
|
+
rescue ArgumentError
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def parse_time(value)
|
|
67
|
+
return nil if value.nil? || value.empty?
|
|
68
|
+
|
|
69
|
+
Time.iso8601(value)
|
|
70
|
+
rescue ArgumentError
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ksef
|
|
4
|
+
# Inbound-invoice retrieval. All operations require an open session
|
|
5
|
+
# (see {Ksef::Sessions#with_interactive}).
|
|
6
|
+
class Invoices
|
|
7
|
+
SUBJECT_TYPE_MAP = {
|
|
8
|
+
issuer: "Subject1",
|
|
9
|
+
seller: "Subject1",
|
|
10
|
+
recipient: "Subject2",
|
|
11
|
+
buyer: "Subject2",
|
|
12
|
+
third: "Subject3",
|
|
13
|
+
subject_authorized: "SubjectAuthorized"
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
DATE_TYPE_MAP = {
|
|
17
|
+
issue: "Issue",
|
|
18
|
+
invoicing: "Invoicing",
|
|
19
|
+
permanent_storage: "PermanentStorage"
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
DEFAULT_PAGE_SIZE = 100
|
|
23
|
+
|
|
24
|
+
def initialize(client)
|
|
25
|
+
@client = client
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Queries invoice metadata.
|
|
29
|
+
#
|
|
30
|
+
# @param subject_type [Symbol] :recipient (default), :issuer, :third, :subject_authorized
|
|
31
|
+
# @param date_from [Time, DateTime, String] start of date range (inclusive)
|
|
32
|
+
# @param date_to [Time, DateTime, String, nil] end of date range, defaults to "now"
|
|
33
|
+
# @param date_type [Symbol] :permanent_storage (default), :invoicing, :issue
|
|
34
|
+
# @param page_size [Integer] 10..250
|
|
35
|
+
# @param page_offset [Integer]
|
|
36
|
+
# @param sort_order [String] "Asc" (default) or "Desc"
|
|
37
|
+
# @param extra_filters [Hash] additional InvoiceQueryFilters fields, passed through verbatim
|
|
38
|
+
# @return [Array<Ksef::InvoiceHeader>]
|
|
39
|
+
def query(subject_type: :recipient, date_from:, date_to: nil, date_type: :permanent_storage,
|
|
40
|
+
page_size: DEFAULT_PAGE_SIZE, page_offset: 0, sort_order: "Asc", extra_filters: {})
|
|
41
|
+
filters = {
|
|
42
|
+
"subjectType" => translate_subject(subject_type),
|
|
43
|
+
"dateRange" => {
|
|
44
|
+
"dateType" => translate_date_type(date_type),
|
|
45
|
+
"from" => to_iso8601(date_from)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
filters["dateRange"]["to"] = to_iso8601(date_to) if date_to
|
|
49
|
+
filters.merge!(stringify_keys(extra_filters))
|
|
50
|
+
|
|
51
|
+
response = require_session.connection.request(
|
|
52
|
+
:post,
|
|
53
|
+
"/invoices/query/metadata",
|
|
54
|
+
body: filters,
|
|
55
|
+
query: { "pageOffset" => page_offset, "pageSize" => page_size, "sortOrder" => sort_order },
|
|
56
|
+
bearer_token: current_access_token
|
|
57
|
+
)
|
|
58
|
+
body = Internal::Connection.parse_json(response)
|
|
59
|
+
Array(body["invoices"]).map { |raw| InvoiceHeader.new(raw) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Fetches the raw FA(3) XML for the invoice identified by `ksef_reference_number`.
|
|
63
|
+
# @return [String] XML document bytes
|
|
64
|
+
def fetch_xml(ksef_reference_number)
|
|
65
|
+
raise ArgumentError, "ksef_reference_number cannot be blank" if blank?(ksef_reference_number)
|
|
66
|
+
|
|
67
|
+
response = require_session.connection.request(
|
|
68
|
+
:get,
|
|
69
|
+
"/invoices/ksef/#{ksef_reference_number}",
|
|
70
|
+
headers: { "Accept" => "application/xml" },
|
|
71
|
+
bearer_token: current_access_token
|
|
72
|
+
)
|
|
73
|
+
response.body.to_s
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @note Not implemented in v0.1.0.
|
|
77
|
+
#
|
|
78
|
+
# KSeF 2.0 does not currently expose a server-rendered PDF/HTML
|
|
79
|
+
# visualisation of an invoice through the public API. The visualisation
|
|
80
|
+
# is produced client-side from the FA(3) XML using the official XSLT
|
|
81
|
+
# (`wizualizacja-faktury_v3-0.xsl`) shipped with the ksef-docs repo, or
|
|
82
|
+
# by combining the XML with a PDF rendering library (e.g. WeasyPrint,
|
|
83
|
+
# Puppeteer + the official HTML preview).
|
|
84
|
+
#
|
|
85
|
+
# @param _ksef_reference_number [String]
|
|
86
|
+
def fetch_visualisation(_ksef_reference_number)
|
|
87
|
+
raise NotImplementedError, <<~MSG
|
|
88
|
+
KSeF 2.0 has no public endpoint that returns a PDF visualisation of an
|
|
89
|
+
invoice. Generate it client-side from the XML retrieved via #fetch_xml
|
|
90
|
+
using the official XSLT (wizualizacja-faktury_v3-0.xsl) and your
|
|
91
|
+
preferred renderer. Tracked for a future ksef-rb release.
|
|
92
|
+
MSG
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @note Not implemented in v0.1.0.
|
|
96
|
+
#
|
|
97
|
+
# UPO (Urzędowe Poświadczenie Odbioru) downloads are scoped to the
|
|
98
|
+
# *sender* sessions that produced them (see `GET /sessions/{ref}/upo/...`).
|
|
99
|
+
# Recipient-side UPO retrieval is not available in v0.1.0; outbound
|
|
100
|
+
# issuance is also stubbed.
|
|
101
|
+
def fetch_upo(_ksef_reference_number)
|
|
102
|
+
raise NotImplementedError,
|
|
103
|
+
"UPO download is not implemented in ksef-rb v#{Ksef::VERSION}. " \
|
|
104
|
+
"Tracked alongside outbound invoice issuance."
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def translate_subject(symbol)
|
|
110
|
+
SUBJECT_TYPE_MAP.fetch(symbol) do
|
|
111
|
+
raise ArgumentError,
|
|
112
|
+
"Unknown subject_type #{symbol.inspect}. " \
|
|
113
|
+
"Expected one of: #{SUBJECT_TYPE_MAP.keys.inspect}"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def translate_date_type(symbol)
|
|
118
|
+
DATE_TYPE_MAP.fetch(symbol) do
|
|
119
|
+
raise ArgumentError,
|
|
120
|
+
"Unknown date_type #{symbol.inspect}. " \
|
|
121
|
+
"Expected one of: #{DATE_TYPE_MAP.keys.inspect}"
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def to_iso8601(value)
|
|
126
|
+
case value
|
|
127
|
+
when nil then nil
|
|
128
|
+
when String then value
|
|
129
|
+
when Date then value.iso8601
|
|
130
|
+
else value.utc.iso8601
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def stringify_keys(hash)
|
|
135
|
+
hash.each_with_object({}) { |(k, v), out| out[k.to_s] = v }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def require_session
|
|
139
|
+
session = @client.current_session
|
|
140
|
+
raise AuthError, "No active KSeF session. Call client.sessions.with_interactive { ... } first." if session.nil?
|
|
141
|
+
raise AuthError, "Session #{session.reference_number} has been terminated." if session.terminated?
|
|
142
|
+
|
|
143
|
+
@client
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def current_access_token
|
|
147
|
+
@client.current_session.access_token
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def blank?(value)
|
|
151
|
+
value.nil? || value.to_s.empty?
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
data/lib/ksef/session.rb
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ksef
|
|
4
|
+
# An open authenticated session against the KSeF API.
|
|
5
|
+
#
|
|
6
|
+
# Holds the access/refresh tokens minted by `/auth/token/redeem` together
|
|
7
|
+
# with the authentication operation's reference number. Instances are
|
|
8
|
+
# produced by {Ksef::Sessions#open} (or {#with_interactive}) and consumed
|
|
9
|
+
# by resource classes through {Ksef::Client#current_session}.
|
|
10
|
+
class Session
|
|
11
|
+
attr_reader :reference_number,
|
|
12
|
+
:access_token, :access_token_valid_until,
|
|
13
|
+
:refresh_token, :refresh_token_valid_until
|
|
14
|
+
|
|
15
|
+
def initialize(reference_number:, access_token:, refresh_token:,
|
|
16
|
+
access_token_valid_until: nil, refresh_token_valid_until: nil)
|
|
17
|
+
@reference_number = reference_number
|
|
18
|
+
@access_token = access_token
|
|
19
|
+
@refresh_token = refresh_token
|
|
20
|
+
@access_token_valid_until = access_token_valid_until
|
|
21
|
+
@refresh_token_valid_until = refresh_token_valid_until
|
|
22
|
+
@terminated = false
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def terminated?
|
|
26
|
+
@terminated
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Marks the session as closed locally. Network teardown is performed by
|
|
30
|
+
# {Ksef::Sessions#terminate}.
|
|
31
|
+
def mark_terminated!
|
|
32
|
+
@terminated = true
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def to_s
|
|
36
|
+
"#<Ksef::Session reference_number=#{@reference_number.inspect} " \
|
|
37
|
+
"terminated=#{@terminated}>"
|
|
38
|
+
end
|
|
39
|
+
alias inspect to_s
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ksef
|
|
4
|
+
# Manages the KSeF interactive session lifecycle:
|
|
5
|
+
#
|
|
6
|
+
# 1. `POST /auth/challenge` → challenge + timestamp
|
|
7
|
+
# 2. encrypt the integration token with the KSeF public RSA key
|
|
8
|
+
# 3. `POST /auth/ksef-token` → authentication operation token
|
|
9
|
+
# 4. poll `GET /auth/{ref}` → wait for status = success
|
|
10
|
+
# 5. `POST /auth/token/redeem` → access + refresh tokens
|
|
11
|
+
# 6. on close: `DELETE /auth/sessions/current`
|
|
12
|
+
#
|
|
13
|
+
# The most common entry point is {#with_interactive}, which sets up the
|
|
14
|
+
# session, yields it to the caller's block, and tears it down on exit.
|
|
15
|
+
class Sessions
|
|
16
|
+
AUTH_SUCCESS_STATUS = 200
|
|
17
|
+
AUTH_PENDING_STATUS = 100
|
|
18
|
+
DEFAULT_POLL_INTERVAL = 1.0
|
|
19
|
+
DEFAULT_POLL_TIMEOUT = 60
|
|
20
|
+
|
|
21
|
+
def initialize(client)
|
|
22
|
+
@client = client
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Opens a session, yields it, and ensures it is terminated.
|
|
26
|
+
#
|
|
27
|
+
# @yield [Ksef::Session]
|
|
28
|
+
# @return whatever the block returns
|
|
29
|
+
def with_interactive(**opts)
|
|
30
|
+
session = open(**opts)
|
|
31
|
+
@client.current_session = session
|
|
32
|
+
begin
|
|
33
|
+
yield session
|
|
34
|
+
ensure
|
|
35
|
+
terminate(session) unless session.terminated?
|
|
36
|
+
@client.current_session = nil
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Acquires a new authenticated session. Callers can then attach it via
|
|
41
|
+
# {Ksef::Client#current_session=} if they prefer manual management.
|
|
42
|
+
#
|
|
43
|
+
# @param poll_interval [Float] seconds between status polls
|
|
44
|
+
# @param poll_timeout [Integer] total seconds to wait for auth completion
|
|
45
|
+
# @return [Ksef::Session]
|
|
46
|
+
def open(poll_interval: DEFAULT_POLL_INTERVAL, poll_timeout: DEFAULT_POLL_TIMEOUT)
|
|
47
|
+
credentials = @client.credentials
|
|
48
|
+
case credentials
|
|
49
|
+
when Credentials::Token
|
|
50
|
+
open_with_token(credentials, poll_interval: poll_interval, poll_timeout: poll_timeout)
|
|
51
|
+
else
|
|
52
|
+
raise NotImplementedError,
|
|
53
|
+
"Only Ksef::Credentials::Token is supported in v#{Ksef::VERSION}"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Closes the session by calling `DELETE /auth/sessions/current`.
|
|
58
|
+
def terminate(session)
|
|
59
|
+
return if session.terminated?
|
|
60
|
+
|
|
61
|
+
@client.connection.request(
|
|
62
|
+
:delete,
|
|
63
|
+
"/auth/sessions/current",
|
|
64
|
+
bearer_token: session.access_token
|
|
65
|
+
)
|
|
66
|
+
session.mark_terminated!
|
|
67
|
+
session
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Fetches the freshest public-key certificate suitable for token encryption.
|
|
71
|
+
# Cached for the lifetime of the Sessions instance.
|
|
72
|
+
def public_key_for_token_encryption
|
|
73
|
+
@public_key_for_token_encryption ||= fetch_public_key
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def open_with_token(token_credentials, poll_interval:, poll_timeout:)
|
|
79
|
+
challenge_data = fetch_challenge
|
|
80
|
+
key_info = public_key_for_token_encryption
|
|
81
|
+
|
|
82
|
+
encrypted = Internal::TokenEncryptor.encrypt(
|
|
83
|
+
token: token_credentials.value,
|
|
84
|
+
timestamp_ms: challenge_data.fetch("timestampMs"),
|
|
85
|
+
public_key_pem: key_info[:pem]
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
init_response = init_with_token(
|
|
89
|
+
challenge: challenge_data.fetch("challenge"),
|
|
90
|
+
encrypted_token: encrypted,
|
|
91
|
+
public_key_id: key_info[:id]
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
reference_number = init_response.fetch("referenceNumber")
|
|
95
|
+
authentication_token = init_response.fetch("authenticationToken").fetch("token")
|
|
96
|
+
|
|
97
|
+
wait_for_authentication(reference_number, authentication_token,
|
|
98
|
+
poll_interval: poll_interval, poll_timeout: poll_timeout)
|
|
99
|
+
|
|
100
|
+
redeem_tokens(reference_number, authentication_token)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def fetch_challenge
|
|
104
|
+
response = @client.connection.request(:post, "/auth/challenge")
|
|
105
|
+
Internal::Connection.parse_json(response)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def init_with_token(challenge:, encrypted_token:, public_key_id:)
|
|
109
|
+
body = {
|
|
110
|
+
"challenge" => challenge,
|
|
111
|
+
"contextIdentifier" => { "type" => "Nip", "value" => @client.nip },
|
|
112
|
+
"encryptedToken" => encrypted_token
|
|
113
|
+
}
|
|
114
|
+
body["publicKeyId"] = public_key_id if public_key_id
|
|
115
|
+
|
|
116
|
+
response = @client.connection.request(:post, "/auth/ksef-token", body: body)
|
|
117
|
+
Internal::Connection.parse_json(response)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def wait_for_authentication(reference_number, authentication_token,
|
|
121
|
+
poll_interval:, poll_timeout:)
|
|
122
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + poll_timeout
|
|
123
|
+
|
|
124
|
+
loop do
|
|
125
|
+
response = @client.connection.request(
|
|
126
|
+
:get,
|
|
127
|
+
"/auth/#{reference_number}",
|
|
128
|
+
bearer_token: authentication_token
|
|
129
|
+
)
|
|
130
|
+
body = Internal::Connection.parse_json(response)
|
|
131
|
+
status = body.dig("status", "code")
|
|
132
|
+
|
|
133
|
+
return if status == AUTH_SUCCESS_STATUS
|
|
134
|
+
|
|
135
|
+
if status && status != AUTH_PENDING_STATUS
|
|
136
|
+
raise AuthError.new(
|
|
137
|
+
"KSeF authentication failed: #{body.dig("status", "description")}",
|
|
138
|
+
status: status,
|
|
139
|
+
body: body,
|
|
140
|
+
code: status
|
|
141
|
+
)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
145
|
+
raise AuthError, "Timed out waiting for KSeF authentication (ref=#{reference_number})"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
sleep poll_interval
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def redeem_tokens(reference_number, authentication_token)
|
|
153
|
+
response = @client.connection.request(
|
|
154
|
+
:post,
|
|
155
|
+
"/auth/token/redeem",
|
|
156
|
+
bearer_token: authentication_token
|
|
157
|
+
)
|
|
158
|
+
body = Internal::Connection.parse_json(response)
|
|
159
|
+
|
|
160
|
+
access = body.fetch("accessToken")
|
|
161
|
+
refresh = body.fetch("refreshToken")
|
|
162
|
+
|
|
163
|
+
Session.new(
|
|
164
|
+
reference_number: reference_number,
|
|
165
|
+
access_token: access.fetch("token"),
|
|
166
|
+
access_token_valid_until: access["validUntil"],
|
|
167
|
+
refresh_token: refresh.fetch("token"),
|
|
168
|
+
refresh_token_valid_until: refresh["validUntil"]
|
|
169
|
+
)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def fetch_public_key
|
|
173
|
+
response = @client.connection.request(:get, "/security/public-key-certificates")
|
|
174
|
+
list = Internal::Connection.parse_json(response)
|
|
175
|
+
list = list["items"] if list.is_a?(Hash) && list.key?("items")
|
|
176
|
+
|
|
177
|
+
candidate = Array(list).find do |entry|
|
|
178
|
+
Array(entry["usage"]).include?("KsefTokenEncryption")
|
|
179
|
+
end || Array(list).first
|
|
180
|
+
|
|
181
|
+
raise AuthError, "No public-key certificate available for KSeF token encryption" if candidate.nil?
|
|
182
|
+
|
|
183
|
+
{
|
|
184
|
+
id: candidate["publicKeyId"],
|
|
185
|
+
pem: Internal::TokenEncryptor.normalize_public_key(candidate["certificate"])
|
|
186
|
+
}
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
data/lib/ksef/version.rb
ADDED
data/lib/ksef.rb
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ksef/version"
|
|
4
|
+
require_relative "ksef/errors"
|
|
5
|
+
require_relative "ksef/configuration"
|
|
6
|
+
require_relative "ksef/credentials/token"
|
|
7
|
+
require_relative "ksef/credentials/certificate"
|
|
8
|
+
require_relative "ksef/internal/connection"
|
|
9
|
+
require_relative "ksef/internal/token_encryptor"
|
|
10
|
+
require_relative "ksef/session"
|
|
11
|
+
require_relative "ksef/sessions"
|
|
12
|
+
require_relative "ksef/invoice_header"
|
|
13
|
+
require_relative "ksef/invoices"
|
|
14
|
+
require_relative "ksef/client"
|
|
15
|
+
|
|
16
|
+
# Ruby client for the Polish KSeF 2.0 (Krajowy System e-Faktur) API.
|
|
17
|
+
#
|
|
18
|
+
# @example Global configuration
|
|
19
|
+
# Ksef.configure do |c|
|
|
20
|
+
# c.environment = :test
|
|
21
|
+
# c.user_agent = "Pro Bau / ksef-rb #{Ksef::VERSION}"
|
|
22
|
+
# end
|
|
23
|
+
module Ksef
|
|
24
|
+
class << self
|
|
25
|
+
# @return [Ksef::Configuration]
|
|
26
|
+
def configuration
|
|
27
|
+
@configuration ||= Configuration.new
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Yields the singleton {Configuration} for in-place mutation.
|
|
31
|
+
def configure
|
|
32
|
+
yield(configuration)
|
|
33
|
+
configuration
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Resets the global configuration (primarily for tests).
|
|
37
|
+
# @api private
|
|
38
|
+
def reset_configuration!
|
|
39
|
+
@configuration = Configuration.new
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Namespace for implementation details. Anything under {Ksef::Internal} is
|
|
44
|
+
# not part of the supported public API and may change without notice.
|
|
45
|
+
module Internal; end
|
|
46
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ksef-rb
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Michał Siwek
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: base64
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.2'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.2'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: faraday
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '2.0'
|
|
33
|
+
- - "<"
|
|
34
|
+
- !ruby/object:Gem::Version
|
|
35
|
+
version: '3.0'
|
|
36
|
+
type: :runtime
|
|
37
|
+
prerelease: false
|
|
38
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
39
|
+
requirements:
|
|
40
|
+
- - ">="
|
|
41
|
+
- !ruby/object:Gem::Version
|
|
42
|
+
version: '2.0'
|
|
43
|
+
- - "<"
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: '3.0'
|
|
46
|
+
- !ruby/object:Gem::Dependency
|
|
47
|
+
name: faraday-retry
|
|
48
|
+
requirement: !ruby/object:Gem::Requirement
|
|
49
|
+
requirements:
|
|
50
|
+
- - ">="
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: '2.0'
|
|
53
|
+
- - "<"
|
|
54
|
+
- !ruby/object:Gem::Version
|
|
55
|
+
version: '3.0'
|
|
56
|
+
type: :runtime
|
|
57
|
+
prerelease: false
|
|
58
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
59
|
+
requirements:
|
|
60
|
+
- - ">="
|
|
61
|
+
- !ruby/object:Gem::Version
|
|
62
|
+
version: '2.0'
|
|
63
|
+
- - "<"
|
|
64
|
+
- !ruby/object:Gem::Version
|
|
65
|
+
version: '3.0'
|
|
66
|
+
- !ruby/object:Gem::Dependency
|
|
67
|
+
name: nokogiri
|
|
68
|
+
requirement: !ruby/object:Gem::Requirement
|
|
69
|
+
requirements:
|
|
70
|
+
- - ">="
|
|
71
|
+
- !ruby/object:Gem::Version
|
|
72
|
+
version: '1.15'
|
|
73
|
+
type: :runtime
|
|
74
|
+
prerelease: false
|
|
75
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
76
|
+
requirements:
|
|
77
|
+
- - ">="
|
|
78
|
+
- !ruby/object:Gem::Version
|
|
79
|
+
version: '1.15'
|
|
80
|
+
description: |
|
|
81
|
+
A Ruby client for the Polish National e-Invoicing System (KSeF 2.0).
|
|
82
|
+
Targets the FA(3) schema, supports token-based authentication, interactive
|
|
83
|
+
sessions, and inbound invoice retrieval (metadata, XML, and visualisation).
|
|
84
|
+
Pure Ruby with no Rails dependency.
|
|
85
|
+
email:
|
|
86
|
+
- michal.siwek@shape.care
|
|
87
|
+
executables: []
|
|
88
|
+
extensions: []
|
|
89
|
+
extra_rdoc_files: []
|
|
90
|
+
files:
|
|
91
|
+
- CHANGELOG.md
|
|
92
|
+
- LICENSE.txt
|
|
93
|
+
- README.md
|
|
94
|
+
- lib/ksef.rb
|
|
95
|
+
- lib/ksef/client.rb
|
|
96
|
+
- lib/ksef/configuration.rb
|
|
97
|
+
- lib/ksef/credentials/certificate.rb
|
|
98
|
+
- lib/ksef/credentials/token.rb
|
|
99
|
+
- lib/ksef/errors.rb
|
|
100
|
+
- lib/ksef/internal/connection.rb
|
|
101
|
+
- lib/ksef/internal/token_encryptor.rb
|
|
102
|
+
- lib/ksef/invoice_header.rb
|
|
103
|
+
- lib/ksef/invoices.rb
|
|
104
|
+
- lib/ksef/session.rb
|
|
105
|
+
- lib/ksef/sessions.rb
|
|
106
|
+
- lib/ksef/version.rb
|
|
107
|
+
homepage: https://github.com/skycocker/ksef-rb
|
|
108
|
+
licenses:
|
|
109
|
+
- MIT
|
|
110
|
+
metadata:
|
|
111
|
+
homepage_uri: https://github.com/skycocker/ksef-rb
|
|
112
|
+
source_code_uri: https://github.com/skycocker/ksef-rb
|
|
113
|
+
changelog_uri: https://github.com/skycocker/ksef-rb/blob/main/CHANGELOG.md
|
|
114
|
+
bug_tracker_uri: https://github.com/skycocker/ksef-rb/issues
|
|
115
|
+
rubygems_mfa_required: 'true'
|
|
116
|
+
rdoc_options: []
|
|
117
|
+
require_paths:
|
|
118
|
+
- lib
|
|
119
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
120
|
+
requirements:
|
|
121
|
+
- - ">="
|
|
122
|
+
- !ruby/object:Gem::Version
|
|
123
|
+
version: 3.2.0
|
|
124
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
125
|
+
requirements:
|
|
126
|
+
- - ">="
|
|
127
|
+
- !ruby/object:Gem::Version
|
|
128
|
+
version: '0'
|
|
129
|
+
requirements: []
|
|
130
|
+
rubygems_version: 4.0.3
|
|
131
|
+
specification_version: 4
|
|
132
|
+
summary: Ruby client for the Polish KSeF 2.0 (Krajowy System e-Faktur) API
|
|
133
|
+
test_files: []
|