ksef-client-rails 0.1.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +161 -0
  3. data/NOTICE +40 -0
  4. data/README.md +524 -6
  5. data/examples/fa3_full.xml +143 -0
  6. data/examples/fa3_minimal.xml +65 -0
  7. data/examples/fa3_typical.xml +76 -0
  8. data/lib/generators/ksef/install/install_generator.rb +20 -0
  9. data/lib/generators/ksef/install/templates/ksef.rb +25 -0
  10. data/lib/ksef/auth/coordinator.rb +164 -0
  11. data/lib/ksef/auth/ksef_token_encryptor.rb +36 -0
  12. data/lib/ksef/batch/package_builder.rb +125 -0
  13. data/lib/ksef/batch/splitter.rb +99 -0
  14. data/lib/ksef/batch/uploader.rb +114 -0
  15. data/lib/ksef/client.rb +135 -0
  16. data/lib/ksef/configuration.rb +206 -0
  17. data/lib/ksef/crypto/cryptography_service.rb +136 -0
  18. data/lib/ksef/crypto/csr.rb +106 -0
  19. data/lib/ksef/crypto/public_keys.rb +125 -0
  20. data/lib/ksef/environments.rb +61 -0
  21. data/lib/ksef/errors.rb +187 -0
  22. data/lib/ksef/http/connection.rb +432 -0
  23. data/lib/ksef/http/error_parser.rb +194 -0
  24. data/lib/ksef/http/pagination.rb +80 -0
  25. data/lib/ksef/invoices/builder/checks.rb +127 -0
  26. data/lib/ksef/invoices/builder/number.rb +67 -0
  27. data/lib/ksef/invoices/builder/xml.rb +256 -0
  28. data/lib/ksef/invoices/builder.rb +251 -0
  29. data/lib/ksef/invoices/export.rb +241 -0
  30. data/lib/ksef/invoices/sample.rb +78 -0
  31. data/lib/ksef/invoices/schema_outline.rb +120 -0
  32. data/lib/ksef/invoices/validator.rb +200 -0
  33. data/lib/ksef/models/form_code.rb +49 -0
  34. data/lib/ksef/models/ksef_number.rb +74 -0
  35. data/lib/ksef/models/nip.rb +62 -0
  36. data/lib/ksef/polish_time.rb +49 -0
  37. data/lib/ksef/polling.rb +65 -0
  38. data/lib/ksef/qr/code.rb +104 -0
  39. data/lib/ksef/qr/verification_link.rb +215 -0
  40. data/lib/ksef/railtie.rb +20 -0
  41. data/lib/ksef/resources/auth.rb +116 -0
  42. data/lib/ksef/resources/base.rb +120 -0
  43. data/lib/ksef/resources/batch_sessions.rb +43 -0
  44. data/lib/ksef/resources/certificates.rb +82 -0
  45. data/lib/ksef/resources/collective_identifiers.rb +70 -0
  46. data/lib/ksef/resources/exports.rb +47 -0
  47. data/lib/ksef/resources/invoices.rb +67 -0
  48. data/lib/ksef/resources/limits.rb +33 -0
  49. data/lib/ksef/resources/online_sessions.rb +62 -0
  50. data/lib/ksef/resources/peppol.rb +21 -0
  51. data/lib/ksef/resources/permissions.rb +180 -0
  52. data/lib/ksef/resources/security.rb +18 -0
  53. data/lib/ksef/resources/sessions.rb +125 -0
  54. data/lib/ksef/resources/test_data.rb +178 -0
  55. data/lib/ksef/resources/tokens.rb +62 -0
  56. data/lib/ksef/sessions/online.rb +168 -0
  57. data/lib/ksef/sync/incremental.rb +169 -0
  58. data/lib/ksef/testing.rb +153 -0
  59. data/lib/ksef/token_manager.rb +130 -0
  60. data/lib/ksef/version.rb +11 -0
  61. data/lib/ksef/xades/auth_token_request.rb +135 -0
  62. data/lib/ksef/xades/external_signer.rb +58 -0
  63. data/lib/ksef/xades/signer.rb +312 -0
  64. data/lib/ksef-client-rails.rb +3 -2
  65. data/lib/ksef.rb +116 -0
  66. data/schemas/FA/schemat_FA(2)_v1-0E.xsd +3661 -0
  67. data/schemas/FA/schemat_FA(3)_v1-0E.xsd +3950 -0
  68. data/schemas/README.md +53 -0
  69. data/schemas/RR/schemat_FA_RR(1)_v1-1E.xsd +2188 -0
  70. data/schemas/bazowe/ElementarneTypyDanych_v10-0E.xsd +1 -0
  71. data/schemas/bazowe/KodyKrajow_v10-0E.xsd +1283 -0
  72. data/schemas/bazowe/StrukturyDanych_v10-0E.xsd +1 -0
  73. metadata +291 -9
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "base64"
5
+
6
+ module Ksef
7
+ module Crypto
8
+ # PKCS#10 CSR generator for KSeF certificate enrollment.
9
+ #
10
+ # The DN comes from `GET /certificates/enrollments/data`; RDN order and ASN.1 types mirror C#
11
+ # `CreateSubjectDistinguishedName` (see {SUBJECT_FIELDS}). RSA signing uses PKCS#1 v1.5
12
+ # (Java's choice, not C#'s PSS); EC P-256 is the recommended key.
13
+ module Csr
14
+ # RDN order: [OID, response field method, ASN.1 type].
15
+ SUBJECT_FIELDS = [
16
+ ["2.5.4.3", "commonName", OpenSSL::ASN1::UTF8STRING],
17
+ ["2.5.4.4", "surname", OpenSSL::ASN1::UTF8STRING],
18
+ ["2.5.4.42", "givenName", OpenSSL::ASN1::UTF8STRING],
19
+ ["2.5.4.10", "organizationName", OpenSSL::ASN1::UTF8STRING],
20
+ ["2.5.4.97", "organizationIdentifier", OpenSSL::ASN1::UTF8STRING],
21
+ ["2.5.4.6", "countryName", OpenSSL::ASN1::PRINTABLESTRING],
22
+ ["2.5.4.5", "serialNumber", OpenSSL::ASN1::PRINTABLESTRING],
23
+ ["2.5.4.45", "uniqueIdentifier", OpenSSL::ASN1::UTF8STRING]
24
+ ].freeze
25
+
26
+ # CSR generation result: the request plus the private key (both Base64/PEM).
27
+ Result = Struct.new(:csr_der_base64, :private_key, keyword_init: true) do
28
+ # @return [String] private key in PEM format
29
+ def private_key_pem
30
+ private_key.to_pem
31
+ end
32
+
33
+ # @return [String] private key in DER, Base64-encoded (as in the SDKs)
34
+ def private_key_der_base64
35
+ Base64.strict_encode64(private_key.to_der)
36
+ end
37
+
38
+ # The private key never goes to error pages, `pp` output or exception reports.
39
+ # @return [String]
40
+ def inspect
41
+ "#<#{self.class.name} csr_der_base64=#{csr_der_base64.inspect} private_key=[REDACTED]>"
42
+ end
43
+ alias_method :to_s, :inspect
44
+
45
+ # `pp` and IRB print through #pretty_print, which Struct implements without calling #inspect.
46
+ def pretty_print(printer)
47
+ printer.text(inspect)
48
+ end
49
+ end
50
+
51
+ module_function
52
+
53
+ # Generates a CSR with an RSA-2048 key (SHA256 PKCS#1 v1.5 signature).
54
+ # @param enrollment_data [Hash] the /certificates/enrollments/data response
55
+ # @return [Result]
56
+ def generate_rsa(enrollment_data)
57
+ key = OpenSSL::PKey::RSA.new(2048)
58
+ build_request(enrollment_data, key)
59
+ end
60
+
61
+ # Generates a CSR with an EC P-256 key (recommended; ECDSA-SHA256 signature).
62
+ # @param enrollment_data [Hash] the /certificates/enrollments/data response
63
+ # @return [Result]
64
+ def generate_ecdsa(enrollment_data)
65
+ key = OpenSSL::PKey::EC.generate("prime256v1")
66
+ build_request(enrollment_data, key)
67
+ end
68
+
69
+ # Builds and signs the CSR with the given key.
70
+ # @param enrollment_data [Hash]
71
+ # @param key [OpenSSL::PKey::PKey]
72
+ # @return [Result]
73
+ def build_request(enrollment_data, key)
74
+ request = OpenSSL::X509::Request.new
75
+ request.version = 0
76
+ request.subject = build_subject(enrollment_data)
77
+ request.public_key = public_key_of(key)
78
+ request.sign(key, OpenSSL::Digest.new("SHA256"))
79
+
80
+ Result.new(csr_der_base64: Base64.strict_encode64(request.to_der), private_key: key)
81
+ end
82
+
83
+ # Builds the DN preserving order and ASN.1 types (empty fields are skipped).
84
+ # @param data [Hash]
85
+ # @return [OpenSSL::X509::Name]
86
+ def build_subject(data)
87
+ name = OpenSSL::X509::Name.new
88
+ SUBJECT_FIELDS.each do |oid, field, asn1_type|
89
+ value = data[field] || data[field.to_sym]
90
+ next if value.nil? || value.to_s.empty?
91
+
92
+ name.add_entry(oid, value.to_s, asn1_type)
93
+ end
94
+ name
95
+ end
96
+
97
+ # Extracts the public key (SPKI) regardless of type (RSA/EC).
98
+ # On OpenSSL 3.0 keys are immutable, so we round-trip through DER SubjectPublicKeyInfo.
99
+ # @param key [OpenSSL::PKey::PKey]
100
+ # @return [OpenSSL::PKey::PKey] a key holding only the public part
101
+ def public_key_of(key)
102
+ OpenSSL::PKey.read(key.public_to_der)
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "base64"
5
+ require "time"
6
+
7
+ module Ksef
8
+ module Crypto
9
+ # Fetches, caches and selects the Ministry of Finance public-key certificates by usage
10
+ # (`GET /security/public-key-certificates`, anonymous). {#with_rotation_retry} refreshes them
11
+ # on error 21470 — both SDKs leave that refresh to the caller.
12
+ class PublicKeys
13
+ # Certificate usages (values of the PublicKeyCertificateUsage enum).
14
+ SYMMETRIC_KEY_ENCRYPTION = "SymmetricKeyEncryption"
15
+ KSEF_TOKEN_ENCRYPTION = "KsefTokenEncryption"
16
+ # API error code: the data was encrypted under a certificate that has since been rotated.
17
+ KEY_ROTATED = 21_470
18
+
19
+ ENDPOINT = "/security/public-key-certificates"
20
+
21
+ # A single Ministry of Finance public-key certificate.
22
+ Certificate = Struct.new(:certificate_der, :certificate_id, :public_key_id,
23
+ :valid_from, :valid_to, :usages, keyword_init: true) do
24
+ # @param usage [String]
25
+ # @return [Boolean] whether the certificate may be used for the given usage
26
+ def usable_for?(usage)
27
+ usages.include?(usage)
28
+ end
29
+
30
+ # @param time [Time]
31
+ # @return [Boolean] whether the certificate is valid at the given moment
32
+ def valid_at?(time)
33
+ valid_from <= time && time < valid_to
34
+ end
35
+
36
+ # @return [OpenSSL::X509::Certificate] parsed certificate (memoized)
37
+ def openssl_certificate
38
+ @openssl_certificate ||= OpenSSL::X509::Certificate.new(certificate_der)
39
+ end
40
+
41
+ # @return [OpenSSL::PKey::PKey] public key (RSA or EC)
42
+ def public_key
43
+ openssl_certificate.public_key
44
+ end
45
+ end
46
+
47
+ # @param connection [Ksef::Http::Connection] HTTP connection (anonymous call)
48
+ # @param logger [Logger, nil]
49
+ # @param clock [#call] time source (for tests); defaults to -> { Time.now }
50
+ def initialize(connection:, logger: nil, clock: -> { Time.now })
51
+ @connection = connection
52
+ @logger = logger
53
+ @clock = clock
54
+ @cache = nil
55
+ @mutex = Mutex.new
56
+ end
57
+
58
+ # Returns the certificate list (cached; fetched on first call or when +force+).
59
+ # @param force [Boolean] force a refetch
60
+ # @return [Array<Certificate>]
61
+ def all(force: false)
62
+ @mutex.synchronize do
63
+ @cache = fetch_certificates if force || @cache.nil?
64
+ @cache
65
+ end
66
+ end
67
+
68
+ # Picks the newest valid RSA certificate for the given usage — the gem wraps keys and tokens with
69
+ # RSA-OAEP only. When the cached list has none (the cache outlived a rotation in a long-running
70
+ # process), it refetches once before giving up.
71
+ # @param usage [String] SYMMETRIC_KEY_ENCRYPTION or KSEF_TOKEN_ENCRYPTION
72
+ # @return [Certificate]
73
+ # @raise [Ksef::CryptoError] when no valid certificate is available
74
+ def for_usage(usage)
75
+ select_certificate(usage) || select_certificate(usage, force: true) ||
76
+ raise(CryptoError, "No valid Ministry of Finance RSA certificate for usage: #{usage}")
77
+ end
78
+
79
+ # Forces a refetch of the certificates (e.g. after rotation error 21470).
80
+ # @return [Array<Certificate>]
81
+ def refresh!
82
+ @logger&.info("[ksef] refreshing Ministry of Finance public-key certificates (rotation)")
83
+ all(force: true)
84
+ end
85
+
86
+ # Runs the block; when the API answers 21470, refetches the certificates and runs it once more
87
+ # with +true+, so the caller re-encrypts with the new key.
88
+ # @yieldparam retrying [Boolean]
89
+ # @return [Object] the block's result
90
+ def with_rotation_retry
91
+ yield(false)
92
+ rescue ApiError => e
93
+ raise unless e.codes.include?(KEY_ROTATED)
94
+
95
+ refresh!
96
+ yield(true)
97
+ end
98
+
99
+ private
100
+
101
+ def select_certificate(usage, force: false)
102
+ now = @clock.call
103
+ all(force: force)
104
+ .select { |c| c.usable_for?(usage) && c.valid_at?(now) && c.public_key.is_a?(OpenSSL::PKey::RSA) }
105
+ .max_by(&:valid_from)
106
+ end
107
+
108
+ def fetch_certificates
109
+ response = @connection.get(ENDPOINT)
110
+ Array(response.body).map { |item| build_certificate(item) }
111
+ end
112
+
113
+ def build_certificate(item)
114
+ Certificate.new(
115
+ certificate_der: Base64.decode64(item["certificate"]),
116
+ certificate_id: item["certificateId"],
117
+ public_key_id: item["publicKeyId"],
118
+ valid_from: Time.iso8601(item["validFrom"]),
119
+ valid_to: Time.iso8601(item["validTo"]),
120
+ usages: Array(item["usage"])
121
+ )
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ksef
4
+ # The public KSeF environments, each with its API and QR host.
5
+ #
6
+ # The path prefix is `/v2`, as in both SDKs — the `/api/v2` seen in the docs is an
7
+ # OpenAPI generator artifact, not a real path.
8
+ module Environments
9
+ # A single environment: API and QR base URLs plus metadata.
10
+ Environment = Struct.new(:key, :api_base_url, :qr_base_url, :api_path_prefix, :description, keyword_init: true) do
11
+ # @return [String] full base URL for API requests (including the version prefix), no trailing "/"
12
+ def request_base_url
13
+ "#{api_base_url}#{api_path_prefix}"
14
+ end
15
+ end
16
+
17
+ # Test (Release Candidate). Allows self-signed certificates for authentication.
18
+ # Data is NOT isolated between integrators — use random tax identification numbers.
19
+ TEST = Environment.new(
20
+ key: :test,
21
+ api_base_url: "https://api-test.ksef.mf.gov.pl",
22
+ qr_base_url: "https://qr-test.ksef.mf.gov.pl",
23
+ api_path_prefix: "/v2",
24
+ description: "Test environment (RC) — self-signed allowed, shared data"
25
+ ).freeze
26
+
27
+ # Pre-production (Demo) — configuration close to production.
28
+ DEMO = Environment.new(
29
+ key: :demo,
30
+ api_base_url: "https://api-demo.ksef.mf.gov.pl",
31
+ qr_base_url: "https://qr-demo.ksef.mf.gov.pl",
32
+ api_path_prefix: "/v2",
33
+ description: "Pre-production environment (Demo)"
34
+ ).freeze
35
+
36
+ # Production — invoices with full legal effect.
37
+ PROD = Environment.new(
38
+ key: :prod,
39
+ api_base_url: "https://api.ksef.mf.gov.pl",
40
+ qr_base_url: "https://qr.ksef.mf.gov.pl",
41
+ api_path_prefix: "/v2",
42
+ description: "Production environment"
43
+ ).freeze
44
+
45
+ ALL = { test: TEST, demo: DEMO, prod: PROD }.freeze
46
+
47
+ # Returns an environment by key (:test/:demo/:prod), or passes through a ready object.
48
+ #
49
+ # @param key_or_env [Symbol, String, Environment]
50
+ # @return [Environment]
51
+ # @raise [Ksef::ConfigurationError] when the key is unknown
52
+ def self.fetch(key_or_env)
53
+ return key_or_env if key_or_env.is_a?(Environment)
54
+
55
+ key = key_or_env.to_s.downcase.to_sym
56
+ ALL.fetch(key) do
57
+ raise ConfigurationError, "Unknown KSeF environment: #{key_or_env.inspect} (allowed: #{ALL.keys.join(', ')})"
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ksef
4
+ # Base error of the gem; every error below inherits from it.
5
+ #
6
+ # 450/460/470/480/550 are NOT HTTP codes — they are `status.code` values inside an HTTP 200
7
+ # body, hence AuthenticationFailed is a separate branch rather than an ApiError subclass.
8
+ class Error < StandardError; end
9
+
10
+ # Invalid gem configuration detected locally (e.g. missing environment, bad base_url).
11
+ class ConfigurationError < Error; end
12
+
13
+ # Cryptography / XAdES signature layer error (e.g. no private key in the certificate).
14
+ class CryptoError < Error; end
15
+
16
+ # Error returned by the API as an HTTP 4xx/5xx response. {Ksef::Http::ErrorParser} fills the
17
+ # attributes from either response format (legacy `exceptionDetailList[]` or RFC 7807).
18
+ class ApiError < Error
19
+ # @return [Integer, nil] HTTP status code (e.g. 400, 429)
20
+ attr_reader :status
21
+ # @return [Array<Integer>] business error codes from the body (e.g. [21402])
22
+ attr_reader :codes
23
+ # @return [String, nil] trace identifier (trace_id / traceId), when returned
24
+ attr_reader :trace_id
25
+ # @return [Array<Hash>] raw error entries (for diagnostics)
26
+ attr_reader :details
27
+ # @return [Object, nil] raw parsed response body
28
+ attr_reader :body
29
+
30
+ # @param message [String, nil] human-readable message
31
+ # @param status [Integer, nil] HTTP code
32
+ # @param codes [Array<Integer>] business error codes
33
+ # @param trace_id [String, nil]
34
+ # @param details [Array<Hash>] raw error entries
35
+ # @param body [Object, nil] raw response body
36
+ def initialize(message = nil, status: nil, codes: [], trace_id: nil, details: [], body: nil)
37
+ @status = status
38
+ @codes = Array(codes)
39
+ @trace_id = trace_id
40
+ @details = Array(details)
41
+ @body = body
42
+ super(build_message(message))
43
+ end
44
+
45
+ private
46
+
47
+ def build_message(message)
48
+ parts = []
49
+ parts << "HTTP #{status}" if status
50
+ parts << (message || "KSeF API error")
51
+ parts << "codes: #{codes.join(', ')}" unless codes.empty?
52
+ parts << "trace_id: #{trace_id}" if trace_id
53
+ parts.join(" | ")
54
+ end
55
+ end
56
+
57
+ # 400 — invalid request (validation, wrong schema, wrong invoice sizes/hashes).
58
+ class BadRequest < ApiError; end
59
+
60
+ # 400 with code 21184 — the session is under maintenance. Retry later, or open a new session and
61
+ # send the remaining invoices there.
62
+ class SessionUnavailable < BadRequest; end
63
+
64
+ # The authentication can no longer be used — authenticate again: a 400 with code 21301 (the
65
+ # authentication does not allow it, e.g. its KSeF token was revoked) or 21304 (the authentication
66
+ # operation does not exist — also from the auth status), or a 401 from the token refresh (the refresh
67
+ # token expired or was revoked).
68
+ class ReauthenticationRequired < ApiError; end
69
+
70
+ # 401 — missing/invalid authentication token, or the token expired.
71
+ class Unauthorized < ApiError; end
72
+
73
+ # 403 — no permission for the resource in the given context.
74
+ class Forbidden < ApiError; end
75
+
76
+ # 404 — resource does not exist.
77
+ class NotFound < ApiError; end
78
+
79
+ # 410 — resource expired (retention: auth/export 7 days, enrollment/permissions 30 days).
80
+ class Gone < ApiError; end
81
+
82
+ # 409 — conflict (e.g. the operation was already performed).
83
+ class Conflict < ApiError; end
84
+
85
+ # 429 — request limit exceeded. Carries `retry_after` (seconds) from the Retry-After header.
86
+ class RateLimited < ApiError
87
+ # @return [Integer, nil] seconds until a retry is allowed (from the Retry-After header)
88
+ attr_reader :retry_after
89
+
90
+ def initialize(message = nil, retry_after: nil, **)
91
+ @retry_after = retry_after
92
+ super(message, **)
93
+ end
94
+ end
95
+
96
+ # 5xx — server-side error.
97
+ class ServerError < ApiError; end
98
+
99
+ # KSeF rejected an invoice: a `status.code` of 400 or more in the invoice status (an HTTP 200 body), so
100
+ # `#status` is nil and the code is in `#codes` (440 — a duplicate); `#body` is that status. Any other
101
+ # ApiError from waiting for the UPO is not a rejection — the invoice may already be accepted.
102
+ class InvoiceRejected < ApiError; end
103
+
104
+ # Transport failure (DNS, refused connection, TLS, timeout, a dropped connection) once the allowed
105
+ # retries are spent; the original Faraday exception is the #cause. For a call that is not idempotent
106
+ # (e.g. sending an invoice) a failure after the request was written — a reset, a dropped or cut-short
107
+ # response — leaves the outcome unknown, as a {RequestTimeout} does: check the status before sending it
108
+ # again.
109
+ class NetworkError < Error; end
110
+
111
+ # The request timed out waiting for the response. For a call that is not idempotent the outcome is
112
+ # unknown — check the status before sending it again. (A request that never left the process, such
113
+ # as a connect timeout, is a plain {NetworkError}.)
114
+ class RequestTimeout < NetworkError; end
115
+
116
+ # A batch upload failed after the session was declared — a part upload, or closing the session. The
117
+ # reference number names the session to check (and close again) before uploading anew.
118
+ class BatchUploadError < Error
119
+ # @return [String, nil] batch session reference number
120
+ attr_reader :reference_number
121
+ # @return [Integer, nil] ordinal number of the part that failed (nil when closing failed)
122
+ attr_reader :ordinal_number
123
+
124
+ def initialize(message = nil, reference_number: nil, ordinal_number: nil)
125
+ @reference_number = reference_number
126
+ @ordinal_number = ordinal_number
127
+ super([message, ("session: #{reference_number}" if reference_number)].compact.join(" | "))
128
+ end
129
+ end
130
+
131
+ # Auth failed — `status.code` from the polling body signalled an error (450 invalid token,
132
+ # 460 certificate, 470 deceased person, 480 blocked, 425 revoked, 415 no permissions, 550 cancelled).
133
+ class AuthenticationFailed < Error
134
+ # @return [Integer, nil] the status.code value from the response body (e.g. 450)
135
+ attr_reader :code
136
+ # @return [Array<String>] detailed messages (status.details)
137
+ attr_reader :messages
138
+
139
+ def initialize(message = nil, code: nil, messages: [])
140
+ @code = code
141
+ @messages = Array(messages)
142
+ super([message, "status.code=#{code}", *messages].compact.join(" | "))
143
+ end
144
+ end
145
+
146
+ # Invoice failed local validation ({Ksef::Invoices::Validator}): the XSD or KSeF's XML rules, which KSeF
147
+ # would reject too (in libxml2's wording).
148
+ class SchemaValidationError < Error
149
+ # @return [Array<String>] XSD validator messages
150
+ attr_reader :messages
151
+ # @return [Array<Ksef::Invoices::Validator::Issue>] the same violations with element path and line
152
+ attr_reader :errors
153
+ # @return [String, nil] form systemCode (e.g. "FA (3)")
154
+ attr_reader :system_code
155
+
156
+ def initialize(message = nil, messages: [], errors: [], system_code: nil)
157
+ @messages = Array(messages)
158
+ @errors = Array(errors)
159
+ @system_code = system_code
160
+ super([message, *@messages].compact.join(" | "))
161
+ end
162
+ end
163
+
164
+ # A metadata query reached KSeF's 10,000-record limit (`hasMore` and `isTruncated`): the rest is
165
+ # fetched with a narrower `dateRange` bounded by the last record's date — `from` when sorted ascending,
166
+ # `to` when descending.
167
+ class ResultTruncated < Error
168
+ # @return [Hash, nil] the last record returned — its date is where the next query starts
169
+ attr_reader :last_item
170
+
171
+ def initialize(message = nil, last_item: nil)
172
+ @last_item = last_item
173
+ super(message)
174
+ end
175
+ end
176
+
177
+ # Async operation (auth, session, export, enrollment, permissions) exceeded its wait limit.
178
+ class PollingTimeout < Error
179
+ # @return [String, nil] last known status of the operation
180
+ attr_reader :last_status
181
+
182
+ def initialize(message = nil, last_status: nil)
183
+ @last_status = last_status
184
+ super([message, ("last status: #{last_status}" if last_status)].compact.join(" | "))
185
+ end
186
+ end
187
+ end