Class: Ksef::Client

Inherits:
Object
  • Object
show all
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

Classes: Receipt, Session

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(env: :test, auth: nil, clock: -> { Time.now }, sleeper: method(:sleep)) ⇒ Client

Returns a new instance of Client.

Parameters:

  • (defaults to: :test)

    :test, :demo or :prod

  • (defaults to: nil)

    a KSeF-token credential to authenticate with, or an already-redeemed access token

  • passed to Ksef::Configuration — logger:, timeout:, adapter:, proxy:, retry_policy:

  • (defaults to: -> { Time.now })

    returns the current Time; the same injection Sessions::Status and Ksef::Crypto::PublicKeys already take. It reaches Ksef::Crypto::PublicKeys, which decides whether a published certificate is currently valid, and the Auth::AccessToken this client builds, which decides staleness against the validUntil KSeF returned.

    It does not reach an Auth::AccessToken passed in as auth: — that one is already constructed and carries whatever clock it was built with. Build it with the same clock if you need both pinned.

    This is what makes a recorded cassette replayable. A recorded access token is valid for fifteen minutes and Auth::AccessToken refreshes at 80% of that, so about twelve minutes after recording a replay against the real clock reads it as stale and issues a POST /auth/token/refresh that the cassette has no interaction for. Pinning the clock to the moment of recording replays the flow as it happened, rather than rewriting what KSeF said (spec/recorded/session_flow_spec.rb).

  • (defaults to: method(:sleep))

    receives a number of seconds; used between polls of the authentication operation, which is asynchronous (Auth::Client#wait_until_complete). Sessions::Status#poll has taken one since it was written and #wait_until_accepted exposes it; this is the same seam for the one poll the facade performs on its own. A replay wants a no-op — the recorded tier spent eight of its nine seconds asleep between status calls whose answers were already on disk.



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.

Returns:

  • frozen



72
73
74
# File 'lib/ksef/client.rb', line 72

def config
  @config
end

Instance Method Details

#auth ⇒ Ksef::Auth::Client

Returns:



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.

Parameters:

Returns:

  • one per page; a collective UPO holds at most 10 000 invoices, and reading only the first page loses proof for the rest (§12)



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.

Returns:



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.

Parameters:

Returns:

  • FA(3) XML, verbatim



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.

Returns:



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

Returns:



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.

Returns:



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.

Parameters:

  • an FA(3) document

  • (defaults to: true)

    run the FA(3) validator first

  • (defaults to: nil)

    see #session; for the recorded tier only

Returns:



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.

Parameters:

  • (defaults to: nil)

    the session's symmetric key. Generated fresh when omitted, which is what every caller should do — a key is per-session by design (docs/REFERENCE.md §11.2a) and reusing one across sessions is a step towards reusing it across documents, which is what the per-session binding exists to prevent.

    It is injectable for exactly one reason: the recorded test tier (DESIGN.md §9.1). Encryptor.generate draws a random key and IV, and RSA-OAEP padding is randomised on top, so a recorded request body can never be reproduced — a replayed run has to supply the key the recording used. Without this seam the recorded tier would have to drive Sessions::Online directly and would stop testing the facade a user actually calls.

Yield Parameters:

Returns:

  • the block's value



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

Returns:



175
# File 'lib/ksef/client.rb', line 175

def session_status(session_reference) = status_client.session(session_reference)

#sessions ⇒ Ksef::Sessions::Online

Returns:



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.

Returns:



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.

Parameters:

Returns:



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

Returns:



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.

Raises:



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.

Parameters:

Yield Parameters:

Returns:

  • no longer in progress

Raises:

  • when the deadline passes with the session still working



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.

Parameters:

Returns:

  • always accepted

Raises:

  • with KSeF's own wording, and the original's references when the rejection was a duplicate

  • when the deadline passes with the invoice still processing



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