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,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module Ksef
6
+ module Auth
7
+ # XSD validation for `AuthTokenRequest`, mirroring {Ksef::FA3::Validator}.
8
+ #
9
+ # Both pinned schema files are structurally identical — diffing them shows the only
10
+ # differences are the target namespace and the three IP patterns, which v2.0 got wrong
11
+ # badly enough that libxml2 will not compile the file at all (docs/REFERENCE.md §14.4).
12
+ #
13
+ # So **v2.1's file is the single source of validation rules**, and its target namespace
14
+ # is rewritten in memory to match the document being checked. Exactly the approach
15
+ # {Ksef::FA3::Validator} takes to its remote `schemaLocation`, and for the same reason:
16
+ # the pinned files stay byte-identical, so their recorded digests keep verifying.
17
+ #
18
+ # The upshot is that a 2.0 document — which is what the API expects and what both
19
+ # official clients emit — gets validated against rules that actually compile, which is
20
+ # strictly better than validating it against v2.0 itself. That is impossible.
21
+ module Validator
22
+ SCHEMA_DIR = File.expand_path("schema", __dir__)
23
+ SOURCE = File.join(SCHEMA_DIR, "schemat_auth_v2-1.xsd")
24
+ SOURCE_NAMESPACE = NAMESPACES.fetch("2.1")
25
+
26
+ class << self
27
+ # @param namespace [String] one of {Ksef::Auth::NAMESPACES}' values
28
+ # @return [Nokogiri::XML::Schema] memoised per namespace; compiling is not free and
29
+ # a client authenticating repeatedly should pay once
30
+ def schema_for(namespace)
31
+ reject_unknown_namespace(namespace)
32
+ schemas[namespace] ||= Nokogiri::XML::Schema(retargeted_source(namespace))
33
+ end
34
+
35
+ # @param xml [String, Nokogiri::XML::Document]
36
+ # @return [Array<String>] validation messages, empty when the document is valid
37
+ def errors_for(xml)
38
+ document = xml.is_a?(Nokogiri::XML::Document) ? xml : Nokogiri::XML(xml)
39
+ schema_for(namespace_of(document)).validate(document).map(&:message)
40
+ end
41
+
42
+ # @return [Boolean]
43
+ def valid?(xml) = errors_for(xml).empty?
44
+
45
+ # @param advisory [String] appended to the message; used to explain that a failure
46
+ # may stem from an upstream schema defect rather than the caller's data
47
+ # @raise [Ksef::ValidationError] listing every schema violation
48
+ def validate!(xml, advisory: "")
49
+ errors = errors_for(xml)
50
+ return true if errors.empty?
51
+
52
+ detail = errors.map { |e| " - #{e}" }.join("\n")
53
+ raise ValidationError, "AuthTokenRequest is not schema-valid:\n#{detail}#{advisory}"
54
+ end
55
+
56
+ private
57
+
58
+ def schemas = @schemas ||= {}
59
+
60
+ # Read from the document rather than assumed, so a request built for either version
61
+ # validates against the matching rules — but only the two known namespaces are
62
+ # accepted, otherwise a typo would silently validate against itself.
63
+ def namespace_of(document)
64
+ document.root&.namespace&.href
65
+ end
66
+
67
+ def reject_unknown_namespace(namespace)
68
+ return if NAMESPACES.value?(namespace)
69
+
70
+ raise ValidationError,
71
+ "AuthTokenRequest declares namespace #{namespace.inspect}, which is not a known " \
72
+ "schema version. Expected one of #{NAMESPACES.values.map(&:inspect).join(", ")}."
73
+ end
74
+
75
+ def retargeted_source(namespace)
76
+ source = File.read(SOURCE, encoding: "UTF-8")
77
+ return source if namespace == SOURCE_NAMESPACE
78
+
79
+ source.gsub(SOURCE_NAMESPACE, namespace)
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module Ksef
6
+ module Auth
7
+ # The namespace URIs and algorithm identifiers of the XAdES signature
8
+ # (docs/REFERENCE.md §4.3).
9
+ #
10
+ # Its own module so that {Signer} and {SignatureTemplate} share one definition rather
11
+ # than one referring to the other's constants. Included rather than referenced, so
12
+ # `Signer::DS` keeps resolving — these names are part of the public vocabulary and are
13
+ # asserted directly in the specs.
14
+ module Xades
15
+ # Read from pinned artifacts, not from memory: `spec/fixtures/xades/` holds the
16
+ # schemas upstream redistributes, and these are their target namespaces (§4.3).
17
+ DS = "http://www.w3.org/2000/09/xmldsig#"
18
+ XADES = "http://uri.etsi.org/01903/v1.3.2#"
19
+
20
+ # Every one of these appears in the Ministry's allow-list in `auth/podpis-xades.md`,
21
+ # so the combination needs no further verification (§4.3).
22
+ CANONICALIZATION = "http://www.w3.org/2001/10/xml-exc-c14n#"
23
+ SIGNATURE_METHOD = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"
24
+ DIGEST_METHOD = "http://www.w3.org/2001/04/xmlenc#sha256"
25
+ ENVELOPED_SIGNATURE = "http://www.w3.org/2000/09/xmldsig#enveloped-signature"
26
+
27
+ # The one identifier the allow-list does not state. Fixed by ETSI TS 101 903 and
28
+ # taken from `ksef-client-csharp`'s `SignatureService.SignedPropertiesType`; ledgered
29
+ # in §4.3 with that provenance rather than treated as common knowledge.
30
+ SIGNED_PROPERTIES_TYPE = "http://uri.etsi.org/01903#SignedProperties"
31
+
32
+ C14N_MODE = Nokogiri::XML::XML_C14N_EXCLUSIVE_1_0
33
+
34
+ SIGNATURE_ID = "Signature"
35
+ SIGNED_PROPERTIES_ID = "SignedProperties"
36
+
37
+ XPATH_NAMESPACES = { "ds" => DS, "xades" => XADES }.freeze
38
+ end
39
+ end
40
+ end
data/lib/ksef/auth.rb ADDED
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Ksef
6
+ # Authentication against KSeF (docs/REFERENCE.md §4).
7
+ module Auth
8
+ # Both schema versions of the `AuthTokenRequest` document.
9
+ #
10
+ # **2.0 is the default, and that is a deliberate correction.** v2.1 is the newer file,
11
+ # but every piece of available evidence points at 2.0 as what the API actually expects:
12
+ # both official clients bind to it (`[XmlRoot(Namespace = "…/2.0")]` in
13
+ # `AuthenticationTokenRequest.cs`; JAXB classes generated against 2.0 in
14
+ # `ksef-client-java`), the Java client bundles its own copy of the 2.0 schema, and
15
+ # every worked example in `CIRFMF/ksef-api` declares 2.0. Nothing observed so far
16
+ # emits 2.1. See §14.4.
17
+ NAMESPACES = {
18
+ "2.0" => "http://ksef.mf.gov.pl/auth/token/2.0",
19
+ "2.1" => "http://ksef.mf.gov.pl/auth/token/2.1"
20
+ }.freeze
21
+
22
+ DEFAULT_SCHEMA_VERSION = "2.0"
23
+
24
+ class << self
25
+ # Parses the contract's `date-time` fields.
26
+ #
27
+ # Returns `nil` rather than raising on an unparseable value: these timestamps are
28
+ # informational — expiry hints, start times — and a malformed one is no reason to
29
+ # fail an authentication that otherwise succeeded. The fields that actually matter
30
+ # are the tokens themselves.
31
+ #
32
+ # @return [Time, nil]
33
+ def time(value)
34
+ return value if value.is_a?(Time)
35
+ return nil if value.nil? || value.to_s.empty?
36
+
37
+ Time.iso8601(value.to_s)
38
+ rescue ArgumentError
39
+ nil
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ksef
4
+ class Client
5
+ # What {Ksef::Client#send_invoice} returns: proof that KSeF accepted the *upload*, and
6
+ # the pair of references needed to find out what became of the invoice.
7
+ #
8
+ # ## Why `#reference` returns the whole object
9
+ #
10
+ # DESIGN.md §8's contract reads `client.wait_until_accepted(result.reference)`, which
11
+ # looks like it wants a single string. It cannot be one: every status and UPO endpoint is
12
+ # keyed on **both** the session and the invoice (`GET /sessions/{ref}/invoices/{ref}`), so
13
+ # an invoice reference on its own cannot look anything up.
14
+ #
15
+ # Rather than bend the API into passing two arguments, or hiding the session on a client
16
+ # that has to stay thread-safe, `#reference` returns `self` — because the pair genuinely
17
+ # *is* the reference of a submission. §8's snippet then runs verbatim, and nothing has to
18
+ # remember which session an invoice went through.
19
+ #
20
+ # Note what this is **not**: acceptance. `POST .../invoices` answers `202`, meaning the
21
+ # upload was taken, and whether the invoice itself is accepted arrives asynchronously
22
+ # (§12.1). {Ksef::Client#wait_until_accepted} is what settles that.
23
+ Receipt = Data.define(:session_reference, :invoice_reference, :session_valid_until) do
24
+ # See the class note: the pair is the reference.
25
+ def reference = self
26
+
27
+ # The invoice's own reference number, which is what a human quotes.
28
+ def to_s = invoice_reference.to_s
29
+
30
+ # The session closes itself at this point (§11), after which the collective UPO is
31
+ # generated. Exposed because a long batch may want to check rather than assume.
32
+ def session_expired?(now = Time.now)
33
+ !session_valid_until.nil? && session_valid_until <= now
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ksef
4
+ class Client
5
+ # The handle {Ksef::Client#session} yields — one open session, for sending several
6
+ # invoices through deliberately.
7
+ #
8
+ # ## Why batching is opt-in rather than the default
9
+ #
10
+ # `Ksef::Client#send_invoice` opens a fresh session per call (docs/REFERENCE.md §11.2a,
11
+ # decided 2026-08-23). That is the safe default, not the efficient one: a session lives
12
+ # twelve hours and takes ten thousand invoices, so opening one per invoice spends
13
+ # `POST /sessions/online`'s budget of 30/min where a batch would spend one.
14
+ #
15
+ # So this exists for anyone sending more than a handful — and it is a block, not a
16
+ # returned object, because the session must be closed. Closing is what triggers
17
+ # generation of the collective UPO (§11); a session merely abandoned closes itself at
18
+ # `validUntil` up to twelve hours later, and the proof of receipt waits that long with it.
19
+ #
20
+ # client.session do |batch|
21
+ # invoices.each { |invoice| batch.send_invoice(invoice) }
22
+ # end # ← closed here, whatever happened inside
23
+ #
24
+ # The whole session shares one symmetric key, which is exactly why {Sessions::Online}
25
+ # binds the encryptor to the session rather than taking one per send.
26
+ class Session
27
+ # @return [Ksef::Sessions::Online::Session] the open session, key and all
28
+ attr_reader :opened
29
+
30
+ def initialize(client, opened)
31
+ @client = client
32
+ @opened = opened
33
+ @receipts = []
34
+ end
35
+
36
+ # Validates, encrypts and submits one invoice into this session.
37
+ #
38
+ # @param invoice [#to_xml, String]
39
+ # @param validate [Boolean] run the FA(3) validator first. On by default: a document
40
+ # KSeF will reject costs a round trip and a slot in the session, and the local
41
+ # validator is free (DESIGN.md §7.7).
42
+ # @return [Receipt]
43
+ def send_invoice(invoice, validate: true)
44
+ @client.validate_invoice!(invoice) if validate
45
+ submission = @client.sessions.send_invoice(@opened, invoice)
46
+
47
+ Receipt.new(
48
+ session_reference: @opened.reference_number,
49
+ invoice_reference: submission.reference_number,
50
+ session_valid_until: @opened.valid_until
51
+ ).tap { |receipt| @receipts << receipt }
52
+ end
53
+
54
+ # Every receipt from this session, in the order they were submitted.
55
+ #
56
+ # Worth keeping: after {Ksef::Client#session} returns, the collective UPO covers
57
+ # exactly these invoices, and a caller that discarded the receipts has no way to ask
58
+ # about any of them individually.
59
+ #
60
+ # @return [Array<Receipt>]
61
+ def receipts = @receipts.dup
62
+
63
+ def reference_number = @opened.reference_number
64
+
65
+ def to_s = reference_number.to_s
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,314 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ksef
4
+ # The facade — DESIGN.md §8's public API contract.
5
+ #
6
+ # client = Ksef::Client.new(
7
+ # env: :test,
8
+ # auth: Ksef::Auth::Token.new(context_nip: "9999999999", token: ENV["KSEF_TOKEN"])
9
+ # )
10
+ #
11
+ # result = client.send_invoice(invoice)
12
+ # status = client.wait_until_accepted(result.reference)
13
+ # status.ksef_number
14
+ # upo = client.upo(result.reference)
15
+ #
16
+ # Everything below the facade is usable directly — {Sessions::Online}, {Sessions::Status},
17
+ # {UPO::Client} and {Auth::Client} are all public, and a caller who wants the granular
18
+ # calls should reach for them. This assembles them into the twenty-line path.
19
+ #
20
+ # ## Authentication happens once, lazily, and under a mutex
21
+ #
22
+ # Constructing a client performs no I/O. The first call that needs a credential runs the
23
+ # whole KSeF-token flow — challenge, encrypt, submit, poll, redeem — and hands the result
24
+ # to {Auth::AccessToken}, which then refreshes itself at ~80% of its lifetime. A burst of
25
+ # threads produces one authentication, not one each, because the check happens inside the
26
+ # lock.
27
+ #
28
+ # ## Thread safety
29
+ #
30
+ # A single instance is safe to share (DESIGN.md §5.2), and the design is what makes that
31
+ # true rather than a promise about it. The configuration is frozen at construction; the
32
+ # Faraday connections are shared and stateless; the only mutable state is the memoised
33
+ # credential, guarded by one mutex. **No session is ever held on the client** — that was
34
+ # the deciding argument for opening a fresh one per {#send_invoice} (§11.2a), since a
35
+ # session cached here would be mutable state two threads could submit into at once.
36
+ class Client
37
+ # @param env [Symbol, Ksef::Environments::Environment] `:test`, `:demo` or `:prod`
38
+ # @param auth [Ksef::Auth::Token, Ksef::Auth::AccessToken] a KSeF-token credential to
39
+ # authenticate with, or an already-redeemed access token
40
+ # @param options [Hash] passed to {Ksef::Configuration} — `logger:`, `timeout:`,
41
+ # `adapter:`, `proxy:`, `retry_policy:`
42
+ # @param clock [#call] returns the current {Time}; the same injection {Sessions::Status}
43
+ # and {Crypto::PublicKeys} already take. It reaches {Crypto::PublicKeys}, which decides
44
+ # whether a published certificate is currently valid, and the {Auth::AccessToken} this
45
+ # client builds, which decides staleness against the `validUntil` KSeF returned.
46
+ #
47
+ # **It does not reach an `Auth::AccessToken` passed in as `auth:`** — that one is already
48
+ # constructed and carries whatever clock it was built with. Build it with the same clock
49
+ # if you need both pinned.
50
+ #
51
+ # **This is what makes a recorded cassette replayable.** A recorded access token is
52
+ # valid for fifteen minutes and {Auth::AccessToken} refreshes at 80% of that, so about
53
+ # twelve minutes after recording a replay against the real clock reads it as stale and
54
+ # issues a `POST /auth/token/refresh` that the cassette has no interaction for. Pinning
55
+ # the clock to the moment of recording replays the flow as it happened, rather than
56
+ # rewriting what KSeF said (`spec/recorded/session_flow_spec.rb`).
57
+ # @param sleeper [#call] receives a number of seconds; used between polls of the
58
+ # authentication operation, which is asynchronous ({Auth::Client#wait_until_complete}).
59
+ # {Sessions::Status#poll} has taken one since it was written and {#wait_until_accepted}
60
+ # exposes it; this is the same seam for the one poll the facade performs on its own.
61
+ # A replay wants a no-op — the recorded tier spent eight of its nine seconds asleep
62
+ # between status calls whose answers were already on disk.
63
+ def initialize(env: :test, auth: nil, clock: -> { Time.now }, sleeper: method(:sleep), **)
64
+ @config = Configuration.new(env: env, auth: auth, **)
65
+ @mutex = Mutex.new
66
+ @clock = clock
67
+ @sleeper = sleeper
68
+ @credential = auth.is_a?(Auth::AccessToken) ? auth : nil
69
+ end
70
+
71
+ # @return [Ksef::Configuration] frozen
72
+ attr_reader :config
73
+
74
+ # Sends one invoice, in a session of its own.
75
+ #
76
+ # Fresh session per call is the decided default (§11.2a). For more than a handful of
77
+ # invoices use {#session}, which opens one session for all of them. The budgets to compare
78
+ # are the hourly ones: 120 session opens an hour against 180 invoice sends (§6.1). Per
79
+ # minute both are 30, so the saving is real but smaller than it first looks.
80
+ #
81
+ # @param invoice [#to_xml, String] an FA(3) document
82
+ # @param validate [Boolean] run the FA(3) validator first
83
+ # @return [Receipt]
84
+ # @param encryptor [Crypto::Encryptor, nil] see {#session}; for the recorded tier only
85
+ def send_invoice(invoice, validate: true, encryptor: nil)
86
+ session(encryptor: encryptor) { |batch| batch.send_invoice(invoice, validate: validate) }
87
+ end
88
+
89
+ # Opens one session, yields a handle, and closes it however the block ends.
90
+ #
91
+ # Closing is what starts generation of the collective UPO (§11), so it matters that it
92
+ # happens — hence a block rather than a returned session.
93
+ #
94
+ # @yieldparam batch [Session]
95
+ # @param encryptor [Crypto::Encryptor, nil] the session's symmetric key. Generated fresh
96
+ # when omitted, which is what every caller should do — a key is per-session by design
97
+ # (docs/REFERENCE.md §11.2a) and reusing one across sessions is a step towards reusing
98
+ # it across *documents*, which is what the per-session binding exists to prevent.
99
+ #
100
+ # It is injectable for exactly one reason: **the recorded test tier** (DESIGN.md §9.1).
101
+ # `Encryptor.generate` draws a random key and IV, and RSA-OAEP padding is randomised on
102
+ # top, so a recorded request body can never be reproduced — a replayed run has to supply
103
+ # the key the recording used. Without this seam the recorded tier would have to drive
104
+ # {Sessions::Online} directly and would stop testing the facade a user actually calls.
105
+ # @return the block's value
106
+ def session(form_code: Sessions::DEFAULT_FORM_CODE, upo_version: Sessions::UPO_VERSION,
107
+ encryptor: nil)
108
+ # Wrapped in the §10.2 remediation: certificates are cached for an hour, so an
109
+ # emergency key rotation inside that window makes the cached `publicKeyId` unknown and
110
+ # the open fails with `21470`. `with_key_rotation` re-fetches and re-selects, and the
111
+ # certificate is chosen *inside* the block so the second attempt uses the new key.
112
+ #
113
+ # This is not a retry of a POST in the sense the hard rule forbids: a 21470 means the
114
+ # request was declined outright, so there is no session to duplicate.
115
+ opened = public_keys.with_key_rotation do
116
+ sessions.open(
117
+ encryptor: encryptor || Crypto::Encryptor.generate,
118
+ certificate: public_keys.symmetric_key_encryption,
119
+ form_code: form_code,
120
+ upo_version: upo_version
121
+ )
122
+ end
123
+ yield Session.new(self, opened)
124
+ ensure
125
+ sessions.close(opened) if opened
126
+ end
127
+
128
+ # Waits until KSeF has decided about one invoice.
129
+ #
130
+ # @param receipt [Receipt] from {#send_invoice}
131
+ # @return [Ksef::Sessions::InvoiceState] always accepted
132
+ # @raise [Ksef::InvoiceRejectedError] with KSeF's own wording, and the original's
133
+ # references when the rejection was a duplicate
134
+ # @raise [Ksef::TimeoutError] when the deadline passes with the invoice still processing
135
+ def wait_until_accepted(receipt, **)
136
+ status_client.wait_until_accepted(receipt.session_reference, receipt.invoice_reference, **)
137
+ end
138
+
139
+ # The current status of one invoice, without waiting.
140
+ #
141
+ # @return [Ksef::Sessions::InvoiceState]
142
+ def invoice_status(receipt)
143
+ status_client.invoice(receipt.session_reference, receipt.invoice_reference)
144
+ end
145
+
146
+ # The UPO for one invoice — the signed proof of receipt. **Archive the bytes verbatim**
147
+ # (§12); {UPO::Document#write} does that.
148
+ #
149
+ # Uses the metered per-invoice route rather than chasing the unmetered pre-signed link,
150
+ # which is the opposite of what §14.2 prefers — deliberately, and only here. Obtaining
151
+ # that link costs a metered status call first, so for a single invoice the direct route
152
+ # is one request against two. The unmetered link earns its keep on *collective* UPOs and
153
+ # in bulk, where {UPO::Client#fetch} is the right entry point.
154
+ #
155
+ # @param receipt [Receipt]
156
+ # @return [Ksef::UPO::Document]
157
+ def upo(receipt)
158
+ upo_client.for_invoice(receipt.session_reference, receipt.invoice_reference)
159
+ end
160
+
161
+ # The collective UPO for a whole session, following the unmetered link when it is still
162
+ # valid. Available only after the session has closed *and* finished processing, so call
163
+ # {#wait_for_session} first — `170` means closed but not done.
164
+ #
165
+ # @param session_reference [String]
166
+ # @return [Array<Ksef::UPO::Document>] one per page; a collective UPO holds at most
167
+ # 10 000 invoices, and reading only the first page loses proof for the rest (§12)
168
+ def collective_upo(session_reference)
169
+ status_client.session(session_reference).upo_pages.map do |page|
170
+ upo_client.fetch(page, session_reference: session_reference)
171
+ end
172
+ end
173
+
174
+ # @return [Ksef::Sessions::SessionState]
175
+ def session_status(session_reference) = status_client.session(session_reference)
176
+
177
+ # Waits until KSeF has finished processing a whole session.
178
+ #
179
+ # **Closing a session and finishing it are two different clocks.** {#session} closes on
180
+ # the way out, which *starts* asynchronous generation of the collective UPO; the session
181
+ # then sits at `170` until that finishes at `200`. {#wait_until_accepted} does not cover
182
+ # it — an accepted invoice says nothing about the session's own progress — so a caller
183
+ # that sends, waits for the invoice and then asks for {#collective_upo} is racing.
184
+ #
185
+ # This existed on {Sessions::Status} from the start and was simply not reachable through
186
+ # the facade, which left `#collective_upo`'s own documentation telling callers to poll
187
+ # {#session_status} by hand. Two places in this repository did exactly that, and one of
188
+ # them got it wrong: `spec/integration/session_flow_spec.rb` read the status once and
189
+ # asserted a terminal code, which passed until the nightly of 2026-09-13 found TEST still
190
+ # at `170`.
191
+ #
192
+ # @param session_reference [String]
193
+ # @param deadline [Numeric] seconds; defaults to {Sessions::Status::DEFAULT_DEADLINE}
194
+ # @yieldparam state [Ksef::Sessions::SessionState] after each poll, for progress reporting
195
+ # @return [Ksef::Sessions::SessionState] no longer in progress
196
+ # @raise [Ksef::TimeoutError] when the deadline passes with the session still working
197
+ def wait_for_session(session_reference, **, &)
198
+ status_client.wait_for_session(session_reference, **, &)
199
+ end
200
+
201
+ # Downloads an invoice KSeF holds, by its KSeF number.
202
+ #
203
+ # @param ksef_number [String, Ksef::KsefNumber]
204
+ # @return [String] FA(3) XML, verbatim
205
+ def download_invoice(ksef_number) = invoices.download(ksef_number)
206
+
207
+ # Runs the FA(3) validator, if the document knows how to validate itself.
208
+ #
209
+ # A raw XML String does not — the transport layer accepts any `#to_xml` or a String
210
+ # (DESIGN.md §5), and refusing one here would break that contract to enforce a check the
211
+ # caller may already have done. But it is still **bytes**, and the byte-level admission
212
+ # rules of docs/REFERENCE.md §15.1 apply to bytes whatever produced them. So a String gets
213
+ # tier 1b: a review on 2026-08-24 pointed out that the gem, handed the poison fixture as a
214
+ # String, would have shipped the very document tier 1 was built to stop — mis-encoded ERP
215
+ # text being precisely the case §15.1 calls likely.
216
+ #
217
+ # Not the schema tier: validating a caller's own XML against FA(3) would reject the batch
218
+ # and RR structures the transport layer is meant to carry.
219
+ #
220
+ # @raise [Ksef::ValidationError]
221
+ def validate_invoice!(invoice)
222
+ return invoice.validate! if invoice.respond_to?(:validate!)
223
+ return unless invoice.is_a?(String)
224
+
225
+ issues = FA3::DocumentValidator.errors_for(invoice)
226
+ return if issues.empty?
227
+
228
+ raise ValidationError,
229
+ "The XML given is not admissible:\n#{issues.sort.map { |issue| " - #{issue}" }.join("\n")}"
230
+ end
231
+
232
+ # @return [Ksef::Sessions::Online]
233
+ def sessions = @sessions ||= Sessions::Online.new(connection, credential)
234
+
235
+ # @return [Ksef::Sessions::Status]
236
+ #
237
+ # Named `status_client` rather than `status` so it cannot be mistaken for
238
+ # {#invoice_status} or {#session_status}, which return an actual status.
239
+ def status_client = @status_client ||= Sessions::Status.new(connection, credential)
240
+
241
+ # @return [Ksef::UPO::Client]
242
+ def upo_client
243
+ @upo_client ||= UPO::Client.new(
244
+ connection, credential, clock: @clock, storage: HTTP::Connection.storage(config)
245
+ )
246
+ end
247
+
248
+ # @return [Ksef::Invoices::Client]
249
+ def invoices = @invoices ||= Invoices::Client.new(connection, credential)
250
+
251
+ # The clock goes here too, and that is not symmetry for its own sake: `#for_usage` filters
252
+ # published certificates on `valid_at?`, so a replay running after they expire finds none
253
+ # and raises. The cassettes' certificates run to 2027-09-29 — pinning only
254
+ # {Auth::AccessToken} would have left the tier a time bomb with a longer fuse.
255
+ #
256
+ # @return [Ksef::Crypto::PublicKeys]
257
+ def public_keys = @public_keys ||= Crypto::PublicKeys.new(connection, clock: @clock)
258
+
259
+ # @return [Ksef::Auth::Client]
260
+ def auth = @auth ||= Auth::Client.new(connection)
261
+
262
+ # The access token, authenticating first if that has not happened yet.
263
+ #
264
+ # @return [Ksef::Auth::AccessToken]
265
+ def credential
266
+ @mutex.synchronize { @credential ||= authenticate_with_rotation! }
267
+ end
268
+
269
+ def inspect = "#<Ksef::Client env=#{config.environment.name.inspect}>"
270
+
271
+ private
272
+
273
+ def connection = @connection ||= HTTP::Connection.build(config)
274
+
275
+ # The KSeF-token flow of §4.5, end to end: submit, poll, redeem. Callers hold the mutex.
276
+ def authenticate!
277
+ initiated = auth.submit_ksef_token(token_request)
278
+ auth.authenticate!(initiated.reference_number, token: initiated.authentication_token,
279
+ sleeper: @sleeper)
280
+
281
+ Auth::AccessToken.new(
282
+ auth.redeem(token: initiated.authentication_token), client: auth, clock: @clock
283
+ )
284
+ end
285
+
286
+ # A fresh challenge and the key that wraps the token. Both are fetched here rather than
287
+ # cached: the challenge is single-use and lives ten minutes (§4), and the certificate
288
+ # comes from {Crypto::PublicKeys}, which does its own caching.
289
+ def token_request
290
+ credential_token.authentication_request(
291
+ challenge: auth.challenge, certificate: public_keys.token_encryption
292
+ )
293
+ end
294
+
295
+ # Authentication consumes a published key too, so it gets the same remediation. The
296
+ # challenge is re-fetched on the second attempt as well, which is correct: challenges are
297
+ # single-use, so replaying one would fail for a different reason.
298
+ def authenticate_with_rotation!
299
+ public_keys.with_key_rotation { authenticate! }
300
+ end
301
+
302
+ # A usable credential, or a message saying where one comes from — the failure a caller
303
+ # is most likely to hit on their first attempt.
304
+ def credential_token
305
+ token = config.auth
306
+ return token if token.respond_to?(:authentication_request)
307
+
308
+ raise ConfigurationError,
309
+ "Ksef::Client needs auth: a Ksef::Auth::Token (or an already-redeemed " \
310
+ "Ksef::Auth::AccessToken), got #{token.inspect}. A KSeF token is minted by " \
311
+ "POST /tokens after a one-time XAdES authentication (docs/REFERENCE.md §6a.2)."
312
+ end
313
+ end
314
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ksef
4
+ module Crypto
5
+ # One entry from `GET /security/public-key-certificates` — the contract's
6
+ # `PublicKeyCertificate` (docs/REFERENCE.md §10.2).
7
+ #
8
+ # The Ministry publishes a list rather than a single key, and the entries are not
9
+ # interchangeable: `usage` says what each may encrypt, and two entries can be valid at
10
+ # once during a planned key rotation. {PublicKeys} applies the documented selection
11
+ # rule; this class is the value object it selects from.
12
+ Certificate = Data.define(:certificate, :certificate_id, :public_key_id, :valid_from, :valid_to, :usage)
13
+
14
+ # Reopened rather than using a `Data.define` block so the usage constants land on the
15
+ # class rather than on `Object`.
16
+ class Certificate
17
+ # The two members of the contract's `PublicKeyCertificateUsage` enum. Keeping them
18
+ # here rather than as bare strings at call sites means a typo is a `NameError` at
19
+ # load time instead of an empty selection at runtime.
20
+ KSEF_TOKEN_ENCRYPTION = "KsefTokenEncryption"
21
+ SYMMETRIC_KEY_ENCRYPTION = "SymmetricKeyEncryption"
22
+ USAGES = [KSEF_TOKEN_ENCRYPTION, SYMMETRIC_KEY_ENCRYPTION].freeze
23
+
24
+ def self.from(payload)
25
+ new(
26
+ certificate: payload["certificate"],
27
+ certificate_id: payload["certificateId"],
28
+ public_key_id: payload["publicKeyId"],
29
+ valid_from: time(payload["validFrom"]),
30
+ valid_to: time(payload["validTo"]),
31
+ usage: Array(payload["usage"]).freeze
32
+ )
33
+ end
34
+
35
+ # Parsed strictly here, unlike {Ksef::Auth.time}'s informational timestamps: these
36
+ # two bound the window in which the key may be used, so an unparseable value must
37
+ # not read as "no constraint". It becomes `nil`, and {#valid_at?} then refuses the
38
+ # certificate outright — fail closed, since sending a payload wrapped with a lapsed
39
+ # key is worse than declining to send one.
40
+ def self.time(value)
41
+ return value if value.is_a?(Time)
42
+
43
+ Time.iso8601(value.to_s)
44
+ rescue ArgumentError
45
+ nil
46
+ end
47
+ private_class_method :time
48
+
49
+ # @param kind [String] one of {USAGES}
50
+ def usable_for?(kind) = usage.include?(kind)
51
+
52
+ def valid_at?(now = Time.now)
53
+ return false if valid_from.nil? || valid_to.nil?
54
+
55
+ now.between?(valid_from, valid_to)
56
+ end
57
+
58
+ # The contract ships the certificate as **DER, base64-encoded, without PEM armour**,
59
+ # so it cannot be handed to OpenSSL as text.
60
+ #
61
+ # Not memoised: `Data` instances are frozen, and this is called once per session
62
+ # open or authentication, where a DER parse is not worth caching around.
63
+ #
64
+ # @return [OpenSSL::X509::Certificate]
65
+ def x509 = OpenSSL::X509::Certificate.new(Crypto.decode(certificate))
66
+
67
+ # @return [OpenSSL::PKey::RSA]
68
+ def public_key = x509.public_key
69
+
70
+ # RSA-OAEP under this certificate's key. The plaintexts KSeF asks for are a 32-byte
71
+ # symmetric key (§10.1) and a short `token|timestamp` string (§4.5), both far inside
72
+ # {Crypto::MAX_OAEP_PLAINTEXT_BYTES}.
73
+ #
74
+ # @return [String] raw ciphertext; base64 it with {Crypto.encode} before sending
75
+ def encrypt(plaintext) = Crypto.rsa_encrypt(plaintext, public_key)
76
+ end
77
+ end
78
+ end