ksef-client-rails 0.1.0 → 1.0.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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +161 -0
  3. data/NOTICE +40 -0
  4. data/README.md +524 -6
  5. data/examples/fa3_full.xml +143 -0
  6. data/examples/fa3_minimal.xml +65 -0
  7. data/examples/fa3_typical.xml +76 -0
  8. data/lib/generators/ksef/install/install_generator.rb +20 -0
  9. data/lib/generators/ksef/install/templates/ksef.rb +25 -0
  10. data/lib/ksef/auth/coordinator.rb +164 -0
  11. data/lib/ksef/auth/ksef_token_encryptor.rb +36 -0
  12. data/lib/ksef/batch/package_builder.rb +125 -0
  13. data/lib/ksef/batch/splitter.rb +99 -0
  14. data/lib/ksef/batch/uploader.rb +114 -0
  15. data/lib/ksef/client.rb +135 -0
  16. data/lib/ksef/configuration.rb +206 -0
  17. data/lib/ksef/crypto/cryptography_service.rb +136 -0
  18. data/lib/ksef/crypto/csr.rb +106 -0
  19. data/lib/ksef/crypto/public_keys.rb +125 -0
  20. data/lib/ksef/environments.rb +61 -0
  21. data/lib/ksef/errors.rb +187 -0
  22. data/lib/ksef/http/connection.rb +432 -0
  23. data/lib/ksef/http/error_parser.rb +194 -0
  24. data/lib/ksef/http/pagination.rb +80 -0
  25. data/lib/ksef/invoices/builder/checks.rb +127 -0
  26. data/lib/ksef/invoices/builder/number.rb +67 -0
  27. data/lib/ksef/invoices/builder/xml.rb +256 -0
  28. data/lib/ksef/invoices/builder.rb +251 -0
  29. data/lib/ksef/invoices/export.rb +241 -0
  30. data/lib/ksef/invoices/sample.rb +78 -0
  31. data/lib/ksef/invoices/schema_outline.rb +120 -0
  32. data/lib/ksef/invoices/validator.rb +200 -0
  33. data/lib/ksef/models/form_code.rb +49 -0
  34. data/lib/ksef/models/ksef_number.rb +74 -0
  35. data/lib/ksef/models/nip.rb +62 -0
  36. data/lib/ksef/polish_time.rb +49 -0
  37. data/lib/ksef/polling.rb +65 -0
  38. data/lib/ksef/qr/code.rb +104 -0
  39. data/lib/ksef/qr/verification_link.rb +215 -0
  40. data/lib/ksef/railtie.rb +20 -0
  41. data/lib/ksef/resources/auth.rb +116 -0
  42. data/lib/ksef/resources/base.rb +120 -0
  43. data/lib/ksef/resources/batch_sessions.rb +43 -0
  44. data/lib/ksef/resources/certificates.rb +82 -0
  45. data/lib/ksef/resources/collective_identifiers.rb +70 -0
  46. data/lib/ksef/resources/exports.rb +47 -0
  47. data/lib/ksef/resources/invoices.rb +67 -0
  48. data/lib/ksef/resources/limits.rb +33 -0
  49. data/lib/ksef/resources/online_sessions.rb +62 -0
  50. data/lib/ksef/resources/peppol.rb +21 -0
  51. data/lib/ksef/resources/permissions.rb +180 -0
  52. data/lib/ksef/resources/security.rb +18 -0
  53. data/lib/ksef/resources/sessions.rb +125 -0
  54. data/lib/ksef/resources/test_data.rb +178 -0
  55. data/lib/ksef/resources/tokens.rb +62 -0
  56. data/lib/ksef/sessions/online.rb +168 -0
  57. data/lib/ksef/sync/incremental.rb +169 -0
  58. data/lib/ksef/testing.rb +153 -0
  59. data/lib/ksef/token_manager.rb +130 -0
  60. data/lib/ksef/version.rb +11 -0
  61. data/lib/ksef/xades/auth_token_request.rb +135 -0
  62. data/lib/ksef/xades/external_signer.rb +58 -0
  63. data/lib/ksef/xades/signer.rb +312 -0
  64. data/lib/ksef-client-rails.rb +3 -2
  65. data/lib/ksef.rb +116 -0
  66. data/schemas/FA/schemat_FA(2)_v1-0E.xsd +3661 -0
  67. data/schemas/FA/schemat_FA(3)_v1-0E.xsd +3950 -0
  68. data/schemas/README.md +53 -0
  69. data/schemas/RR/schemat_FA_RR(1)_v1-1E.xsd +2188 -0
  70. data/schemas/bazowe/ElementarneTypyDanych_v10-0E.xsd +1 -0
  71. data/schemas/bazowe/KodyKrajow_v10-0E.xsd +1283 -0
  72. data/schemas/bazowe/StrukturyDanych_v10-0E.xsd +1 -0
  73. metadata +291 -9
