Class: Ksef::UPO::Client

Inherits:
Object
  • Object
show all
Defined in:
lib/ksef/upo/client.rb

Overview

Retrieving UPO documents, by either of the two available routes (docs/REFERENCE.md §12, §14.2).

Two routes, and which to prefer

The pre-signed link in a session's upo.pages[] or an invoice's upoDownloadUrl is unmetered, carries an integrity hash, and expires. The API route is metered against a budget where GET /sessions already allows only 10 requests a minute (§6.1), and publishes no hash.

So the link is preferred and the API route is the fallback, which is the resolution §14.2 arrived at — after an earlier revision of the ledger got it backwards and concluded the link should be ignored. #fetch implements that preference.

The connection split is the safety mechanism

Requests to a pre-signed link go over storage, a connection built by HTTP::Connection.storage with no credential and no base URL. Sending the access token to third-party storage would leak it, and the contract says not to; using a separate connection means no code path here can.

Instance Method Summary collapse

Constructor Details

#initialize(connection, credential, storage:, clock: -> { Time.now }) ⇒ Client

Returns a new instance of Client.

Parameters:

  • connection (Faraday::Connection) —

    the authenticated API connection, for the metered routes

  • credential (Ksef::Auth::AccessToken, String) —

    anything with #bearer

  • storage (Faraday::Connection) —

    from HTTP::Connection.storage — must not carry a credential

  • clock (#call) (defaults to: -> { Time.now }) —

    returns the current Time. #fetch is the only route decision in this library that depends on the clock, so it is the only reason this exists — see there for what reading the wall clock instead cost.



36
37
38
39
40
41
# File 'lib/ksef/upo/client.rb', line 36

def initialize(connection, credential, storage:, clock: -> { Time.now })
  @connection = connection
  @credential = credential
  @storage = storage
  @clock = clock
end

Instance Method Details

#collective(session_reference, upo_reference) ⇒ Object

The collective UPO for a whole session, over the metered API route.



62
63
64
65
66
67
# File 'lib/ksef/upo/client.rb', line 62

def collective(session_reference, upo_reference)
  session = Sessions.reference_number!(session_reference)
  upo = Sessions.reference_number!(upo_reference)

  via_api("sessions/#{session}/upo/#{upo}")
end

#download(page) ⇒ Document

Follows a pre-signed link, verifying the published hash.

Parameters:

Returns:

Raises:



48
49
50
51
52
53
54
55
56
57
58
59
# File 'lib/ksef/upo/client.rb', line 48

def download(page)
  url = page.respond_to?(:download_url) ? page.download_url : page.to_s
  raise ValidationError, "No download URL to follow" if url.to_s.empty?

  # No Authorization header is set here, and `@storage` has none by construction.
  response = @storage.get(absolute(url))
  Document.new(
    xml: response.body.to_s,
    published_hash: response.headers[HASH_HEADER],
    source: :storage
  ).verify!
end

#fetch(page, session_reference:) ⇒ Document

Prefers the unmetered link and falls back to the metered route when it has expired or is missing — the resolution of §14.2, in one call.

The expiry is judged against the injected clock, not Time.now. This read the wall clock until 2026-09-03, which made it the one route decision in this library the recorded tier's pinned clock could not reach. KSeF signs the link for about three days, so spec/recorded/invoice_download_spec.rb replayed correctly for three and then started taking the metered fallback — a request its cassette had never recorded, since the recording itself took the link. The tier went red on 2026-08-29 with nothing changed, and nothing noticed until 2026-09-03 because no push ran the suite in between.

Same failure as the fifteen-minute access token (Client#initialize), one layer down: a recorded response is not a fixture, it decays, and every decision made against it has to be made against the moment it was recorded.

Parameters:

Returns:



106
107
108
109
110
# File 'lib/ksef/upo/client.rb', line 106

def fetch(page, session_reference:)
  return download(page) unless page.download_url.to_s.empty? || page.expired?(@clock.call)

  collective(session_reference, page.reference_number)
end

#for_invoice(session_reference, invoice_reference) ⇒ Object

One invoice's UPO, addressed by its submission reference.



70
71
72
73
74
75
# File 'lib/ksef/upo/client.rb', line 70

def for_invoice(session_reference, invoice_reference)
  session = Sessions.reference_number!(session_reference)
  invoice = Sessions.reference_number!(invoice_reference)

  via_api("sessions/#{session}/invoices/#{invoice}/upo")
end

#for_ksef_number(session_reference, ksef_number) ⇒ Object

One invoice's UPO, addressed by its KSeF number.

The number is parsed before use, so a mistyped one fails here on its checksum rather than as an opaque 404 (§13).



81
82
83
84
85
86
# File 'lib/ksef/upo/client.rb', line 81

def for_ksef_number(session_reference, ksef_number)
  session = Sessions.reference_number!(session_reference)
  number = KsefNumber.parse(ksef_number)

  via_api("sessions/#{session}/invoices/ksef/#{number}/upo")
end