ksef_client 0.1.0.rc1

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.
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Ksef
6
+ # The three public KSeF API 2.0 environments.
7
+ #
8
+ # Every base URL here was read from that environment's *own* OpenAPI document
9
+ # (`servers[0].url`, served at `https://<host>/docs/v2/openapi.json`) on 2026-08-21,
10
+ # rather than inferred by analogy. See docs/REFERENCE.md §2. Do not edit a URL without
11
+ # re-verifying it against that source and updating the ledger.
12
+ #
13
+ # Note the base URL already carries `/v2`; endpoint paths are appended bare. There is
14
+ # no `/api` segment (docs/REFERENCE.md §7.2).
15
+ module Environments
16
+ Environment = Data.define(:name, :base_url, :supports_test_data) do
17
+ # TEST alone exposes the `/testdata/*` helper API and `/collective-identifiers*`.
18
+ # Code touching those paths must be guarded on this.
19
+ def test_data_api? = supports_test_data
20
+
21
+ def production? = name == :prod
22
+ end
23
+
24
+ TEST = Environment.new(
25
+ name: :test,
26
+ base_url: "https://api-test.ksef.mf.gov.pl/v2",
27
+ supports_test_data: true
28
+ )
29
+
30
+ DEMO = Environment.new(
31
+ name: :demo,
32
+ base_url: "https://api-demo.ksef.mf.gov.pl/v2",
33
+ supports_test_data: false
34
+ )
35
+
36
+ PROD = Environment.new(
37
+ name: :prod,
38
+ base_url: "https://api.ksef.mf.gov.pl/v2",
39
+ supports_test_data: false
40
+ )
41
+
42
+ ALL = { test: TEST, demo: DEMO, prod: PROD }.freeze
43
+ NAMES = ALL.keys.freeze
44
+
45
+ class << self
46
+ # @param env [Symbol, String, Environment]
47
+ # @return [Environment]
48
+ # @raise [Ksef::ConfigurationError] on an unknown environment
49
+ def fetch(env)
50
+ return env if env.is_a?(Environment)
51
+
52
+ key = env.respond_to?(:to_sym) ? env.to_sym : nil
53
+ ALL.fetch(key) do
54
+ raise ConfigurationError,
55
+ "Unknown KSeF environment #{env.inspect}. Known: #{NAMES.map(&:inspect).join(", ")}. " \
56
+ "For a non-public deployment, pass Ksef::Environments.custom(base_url: ...)."
57
+ end
58
+ end
59
+
60
+ # Escape hatch for deployments not covered by the three published environments
61
+ # (DESIGN.md §6.1).
62
+ #
63
+ # @param base_url [String] must be HTTPS, and should include the `/v2` suffix
64
+ # @return [Environment]
65
+ def custom(base_url:, name: :custom, supports_test_data: false)
66
+ uri = begin
67
+ URI.parse(base_url)
68
+ rescue URI::InvalidURIError => e
69
+ raise ConfigurationError, "Invalid base_url #{base_url.inspect}: #{e.message}"
70
+ end
71
+
72
+ unless uri.is_a?(URI::HTTPS)
73
+ raise ConfigurationError,
74
+ "base_url must be HTTPS, got #{base_url.inspect}. TLS is not optional (DESIGN.md §4.5)."
75
+ end
76
+
77
+ Environment.new(
78
+ name: name.to_sym,
79
+ base_url: base_url.chomp("/"),
80
+ supports_test_data: supports_test_data
81
+ )
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The error hierarchy from DESIGN.md §6.7, extended with the 403 and 410 branches the
4
+ # API actually returns (docs/REFERENCE.md §7.4).
5
+ #
6
+ # This file defines many constants and no `Ksef::Errors`, so Zeitwerk cannot manage it —
7
+ # `lib/ksef.rb` ignores it and requires it eagerly.
8
+ module Ksef
9
+ # Base class for everything this gem raises. Rescue this to catch all of it.
10
+ #
11
+ # `#problem` carries the parsed KSeF error body when the error came from an API
12
+ # response, and is nil for locally-raised errors.
13
+ class Error < StandardError
14
+ # @return [Ksef::ProblemDetails, nil]
15
+ attr_reader :problem
16
+
17
+ def initialize(message = nil, problem: nil)
18
+ @problem = problem
19
+ super(message)
20
+ end
21
+ end
22
+
23
+ # Invalid or missing client configuration. Raised locally, before any request.
24
+ class ConfigurationError < Error; end
25
+
26
+ # Challenge, KSeF-token, signature or JWT problems, including HTTP 401.
27
+ class AuthenticationError < Error; end
28
+
29
+ # Raised locally by the FA(3) validator (DESIGN.md §7.7), never by the transport layer.
30
+ class ValidationError < Error; end
31
+
32
+ # Any error response from the KSeF API.
33
+ class ApiError < Error
34
+ # @return [Integer, nil] HTTP status
35
+ def status = problem&.status
36
+
37
+ # @return [Integer, nil] the first KSeF error code, when the body carried one
38
+ def code = problem&.code
39
+
40
+ # @return [Array<String>] flattened detail messages
41
+ def details = problem&.details || []
42
+
43
+ # @return [String, nil] the Ministry's correlation id — quote this in support requests
44
+ def trace_id = problem&.trace_id
45
+
46
+ # @return [Object, nil] the undecoded response body
47
+ def raw = problem&.raw
48
+ end
49
+
50
+ # The invoice was rejected by KSeF on schema or business grounds.
51
+ class InvoiceRejectedError < ApiError; end
52
+
53
+ # A session could not be opened, used or closed.
54
+ class SessionError < ApiError; end
55
+
56
+ # HTTP 403. Carries a structured reason (docs/REFERENCE.md §5.3) rather than just prose.
57
+ class AuthorizationError < ApiError
58
+ # @return [String, nil] one of `missing-permissions`, `ip-not-allowed`,
59
+ # `insufficient-resource-access`, `auth-method-not-allowed`,
60
+ # `security-service-blocked`, `context-type-not-allowed`
61
+ def reason_code = problem&.reason_code
62
+
63
+ # @return [Hash] reason-dependent payload, e.g. `requiredAnyOfPermissions`
64
+ def security = problem&.security || {}
65
+ end
66
+
67
+ # HTTP 410. The resource existed but is no longer available.
68
+ class ResourceGoneError < ApiError; end
69
+
70
+ # HTTP 429. Retryable, but only for idempotent requests (DESIGN.md §6.7).
71
+ class RateLimitedError < ApiError
72
+ # @return [Integer, nil] seconds to wait, from the `Retry-After` header.
73
+ # Authoritative — the block period is dynamic and lengthens with repeat offences
74
+ # (docs/REFERENCE.md §6). Never work around a 429 by rotating IPs.
75
+ attr_reader :retry_after
76
+
77
+ def initialize(message = nil, problem: nil, retry_after: nil)
78
+ @retry_after = retry_after
79
+ super(message, problem: problem)
80
+ end
81
+ end
82
+
83
+ # HTTP 5xx. Not declared anywhere in the OpenAPI contract (docs/REFERENCE.md §5.4), so
84
+ # the body shape is unknown and `#problem` may hold only a raw payload.
85
+ class ServerError < ApiError; end
86
+
87
+ # The request exceeded the configured open or read timeout.
88
+ class TimeoutError < Error; end
89
+
90
+ # The connection failed, TLS included.
91
+ class ConnectionError < Error; end
92
+ end
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Ministerstwo Finansów
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.
@@ -0,0 +1 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>