@@ -0,0 +1,143 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <Faktura xmlns="http://crd.gov.pl/wzor/2025/06/25/13775/">
3
+ <Naglowek>
4
+ <KodFormularza kodSystemowy="FA (3)" wersjaSchemy="1-0E">FA</KodFormularza>
5
+ <WariantFormularza>3</WariantFormularza>
6
+ <DataWytworzeniaFa>2026-02-01T10:00:00Z</DataWytworzeniaFa>
7
+ <SystemInfo>ksef-client-rails</SystemInfo>
8
+ </Naglowek>
9
+ <Podmiot1>
10
+ <DaneIdentyfikacyjne>
11
+ <NIP>5555555555</NIP>
12
+ <Nazwa>Seller Ltd.</Nazwa>
13
+ </DaneIdentyfikacyjne>
14
+ <Adres>
15
+ <KodKraju>PL</KodKraju>
16
+ <AdresL1>1 Test Street, 00-001 Warsaw</AdresL1>
17
+ </Adres>
18
+ <DaneKontaktowe>
19
+ <Email>invoices@seller.example</Email>
20
+ <Telefon>+48 22 000 00 00</Telefon>
21
+ </DaneKontaktowe>
22
+ </Podmiot1>
23
+ <Podmiot2>
24
+ <DaneIdentyfikacyjne>
25
+ <NIP>1111111111</NIP>
26
+ <Nazwa>Buyer Inc.</Nazwa>
27
+ </DaneIdentyfikacyjne>
28
+ <Adres>
29
+ <KodKraju>PL</KodKraju>
30
+ <AdresL1>2 Example Street, 00-002 Warsaw</AdresL1>
31
+ </Adres>
32
+ <DaneKontaktowe>
33
+ <Email>accounts@buyer.example</Email>
34
+ </DaneKontaktowe>
35
+ <JST>2</JST>
36
+ <GV>2</GV>
37
+ </Podmiot2>
38
+ <Fa>
39
+ <KodWaluty>PLN</KodWaluty>
40
+ <P_1>2026-02-01</P_1>
41
+ <P_1M>Warsaw</P_1M>
42
+ <P_2>FV/2026/02/1</P_2>
43
+ <P_6>2026-02-01</P_6>
44
+ <P_13_1>1900.00</P_13_1>
45
+ <P_14_1>437.00</P_14_1>
46
+ <P_13_2>120.00</P_13_2>
47
+ <P_14_2>9.60</P_14_2>
48
+ <P_13_3>50.00</P_13_3>
49
+ <P_14_3>2.50</P_14_3>
50
+ <P_13_6_1>20.00</P_13_6_1>
51
+ <P_13_7>30.00</P_13_7>
52
+ <P_15>2569.10</P_15>
53
+ <Adnotacje>
54
+ <P_16>2</P_16>
55
+ <P_17>2</P_17>
56
+ <P_18>2</P_18>
57
+ <P_18A>2</P_18A>
58
+ <Zwolnienie>
59
+ <P_19>1</P_19>
60
+ <P_19A>Art. 43 ust. 1 pkt 37 ustawy o VAT</P_19A>
61
+ </Zwolnienie>
62
+ <NoweSrodkiTransportu>
63
+ <P_22N>1</P_22N>
64
+ </NoweSrodkiTransportu>
65
+ <P_23>2</P_23>
66
+ <PMarzy>
67
+ <P_PMarzyN>1</P_PMarzyN>
68
+ </PMarzy>
69
+ </Adnotacje>
70
+ <RodzajFaktury>VAT</RodzajFaktury>
71
+ <FaWiersz>
72
+ <NrWierszaFa>1</NrWierszaFa>
73
+ <P_7>Software licence</P_7>
74
+ <Indeks>LIC-01</Indeks>
75
+ <GTIN>5901234123457</GTIN>
76
+ <PKWiU>58.29.50.0</PKWiU>
77
+ <CN>8523</CN>
78
+ <P_8A>szt.</P_8A>
79
+ <P_8B>2</P_8B>
80
+ <P_9A>1000.00</P_9A>
81
+ <P_10>100.00</P_10>
82
+ <P_11>1900.00</P_11>
83
+ <P_12>23</P_12>
84
+ <GTU>GTU_12</GTU>
85
+ </FaWiersz>
86
+ <FaWiersz>
87
+ <NrWierszaFa>2</NrWierszaFa>
88
+ <P_7>Printed manual</P_7>
89
+ <P_8A>szt.</P_8A>
90
+ <P_8B>1</P_8B>
91
+ <P_9A>50.00</P_9A>
92
+ <P_11>50.00</P_11>
93
+ <P_12>5</P_12>
94
+ </FaWiersz>
95
+ <FaWiersz>
96
+ <NrWierszaFa>3</NrWierszaFa>
97
+ <P_7>Catering</P_7>
98
+ <P_8A>szt.</P_8A>
99
+ <P_8B>3</P_8B>
100
+ <P_9A>40.00</P_9A>
101
+ <P_11>120.00</P_11>
102
+ <P_12>8</P_12>
103
+ </FaWiersz>
104
+ <FaWiersz>
105
+ <NrWierszaFa>4</NrWierszaFa>
106
+ <P_7>Zero-rated supply</P_7>
107
+ <P_8A>szt.</P_8A>
108
+ <P_8B>1</P_8B>
109
+ <P_9A>20.00</P_9A>
110
+ <P_11>20.00</P_11>
111
+ <P_12>0 KR</P_12>
112
+ </FaWiersz>
113
+ <FaWiersz>
114
+ <NrWierszaFa>5</NrWierszaFa>
115
+ <P_7>Insurance</P_7>
116
+ <P_8A>szt.</P_8A>
117
+ <P_8B>1</P_8B>
118
+ <P_9A>30.00</P_9A>
119
+ <P_11>30.00</P_11>
120
+ <P_12>zw</P_12>
121
+ </FaWiersz>
122
+ <Platnosc>
123
+ <TerminPlatnosci>
124
+ <Termin>2026-02-15</Termin>
125
+ </TerminPlatnosci>
126
+ <FormaPlatnosci>6</FormaPlatnosci>
127
+ <RachunekBankowy>
128
+ <NrRB>17100000000000000000000001</NrRB>
129
+ <NazwaBanku>Example Bank</NazwaBanku>
130
+ </RachunekBankowy>
131
+ </Platnosc>
132
+ </Fa>
133
+ <Stopka>
134
+ <Informacje>
135
+ <StopkaFaktury>Thank you for your business.</StopkaFaktury>
136
+ </Informacje>
137
+ <Rejestry>
138
+ <KRS>0000123456</KRS>
139
+ <REGON>123456785</REGON>
140
+ <BDO>000012345</BDO>
141
+ </Rejestry>
142
+ </Stopka>
143
+ </Faktura>
@@ -0,0 +1,65 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <Faktura xmlns="http://crd.gov.pl/wzor/2025/06/25/13775/">
3
+ <Naglowek>
4
+ <KodFormularza kodSystemowy="FA (3)" wersjaSchemy="1-0E">FA</KodFormularza>
5
+ <WariantFormularza>3</WariantFormularza>
6
+ <DataWytworzeniaFa>2026-02-01T10:00:00Z</DataWytworzeniaFa>
7
+ <SystemInfo>ksef-client-rails</SystemInfo>
8
+ </Naglowek>
9
+ <Podmiot1>
10
+ <DaneIdentyfikacyjne>
11
+ <NIP>5555555555</NIP>
12
+ <Nazwa>Seller Ltd.</Nazwa>
13
+ </DaneIdentyfikacyjne>
14
+ <Adres>
15
+ <KodKraju>PL</KodKraju>
16
+ <AdresL1>1 Test Street, 00-001 Warsaw</AdresL1>
17
+ </Adres>
18
+ </Podmiot1>
19
+ <Podmiot2>
20
+ <DaneIdentyfikacyjne>
21
+ <NIP>1111111111</NIP>
22
+ <Nazwa>Buyer Inc.</Nazwa>
23
+ </DaneIdentyfikacyjne>
24
+ <Adres>
25
+ <KodKraju>PL</KodKraju>
26
+ <AdresL1>2 Example Street, 00-002 Warsaw</AdresL1>
27
+ </Adres>
28
+ <JST>2</JST>
29
+ <GV>2</GV>
30
+ </Podmiot2>
31
+ <Fa>
32
+ <KodWaluty>PLN</KodWaluty>
33
+ <P_1>2026-02-01</P_1>
34
+ <P_2>FV/2026/02/1</P_2>
35
+ <P_13_1>100.00</P_13_1>
36
+ <P_14_1>23.00</P_14_1>
37
+ <P_15>123.00</P_15>
38
+ <Adnotacje>
39
+ <P_16>2</P_16>
40
+ <P_17>2</P_17>
41
+ <P_18>2</P_18>
42
+ <P_18A>2</P_18A>
43
+ <Zwolnienie>
44
+ <P_19N>1</P_19N>
45
+ </Zwolnienie>
46
+ <NoweSrodkiTransportu>
47
+ <P_22N>1</P_22N>
48
+ </NoweSrodkiTransportu>
49
+ <P_23>2</P_23>
50
+ <PMarzy>
51
+ <P_PMarzyN>1</P_PMarzyN>
52
+ </PMarzy>
53
+ </Adnotacje>
54
+ <RodzajFaktury>VAT</RodzajFaktury>
55
+ <FaWiersz>
56
+ <NrWierszaFa>1</NrWierszaFa>
57
+ <P_7>Demo service</P_7>
58
+ <P_8A>szt.</P_8A>
59
+ <P_8B>1</P_8B>
60
+ <P_9A>100.00</P_9A>
61
+ <P_11>100.00</P_11>
62
+ <P_12>23</P_12>
63
+ </FaWiersz>
64
+ </Fa>
65
+ </Faktura>
@@ -0,0 +1,76 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <Faktura xmlns="http://crd.gov.pl/wzor/2025/06/25/13775/">
3
+ <Naglowek>
4
+ <KodFormularza kodSystemowy="FA (3)" wersjaSchemy="1-0E">FA</KodFormularza>
5
+ <WariantFormularza>3</WariantFormularza>
6
+ <DataWytworzeniaFa>2026-02-01T10:00:00Z</DataWytworzeniaFa>
7
+ <SystemInfo>ksef-client-rails</SystemInfo>
8
+ </Naglowek>
9
+ <Podmiot1>
10
+ <DaneIdentyfikacyjne>
11
+ <NIP>5555555555</NIP>
12
+ <Nazwa>Seller Ltd.</Nazwa>
13
+ </DaneIdentyfikacyjne>
14
+ <Adres>
15
+ <KodKraju>PL</KodKraju>
16
+ <AdresL1>1 Test Street, 00-001 Warsaw</AdresL1>
17
+ </Adres>
18
+ </Podmiot1>
19
+ <Podmiot2>
20
+ <DaneIdentyfikacyjne>
21
+ <NIP>1111111111</NIP>
22
+ <Nazwa>Buyer Inc.</Nazwa>
23
+ </DaneIdentyfikacyjne>
24
+ <Adres>
25
+ <KodKraju>PL</KodKraju>
26
+ <AdresL1>2 Example Street, 00-002 Warsaw</AdresL1>
27
+ </Adres>
28
+ <JST>2</JST>
29
+ <GV>2</GV>
30
+ </Podmiot2>
31
+ <Fa>
32
+ <KodWaluty>PLN</KodWaluty>
33
+ <P_1>2026-02-01</P_1>
34
+ <P_1M>Warsaw</P_1M>
35
+ <P_2>FV/2026/02/1</P_2>
36
+ <P_6>2026-02-01</P_6>
37
+ <P_13_1>100.00</P_13_1>
38
+ <P_14_1>23.00</P_14_1>
39
+ <P_15>123.00</P_15>
40
+ <Adnotacje>
41
+ <P_16>2</P_16>
42
+ <P_17>2</P_17>
43
+ <P_18>2</P_18>
44
+ <P_18A>2</P_18A>
45
+ <Zwolnienie>
46
+ <P_19N>1</P_19N>
47
+ </Zwolnienie>
48
+ <NoweSrodkiTransportu>
49
+ <P_22N>1</P_22N>
50
+ </NoweSrodkiTransportu>
51
+ <P_23>2</P_23>
52
+ <PMarzy>
53
+ <P_PMarzyN>1</P_PMarzyN>
54
+ </PMarzy>
55
+ </Adnotacje>
56
+ <RodzajFaktury>VAT</RodzajFaktury>
57
+ <FaWiersz>
58
+ <NrWierszaFa>1</NrWierszaFa>
59
+ <P_7>Demo service</P_7>
60
+ <P_8A>szt.</P_8A>
61
+ <P_8B>1</P_8B>
62
+ <P_9A>100.00</P_9A>
63
+ <P_11>100.00</P_11>
64
+ <P_12>23</P_12>
65
+ </FaWiersz>
66
+ <Platnosc>
67
+ <TerminPlatnosci>
68
+ <Termin>2026-02-15</Termin>
69
+ </TerminPlatnosci>
70
+ <FormaPlatnosci>6</FormaPlatnosci>
71
+ <RachunekBankowy>
72
+ <NrRB>17100000000000000000000001</NrRB>
73
+ </RachunekBankowy>
74
+ </Platnosc>
75
+ </Fa>
76
+ </Faktura>
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+
5
+ module Ksef
6
+ module Generators
7
+ # Install generator: `rails g ksef:install`.
8
+ # Creates `config/initializers/ksef.rb` with pointers to Rails credentials.
9
+ class InstallGenerator < ::Rails::Generators::Base
10
+ source_root File.expand_path("templates", __dir__)
11
+
12
+ desc "Creates the KSeF initializer at config/initializers/ksef.rb"
13
+
14
+ # Copies the initializer template into the application.
15
+ def copy_initializer
16
+ template "ksef.rb", "config/initializers/ksef.rb"
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ # KSeF client configuration (the `ksef-client-rails` gem) — https://github.com/CIRFMF/ksef-api
4
+ # Keep the KSeF token and certificates in Rails credentials, never in the repository.
5
+ # This file runs after `config.ksef.*` from config/application.rb (railtie), so whatever it sets wins.
6
+
7
+ Ksef.configure do |config|
8
+ # Environment: :test | :demo | :prod — set it explicitly for every deployment. Never derive it from
9
+ # Rails.env: staging usually runs with RAILS_ENV=production, and every invoice sent to :prod is
10
+ # legally binding. Without KSEF_ENV (or with it empty) the environment stays as configured (default :test).
11
+ config.environment = ENV["KSEF_ENV"].presence || config.environment
12
+
13
+ # Timeouts in seconds (defaults: 60 and 15):
14
+ # config.timeout = 60
15
+ # config.open_timeout = 15
16
+
17
+ # The railtie logs to Rails.logger; nil turns logging off:
18
+ # config.logger = nil
19
+
20
+ # A 429 asking to wait longer than this (seconds) raises Ksef::RateLimited instead of sleeping:
21
+ # config.max_retry_after = 60
22
+
23
+ # API feature flags sent as X-KSeF-Feature (e.g. "subject-identifier-validation" on TEST/DEMO):
24
+ # config.ksef_features = %w[]
25
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ksef
4
+ module Auth
5
+ # Authentication coordinator: orchestrates the full flow
6
+ # challenge → signature|token → POST → polling GET /auth/{ref} → redeem → (refresh).
7
+ #
8
+ # Polling returns `status.code` inside a 200 body (NOT an HTTP code): 100 in progress,
9
+ # 200 OK, everything else is an error — see {Ksef::AuthenticationFailed}.
10
+ class Coordinator
11
+ # The "operation in progress" code.
12
+ STATUS_IN_PROGRESS = 100
13
+ # The "authenticated" code.
14
+ STATUS_SUCCESS = 200
15
+
16
+ # Context key → AuthenticationContextIdentifierType enum value (PascalCase).
17
+ CONTEXT_TYPES = {
18
+ nip: "Nip",
19
+ internal_id: "InternalId",
20
+ nip_vat_ue: "NipVatUe",
21
+ peppol_id: "PeppolId"
22
+ }.freeze
23
+
24
+ # Authentication result: the token pair plus the auth session reference number.
25
+ Result = Struct.new(:reference_number, :access_token, :access_token_valid_until,
26
+ :refresh_token, :refresh_token_valid_until, keyword_init: true) do
27
+ # Tokens never go to error pages, `pp` output or exception reports.
28
+ # @return [String]
29
+ def inspect
30
+ "#<#{self.class.name} reference_number=#{reference_number.inspect} " \
31
+ "access_token=[REDACTED] access_token_valid_until=#{access_token_valid_until.inspect} " \
32
+ "refresh_token=[REDACTED] refresh_token_valid_until=#{refresh_token_valid_until.inspect}>"
33
+ end
34
+ alias_method :to_s, :inspect
35
+
36
+ # `pp` and IRB print through #pretty_print, which Struct implements without calling #inspect.
37
+ def pretty_print(printer)
38
+ printer.text(inspect)
39
+ end
40
+ end
41
+
42
+ # @param client [Ksef::Client]
43
+ def initialize(client)
44
+ @client = client
45
+ end
46
+
47
+ # Authenticates with an XAdES signature (local certificate or an external signer).
48
+ #
49
+ # @param context [Hash] one of {nip:}, {internal_id:}, {nip_vat_ue:}, {peppol_id:}; a NIP is
50
+ # normalized ({Ksef::Nip.normalize!}) and an invalid one raises Ksef::ConfigurationError
51
+ # @param signer [#sign, #call] {Ksef::Xades::Signer} or a lambda signing the XML
52
+ # @param subject_identifier_type [Symbol] :certificate_subject|:certificate_fingerprint
53
+ # @param allowed_ips [Hash, nil] {addresses:, ranges:, masks:}
54
+ # @param verify_certificate_chain [Boolean]
55
+ # @param poll [Hash] polling options (timeout:, interval:, ...)
56
+ # @return [Result]
57
+ # @raise [Ksef::ConfigurationError] for an invalid context, subject type, allowed_ips or signer — before
58
+ # any request
59
+ def authenticate(context:, signer:, subject_identifier_type: :certificate_subject,
60
+ allowed_ips: nil, verify_certificate_chain: false, poll: {})
61
+ signer = Xades::ExternalSigner.wrap(signer) # an unusable signer fails before any request
62
+ request = Xades::AuthTokenRequest.new(context: context, subject_identifier_type: subject_identifier_type,
63
+ allowed_ips: allowed_ips)
64
+ request.challenge = @client.auth.challenge["challenge"]
65
+ signed = Xades::ExternalSigner.signed!(signer.sign(request.to_xml))
66
+ init = @client.auth.submit_xades(signed, verify_certificate_chain: verify_certificate_chain)
67
+ finalize(init, poll)
68
+ end
69
+
70
+ # Authenticates with an encrypted KSeF token (RSA-OAEP).
71
+ #
72
+ # @param context [Hash] one of {nip:}, {internal_id:}, {nip_vat_ue:}, {peppol_id:}; a NIP is
73
+ # normalized ({Ksef::Nip.normalize!}) and an invalid one raises Ksef::ConfigurationError
74
+ # @param token [String] KSeF token (secret)
75
+ # @param poll [Hash] polling options
76
+ # @return [Result]
77
+ def authenticate_with_token(context:, token:, poll: {})
78
+ type, value = context_type_and_value(context)
79
+ encryptor = KsefTokenEncryptor.new(crypto: @client.crypto, public_keys: @client.public_keys)
80
+ # A retry after key rotation starts over with a fresh challenge.
81
+ init = @client.public_keys.with_rotation_retry do
82
+ challenge = @client.auth.challenge
83
+ encrypted = encryptor.encrypt(token: token, timestamp_ms: challenge["timestampMs"])
84
+ @client.auth.submit_ksef_token(
85
+ challenge: challenge["challenge"],
86
+ context_type: type,
87
+ context_value: value,
88
+ encrypted_token: encrypted.encrypted_token,
89
+ public_key_id: encrypted.public_key_id
90
+ )
91
+ end
92
+ finalize(init, poll)
93
+ end
94
+
95
+ # Refreshes the accessToken using the refresh token.
96
+ # @param refresh_token [String]
97
+ # @return [Hash] { "accessToken" => {token, validUntil} }
98
+ def refresh(refresh_token:)
99
+ @client.auth.refresh(refresh_token: refresh_token)
100
+ end
101
+
102
+ private
103
+
104
+ def finalize(init, poll_opts)
105
+ reference_number = init["referenceNumber"]
106
+ auth_token = token!(init, "authenticationToken", "the authentication init")
107
+ wait_until_authenticated(reference_number, auth_token, poll_opts)
108
+
109
+ tokens = @client.auth.redeem(authentication_token: auth_token)
110
+ Result.new(
111
+ reference_number: reference_number,
112
+ access_token: token!(tokens, "accessToken", "POST /auth/token/redeem"),
113
+ access_token_valid_until: tokens.dig("accessToken", "validUntil"),
114
+ refresh_token: token!(tokens, "refreshToken", "POST /auth/token/redeem"),
115
+ refresh_token_valid_until: tokens.dig("refreshToken", "validUntil")
116
+ )
117
+ end
118
+
119
+ def token!(response, key, operation)
120
+ token = response[key].is_a?(Hash) ? response[key]["token"] : nil
121
+ return token unless token.to_s.empty?
122
+
123
+ raise ApiError, "#{operation}: KSeF's response lacks #{key}.token"
124
+ end
125
+
126
+ def wait_until_authenticated(reference_number, auth_token, poll_opts)
127
+ Polling.run(description: "auth #{reference_number}", logger: @client.config.logger, **poll_opts) do
128
+ response = @client.auth.status(reference_number, authentication_token: auth_token)
129
+ interpret_status(response)
130
+ end
131
+ end
132
+
133
+ def interpret_status(response)
134
+ status = response["status"] || {}
135
+ case status["code"]
136
+ when STATUS_IN_PROGRESS
137
+ Polling.pending
138
+ when STATUS_SUCCESS
139
+ response
140
+ else
141
+ raise AuthenticationFailed.new(
142
+ status["description"] || "Authentication failed",
143
+ code: status["code"],
144
+ messages: status["details"]
145
+ )
146
+ end
147
+ end
148
+
149
+ def context_type_and_value(context)
150
+ unless context.is_a?(Hash)
151
+ raise ConfigurationError, "context must be a Hash with exactly one of: #{CONTEXT_TYPES.keys.join(', ')}"
152
+ end
153
+
154
+ pairs = context.transform_keys(&:to_sym)
155
+ key, value = pairs.first
156
+ unless pairs.size == 1 && CONTEXT_TYPES.key?(key)
157
+ raise ConfigurationError, "context must contain exactly one of: #{CONTEXT_TYPES.keys.join(', ')}"
158
+ end
159
+
160
+ [CONTEXT_TYPES.fetch(key), key == :nip ? Nip.normalize!(value, name: "context nip") : value.to_s]
161
+ end
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+
5
+ module Ksef
6
+ module Auth
7
+ # Encrypts the KSeF token for token authentication: `"#{token}|#{timestampMs}"`
8
+ # wrapped with RSA-OAEP(SHA-256), Base64. The timestamp must come from `/auth/challenge`
9
+ # (epoch MILLISECONDS), never the local clock. ECIES is out of scope: undocumented in the API
10
+ # docs and assembled incompatibly between the two SDKs.
11
+ class KsefTokenEncryptor
12
+ # @param crypto [Ksef::Crypto::CryptographyService]
13
+ # @param public_keys [Ksef::Crypto::PublicKeys]
14
+ def initialize(crypto:, public_keys:)
15
+ @crypto = crypto
16
+ @public_keys = public_keys
17
+ end
18
+
19
+ # Encryption result: Base64 token + public key identifier.
20
+ Result = Struct.new(:encrypted_token, :public_key_id, keyword_init: true)
21
+
22
+ # @param token [String] KSeF token (secret)
23
+ # @param timestamp_ms [Integer] timestampMs from /auth/challenge
24
+ # @return [Result]
25
+ def encrypt(token:, timestamp_ms:)
26
+ cert = @public_keys.for_usage(Crypto::PublicKeys::KSEF_TOKEN_ENCRYPTION)
27
+ payload = "#{token}|#{timestamp_ms}"
28
+ ciphertext = @crypto.rsa_oaep_sha256(cert.public_key, payload)
29
+ Result.new(
30
+ encrypted_token: Base64.strict_encode64(ciphertext),
31
+ public_key_id: cert.public_key_id
32
+ )
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ module Ksef
6
+ module Batch
7
+ # Builds the batch package (ZIP or tar.gz) plus a local hash→file map for correlating statuses.
8
+ # File names stay local — `BatchFilePartInfo` has no `fileName`. A tar entry name must fit the
9
+ # 100-byte USTAR name field: a longer one would need a GNU long-name record, which KSeF is not known to read.
10
+ class PackageBuilder
11
+ # The built package: raw (plaintext) bytes + a name→{hash,size} map of the files inside, keyed by
12
+ # the name in the package.
13
+ Package = Struct.new(:bytes, :files, keyword_init: true) do
14
+ # The package size and file count — never its bytes.
15
+ # @return [String]
16
+ def inspect
17
+ "#<#{self.class.name} bytes=#{bytes.to_s.bytesize} files=#{files.to_h.size}>"
18
+ end
19
+ alias_method :to_s, :inspect
20
+
21
+ # `pp` and IRB print through #pretty_print, which Struct implements without calling #inspect.
22
+ def pretty_print(printer)
23
+ printer.text(inspect)
24
+ end
25
+ end
26
+
27
+ # Longest tar entry name, in bytes (the USTAR `name` field).
28
+ TAR_NAME_LIMIT = 100
29
+
30
+ # ZIP general-purpose flag bit 11 (APPNOTE 4.4.4): the entry name is UTF-8. rubyzip sets it only
31
+ # under the global `Zip.unicode_names`, which would change rubyzip for the whole process.
32
+ UTF8_NAME_FLAG = 0x0800
33
+
34
+ # Builds a ZIP package.
35
+ # @param invoices [Hash{String=>String}, Array<String>] invoices (name→XML or a list of XML)
36
+ # @return [Package]
37
+ # @raise [Ksef::ConfigurationError] for no invoices, or two names that are the same in the package
38
+ def self.zip(invoices)
39
+ require "zip"
40
+ named = normalize(invoices)
41
+ buffer = Zip::OutputStream.write_buffer do |zos|
42
+ named.each do |name, xml|
43
+ zos.put_next_entry(zip_entry(name))
44
+ zos.write(xml)
45
+ end
46
+ end
47
+ Package.new(bytes: buffer.string, files: files_map(named))
48
+ end
49
+
50
+ # Builds a tar.gz package (requires the `minitar` gem).
51
+ # @param invoices [Hash{String=>String}, Array<String>]
52
+ # @return [Package]
53
+ # @raise [Ksef::ConfigurationError] when minitar is not installed, for no invoices, two names that are
54
+ # the same in the package, or a name over {TAR_NAME_LIMIT} bytes
55
+ def self.tar_gz(invoices)
56
+ Ksef.require_optional("minitar", "A tar.gz package")
57
+ require "zlib"
58
+ named = normalize(invoices)
59
+ too_long = named.keys.select { |name| name.bytesize > TAR_NAME_LIMIT }
60
+ unless too_long.empty?
61
+ raise ConfigurationError, "tar entry names are limited to #{TAR_NAME_LIMIT} bytes: #{too_long.join(', ')}"
62
+ end
63
+
64
+ Package.new(bytes: tar_bytes(named), files: files_map(named))
65
+ end
66
+
67
+ def self.tar_bytes(named)
68
+ io = StringIO.new("".b)
69
+ Zlib::GzipWriter.wrap(io) do |gz|
70
+ Minitar::Writer.open(gz) do |tar|
71
+ named.each do |name, xml|
72
+ tar.add_file_simple(name.b, mode: 0o644, size: xml.bytesize) { |stream| stream.write(xml) }
73
+ end
74
+ end
75
+ end
76
+ io.string
77
+ end
78
+
79
+ def self.zip_entry(name)
80
+ entry = Zip::Entry.new("", name)
81
+ entry.gp_flags |= UTF8_NAME_FLAG unless name.ascii_only?
82
+ entry
83
+ end
84
+
85
+ # Normalizes the input into a name→XML map with the names as they go into the package: UTF-8, with
86
+ # `/` for a backslash (auto-generated names for a list).
87
+ def self.normalize(invoices)
88
+ raise ConfigurationError, "a batch package needs at least one invoice" if invoices.nil? || invoices.empty?
89
+ return invoices.each_with_index.to_h { |xml, i| [format("invoice-%04d.xml", i + 1), xml] } unless
90
+ invoices.is_a?(Hash)
91
+
92
+ named = invoices.to_h { |name, xml| [package_name(name), xml] }
93
+ return named if named.size == invoices.size
94
+
95
+ duplicates = invoices.keys.group_by { |name| package_name(name) }.select { |_, keys| keys.size > 1 }
96
+ raise ConfigurationError, "invoice names collide in the package: #{duplicates.values.map(&:inspect).join('; ')}"
97
+ end
98
+
99
+ def self.package_name(name)
100
+ text = name.to_s
101
+ text = text.encode(Encoding::UTF_8) unless text.encoding == Encoding::UTF_8
102
+ raise ConfigurationError, "invoice name #{text.inspect} is not valid UTF-8" unless text.valid_encoding?
103
+ raise ConfigurationError, "invoice names must not be blank" if text.strip.empty?
104
+
105
+ name = text.tr("\\", "/")
106
+ return name unless name.start_with?("/") || name.split("/").include?("..")
107
+
108
+ raise ConfigurationError, "invoice name #{text.inspect} must not be absolute or contain a .. segment"
109
+ rescue EncodingError
110
+ raise ConfigurationError, "invoice name #{name.inspect} cannot be converted to UTF-8"
111
+ end
112
+
113
+ def self.files_map(named)
114
+ require "digest"
115
+ require "base64"
116
+ named.transform_values do |xml|
117
+ bytes = xml.b
118
+ { "fileSize" => bytes.bytesize, "fileHash" => Base64.strict_encode64(Digest::SHA256.digest(bytes)) }
119
+ end
120
+ end
121
+
122
+ private_class_method :tar_bytes, :zip_entry, :normalize, :package_name, :files_map
123
+ end
124
+ end
125
+ end