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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +161 -0
- data/NOTICE +40 -0
- data/README.md +524 -6
- data/examples/fa3_full.xml +143 -0
- data/examples/fa3_minimal.xml +65 -0
- data/examples/fa3_typical.xml +76 -0
- data/lib/generators/ksef/install/install_generator.rb +20 -0
- data/lib/generators/ksef/install/templates/ksef.rb +25 -0
- data/lib/ksef/auth/coordinator.rb +164 -0
- data/lib/ksef/auth/ksef_token_encryptor.rb +36 -0
- data/lib/ksef/batch/package_builder.rb +125 -0
- data/lib/ksef/batch/splitter.rb +99 -0
- data/lib/ksef/batch/uploader.rb +114 -0
- data/lib/ksef/client.rb +135 -0
- data/lib/ksef/configuration.rb +206 -0
- data/lib/ksef/crypto/cryptography_service.rb +136 -0
- data/lib/ksef/crypto/csr.rb +106 -0
- data/lib/ksef/crypto/public_keys.rb +125 -0
- data/lib/ksef/environments.rb +61 -0
- data/lib/ksef/errors.rb +187 -0
- data/lib/ksef/http/connection.rb +432 -0
- data/lib/ksef/http/error_parser.rb +194 -0
- data/lib/ksef/http/pagination.rb +80 -0
- data/lib/ksef/invoices/builder/checks.rb +127 -0
- data/lib/ksef/invoices/builder/number.rb +67 -0
- data/lib/ksef/invoices/builder/xml.rb +256 -0
- data/lib/ksef/invoices/builder.rb +251 -0
- data/lib/ksef/invoices/export.rb +241 -0
- data/lib/ksef/invoices/sample.rb +78 -0
- data/lib/ksef/invoices/schema_outline.rb +120 -0
- data/lib/ksef/invoices/validator.rb +200 -0
- data/lib/ksef/models/form_code.rb +49 -0
- data/lib/ksef/models/ksef_number.rb +74 -0
- data/lib/ksef/models/nip.rb +62 -0
- data/lib/ksef/polish_time.rb +49 -0
- data/lib/ksef/polling.rb +65 -0
- data/lib/ksef/qr/code.rb +104 -0
- data/lib/ksef/qr/verification_link.rb +215 -0
- data/lib/ksef/railtie.rb +20 -0
- data/lib/ksef/resources/auth.rb +116 -0
- data/lib/ksef/resources/base.rb +120 -0
- data/lib/ksef/resources/batch_sessions.rb +43 -0
- data/lib/ksef/resources/certificates.rb +82 -0
- data/lib/ksef/resources/collective_identifiers.rb +70 -0
- data/lib/ksef/resources/exports.rb +47 -0
- data/lib/ksef/resources/invoices.rb +67 -0
- data/lib/ksef/resources/limits.rb +33 -0
- data/lib/ksef/resources/online_sessions.rb +62 -0
- data/lib/ksef/resources/peppol.rb +21 -0
- data/lib/ksef/resources/permissions.rb +180 -0
- data/lib/ksef/resources/security.rb +18 -0
- data/lib/ksef/resources/sessions.rb +125 -0
- data/lib/ksef/resources/test_data.rb +178 -0
- data/lib/ksef/resources/tokens.rb +62 -0
- data/lib/ksef/sessions/online.rb +168 -0
- data/lib/ksef/sync/incremental.rb +169 -0
- data/lib/ksef/testing.rb +153 -0
- data/lib/ksef/token_manager.rb +130 -0
- data/lib/ksef/version.rb +11 -0
- data/lib/ksef/xades/auth_token_request.rb +135 -0
- data/lib/ksef/xades/external_signer.rb +58 -0
- data/lib/ksef/xades/signer.rb +312 -0
- data/lib/ksef-client-rails.rb +3 -2
- data/lib/ksef.rb +116 -0
- data/schemas/FA/schemat_FA(2)_v1-0E.xsd +3661 -0
- data/schemas/FA/schemat_FA(3)_v1-0E.xsd +3950 -0
- data/schemas/README.md +53 -0
- data/schemas/RR/schemat_FA_RR(1)_v1-1E.xsd +2188 -0
- data/schemas/bazowe/ElementarneTypyDanych_v10-0E.xsd +1 -0
- data/schemas/bazowe/KodyKrajow_v10-0E.xsd +1283 -0
- data/schemas/bazowe/StrukturyDanych_v10-0E.xsd +1 -0
- 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
|
data/lib/ksef/errors.rb
ADDED
|
@@ -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
|