Class: Ksef::Client
- Inherits:
-
Object
- Object
- Ksef::Client
- Defined in:
- lib/ksef/client.rb,
lib/ksef/client/receipt.rb,
lib/ksef/client/session.rb
Overview
The facade — DESIGN.md §8's public API contract.
client = Ksef::Client.new(
env: :test,
auth: Ksef::Auth::Token.new(context_nip: "9999999999", token: ENV["KSEF_TOKEN"])
)
result = client.send_invoice(invoice)
status = client.wait_until_accepted(result.reference)
status.ksef_number
upo = client.upo(result.reference)
Everything below the facade is usable directly — Sessions::Online, Sessions::Status, UPO::Client and Auth::Client are all public, and a caller who wants the granular calls should reach for them. This assembles them into the twenty-line path.
Authentication happens once, lazily, and under a mutex
Constructing a client performs no I/O. The first call that needs a credential runs the whole KSeF-token flow — challenge, encrypt, submit, poll, redeem — and hands the result to Auth::AccessToken, which then refreshes itself at ~80% of its lifetime. A burst of threads produces one authentication, not one each, because the check happens inside the lock.
Thread safety
A single instance is safe to share (DESIGN.md §5.2), and the design is what makes that true rather than a promise about it. The configuration is frozen at construction; the Faraday connections are shared and stateless; the only mutable state is the memoised credential, guarded by one mutex. No session is ever held on the client — that was the deciding argument for opening a fresh one per #send_invoice (§11.2a), since a session cached here would be mutable state two threads could submit into at once.
Defined Under Namespace
Instance Attribute Summary collapse
-
#config ⇒ Ksef::Configuration
readonly
Frozen.
Instance Method Summary collapse
- #auth ⇒ Ksef::Auth::Client
-
#collective_upo(session_reference) ⇒ Array<Ksef::UPO::Document>
The collective UPO for a whole session, following the unmetered link when it is still valid.
-
#credential ⇒ Ksef::Auth::AccessToken
The access token, authenticating first if that has not happened yet.
-
#download_invoice(ksef_number) ⇒ String
Downloads an invoice KSeF holds, by its KSeF number.
-
#initialize(env: :test, auth: nil, clock: -> { Time.now }, sleeper: method(:sleep)) ⇒ Client
constructor
A new instance of Client.
- #inspect ⇒ Object
-
#invoice_status(receipt) ⇒ Ksef::Sessions::InvoiceState
The current status of one invoice, without waiting.
- #invoices ⇒ Ksef::Invoices::Client
-
#public_keys ⇒ Ksef::Crypto::PublicKeys
The clock goes here too, and that is not symmetry for its own sake:
#for_usagefilters published certificates onvalid_at?, so a replay running after they expire finds none and raises. -
#send_invoice(invoice, validate: true, encryptor: nil) ⇒ Receipt
Sends one invoice, in a session of its own.
-
#session(form_code: Sessions::DEFAULT_FORM_CODE, upo_version: Sessions::UPO_VERSION, encryptor: nil) {|batch| ... } ⇒ Object
Opens one session, yields a handle, and closes it however the block ends.
- #session_status(session_reference) ⇒ Ksef::Sessions::SessionState
- #sessions ⇒ Ksef::Sessions::Online
-
#status_client ⇒ Ksef::Sessions::Status
Named
status_clientrather thanstatusso it cannot be mistaken for #invoice_status or #session_status, which return an actual status. -
#upo(receipt) ⇒ Ksef::UPO::Document
The UPO for one invoice — the signed proof of receipt.
- #upo_client ⇒ Ksef::UPO::Client
-
#validate_invoice!(invoice) ⇒ Object
Runs the FA(3) validator, if the document knows how to validate itself.
-
#wait_for_session(session_reference) {|state| ... } ⇒ Ksef::Sessions::SessionState
Waits until KSeF has finished processing a whole session.
-
#wait_until_accepted(receipt) ⇒ Ksef::Sessions::InvoiceState
Waits until KSeF has decided about one invoice.
Constructor Details
#initialize(env: :test, auth: nil, clock: -> { Time.now }, sleeper: method(:sleep)) ⇒ Client
Returns a new instance of Client.
63 64 65 66 67 68 69 |
# File 'lib/ksef/client.rb', line 63 def initialize(env: :test, auth: nil, clock: -> { Time.now }, sleeper: method(:sleep), **) @config = Configuration.new(env: env, auth: auth, **) @mutex = Mutex.new @clock = clock @sleeper = sleeper @credential = auth.is_a?(Auth::AccessToken) ? auth : nil end |
Instance Attribute Details
#config ⇒ Ksef::Configuration (readonly)
Returns frozen.
72 73 74 |
# File 'lib/ksef/client.rb', line 72 def config @config end |
Instance Method Details
#auth ⇒ Ksef::Auth::Client
260 |
# File 'lib/ksef/client.rb', line 260 def auth = @auth ||= Auth::Client.new(connection) |
#collective_upo(session_reference) ⇒ Array<Ksef::UPO::Document>
The collective UPO for a whole session, following the unmetered link when it is still
valid. Available only after the session has closed and finished processing, so call
#wait_for_session first — 170 means closed but not done.
168 169 170 171 172 |
# File 'lib/ksef/client.rb', line 168 def collective_upo(session_reference) status_client.session(session_reference).upo_pages.map do |page| upo_client.fetch(page, session_reference: session_reference) end end |
#credential ⇒ Ksef::Auth::AccessToken
The access token, authenticating first if that has not happened yet.
265 266 267 |
# File 'lib/ksef/client.rb', line 265 def credential @mutex.synchronize { @credential ||= authenticate_with_rotation! } end |
#download_invoice(ksef_number) ⇒ String
Downloads an invoice KSeF holds, by its KSeF number.
205 |
# File 'lib/ksef/client.rb', line 205 def download_invoice(ksef_number) = invoices.download(ksef_number) |
#inspect ⇒ Object
269 |
# File 'lib/ksef/client.rb', line 269 def inspect = "#<Ksef::Client env=#{config.environment.name.inspect}>" |
#invoice_status(receipt) ⇒ Ksef::Sessions::InvoiceState
The current status of one invoice, without waiting.
142 143 144 |
# File 'lib/ksef/client.rb', line 142 def invoice_status(receipt) status_client.invoice(receipt.session_reference, receipt.invoice_reference) end |
#invoices ⇒ Ksef::Invoices::Client
249 |
# File 'lib/ksef/client.rb', line 249 def invoices = @invoices ||= Invoices::Client.new(connection, credential) |
#public_keys ⇒ Ksef::Crypto::PublicKeys
The clock goes here too, and that is not symmetry for its own sake: #for_usage filters
published certificates on valid_at?, so a replay running after they expire finds none
and raises. The cassettes' certificates run to 2027-09-29 — pinning only
Auth::AccessToken would have left the tier a time bomb with a longer fuse.
257 |
# File 'lib/ksef/client.rb', line 257 def public_keys = @public_keys ||= Crypto::PublicKeys.new(connection, clock: @clock) |
#send_invoice(invoice, validate: true, encryptor: nil) ⇒ Receipt
Sends one invoice, in a session of its own.
Fresh session per call is the decided default (§11.2a). For more than a handful of invoices use #session, which opens one session for all of them. The budgets to compare are the hourly ones: 120 session opens an hour against 180 invoice sends (§6.1). Per minute both are 30, so the saving is real but smaller than it first looks.
85 86 87 |
# File 'lib/ksef/client.rb', line 85 def send_invoice(invoice, validate: true, encryptor: nil) session(encryptor: encryptor) { |batch| batch.send_invoice(invoice, validate: validate) } end |
#session(form_code: Sessions::DEFAULT_FORM_CODE, upo_version: Sessions::UPO_VERSION, encryptor: nil) {|batch| ... } ⇒ Object
Opens one session, yields a handle, and closes it however the block ends.
Closing is what starts generation of the collective UPO (§11), so it matters that it happens — hence a block rather than a returned session.
106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 |
# File 'lib/ksef/client.rb', line 106 def session(form_code: Sessions::DEFAULT_FORM_CODE, upo_version: Sessions::UPO_VERSION, encryptor: nil) # Wrapped in the §10.2 remediation: certificates are cached for an hour, so an # emergency key rotation inside that window makes the cached `publicKeyId` unknown and # the open fails with `21470`. `with_key_rotation` re-fetches and re-selects, and the # certificate is chosen *inside* the block so the second attempt uses the new key. # # This is not a retry of a POST in the sense the hard rule forbids: a 21470 means the # request was declined outright, so there is no session to duplicate. opened = public_keys.with_key_rotation do sessions.open( encryptor: encryptor || Crypto::Encryptor.generate, certificate: public_keys.symmetric_key_encryption, form_code: form_code, upo_version: upo_version ) end yield Session.new(self, opened) ensure sessions.close(opened) if opened end |
#session_status(session_reference) ⇒ Ksef::Sessions::SessionState
175 |
# File 'lib/ksef/client.rb', line 175 def session_status(session_reference) = status_client.session(session_reference) |
#sessions ⇒ Ksef::Sessions::Online
233 |
# File 'lib/ksef/client.rb', line 233 def sessions = @sessions ||= Sessions::Online.new(connection, credential) |
#status_client ⇒ Ksef::Sessions::Status
Named status_client rather than status so it cannot be mistaken for
#invoice_status or #session_status, which return an actual status.
239 |
# File 'lib/ksef/client.rb', line 239 def status_client = @status_client ||= Sessions::Status.new(connection, credential) |
#upo(receipt) ⇒ Ksef::UPO::Document
The UPO for one invoice — the signed proof of receipt. Archive the bytes verbatim (§12); UPO::Document#write does that.
Uses the metered per-invoice route rather than chasing the unmetered pre-signed link, which is the opposite of what §14.2 prefers — deliberately, and only here. Obtaining that link costs a metered status call first, so for a single invoice the direct route is one request against two. The unmetered link earns its keep on collective UPOs and in bulk, where UPO::Client#fetch is the right entry point.
157 158 159 |
# File 'lib/ksef/client.rb', line 157 def upo(receipt) upo_client.for_invoice(receipt.session_reference, receipt.invoice_reference) end |
#upo_client ⇒ Ksef::UPO::Client
242 243 244 245 246 |
# File 'lib/ksef/client.rb', line 242 def upo_client @upo_client ||= UPO::Client.new( connection, credential, clock: @clock, storage: HTTP::Connection.storage(config) ) end |
#validate_invoice!(invoice) ⇒ Object
Runs the FA(3) validator, if the document knows how to validate itself.
A raw XML String does not — the transport layer accepts any #to_xml or a String
(DESIGN.md §5), and refusing one here would break that contract to enforce a check the
caller may already have done. But it is still bytes, and the byte-level admission
rules of docs/REFERENCE.md §15.1 apply to bytes whatever produced them. So a String gets
tier 1b: a review on 2026-08-24 pointed out that the gem, handed the poison fixture as a
String, would have shipped the very document tier 1 was built to stop — mis-encoded ERP
text being precisely the case §15.1 calls likely.
Not the schema tier: validating a caller's own XML against FA(3) would reject the batch and RR structures the transport layer is meant to carry.
221 222 223 224 225 226 227 228 229 230 |
# File 'lib/ksef/client.rb', line 221 def validate_invoice!(invoice) return invoice.validate! if invoice.respond_to?(:validate!) return unless invoice.is_a?(String) issues = FA3::DocumentValidator.errors_for(invoice) return if issues.empty? raise ValidationError, "The XML given is not admissible:\n#{issues.sort.map { |issue| " - #{issue}" }.join("\n")}" end |
#wait_for_session(session_reference) {|state| ... } ⇒ Ksef::Sessions::SessionState
Waits until KSeF has finished processing a whole session.
Closing a session and finishing it are two different clocks. #session closes on
the way out, which starts asynchronous generation of the collective UPO; the session
then sits at 170 until that finishes at 200. #wait_until_accepted does not cover
it — an accepted invoice says nothing about the session's own progress — so a caller
that sends, waits for the invoice and then asks for #collective_upo is racing.
This existed on Sessions::Status from the start and was simply not reachable through
the facade, which left #collective_upo's own documentation telling callers to poll
#session_status by hand. Two places in this repository did exactly that, and one of
them got it wrong: spec/integration/session_flow_spec.rb read the status once and
asserted a terminal code, which passed until the nightly of 2026-09-13 found TEST still
at 170.
197 198 199 |
# File 'lib/ksef/client.rb', line 197 def wait_for_session(session_reference, **, &) status_client.wait_for_session(session_reference, **, &) end |
#wait_until_accepted(receipt) ⇒ Ksef::Sessions::InvoiceState
Waits until KSeF has decided about one invoice.
135 136 137 |
# File 'lib/ksef/client.rb', line 135 def wait_until_accepted(receipt, **) status_client.wait_until_accepted(receipt.session_reference, receipt.invoice_reference, **) end |