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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1424 -0
- data/CONTRIBUTING.md +45 -6
- data/README.md +377 -25
- data/SECURITY.md +25 -2
- data/docs/REFERENCE.md +3099 -34
- data/docs/errors.md +33 -4
- data/docs/field_mapping.md +390 -0
- data/lib/ksef/auth/access_token.rb +150 -0
- data/lib/ksef/auth/authorization_policy.rb +88 -0
- data/lib/ksef/auth/challenge.rb +53 -0
- data/lib/ksef/auth/client.rb +143 -0
- data/lib/ksef/auth/initiation.rb +22 -0
- data/lib/ksef/auth/operation_status.rb +34 -0
- data/lib/ksef/auth/schema/schemat_auth_v2-0.xsd +109 -0
- data/lib/ksef/auth/schema/schemat_auth_v2-1.xsd +109 -0
- data/lib/ksef/auth/signature_template.rb +120 -0
- data/lib/ksef/auth/signer.rb +132 -0
- data/lib/ksef/auth/status.rb +69 -0
- data/lib/ksef/auth/token.rb +121 -0
- data/lib/ksef/auth/token_info.rb +27 -0
- data/lib/ksef/auth/token_request.rb +142 -0
- data/lib/ksef/auth/tokens.rb +24 -0
- data/lib/ksef/auth/validator.rb +84 -0
- data/lib/ksef/auth/xades.rb +40 -0
- data/lib/ksef/auth.rb +43 -0
- data/lib/ksef/client/receipt.rb +37 -0
- data/lib/ksef/client/session.rb +68 -0
- data/lib/ksef/client.rb +314 -0
- data/lib/ksef/crypto/certificate.rb +78 -0
- data/lib/ksef/crypto/digest.rb +25 -0
- data/lib/ksef/crypto/encryptor.rb +131 -0
- data/lib/ksef/crypto/public_keys.rb +146 -0
- data/lib/ksef/crypto.rb +66 -0
- data/lib/ksef/environments.rb +1 -1
- data/lib/ksef/errors.rb +35 -2
- data/lib/ksef/fa3/address.rb +64 -0
- data/lib/ksef/fa3/advance_checks.rb +55 -0
- data/lib/ksef/fa3/advance_invoice.rb +55 -0
- data/lib/ksef/fa3/advance_reader.rb +59 -0
- data/lib/ksef/fa3/attachment.rb +43 -0
- data/lib/ksef/fa3/attachment_checks.rb +121 -0
- data/lib/ksef/fa3/attachment_reader.rb +99 -0
- data/lib/ksef/fa3/attachment_table.rb +98 -0
- data/lib/ksef/fa3/builder/advances.rb +57 -0
- data/lib/ksef/fa3/builder/corrections.rb +46 -0
- data/lib/ksef/fa3/builder/subjects.rb +71 -0
- data/lib/ksef/fa3/builder.rb +149 -0
- data/lib/ksef/fa3/business_validator.rb +134 -0
- data/lib/ksef/fa3/canonical.rb +44 -0
- data/lib/ksef/fa3/corrected_invoice.rb +53 -0
- data/lib/ksef/fa3/correction.rb +151 -0
- data/lib/ksef/fa3/correction_checks.rb +106 -0
- data/lib/ksef/fa3/correction_reader.rb +88 -0
- data/lib/ksef/fa3/data_block.rb +75 -0
- data/lib/ksef/fa3/document_mapping.rb +142 -0
- data/lib/ksef/fa3/document_validator.rb +174 -0
- data/lib/ksef/fa3/element_tree.rb +49 -0
- data/lib/ksef/fa3/field_checks.rb +151 -0
- data/lib/ksef/fa3/formatting.rb +273 -0
- data/lib/ksef/fa3/generated/enums.rb +652 -0
- data/lib/ksef/fa3/generated/types.rb +3308 -0
- data/lib/ksef/fa3/invoice.rb +259 -0
- data/lib/ksef/fa3/issue.rb +32 -0
- data/lib/ksef/fa3/line.rb +162 -0
- data/lib/ksef/fa3/meta_entry.rb +45 -0
- data/lib/ksef/fa3/model_validator.rb +219 -0
- data/lib/ksef/fa3/nip.rb +59 -0
- data/lib/ksef/fa3/node_reader.rb +46 -0
- data/lib/ksef/fa3/order.rb +55 -0
- data/lib/ksef/fa3/order_line.rb +70 -0
- data/lib/ksef/fa3/parser.rb +208 -0
- data/lib/ksef/fa3/provenance.rb +137 -0
- data/lib/ksef/fa3/rounding_inference.rb +87 -0
- data/lib/ksef/fa3/row_reader.rb +66 -0
- data/lib/ksef/fa3/serializer.rb +140 -0
- data/lib/ksef/fa3/subject.rb +128 -0
- data/lib/ksef/fa3/subject_checks.rb +126 -0
- data/lib/ksef/fa3/subject_reader.rb +76 -0
- data/lib/ksef/fa3/summaries.rb +107 -0
- data/lib/ksef/fa3/summary_checks.rb +75 -0
- data/lib/ksef/fa3/table_column.rb +49 -0
- data/lib/ksef/fa3/totals.rb +119 -0
- data/lib/ksef/fa3/validator.rb +79 -0
- data/lib/ksef/fa3/vat_rate.rb +94 -0
- data/lib/ksef/fa3.rb +59 -0
- data/lib/ksef/http/connection.rb +47 -4
- data/lib/ksef/http/json_decoder.rb +53 -0
- data/lib/ksef/http/retry.rb +105 -0
- data/lib/ksef/invoices/client.rb +72 -0
- data/lib/ksef/ksef_number.rb +150 -0
- data/lib/ksef/sessions/invoice_codes.rb +70 -0
- data/lib/ksef/sessions/invoice_state.rb +68 -0
- data/lib/ksef/sessions/online.rb +169 -0
- data/lib/ksef/sessions/session_codes.rb +73 -0
- data/lib/ksef/sessions/session_state.rb +48 -0
- data/lib/ksef/sessions/status.rb +138 -0
- data/lib/ksef/sessions/upo_page.rb +36 -0
- data/lib/ksef/sessions.rb +91 -0
- data/lib/ksef/upo/client.rb +154 -0
- data/lib/ksef/upo/document.rb +74 -0
- data/lib/ksef/upo/schema/upo-v4-3.xsd +293 -0
- data/lib/ksef/upo/validation.rb +37 -0
- data/lib/ksef/upo/validator.rb +124 -0
- data/lib/ksef/upo.rb +55 -0
- data/lib/ksef/version.rb +1 -1
- data/lib/ksef.rb +18 -0
- 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
|
data/lib/ksef/crypto.rb
ADDED
|
@@ -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
|
data/lib/ksef/environments.rb
CHANGED
|
@@ -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
|
|
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
|
+
# 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
|
-
#
|
|
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
|