ksef_client 0.1.0.rc1 → 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.
Files changed (108) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1424 -0
  3. data/CONTRIBUTING.md +45 -6
  4. data/README.md +377 -25
  5. data/SECURITY.md +25 -2
  6. data/docs/REFERENCE.md +3099 -34
  7. data/docs/errors.md +33 -4
  8. data/docs/field_mapping.md +390 -0
  9. data/lib/ksef/auth/access_token.rb +150 -0
  10. data/lib/ksef/auth/authorization_policy.rb +88 -0
  11. data/lib/ksef/auth/challenge.rb +53 -0
  12. data/lib/ksef/auth/client.rb +143 -0
  13. data/lib/ksef/auth/initiation.rb +22 -0
  14. data/lib/ksef/auth/operation_status.rb +34 -0
  15. data/lib/ksef/auth/schema/schemat_auth_v2-0.xsd +109 -0
  16. data/lib/ksef/auth/schema/schemat_auth_v2-1.xsd +109 -0
  17. data/lib/ksef/auth/signature_template.rb +120 -0
  18. data/lib/ksef/auth/signer.rb +132 -0
  19. data/lib/ksef/auth/status.rb +69 -0
  20. data/lib/ksef/auth/token.rb +121 -0
  21. data/lib/ksef/auth/token_info.rb +27 -0
  22. data/lib/ksef/auth/token_request.rb +142 -0
  23. data/lib/ksef/auth/tokens.rb +24 -0
  24. data/lib/ksef/auth/validator.rb +84 -0
  25. data/lib/ksef/auth/xades.rb +40 -0
  26. data/lib/ksef/auth.rb +43 -0
  27. data/lib/ksef/client/receipt.rb +37 -0
  28. data/lib/ksef/client/session.rb +68 -0
  29. data/lib/ksef/client.rb +314 -0
  30. data/lib/ksef/crypto/certificate.rb +78 -0
  31. data/lib/ksef/crypto/digest.rb +25 -0
  32. data/lib/ksef/crypto/encryptor.rb +131 -0
  33. data/lib/ksef/crypto/public_keys.rb +146 -0
  34. data/lib/ksef/crypto.rb +66 -0
  35. data/lib/ksef/environments.rb +1 -1
  36. data/lib/ksef/errors.rb +35 -2
  37. data/lib/ksef/fa3/address.rb +64 -0
  38. data/lib/ksef/fa3/advance_checks.rb +55 -0
  39. data/lib/ksef/fa3/advance_invoice.rb +55 -0
  40. data/lib/ksef/fa3/advance_reader.rb +59 -0
  41. data/lib/ksef/fa3/attachment.rb +43 -0
  42. data/lib/ksef/fa3/attachment_checks.rb +121 -0
  43. data/lib/ksef/fa3/attachment_reader.rb +99 -0
  44. data/lib/ksef/fa3/attachment_table.rb +98 -0
  45. data/lib/ksef/fa3/builder/advances.rb +57 -0
  46. data/lib/ksef/fa3/builder/corrections.rb +46 -0
  47. data/lib/ksef/fa3/builder/subjects.rb +71 -0
  48. data/lib/ksef/fa3/builder.rb +149 -0
  49. data/lib/ksef/fa3/business_validator.rb +134 -0
  50. data/lib/ksef/fa3/canonical.rb +44 -0
  51. data/lib/ksef/fa3/corrected_invoice.rb +53 -0
  52. data/lib/ksef/fa3/correction.rb +151 -0
  53. data/lib/ksef/fa3/correction_checks.rb +106 -0
  54. data/lib/ksef/fa3/correction_reader.rb +88 -0
  55. data/lib/ksef/fa3/data_block.rb +75 -0
  56. data/lib/ksef/fa3/document_mapping.rb +142 -0
  57. data/lib/ksef/fa3/document_validator.rb +174 -0
  58. data/lib/ksef/fa3/element_tree.rb +49 -0
  59. data/lib/ksef/fa3/field_checks.rb +151 -0
  60. data/lib/ksef/fa3/formatting.rb +273 -0
  61. data/lib/ksef/fa3/generated/enums.rb +652 -0
  62. data/lib/ksef/fa3/generated/types.rb +3308 -0
  63. data/lib/ksef/fa3/invoice.rb +259 -0
  64. data/lib/ksef/fa3/issue.rb +32 -0
  65. data/lib/ksef/fa3/line.rb +162 -0
  66. data/lib/ksef/fa3/meta_entry.rb +45 -0
  67. data/lib/ksef/fa3/model_validator.rb +219 -0
  68. data/lib/ksef/fa3/nip.rb +59 -0
  69. data/lib/ksef/fa3/node_reader.rb +46 -0
  70. data/lib/ksef/fa3/order.rb +55 -0
  71. data/lib/ksef/fa3/order_line.rb +70 -0
  72. data/lib/ksef/fa3/parser.rb +208 -0
  73. data/lib/ksef/fa3/provenance.rb +137 -0
  74. data/lib/ksef/fa3/rounding_inference.rb +87 -0
  75. data/lib/ksef/fa3/row_reader.rb +66 -0
  76. data/lib/ksef/fa3/serializer.rb +140 -0
  77. data/lib/ksef/fa3/subject.rb +128 -0
  78. data/lib/ksef/fa3/subject_checks.rb +126 -0
  79. data/lib/ksef/fa3/subject_reader.rb +76 -0
  80. data/lib/ksef/fa3/summaries.rb +107 -0
  81. data/lib/ksef/fa3/summary_checks.rb +75 -0
  82. data/lib/ksef/fa3/table_column.rb +49 -0
  83. data/lib/ksef/fa3/totals.rb +119 -0
  84. data/lib/ksef/fa3/validator.rb +79 -0
  85. data/lib/ksef/fa3/vat_rate.rb +94 -0
  86. data/lib/ksef/fa3.rb +59 -0
  87. data/lib/ksef/http/connection.rb +47 -4
  88. data/lib/ksef/http/json_decoder.rb +53 -0
  89. data/lib/ksef/http/retry.rb +105 -0
  90. data/lib/ksef/invoices/client.rb +72 -0
  91. data/lib/ksef/ksef_number.rb +150 -0
  92. data/lib/ksef/sessions/invoice_codes.rb +70 -0
  93. data/lib/ksef/sessions/invoice_state.rb +68 -0
  94. data/lib/ksef/sessions/online.rb +169 -0
  95. data/lib/ksef/sessions/session_codes.rb +73 -0
  96. data/lib/ksef/sessions/session_state.rb +48 -0
  97. data/lib/ksef/sessions/status.rb +138 -0
  98. data/lib/ksef/sessions/upo_page.rb +36 -0
  99. data/lib/ksef/sessions.rb +91 -0
  100. data/lib/ksef/upo/client.rb +154 -0
  101. data/lib/ksef/upo/document.rb +74 -0
  102. data/lib/ksef/upo/schema/upo-v4-3.xsd +293 -0
  103. data/lib/ksef/upo/validation.rb +37 -0
  104. data/lib/ksef/upo/validator.rb +124 -0
  105. data/lib/ksef/upo.rb +55 -0
  106. data/lib/ksef/version.rb +1 -1
  107. data/lib/ksef.rb +18 -0
  108. metadata +105 -4
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ksef
4
+ module Crypto
5
+ # The SHA-256-and-size pair KSeF wants beside a payload (docs/REFERENCE.md §11.1).
6
+ #
7
+ # `POST /sessions/online/{ref}/invoices` carries **four** integrity values — the hash
8
+ # and size of the plaintext invoice *and* of the ciphertext. Computing one over the
9
+ # wrong artifact is an easy mistake and a silent one, which is why
10
+ # {Ksef::Crypto::Encryptor#seal} produces both at once rather than leaving a caller to
11
+ # pair them up.
12
+ #
13
+ # `size` is a **byte** count, not a character count. An invoice full of Polish
14
+ # characters has more bytes than characters, and reporting the shorter figure would be
15
+ # rejected downstream.
16
+ Digest = Data.define(:bytes, :size) do
17
+ # @param content [String] the exact bytes that will be sent
18
+ def self.of(content) = new(bytes: Crypto.sha256(content), size: content.bytesize)
19
+
20
+ # @return [String] the wire form: base64 of the digest, always 44 characters, which
21
+ # is what the contract's `Sha256HashBase64` constrains it to
22
+ def base64 = Crypto.encode(bytes)
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ksef
4
+ module Crypto
5
+ # A session's symmetric key, and the AES-256-CBC encryption done under it
6
+ # (docs/REFERENCE.md §10.1).
7
+ #
8
+ # ## The IV is not prefixed to the ciphertext
9
+ #
10
+ # This is the trap worth knowing about before reading anything else here.
11
+ # `sesja-interaktywna.md` describes the IV as "dołączanego jako prefiks do szyfrogramu"
12
+ # — prefixed to the ciphertext. **It is not**, and following that prose produces a
13
+ # payload KSeF cannot decrypt. The IV travels once, as a discrete field of the
14
+ # session-open request, and every per-invoice ciphertext is bare. Three higher-precedence
15
+ # sources agree against the prose, the OpenAPI contract among them (§14.1) — and the
16
+ # contract's own worked example settles it arithmetically: a 6480-byte invoice becomes
17
+ # 6496 bytes encrypted. 6480 is a whole number of blocks, so PKCS#7 adds exactly one
18
+ # block of padding; a prefixed IV would have made it 6512.
19
+ #
20
+ # So {#encrypt} returns bare ciphertext, and the IV is exposed separately for the
21
+ # session-open request via {#encryption_info}.
22
+ class Encryptor
23
+ REDACTED = "[REDACTED]"
24
+
25
+ # What {#seal} produces: the ciphertext to send, plus the integrity metadata for both
26
+ # forms of the document. Bundled together because §11.1 requires the hash and size of
27
+ # the plaintext *and* of the ciphertext, and hashing the wrong one of the two is a
28
+ # silent error that only the server can detect.
29
+ Sealed = Data.define(:ciphertext, :plaintext_digest, :ciphertext_digest) do
30
+ # @return [String] base64 ciphertext, the wire form
31
+ def content = Crypto.encode(ciphertext)
32
+ end
33
+
34
+ # The IV is not secret — it is sent to the API in the clear — so unlike the key it
35
+ # has a reader. It is still kept out of {#inspect}, which DESIGN.md §4.5 requires.
36
+ attr_reader :iv
37
+
38
+ # A fresh key and IV from the CSPRNG. §10.1 records a per-session key as
39
+ # *recommended* by the docs rather than required; recommended is reason enough.
40
+ def self.generate
41
+ cipher = OpenSSL::Cipher.new(CIPHER)
42
+ new(key: cipher.random_key, iv: cipher.random_iv)
43
+ end
44
+
45
+ # @param key [String] 32 raw bytes
46
+ # @param iv [String] 16 raw bytes
47
+ # @raise [Ksef::CryptoError] on the wrong length
48
+ def initialize(key:, iv:)
49
+ @key = check("key", key, KEY_BYTES)
50
+ @iv = check("iv", iv, IV_BYTES)
51
+ freeze
52
+ end
53
+
54
+ # @param plaintext [String] the invoice XML, already serialised. Deliberately not
55
+ # `#to_xml`-coercing: what gets hashed has to be exactly what gets encrypted, so
56
+ # the bytes are settled one layer up.
57
+ # @return [String] bare ciphertext, binary — see the class note on the IV
58
+ def encrypt(plaintext) = transform(:encrypt, plaintext)
59
+
60
+ # The inverse. KSeF never asks us to decrypt anything, so this exists for tests and
61
+ # for a caller wanting to prove to itself that a payload round-trips before sending
62
+ # it — which beats discovering a key mismatch from a rejected invoice.
63
+ def decrypt(ciphertext) = transform(:decrypt, ciphertext)
64
+
65
+ # Encrypt once, and measure both artifacts while they are in hand.
66
+ #
67
+ # @param plaintext [String]
68
+ # @return [Sealed]
69
+ def seal(plaintext)
70
+ ciphertext = encrypt(plaintext)
71
+ Sealed.new(
72
+ ciphertext: ciphertext,
73
+ plaintext_digest: Digest.of(plaintext),
74
+ ciphertext_digest: Digest.of(ciphertext)
75
+ )
76
+ end
77
+
78
+ # The contract's `EncryptionInfo`, as sent on `POST /sessions/online`,
79
+ # `POST /sessions/batch` and `POST /invoices/exports`.
80
+ #
81
+ # `publicKeyId` is nullable in the contract but always sent here: it names *which*
82
+ # published key did the wrapping, and without it a key rotation turns a decryptable
83
+ # payload into an undecryptable one with nothing to diagnose it by (§10.2).
84
+ #
85
+ # @param certificate [Certificate] from {PublicKeys#symmetric_key_encryption}
86
+ # @return [Hash]
87
+ def encryption_info(certificate)
88
+ {
89
+ encryptedSymmetricKey: Crypto.encode(certificate.encrypt(@key)),
90
+ initializationVector: Crypto.encode(@iv),
91
+ publicKeyId: certificate.public_key_id
92
+ }
93
+ end
94
+
95
+ # Redacted, and `#to_s` deliberately too: DESIGN.md §4.5 forbids leaking symmetric
96
+ # keys and IVs at default log level, and `"key: #{encryptor}"` in someone's debug
97
+ # line is exactly how that happens.
98
+ def to_s = REDACTED
99
+ def inspect = "#<Ksef::Crypto::Encryptor key=#{REDACTED} iv=#{REDACTED}>"
100
+
101
+ private
102
+
103
+ # A fresh cipher per call. `OpenSSL::Cipher` is stateful, so sharing one would make
104
+ # this class unsafe to use from two threads and would leave a half-consumed cipher
105
+ # behind after any exception.
106
+ def transform(direction, data)
107
+ cipher = OpenSSL::Cipher.new(CIPHER)
108
+ cipher.public_send(direction)
109
+ # On by default; stated because PKCS#7 is a ledgered parameter (§10.1) and not
110
+ # something to leave to a library default.
111
+ cipher.padding = 1
112
+ cipher.key = @key
113
+ cipher.iv = @iv
114
+ cipher.update(data) + cipher.final
115
+ end
116
+
117
+ def check(name, value, expected)
118
+ bytes = value.to_s
119
+ unless bytes.bytesize == expected
120
+ raise CryptoError,
121
+ "The AES #{name} must be #{expected} bytes, got #{bytes.bytesize}. " \
122
+ "KSeF requires AES-256-CBC (docs/REFERENCE.md §10.1); a shorter key would " \
123
+ "silently select a weaker cipher."
124
+ end
125
+
126
+ # Frozen so a caller cannot mutate key material out from under a shared client.
127
+ bytes.dup.freeze
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ksef
4
+ module Crypto
5
+ # The Ministry's published encryption certificates, fetched and cached
6
+ # (docs/REFERENCE.md §10.2).
7
+ #
8
+ # `GET /security/public-key-certificates` is **unauthenticated** — the contract
9
+ # declares no `security` for it — so this needs no credential and can run before any
10
+ # authentication.
11
+ #
12
+ # ## Why there is a TTL, and why it is not "forever"
13
+ #
14
+ # Two rotation modes exist and must not be conflated. *Re-certification* issues a new
15
+ # certificate over the same key pair, leaving `publicKeyId` unchanged. *Key rotation*
16
+ # changes the key pair, so `publicKeyId` changes with it; a planned rotation publishes
17
+ # the successor early and both appear for one `usage` during the overlap, while an
18
+ # **emergency rotation revokes the old key and drops it from the list immediately**.
19
+ # Because the emergency case can happen at any moment, the list must not be cached
20
+ # indefinitely — and {#with_key_rotation} implements the documented recovery for the
21
+ # window between a rotation and the cache expiring.
22
+ #
23
+ # Thread-safe: one mutex guards the cache, and a `Ksef::Client` is required to be
24
+ # shareable across threads (DESIGN.md §5.2).
25
+ class PublicKeys
26
+ PATH = "security/public-key-certificates"
27
+
28
+ # An hour. Long enough that the list is fetched once per process in practice, short
29
+ # enough to bound how long a withdrawn key can linger. Callers who want none of it
30
+ # can pass `ttl: 0`.
31
+ DEFAULT_TTL = 3600
32
+
33
+ # `400` with this code means "the supplied key identifier is unknown or refers to a
34
+ # withdrawn key". The documented response is to re-fetch, re-select and repeat —
35
+ # see {#with_key_rotation}.
36
+ UNKNOWN_KEY_CODE = 21_470
37
+
38
+ # @param connection [Faraday::Connection] usually from {Ksef::HTTP::Connection.build}
39
+ # @param ttl [Numeric] seconds to cache the list for
40
+ # @param clock [#call] injected for tests; returns the current {Time}
41
+ def initialize(connection, ttl: DEFAULT_TTL, clock: -> { Time.now })
42
+ @connection = connection
43
+ @ttl = ttl
44
+ @clock = clock
45
+ @mutex = Mutex.new
46
+ @certificates = nil
47
+ @fetched_at = nil
48
+ end
49
+
50
+ # @return [Array<Certificate>] the cached list, fetching it if stale
51
+ def all
52
+ @mutex.synchronize { cached || load! }
53
+ end
54
+
55
+ # Discards the cache and fetches again. The recovery path of §10.2 after a key
56
+ # rotation, and the only way to see a newly published certificate before the TTL
57
+ # lapses.
58
+ #
59
+ # @return [Array<Certificate>]
60
+ def refresh!
61
+ @mutex.synchronize { load! }
62
+ end
63
+
64
+ # Applies the documented selection rule: filter by `usage`, require validity **at
65
+ # the moment of use**, and where several qualify prefer the latest `validFrom`.
66
+ #
67
+ # Not a judgement call — §10.2 states it, which matters because during a planned
68
+ # rotation overlap two certificates are legitimately valid for the same usage and
69
+ # picking the older one wastes the overlap the Ministry provided.
70
+ #
71
+ # @param kind [String] one of {Certificate::USAGES}
72
+ # @param at [Time, nil] the moment the key will be used; defaults to now
73
+ # @return [Certificate]
74
+ # @raise [Ksef::CryptoError] on an unknown usage, or when nothing published is valid
75
+ def for_usage(kind, at: nil)
76
+ validate_usage!(kind)
77
+ moment = at || now
78
+ usable = all.select { |certificate| certificate.usable_for?(kind) && certificate.valid_at?(moment) }
79
+ usable.max_by(&:valid_from) || raise(CryptoError, nothing_valid(kind, moment))
80
+ end
81
+
82
+ # The key that wraps the KSeF token during authentication (§4.5).
83
+ def token_encryption(at: nil) = for_usage(Certificate::KSEF_TOKEN_ENCRYPTION, at: at)
84
+
85
+ # The key that wraps a session's AES key (§10.1).
86
+ def symmetric_key_encryption(at: nil) = for_usage(Certificate::SYMMETRIC_KEY_ENCRYPTION, at: at)
87
+
88
+ # Runs an operation, and on the one error that says "your key is stale" re-fetches
89
+ # the list and runs it again.
90
+ #
91
+ # **This does not contradict the never-auto-retry-a-POST rule** (DESIGN.md §6.7). A
92
+ # `21470` is the API declining the request outright, so nothing happened server-side
93
+ # and there is no duplicate to create; and this is remediation — the second attempt
94
+ # carries a *different* key identifier — rather than a blind replay. §10.2 prescribes
95
+ # exactly this sequence.
96
+ #
97
+ # The block must therefore re-select the certificate itself, so pass the whole
98
+ # operation rather than a pre-built request:
99
+ #
100
+ # keys.with_key_rotation do
101
+ # session.open(encryption: encryptor.encryption_info(keys.symmetric_key_encryption))
102
+ # end
103
+ #
104
+ # @return the block's value
105
+ def with_key_rotation
106
+ yield
107
+ rescue ApiError => e
108
+ raise unless e.code == UNKNOWN_KEY_CODE
109
+
110
+ refresh!
111
+ yield
112
+ end
113
+
114
+ private
115
+
116
+ def now = @clock.call
117
+
118
+ # A typo would otherwise read as "the Ministry publishes no such key", sending the
119
+ # reader to look for a rotation that never happened.
120
+ def validate_usage!(kind)
121
+ return if Certificate::USAGES.include?(kind)
122
+
123
+ raise CryptoError, "Unknown certificate usage #{kind.inspect}. " \
124
+ "The contract declares #{Certificate::USAGES.map(&:inspect).join(" and ")}."
125
+ end
126
+
127
+ # Callers hold the mutex.
128
+ def cached = fresh? ? @certificates : nil
129
+
130
+ def fresh? = !@fetched_at.nil? && (now - @fetched_at) < @ttl
131
+
132
+ def load!
133
+ @certificates = Array(@connection.get(PATH).body).map { |payload| Certificate.from(payload) }.freeze
134
+ @fetched_at = now
135
+ @certificates
136
+ end
137
+
138
+ def nothing_valid(kind, moment)
139
+ published = @certificates.flat_map(&:usage).uniq
140
+ "No published KSeF certificate for usage #{kind.inspect} is valid at #{moment.iso8601}. " \
141
+ "The list carries #{@certificates.size} certificate(s) covering #{published.inspect}. " \
142
+ "If a rotation has just happened, PublicKeys#refresh! re-reads the list."
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "time"
5
+
6
+ module Ksef
7
+ # Encryption of the payloads KSeF requires to be encrypted (docs/REFERENCE.md §10).
8
+ #
9
+ # Every parameter here is *ledgered*, not chosen. They come from
10
+ # `sesja-interaktywna.md` and `uwierzytelnianie.md` §2.2 — first-tier documentation —
11
+ # corroborated against both reference clients' cryptography services. Do not adjust one
12
+ # without re-reading §10 first; a mismatch here does not fail loudly, it produces a
13
+ # payload KSeF silently cannot decrypt.
14
+ #
15
+ # ## What lives where
16
+ #
17
+ # This module holds the primitives: the algorithm identifiers, base64, SHA-256 and the
18
+ # one RSA operation. {Crypto::Certificate} and {Crypto::PublicKeys} deal with the keys
19
+ # the Ministry publishes, {Crypto::Encryptor} with a session's symmetric key, and
20
+ # {Crypto::Digest} with the integrity metadata that travels beside every payload.
21
+ #
22
+ # Nothing here knows an API field name. Mapping to `EncryptionInfo` and
23
+ # `SendInvoiceRequest` is {Crypto::Encryptor}'s single concession to the wire format,
24
+ # and building the requests themselves belongs to the session layer.
25
+ module Crypto
26
+ # AES-256-CBC with PKCS#7 padding: 256-bit key, 128-bit IV, 128-bit block (§10.1).
27
+ # The Java client names the same thing `AES/CBC/PKCS5Padding`; for a 16-byte block
28
+ # PKCS#5 and PKCS#7 are identical, so that is not a divergence.
29
+ CIPHER = "aes-256-cbc"
30
+ KEY_BYTES = 32
31
+ IV_BYTES = 16
32
+ BLOCK_BYTES = 16
33
+
34
+ # RSAES-OAEP with SHA-256 **and** MGF1-SHA-256 (§10.1), used for both the symmetric
35
+ # key wrap and the KSeF-token payload of §4.5.
36
+ #
37
+ # All three options have to be stated. `OpenSSL::PKey::RSA#public_encrypt` cannot
38
+ # express an MGF1 digest at all, and OpenSSL's default MGF1 digest is SHA-1 — so
39
+ # setting `rsa_oaep_md` alone yields OAEP-SHA256-with-MGF1-SHA1, which is a *different
40
+ # scheme* that KSeF cannot unwrap. It fails at the far end, not here.
41
+ OAEP = { rsa_padding_mode: "oaep", rsa_oaep_md: "sha256", rsa_mgf1_md: "sha256" }.freeze
42
+
43
+ # Longest plaintext RSAES-OAEP can carry for a 2048-bit key: `k - 2*hLen - 2`, so
44
+ # `256 - 64 - 2`. Stated because it pins the digest — with SHA-1 the figure would be
45
+ # 214 — and because it is the reason both encrypted payloads here are small ones.
46
+ MAX_OAEP_PLAINTEXT_BYTES = 190
47
+
48
+ class << self
49
+ # Strict base64, no line breaks, for everything this gem *sends*.
50
+ def encode(bytes) = [bytes].pack("m0")
51
+
52
+ # Lenient base64 for everything it *receives*, which is the asymmetry that matters:
53
+ # `"m0"` raises on a line break, and how a server wraps a long value is its business.
54
+ # Nothing is silently accepted as a result — a certificate that decodes to garbage
55
+ # fails in `OpenSSL::X509::Certificate.new`, with a better message than we would give.
56
+ def decode(text) = text.to_s.unpack1("m")
57
+
58
+ # @return [String] the raw 32-byte digest; {Digest} is the base64-and-size form
59
+ def sha256(bytes) = OpenSSL::Digest::SHA256.digest(bytes)
60
+
61
+ # @param public_key [OpenSSL::PKey::RSA]
62
+ # @return [String] raw ciphertext, one RSA block wide
63
+ def rsa_encrypt(plaintext, public_key) = public_key.encrypt(plaintext, OAEP)
64
+ end
65
+ end
66
+ end
@@ -11,7 +11,7 @@ module Ksef
11
11
  # re-verifying it against that source and updating the ledger.
