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 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).
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ksef
4
+ VERSION = "0.1.0"
5
+ end
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: []