12
12
  #
13
13
  # Note the base URL already carries `/v2`; endpoint paths are appended bare. There is
14
- # no `/api` segment (docs/REFERENCE.md §7.2).
14
+ # no `/api` segment (docs/REFERENCE.md §7 item 2).
15
15
  module Environments
16
16
  Environment = Data.define(:name, :base_url, :supports_test_data) do
17
17
  # TEST alone exposes the `/testdata/*` helper API and `/collective-identifiers*`.
data/lib/ksef/errors.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
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).
4
+ # API actually returns (docs/REFERENCE.md §7 item 4).
5
5
  #
6
6
  # This file defines many constants and no `Ksef::Errors`, so Zeitwerk cannot manage it —
7
7
  # `lib/ksef.rb` ignores it and requires it eagerly.
@@ -26,9 +26,42 @@ module Ksef
26
26
  # Challenge, KSeF-token, signature or JWT problems, including HTTP 401.
27
27
  class AuthenticationError < Error; end
28
28
 
29
- # Raised locally by the FA(3) validator (DESIGN.md §7.7), never by the transport layer.
29
+ # Any locally-detected problem with the caller's input, before or instead of a request.
30
+ #
31
+ # The FA(3) validator (DESIGN.md §7.7) is the largest source, but **not the only one**, and
32
+ # an earlier version of this comment said "never by the transport layer" — which this gem
33
+ # then made false. It is also raised for a malformed KSeF number, a session or invoice
34
+ # reference that could alter a URL path, a challenge in the wrong format, a missing KSeF
35
+ # token, an unknown form code, and a malformed UPO download URL. Seventeen files raise it.
36
+ #
37
+ # So rescue it as "something I passed in is wrong", not as "my invoice is schema-invalid".
30
38
  class ValidationError < Error; end
31
39
 
40
+ # Downloaded bytes did not match the hash the server published for them
41
+ # (docs/REFERENCE.md §14.2).
42
+ #
43
+ # Its own class because the right response is specific and different from every other
44
+ # error here: **fetch it again**. Nothing is wrong with the request, the credentials or
45
+ # the document — the transfer was corrupted. It is not a {ValidationError}, which says
46
+ # the caller's own data is malformed, and it is not an {ApiError}, since the response was
47
+ # a success.
48
+ #
49
+ # It exists at all because the artifact this guards is a UPO: the legal proof that an
50
+ # invoice was received, fetched over an unauthenticated storage link. Silently archiving
51
+ # corrupt bytes as proof of receipt is the one outcome worth a dedicated error.
52
+ class IntegrityError < Error; end
53
+
54
+ # Encryption could not proceed: no published KSeF certificate is valid for the usage
55
+ # needed, or key material is the wrong size (docs/REFERENCE.md §10).
56
+ #
57
+ # Not in DESIGN.md §6.7's hierarchy, and added rather than folded into a neighbour
58
+ # because neither fits. It is not an {ApiError} — the request succeeded, the published
59
+ # list simply has nothing usable in it — nor a {ConfigurationError}, which is documented
60
+ # as local and pre-request. The one branch a caller may want to act on is "no valid key
61
+ # for this usage": after an emergency key rotation that is transient, and
62
+ # {Ksef::Crypto::PublicKeys#refresh!} is the remedy.
63
+ class CryptoError < Error; end
64
+
32
65
  # Any error response from the KSeF API.
33
66
  class ApiError < Error
34
67
  # @return [Integer, nil] HTTP status
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ksef
4
+ module FA3
5
+ # A subject's address (`TAdres`).
6
+ #
7
+ # FA(3) does not model an address as structured fields, and neither does this. `TAdres`
8
+ # is `KodKraju` + `AdresL1` + optional `AdresL2` + optional `GLN`, where the two address
9
+ # lines are free text of up to 512 characters.
10
+ #
11
+ # **That shape is upstream's, not a simplification of it.** The only official code that
12
+ # reads an FA(3) address is `ksef-pdf-generator`'s `FA3/Adres.ts`, which models exactly
13
+ # those four elements; neither the C# nor the Java client models FA(3) at all, both
14
+ # taking the document as bytes. There is no upstream notion of a street or a postal code.
15
+ #
16
+ # So `street`, `city` and `postal_code` are **constructor conveniences, not state**: they
17
+ # compose into `line1` on the way in and are not retained. Retaining them would imply
18
+ # the document can express them, and would make an address built from parts unequal to
19
+ # the identical address given as a line — a distinction FA(3), KSeF and any reader of the
20
+ # invoice are all blind to, and one that would break DESIGN.md §7.6's round-trip law for
21
+ # the most ordinary use of the builder (docs/REFERENCE.md §8.2b).
22
+ #
23
+ # `GLN` is deliberately not carried: it is optional, nothing here needs it, and a parsed
24
+ # document that has one reports it through {Invoice#unmapped_elements} rather than
25
+ # losing it quietly.
26
+ Address = Data.define(:line1, :line2, :country) do
27
+ include Canonical
28
+
29
+ # @param line1 [String, nil] a formatted address line, for callers who have one
30
+ # @param line2 [String, nil] `AdresL2`, omitted from the document when nil
31
+ # @param country [String] `KodKraju`, ISO 3166-1 alpha-2
32
+ # @param street [String, nil] composed into `line1` together with the two below
33
+ # @param city [String, nil]
34
+ # @param postal_code [String, nil]
35
+ # @raise [Ksef::ValidationError] if there is no way to produce the mandatory `AdresL1`
36
+ def initialize(line1: nil, line2: nil, country: "PL", street: nil, city: nil, postal_code: nil)
37
+ composed = line1 || self.class.compose(street: street, city: city, postal_code: postal_code)
38
+ # `AdresL1` is mandatory in `TAdres`, so an address without one can never serialise.
39
+ # Refusing it at construction puts the error where the mistake is, rather than
40
+ # surfacing it later from `#to_fa3` on an invoice that looks complete.
41
+ raise ValidationError, "Address needs either line1, or street/city/postal_code" if composed.nil?
42
+
43
+ super(line1: Formatting.text(composed), line2: Formatting.text(line2),
44
+ country: Formatting.text(country))
45
+ end
46
+
47
+ def to_fa3
48
+ content = { "KodKraju" => country, "AdresL1" => line1 }
49
+ content["AdresL2"] = line2 unless line2.nil?
50
+ content
51
+ end
52
+
53
+ # `"Prosta 1, 00-001 Warszawa"` — street, then postal code and city, skipping whatever
54
+ # is absent so a partial address still yields something usable.
55
+ #
56
+ # @return [String, nil] nil when there is nothing to compose from
57
+ def self.compose(street:, city:, postal_code:)
58
+ locality = [postal_code, city].compact.join(" ").strip
59
+ parts = [street, locality].reject { |part| part.nil? || part.empty? }
60
+ parts.empty? ? nil : parts.join(", ")
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ksef
4
+ module FA3
5
+ # Tier 1a's checks on {Order} and {AdvanceInvoice} — the two structures that make an
6
+ # invoice part of an advance-payment sequence (docs/REFERENCE.md §8.5).
7
+ #
8
+ # Mixed into {ModelValidator}. `Zamowienie`'s positions are checked the way `FaWiersz`'s
9
+ # are, against the same generated enum and the same `xsd:token` facets, because they are
10
+ # the same kind of thing said about the order rather than about the sale.
11
+ module AdvanceChecks
12
+ # Included rather than assumed present: constant lookup is lexical.
13
+ include FieldChecks
14
+
15
+ private
16
+
17
+ def advance_errors(invoice)
18
+ [*order_errors(invoice.order), *settled_errors(invoice.advances)]
19
+ end
20
+
21
+ def order_errors(order)
22
+ return [] if order.nil?
23
+ return [Issue.new(field: "order", message: "is not a Ksef::FA3::Order")] unless order.is_a?(Order)
24
+
25
+ order.lines.each_with_index.flat_map { |line, index| order_line_errors(line, index) }
26
+ end
27
+
28
+ def order_line_errors(line, index)
29
+ field = "order.lines[#{index}]"
30
+ return [Issue.new(field: field, message: "is not a Ksef::FA3::OrderLine")] unless line.is_a?(OrderLine)
31
+
32
+ [
33
+ *text_errors(line.name, "#{field}.name", LONG_TEXT),
34
+ *text_errors(line.unit, "#{field}.unit", SHORT_TEXT),
35
+ # `P_12Z` is optional, unlike `FaWiersz`'s `P_12` — an order position need not state
36
+ # a rate — so nil is a value here rather than a missing one.
37
+ line.vat_rate.nil? ? nil : enum_issue("#{field}.vat_rate", "TStawkaPodatku", line.vat_rate)
38
+ ].compact
39
+ end
40
+
41
+ def settled_errors(advances)
42
+ advances.each_with_index.flat_map do |advance, index|
43
+ field = "advances[#{index}]"
44
+ next [Issue.new(field: field, message: "is not a Ksef::FA3::AdvanceInvoice")] unless
45
+ advance.is_a?(AdvanceInvoice)
46
+
47
+ # The KSeF number's *format* is tier 2's, for the reason §8.4b gives: the only
48
+ # pattern this gem holds comes from the OpenAPI contract, and the XSD's is wider.
49
+ [*text_errors(advance.number, "#{field}.number", SHORT_TEXT),
50
+ encoding_issue(advance.ksef_number, "#{field}.ksef_number")].compact
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ksef
4
+ module FA3
5
+ # An advance invoice a settlement invoice settles (`FakturaZaliczkowa`).
6
+ #
7
+ # A `ROZ` — the invoice of art. 106f ust. 3, issued once the goods are delivered — names
8
+ # every advance invoice already issued for the same transaction, up to **100** of them.
9
+ #
10
+ # ## The choice is inverted from {CorrectedInvoice}'s, and that is the schema's doing
11
+ #
12
+ # `DaneFaKorygowanej` pairs its *marker* with the KSeF number. `FakturaZaliczkowa` pairs it
13
+ # with the plain one:
14
+ #
15
+ # - in KSeF → `NrKSeFFaZaliczkowej` alone, *"numer identyfikujący fakturę zaliczkową w
16
+ # KSeF"*;
17
+ # - outside KSeF → `NrKSeFZN`, *"znacznik faktury zaliczkowej wystawionej poza KSeF"*,
18
+ # followed by `NrFaZaliczkowej`, the number it carried on paper.
19
+ #
20
+ # So the two branches name *different fields*, and modelling it as one nil-able value would
21
+ # lose which. Both are carried, and exactly one must be given — checked here, so a document
22
+ # naming both or neither cannot be built.
23
+ AdvanceInvoice = Data.define(:ksef_number, :number)
24
+
25
+ # Reopened rather than using a `Data.define` block, so the constant below lands on the
26
+ # class *and* resolves from inside `#initialize` — a block body's constant lookup is
27
+ # lexical, against `Ksef::FA3`, so `ONE_OF` defined on the class would not be found there.
28
+ class AdvanceInvoice
29
+ # `#with` must re-run the constructor; on Ruby 3.2 it otherwise skips every invariant.
30
+ include Canonical
31
+
32
+ ONE_OF = "An advance invoice is named by exactly one of ksef_number: (its KSeF number) " \
33
+ "or number: (its own number, for one issued outside KSeF). The schema puts a " \
34
+ "choice here, so naming both or neither is not a document that exists."
35
+
36
+ # @param ksef_number [String, nil] `NrKSeFFaZaliczkowej`, for an advance invoice issued
37
+ # through KSeF
38
+ # @param number [String, nil] `NrFaZaliczkowej`, for one issued outside it
39
+ # @raise [Ksef::ValidationError] unless exactly one of the two is given
40
+ def initialize(ksef_number: nil, number: nil)
41
+ given = [ksef_number, number].compact
42
+ raise ValidationError, ONE_OF unless given.size == 1
43
+
44
+ super(ksef_number: Formatting.text(ksef_number), number: Formatting.text(number))
45
+ end
46
+
47
+ def to_fa3
48
+ return { "NrKSeFFaZaliczkowej" => ksef_number } if ksef_number
49
+
50
+ # `TWybor1` has one member: the marker is present or absent, with no "no" to write.
51
+ { "NrKSeFZN" => "1", "NrFaZaliczkowej" => number }
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ksef
4
+ module FA3
5
+ # Reads the two elements that make an invoice part of an advance-payment sequence:
6
+ # `Zamowienie`, the order a `ZAL` collects against, and `FakturaZaliczkowa`, the advance
7
+ # invoices a `ROZ` settles (docs/REFERENCE.md §8.5).
8
+ #
9
+ # Split out for the reason {CorrectionReader} is: it is the part of reading an invoice that
10
+ # is entirely about one family of types.
11
+ module AdvanceReader
12
+ class << self
13
+ # Namespace-aware element reading; see {NodeReader}.
14
+ include NodeReader
15
+
16
+ # @return [Order, nil]
17
+ def order_from(fa_node)
18
+ node = element(fa_node, "Zamowienie")
19
+ return nil if node.nil?
20
+
21
+ Order.new(total: text!(node, "WartoscZamowienia"),
22
+ lines: elements(node, "ZamowienieWiersz").map { |row| order_line_from(row) })
23
+ end
24
+
25
+ # @return [Array<AdvanceInvoice>] empty when the invoice settles nothing
26
+ def advances_from(fa_node)
27
+ elements(fa_node, "FakturaZaliczkowa").map { |node| advance_from(node) }
28
+ end
29
+
30
+ private
31
+
32
+ def order_line_from(row)
33
+ OrderLine.new(
34
+ name: text(row, "P_7Z"), unit: text(row, "P_8AZ"), quantity: text(row, "P_8BZ"),
35
+ net_unit_price: text(row, "P_9AZ"), net_amount: text(row, "P_11NettoZ"),
36
+ vat_amount: text(row, "P_11VatZ"), vat_rate: text(row, "P_12Z"),
37
+ row_number: text(row, "NrWierszaZam"), state_before: text(row, "StanPrzedZ")
38
+ )
39
+ end
40
+
41
+ # The schema's choice, read the way {CorrectionReader.reference_from} reads its own:
42
+ # a document stating neither branch is refused rather than being repaired into one,
43
+ # since choosing for it would assert something nobody wrote.
44
+ def advance_from(node)
45
+ ksef_number = text(node, "NrKSeFFaZaliczkowej")
46
+ return AdvanceInvoice.new(ksef_number: ksef_number) if ksef_number
47
+
48
+ number = text(node, "NrFaZaliczkowej")
49
+ return AdvanceInvoice.new(number: number) if number
50
+
51
+ raise ValidationError,
52
+ "FakturaZaliczkowa states neither NrKSeFFaZaliczkowej nor NrFaZaliczkowej. " \
53
+ "The schema requires one of the two, and picking one would assert where the " \
54
+ "advance invoice was issued."
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end