@fast-white-cat/integration-ksef-direct 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 (140) hide show
  1. package/README.md +36 -0
  2. package/dist/index.js +2 -0
  3. package/dist/index.js.map +7 -0
  4. package/dist/modules/integration_ksef_direct/acl.js +13 -0
  5. package/dist/modules/integration_ksef_direct/acl.js.map +7 -0
  6. package/dist/modules/integration_ksef_direct/api/get/integration-ksef-direct/documents/[id].js +92 -0
  7. package/dist/modules/integration_ksef_direct/api/get/integration-ksef-direct/documents/[id].js.map +7 -0
  8. package/dist/modules/integration_ksef_direct/api/get/integration-ksef-direct/documents.js +105 -0
  9. package/dist/modules/integration_ksef_direct/api/get/integration-ksef-direct/documents.js.map +7 -0
  10. package/dist/modules/integration_ksef_direct/api/get/integration-ksef-direct/health.js +158 -0
  11. package/dist/modules/integration_ksef_direct/api/get/integration-ksef-direct/health.js.map +7 -0
  12. package/dist/modules/integration_ksef_direct/api/get/integration-ksef-direct/received-documents/[id].js +86 -0
  13. package/dist/modules/integration_ksef_direct/api/get/integration-ksef-direct/received-documents/[id].js.map +7 -0
  14. package/dist/modules/integration_ksef_direct/api/get/integration-ksef-direct/received-documents.js +112 -0
  15. package/dist/modules/integration_ksef_direct/api/get/integration-ksef-direct/received-documents.js.map +7 -0
  16. package/dist/modules/integration_ksef_direct/api/get/integration-ksef-direct/seller-info.js +54 -0
  17. package/dist/modules/integration_ksef_direct/api/get/integration-ksef-direct/seller-info.js.map +7 -0
  18. package/dist/modules/integration_ksef_direct/api/post/integration-ksef-direct/documents/[id]/send.js +64 -0
  19. package/dist/modules/integration_ksef_direct/api/post/integration-ksef-direct/documents/[id]/send.js.map +7 -0
  20. package/dist/modules/integration_ksef_direct/api/post/integration-ksef-direct/documents.js +104 -0
  21. package/dist/modules/integration_ksef_direct/api/post/integration-ksef-direct/documents.js.map +7 -0
  22. package/dist/modules/integration_ksef_direct/api/post/integration-ksef-direct/invoice-numbers.js +41 -0
  23. package/dist/modules/integration_ksef_direct/api/post/integration-ksef-direct/invoice-numbers.js.map +7 -0
  24. package/dist/modules/integration_ksef_direct/api/post/integration-ksef-direct/received-documents/fetch.js +172 -0
  25. package/dist/modules/integration_ksef_direct/api/post/integration-ksef-direct/received-documents/fetch.js.map +7 -0
  26. package/dist/modules/integration_ksef_direct/api/post/integration-ksef-direct/received-documents/sync.js +80 -0
  27. package/dist/modules/integration_ksef_direct/api/post/integration-ksef-direct/received-documents/sync.js.map +7 -0
  28. package/dist/modules/integration_ksef_direct/backend/integration-ksef-direct/documents/new/page.js +441 -0
  29. package/dist/modules/integration_ksef_direct/backend/integration-ksef-direct/documents/new/page.js.map +7 -0
  30. package/dist/modules/integration_ksef_direct/backend/integration-ksef-direct/documents/new/page.meta.js +8 -0
  31. package/dist/modules/integration_ksef_direct/backend/integration-ksef-direct/documents/new/page.meta.js.map +7 -0
  32. package/dist/modules/integration_ksef_direct/backend/integration-ksef-direct/documents/page.js +193 -0
  33. package/dist/modules/integration_ksef_direct/backend/integration-ksef-direct/documents/page.js.map +7 -0
  34. package/dist/modules/integration_ksef_direct/backend/integration-ksef-direct/received-documents/page.js +314 -0
  35. package/dist/modules/integration_ksef_direct/backend/integration-ksef-direct/received-documents/page.js.map +7 -0
  36. package/dist/modules/integration_ksef_direct/backend/page.js +154 -0
  37. package/dist/modules/integration_ksef_direct/backend/page.js.map +7 -0
  38. package/dist/modules/integration_ksef_direct/commands/create-ksef-direct-document.js +80 -0
  39. package/dist/modules/integration_ksef_direct/commands/create-ksef-direct-document.js.map +7 -0
  40. package/dist/modules/integration_ksef_direct/commands/enqueue-ksef-direct-document.js +43 -0
  41. package/dist/modules/integration_ksef_direct/commands/enqueue-ksef-direct-document.js.map +7 -0
  42. package/dist/modules/integration_ksef_direct/data/entities.js +224 -0
  43. package/dist/modules/integration_ksef_direct/data/entities.js.map +7 -0
  44. package/dist/modules/integration_ksef_direct/data/validators.js +103 -0
  45. package/dist/modules/integration_ksef_direct/data/validators.js.map +7 -0
  46. package/dist/modules/integration_ksef_direct/di.js +11 -0
  47. package/dist/modules/integration_ksef_direct/di.js.map +7 -0
  48. package/dist/modules/integration_ksef_direct/events.js +21 -0
  49. package/dist/modules/integration_ksef_direct/events.js.map +7 -0
  50. package/dist/modules/integration_ksef_direct/index.js +10 -0
  51. package/dist/modules/integration_ksef_direct/index.js.map +7 -0
  52. package/dist/modules/integration_ksef_direct/integration.js +56 -0
  53. package/dist/modules/integration_ksef_direct/integration.js.map +7 -0
  54. package/dist/modules/integration_ksef_direct/lib/health.js +32 -0
  55. package/dist/modules/integration_ksef_direct/lib/health.js.map +7 -0
  56. package/dist/modules/integration_ksef_direct/lib/invoiceNumberFormat.js +23 -0
  57. package/dist/modules/integration_ksef_direct/lib/invoiceNumberFormat.js.map +7 -0
  58. package/dist/modules/integration_ksef_direct/lib/ksefClient.js +523 -0
  59. package/dist/modules/integration_ksef_direct/lib/ksefClient.js.map +7 -0
  60. package/dist/modules/integration_ksef_direct/lib/ksefCrypto.js +103 -0
  61. package/dist/modules/integration_ksef_direct/lib/ksefCrypto.js.map +7 -0
  62. package/dist/modules/integration_ksef_direct/lib/ksefFa2Xml.js +123 -0
  63. package/dist/modules/integration_ksef_direct/lib/ksefFa2Xml.js.map +7 -0
  64. package/dist/modules/integration_ksef_direct/lib/ksefXmlParser.js +76 -0
  65. package/dist/modules/integration_ksef_direct/lib/ksefXmlParser.js.map +7 -0
  66. package/dist/modules/integration_ksef_direct/migrations/Migration20260519210000_integration_ksef_direct.js +15 -0
  67. package/dist/modules/integration_ksef_direct/migrations/Migration20260519210000_integration_ksef_direct.js.map +7 -0
  68. package/dist/modules/integration_ksef_direct/migrations/Migration20260520120000_ksef_direct_documents.js +17 -0
  69. package/dist/modules/integration_ksef_direct/migrations/Migration20260520120000_ksef_direct_documents.js.map +7 -0
  70. package/dist/modules/integration_ksef_direct/migrations/Migration20260520220000_ksef_direct_send_queue.js +15 -0
  71. package/dist/modules/integration_ksef_direct/migrations/Migration20260520220000_ksef_direct_send_queue.js.map +7 -0
  72. package/dist/modules/integration_ksef_direct/migrations/Migration20260520230000_ksef_direct_seller_per_document.js +17 -0
  73. package/dist/modules/integration_ksef_direct/migrations/Migration20260520230000_ksef_direct_seller_per_document.js.map +7 -0
  74. package/dist/modules/integration_ksef_direct/migrations/Migration20260521120000_ksef_direct_received_documents.js +16 -0
  75. package/dist/modules/integration_ksef_direct/migrations/Migration20260521120000_ksef_direct_received_documents.js.map +7 -0
  76. package/dist/modules/integration_ksef_direct/migrations/Migration20260521130000_ksef_received_download_urls.js +15 -0
  77. package/dist/modules/integration_ksef_direct/migrations/Migration20260521130000_ksef_received_download_urls.js.map +7 -0
  78. package/dist/modules/integration_ksef_direct/setup.js +11 -0
  79. package/dist/modules/integration_ksef_direct/setup.js.map +7 -0
  80. package/dist/modules/integration_ksef_direct/subscribers/auto-enqueue-ksef-document.js +19 -0
  81. package/dist/modules/integration_ksef_direct/subscribers/auto-enqueue-ksef-document.js.map +7 -0
  82. package/dist/modules/integration_ksef_direct/workers/check-ksef-document-status.js +103 -0
  83. package/dist/modules/integration_ksef_direct/workers/check-ksef-document-status.js.map +7 -0
  84. package/dist/modules/integration_ksef_direct/workers/send-ksef-document.js +104 -0
  85. package/dist/modules/integration_ksef_direct/workers/send-ksef-document.js.map +7 -0
  86. package/dist/modules/integration_ksef_direct/workers/sync-received-documents.js +137 -0
  87. package/dist/modules/integration_ksef_direct/workers/sync-received-documents.js.map +7 -0
  88. package/dist/types/declarations.d.js +1 -0
  89. package/dist/types/declarations.d.js.map +7 -0
  90. package/package.json +98 -0
  91. package/src/index.ts +1 -0
  92. package/src/modules/integration_ksef_direct/__tests__/invoiceNumberFormat.test.ts +42 -0
  93. package/src/modules/integration_ksef_direct/__tests__/ksefFa2Xml.test.ts +407 -0
  94. package/src/modules/integration_ksef_direct/__tests__/ksefXmlParser.test.ts +230 -0
  95. package/src/modules/integration_ksef_direct/acl.ts +9 -0
  96. package/src/modules/integration_ksef_direct/api/get/integration-ksef-direct/documents/[id].ts +94 -0
  97. package/src/modules/integration_ksef_direct/api/get/integration-ksef-direct/documents.ts +111 -0
  98. package/src/modules/integration_ksef_direct/api/get/integration-ksef-direct/health.ts +194 -0
  99. package/src/modules/integration_ksef_direct/api/get/integration-ksef-direct/received-documents/[id].ts +88 -0
  100. package/src/modules/integration_ksef_direct/api/get/integration-ksef-direct/received-documents.ts +119 -0
  101. package/src/modules/integration_ksef_direct/api/get/integration-ksef-direct/seller-info.ts +62 -0
  102. package/src/modules/integration_ksef_direct/api/post/integration-ksef-direct/documents/[id]/send.ts +64 -0
  103. package/src/modules/integration_ksef_direct/api/post/integration-ksef-direct/documents.ts +109 -0
  104. package/src/modules/integration_ksef_direct/api/post/integration-ksef-direct/invoice-numbers.ts +40 -0
  105. package/src/modules/integration_ksef_direct/api/post/integration-ksef-direct/received-documents/fetch.ts +185 -0
  106. package/src/modules/integration_ksef_direct/api/post/integration-ksef-direct/received-documents/sync.ts +86 -0
  107. package/src/modules/integration_ksef_direct/backend/integration-ksef-direct/documents/new/page.meta.ts +4 -0
  108. package/src/modules/integration_ksef_direct/backend/integration-ksef-direct/documents/new/page.tsx +470 -0
  109. package/src/modules/integration_ksef_direct/backend/integration-ksef-direct/documents/page.tsx +233 -0
  110. package/src/modules/integration_ksef_direct/backend/integration-ksef-direct/received-documents/page.tsx +415 -0
  111. package/src/modules/integration_ksef_direct/backend/page.tsx +183 -0
  112. package/src/modules/integration_ksef_direct/commands/create-ksef-direct-document.ts +93 -0
  113. package/src/modules/integration_ksef_direct/commands/enqueue-ksef-direct-document.ts +57 -0
  114. package/src/modules/integration_ksef_direct/data/entities.ts +195 -0
  115. package/src/modules/integration_ksef_direct/data/validators.ts +115 -0
  116. package/src/modules/integration_ksef_direct/di.ts +9 -0
  117. package/src/modules/integration_ksef_direct/events.ts +18 -0
  118. package/src/modules/integration_ksef_direct/i18n/en.json +115 -0
  119. package/src/modules/integration_ksef_direct/i18n/pl.json +115 -0
  120. package/src/modules/integration_ksef_direct/index.ts +6 -0
  121. package/src/modules/integration_ksef_direct/integration.ts +54 -0
  122. package/src/modules/integration_ksef_direct/lib/health.ts +43 -0
  123. package/src/modules/integration_ksef_direct/lib/invoiceNumberFormat.ts +23 -0
  124. package/src/modules/integration_ksef_direct/lib/ksefClient.ts +668 -0
  125. package/src/modules/integration_ksef_direct/lib/ksefCrypto.ts +138 -0
  126. package/src/modules/integration_ksef_direct/lib/ksefFa2Xml.ts +147 -0
  127. package/src/modules/integration_ksef_direct/lib/ksefXmlParser.ts +97 -0
  128. package/src/modules/integration_ksef_direct/migrations/.snapshot-open-mercato.json +1028 -0
  129. package/src/modules/integration_ksef_direct/migrations/Migration20260519210000_integration_ksef_direct.ts +15 -0
  130. package/src/modules/integration_ksef_direct/migrations/Migration20260520120000_ksef_direct_documents.ts +17 -0
  131. package/src/modules/integration_ksef_direct/migrations/Migration20260520220000_ksef_direct_send_queue.ts +15 -0
  132. package/src/modules/integration_ksef_direct/migrations/Migration20260520230000_ksef_direct_seller_per_document.ts +17 -0
  133. package/src/modules/integration_ksef_direct/migrations/Migration20260521120000_ksef_direct_received_documents.ts +16 -0
  134. package/src/modules/integration_ksef_direct/migrations/Migration20260521130000_ksef_received_download_urls.ts +15 -0
  135. package/src/modules/integration_ksef_direct/setup.ts +9 -0
  136. package/src/modules/integration_ksef_direct/subscribers/auto-enqueue-ksef-document.ts +21 -0
  137. package/src/modules/integration_ksef_direct/workers/check-ksef-document-status.ts +129 -0
  138. package/src/modules/integration_ksef_direct/workers/send-ksef-document.ts +137 -0
  139. package/src/modules/integration_ksef_direct/workers/sync-received-documents.ts +171 -0
  140. package/src/types/declarations.d.ts +1 -0
@@ -0,0 +1,668 @@
1
+ import { z } from 'zod'
2
+ import { fetchPublicKey, clearPublicKeyCache, encryptKsefToken } from './ksefCrypto'
3
+
4
+ type KsefEnvironment = 'test' | 'production'
5
+
6
+ const BASE_URLS: Record<KsefEnvironment, string> = {
7
+ test: 'https://api-test.ksef.mf.gov.pl/v2',
8
+ production: 'https://api.ksef.mf.gov.pl/v2',
9
+ }
10
+
11
+ export interface KsefCredentials {
12
+ ksefToken: string
13
+ nip: string
14
+ environment: KsefEnvironment
15
+ tenantId?: string
16
+ }
17
+
18
+ interface KsefTokenCache {
19
+ accessToken: string
20
+ accessTokenExp: Date
21
+ refreshToken: string
22
+ refreshTokenExp: Date
23
+ }
24
+
25
+ const TOKEN_CACHE = new Map<string, KsefTokenCache>()
26
+
27
+ const ChallengeResponseSchema = z.object({
28
+ challenge: z.string(),
29
+ timestampMs: z.number().optional(),
30
+ })
31
+
32
+ const KsefTokenResponseSchema = z.object({
33
+ referenceNumber: z.string(),
34
+ authenticationToken: z.object({
35
+ token: z.string(),
36
+ validUntil: z.string().optional(),
37
+ }),
38
+ })
39
+
40
+ const AuthStatusSchema = z.object({
41
+ status: z.object({
42
+ code: z.number(),
43
+ description: z.string().optional(),
44
+ }),
45
+ })
46
+
47
+ const RedeemResponseSchema = z.object({
48
+ sessionToken: z.object({
49
+ token: z.string(),
50
+ generatedAt: z.string().optional(),
51
+ validUntil: z.string().optional(),
52
+ }).optional(),
53
+ accessToken: z.object({
54
+ token: z.string(),
55
+ validUntil: z.string().optional(),
56
+ }).optional(),
57
+ refreshToken: z.object({
58
+ token: z.string(),
59
+ validUntil: z.string().optional(),
60
+ }).optional(),
61
+ }).passthrough()
62
+
63
+ const RateLimitsSchema = z.object({
64
+ otherPerSecond: z.number().optional(),
65
+ otherPerMinute: z.number().optional(),
66
+ })
67
+
68
+ export type KsefRateLimits = z.infer<typeof RateLimitsSchema>
69
+
70
+ function cacheKey(credentials: KsefCredentials): string {
71
+ return `${credentials.environment}:${credentials.tenantId ?? ''}:${credentials.nip}`
72
+ }
73
+
74
+ async function ksefFetch(url: string, options: RequestInit): Promise<Response> {
75
+ const response = await fetch(url, {
76
+ ...options,
77
+ signal: AbortSignal.timeout(15_000),
78
+ })
79
+
80
+ if (response.status === 429) {
81
+ const retryAfter = response.headers.get('Retry-After')
82
+ const waitMs = Math.min(retryAfter ? parseInt(retryAfter, 10) * 1000 : 2000, 10_000)
83
+ await new Promise((resolve) => setTimeout(resolve, waitMs))
84
+ return fetch(url, { ...options, signal: AbortSignal.timeout(15_000) })
85
+ }
86
+
87
+ return response
88
+ }
89
+
90
+ async function getValidAccessToken(credentials: KsefCredentials): Promise<string> {
91
+ const key = cacheKey(credentials)
92
+ const cached = TOKEN_CACHE.get(key)
93
+ const now = new Date()
94
+
95
+ if (cached) {
96
+ const refreshThreshold = new Date(cached.accessTokenExp.getTime() - 2 * 60 * 1000)
97
+ if (now < refreshThreshold) {
98
+ return cached.accessToken
99
+ }
100
+
101
+ if (now < cached.refreshTokenExp) {
102
+ const newAccessToken = await refreshAccessToken(cached.refreshToken, credentials.environment)
103
+ TOKEN_CACHE.set(key, {
104
+ ...cached,
105
+ accessToken: newAccessToken,
106
+ accessTokenExp: new Date(Date.now() + 14 * 60 * 1000),
107
+ })
108
+ return newAccessToken
109
+ }
110
+ }
111
+
112
+ return authenticate(credentials)
113
+ }
114
+
115
+ async function authenticate(credentials: KsefCredentials): Promise<string> {
116
+ const baseUrl = BASE_URLS[credentials.environment]
117
+
118
+ const publicKeyPem = await fetchPublicKey(credentials.environment)
119
+
120
+ const challengeRes = await ksefFetch(`${baseUrl}/auth/challenge`, {
121
+ method: 'POST',
122
+ headers: { 'Content-Type': 'application/json' },
123
+ body: JSON.stringify({}),
124
+ })
125
+ if (!challengeRes.ok) {
126
+ throw new KsefAuthError(`Challenge request failed: HTTP ${challengeRes.status}`, 'AUTH_CHALLENGE_FAILED')
127
+ }
128
+ const challengeData = ChallengeResponseSchema.parse(await challengeRes.json())
129
+
130
+ const timestampMs = challengeData.timestampMs ?? Date.now()
131
+ let encryptedToken: string
132
+ try {
133
+ encryptedToken = encryptKsefToken(credentials.ksefToken, timestampMs, publicKeyPem)
134
+ } catch {
135
+ clearPublicKeyCache(credentials.environment)
136
+ const freshKey = await fetchPublicKey(credentials.environment)
137
+ encryptedToken = encryptKsefToken(credentials.ksefToken, timestampMs, freshKey)
138
+ }
139
+
140
+ const ksefTokenRes = await ksefFetch(`${baseUrl}/auth/ksef-token`, {
141
+ method: 'POST',
142
+ headers: { 'Content-Type': 'application/json' },
143
+ body: JSON.stringify({
144
+ challenge: challengeData.challenge,
145
+ contextIdentifier: { type: 'nip', value: credentials.nip },
146
+ encryptedToken,
147
+ }),
148
+ })
149
+ if (!ksefTokenRes.ok) {
150
+ const body = await ksefTokenRes.text().catch(() => '')
151
+ throw new KsefAuthError(
152
+ `KSeF token submission failed: HTTP ${ksefTokenRes.status}${body ? ` — ${body}` : ''}`,
153
+ ksefTokenRes.status === 401 ? 'AUTH_FAILED' : 'AUTH_KSEF_TOKEN_FAILED',
154
+ )
155
+ }
156
+ const tokenData = KsefTokenResponseSchema.parse(await ksefTokenRes.json())
157
+ const authToken = tokenData.authenticationToken.token
158
+
159
+ const authStatusData = await pollAuthStatus(baseUrl, tokenData.referenceNumber, authToken)
160
+ if (!authStatusData) {
161
+ throw new KsefAuthError('Authentication timed out waiting for KSeF status', 'AUTH_TIMEOUT')
162
+ }
163
+
164
+ const redeemRes = await ksefFetch(`${baseUrl}/auth/token/redeem`, {
165
+ method: 'POST',
166
+ headers: {
167
+ 'Content-Type': 'application/json',
168
+ 'Authorization': `Bearer ${authToken}`,
169
+ },
170
+ body: JSON.stringify({}),
171
+ })
172
+ if (!redeemRes.ok) {
173
+ const body = await redeemRes.text().catch(() => '')
174
+ throw new KsefAuthError(`Token redeem failed: HTTP ${redeemRes.status}${body ? ` — ${body}` : ''}`, 'AUTH_REDEEM_FAILED')
175
+ }
176
+
177
+ const redeemRaw = await redeemRes.json() as Record<string, unknown>
178
+ const redeemData = RedeemResponseSchema.parse(redeemRaw)
179
+ const sessionTokenObj = redeemData.sessionToken ?? redeemData.accessToken
180
+ if (!sessionTokenObj) {
181
+ throw new KsefAuthError(
182
+ `Unexpected redeem response shape: ${JSON.stringify(redeemRaw)}`,
183
+ 'AUTH_REDEEM_FAILED',
184
+ )
185
+ }
186
+ const accessToken = sessionTokenObj.token
187
+ const refreshTokenValue = redeemData.refreshToken?.token ?? accessToken
188
+ const refreshTokenExp = redeemData.refreshToken?.validUntil
189
+ ? new Date(redeemData.refreshToken.validUntil)
190
+ : new Date(Date.now() + 60 * 60 * 1000)
191
+ const accessTokenExp = sessionTokenObj.validUntil
192
+ ? new Date(sessionTokenObj.validUntil)
193
+ : new Date(Date.now() + 14 * 60 * 1000)
194
+
195
+ TOKEN_CACHE.set(cacheKey(credentials), {
196
+ accessToken,
197
+ accessTokenExp,
198
+ refreshToken: refreshTokenValue,
199
+ refreshTokenExp,
200
+ })
201
+
202
+ return accessToken
203
+ }
204
+
205
+ async function pollAuthStatus(baseUrl: string, referenceNumber: string, authToken: string): Promise<boolean> {
206
+ const deadline = Date.now() + 30_000
207
+ let delay = 1000
208
+
209
+ while (Date.now() < deadline) {
210
+ await new Promise((resolve) => setTimeout(resolve, delay))
211
+ delay = Math.min(delay * 1.5, 5000)
212
+
213
+ const res = await ksefFetch(`${baseUrl}/auth/${referenceNumber}`, {
214
+ method: 'GET',
215
+ headers: {
216
+ 'Accept': 'application/json',
217
+ 'Authorization': `Bearer ${authToken}`,
218
+ },
219
+ })
220
+
221
+ if (res.ok) {
222
+ const data = AuthStatusSchema.safeParse(await res.json())
223
+ if (data.success) {
224
+ const code = data.data.status.code
225
+ if (code === 200) return true
226
+ if (code >= 400) {
227
+ throw new KsefAuthError(
228
+ `KSeF authentication failed: ${data.data.status.description ?? `status ${code}`}`,
229
+ 'AUTH_FAILED',
230
+ )
231
+ }
232
+ }
233
+ }
234
+ }
235
+
236
+ return false
237
+ }
238
+
239
+ async function refreshAccessToken(refreshToken: string, environment: KsefEnvironment): Promise<string> {
240
+ const baseUrl = BASE_URLS[environment]
241
+ const res = await ksefFetch(`${baseUrl}/auth/token/refresh`, {
242
+ method: 'POST',
243
+ headers: {
244
+ 'Content-Type': 'application/json',
245
+ 'Authorization': `Bearer ${refreshToken}`,
246
+ },
247
+ body: JSON.stringify({}),
248
+ })
249
+
250
+ if (!res.ok) {
251
+ throw new KsefAuthError(`Token refresh failed: HTTP ${res.status}`, 'AUTH_REFRESH_FAILED')
252
+ }
253
+
254
+ const raw = await res.json() as Record<string, unknown>
255
+ const data = RedeemResponseSchema.parse(raw)
256
+ const token = data.sessionToken?.token ?? data.accessToken?.token
257
+ if (!token) throw new KsefAuthError('Unexpected refresh response shape', 'AUTH_REFRESH_FAILED')
258
+ return token
259
+ }
260
+
261
+ export async function verifyAccess(credentials: KsefCredentials): Promise<KsefRateLimits> {
262
+ const baseUrl = BASE_URLS[credentials.environment]
263
+ const key = cacheKey(credentials)
264
+
265
+ let accessToken: string
266
+ try {
267
+ accessToken = await getValidAccessToken(credentials)
268
+ } catch (err) {
269
+ TOKEN_CACHE.delete(key)
270
+ throw err
271
+ }
272
+
273
+ const res = await ksefFetch(`${baseUrl}/rate-limits`, {
274
+ method: 'GET',
275
+ headers: {
276
+ 'Accept': 'application/json',
277
+ 'Authorization': `Bearer ${accessToken}`,
278
+ },
279
+ })
280
+
281
+ if (res.status === 401) {
282
+ TOKEN_CACHE.delete(key)
283
+ const freshToken = await authenticate(credentials)
284
+ const retryRes = await ksefFetch(`${baseUrl}/rate-limits`, {
285
+ method: 'GET',
286
+ headers: {
287
+ 'Accept': 'application/json',
288
+ 'Authorization': `Bearer ${freshToken}`,
289
+ },
290
+ })
291
+ if (!retryRes.ok) {
292
+ throw new KsefNetworkError(`Rate limits check failed after re-auth: HTTP ${retryRes.status}`)
293
+ }
294
+ const parsed = RateLimitsSchema.safeParse(await retryRes.json())
295
+ return parsed.success ? parsed.data : {}
296
+ }
297
+
298
+ if (!res.ok) {
299
+ throw new KsefNetworkError(`Rate limits check failed: HTTP ${res.status}`)
300
+ }
301
+
302
+ const parsed = RateLimitsSchema.safeParse(await res.json())
303
+ return parsed.success ? parsed.data : {}
304
+ }
305
+
306
+ export function clearTokenCache(tenantKey?: string): void {
307
+ if (tenantKey) {
308
+ TOKEN_CACHE.delete(tenantKey)
309
+ } else {
310
+ TOKEN_CACHE.clear()
311
+ }
312
+ }
313
+
314
+ export class KsefAuthError extends Error {
315
+ readonly errorCode: string
316
+ constructor(message: string, errorCode: string) {
317
+ super(message)
318
+ this.name = 'KsefAuthError'
319
+ this.errorCode = errorCode
320
+ }
321
+ }
322
+
323
+ export class KsefNetworkError extends Error {
324
+ readonly errorCode = 'NETWORK_ERROR'
325
+ constructor(message: string) {
326
+ super(message)
327
+ this.name = 'KsefNetworkError'
328
+ }
329
+ }
330
+
331
+ const OpenSessionResponseSchema = z.object({
332
+ referenceNumber: z.string(),
333
+ validUntil: z.string(),
334
+ })
335
+
336
+ const SendInvoiceToSessionResponseSchema = z.object({
337
+ referenceNumber: z.string(),
338
+ })
339
+
340
+ const SessionStatusResponseSchema = z.object({
341
+ status: z.object({
342
+ code: z.number(),
343
+ description: z.string(),
344
+ details: z.array(z.string()).optional().nullable(),
345
+ }),
346
+ successfulInvoiceCount: z.number().optional().nullable(),
347
+ failedInvoiceCount: z.number().optional().nullable(),
348
+ }).passthrough()
349
+
350
+ export async function sendInvoice(
351
+ credentials: KsefCredentials,
352
+ payload: {
353
+ encryptedSymmetricKey: string
354
+ initializationVector: string
355
+ encryptedInvoiceContent: string
356
+ invoiceHash: string
357
+ invoiceSize: number
358
+ encryptedInvoiceHash: string
359
+ encryptedInvoiceSize: number
360
+ publicKeyId?: string
361
+ },
362
+ ): Promise<{ sessionReferenceNumber: string; invoiceReferenceNumber: string }> {
363
+ const baseUrl = BASE_URLS[credentials.environment]
364
+ const accessToken = await getValidAccessToken(credentials)
365
+
366
+ const openRes = await ksefFetch(`${baseUrl}/sessions/online`, {
367
+ method: 'POST',
368
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${accessToken}` },
369
+ body: JSON.stringify({
370
+ formCode: { systemCode: 'FA (2)', schemaVersion: '1-0E', value: 'FA' },
371
+ encryption: {
372
+ encryptedSymmetricKey: payload.encryptedSymmetricKey,
373
+ initializationVector: payload.initializationVector,
374
+ ...(payload.publicKeyId ? { publicKeyId: payload.publicKeyId } : {}),
375
+ },
376
+ }),
377
+ })
378
+
379
+ if (!openRes.ok) {
380
+ const body = await openRes.text().catch(() => '')
381
+ throw new KsefNetworkError(`Session open failed: HTTP ${openRes.status}${body ? ` — ${body}` : ''}`)
382
+ }
383
+
384
+ const openData = OpenSessionResponseSchema.parse(await openRes.json())
385
+ const sessionReferenceNumber = openData.referenceNumber
386
+
387
+ let invoiceReferenceNumber: string
388
+ try {
389
+ const sendRes = await ksefFetch(
390
+ `${baseUrl}/sessions/online/${encodeURIComponent(sessionReferenceNumber)}/invoices`,
391
+ {
392
+ method: 'POST',
393
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${accessToken}` },
394
+ body: JSON.stringify({
395
+ invoiceHash: payload.invoiceHash,
396
+ invoiceSize: payload.invoiceSize,
397
+ encryptedInvoiceHash: payload.encryptedInvoiceHash,
398
+ encryptedInvoiceSize: payload.encryptedInvoiceSize,
399
+ encryptedInvoiceContent: payload.encryptedInvoiceContent,
400
+ }),
401
+ },
402
+ )
403
+
404
+ if (!sendRes.ok) {
405
+ const body = await sendRes.text().catch(() => '')
406
+ throw new KsefNetworkError(`Invoice send failed: HTTP ${sendRes.status}${body ? ` — ${body}` : ''}`)
407
+ }
408
+
409
+ const sendData = SendInvoiceToSessionResponseSchema.parse(await sendRes.json())
410
+ invoiceReferenceNumber = sendData.referenceNumber
411
+ } catch (err) {
412
+ await ksefFetch(
413
+ `${baseUrl}/sessions/online/${encodeURIComponent(sessionReferenceNumber)}/close`,
414
+ { method: 'POST', headers: { 'Authorization': `Bearer ${accessToken}` } },
415
+ ).catch(() => {})
416
+ throw err
417
+ }
418
+
419
+ const closeRes = await ksefFetch(
420
+ `${baseUrl}/sessions/online/${encodeURIComponent(sessionReferenceNumber)}/close`,
421
+ { method: 'POST', headers: { 'Authorization': `Bearer ${accessToken}` } },
422
+ )
423
+
424
+
425
+ return { sessionReferenceNumber, invoiceReferenceNumber }
426
+ }
427
+
428
+ export interface KsefReceivedInvoiceSummary {
429
+ ksefReferenceNumber: string
430
+ sessionReferenceNumber: string
431
+ issueDate: string | null
432
+ sellerNip: string | null
433
+ sellerName: string | null
434
+ grossAmount: string | null
435
+ netAmount: string | null
436
+ vatAmount: string | null
437
+ currency: string | null
438
+ invoiceNumber: string | null
439
+ upoDownloadUrl: string | null
440
+ invoiceDownloadUrl: string | null
441
+ }
442
+
443
+ const SessionListResponseSchema = z.object({
444
+ sessions: z.array(z.object({
445
+ referenceNumber: z.string(),
446
+ }).passthrough()).default([]),
447
+ continuationToken: z.string().nullable().optional(),
448
+ }).passthrough()
449
+
450
+ const SessionInvoiceItemSchema = z.object({
451
+ referenceNumber: z.string().optional(),
452
+ ksefNumber: z.string().optional(),
453
+ ksefReferenceNumber: z.string().optional(),
454
+ issueDate: z.string().nullable().optional(),
455
+ subjectBy: z.object({
456
+ identifier: z.object({
457
+ type: z.string().optional(),
458
+ identifier: z.string().optional(),
459
+ }).optional(),
460
+ name: z.string().optional(),
461
+ }).passthrough().optional(),
462
+ grossAmount: z.union([z.string(), z.number()]).nullable().optional(),
463
+ netAmount: z.union([z.string(), z.number()]).nullable().optional(),
464
+ vatAmount: z.union([z.string(), z.number()]).nullable().optional(),
465
+ currency: z.string().nullable().optional(),
466
+ invoiceNumber: z.string().nullable().optional(),
467
+ upoDownloadUrl: z.string().nullable().optional(),
468
+ invoiceDownloadUrl: z.string().nullable().optional(),
469
+ }).passthrough()
470
+
471
+ const SessionInvoiceListResponseSchema = z.object({
472
+ invoices: z.array(SessionInvoiceItemSchema).default([]),
473
+ continuationToken: z.string().nullable().optional(),
474
+ }).passthrough()
475
+
476
+ async function fetchAllSessionInvoices(
477
+ baseUrl: string,
478
+ accessToken: string,
479
+ sessionRef: string,
480
+ ): Promise<KsefReceivedInvoiceSummary[]> {
481
+ const results: KsefReceivedInvoiceSummary[] = []
482
+ let continuationToken: string | null | undefined = undefined
483
+ let isFirst = true
484
+
485
+ while (isFirst || continuationToken) {
486
+ isFirst = false
487
+ const url = new URL(`${baseUrl}/sessions/${encodeURIComponent(sessionRef)}/invoices`)
488
+ if (continuationToken) url.searchParams.set('continuationToken', continuationToken)
489
+
490
+ const res = await ksefFetch(url.toString(), {
491
+ method: 'GET',
492
+ headers: { 'Accept': 'application/json', 'Authorization': `Bearer ${accessToken}` },
493
+ })
494
+
495
+ if (!res.ok) break
496
+
497
+ const data = SessionInvoiceListResponseSchema.parse(await res.json())
498
+ continuationToken = data.continuationToken ?? null
499
+
500
+ for (const inv of data.invoices) {
501
+ const ksefRef = inv.ksefNumber ?? inv.ksefReferenceNumber
502
+ if (!ksefRef) continue
503
+ const grossRaw = inv.grossAmount
504
+ const netRaw = inv.netAmount
505
+ const vatRaw = inv.vatAmount
506
+ results.push({
507
+ ksefReferenceNumber: ksefRef,
508
+ sessionReferenceNumber: sessionRef,
509
+ issueDate: inv.issueDate ?? null,
510
+ sellerNip: inv.subjectBy?.identifier?.identifier ?? null,
511
+ sellerName: inv.subjectBy?.name ?? null,
512
+ grossAmount: grossRaw != null ? String(grossRaw) : null,
513
+ netAmount: netRaw != null ? String(netRaw) : null,
514
+ vatAmount: vatRaw != null ? String(vatRaw) : null,
515
+ currency: inv.currency ?? null,
516
+ invoiceNumber: inv.invoiceNumber ?? null,
517
+ upoDownloadUrl: inv.upoDownloadUrl ?? null,
518
+ invoiceDownloadUrl: inv.invoiceDownloadUrl ?? null,
519
+ })
520
+ }
521
+
522
+ if (!continuationToken) break
523
+ }
524
+
525
+ return results
526
+ }
527
+
528
+ export async function queryReceivedInvoices(
529
+ credentials: KsefCredentials,
530
+ params: {
531
+ dateFrom: string
532
+ dateTo: string
533
+ },
534
+ ): Promise<{ items: KsefReceivedInvoiceSummary[]; totalCount: number }> {
535
+ const baseUrl = BASE_URLS[credentials.environment]
536
+ const accessToken = await getValidAccessToken(credentials)
537
+ const allItems: KsefReceivedInvoiceSummary[] = []
538
+
539
+ let continuationToken: string | null | undefined = undefined
540
+ let isFirst = true
541
+
542
+ while (isFirst || continuationToken) {
543
+ isFirst = false
544
+ const sessionUrl = new URL(`${baseUrl}/sessions`)
545
+ sessionUrl.searchParams.set('sessionType', 'online')
546
+ sessionUrl.searchParams.set('direction', 'received')
547
+ sessionUrl.searchParams.set('dateFrom', params.dateFrom)
548
+ sessionUrl.searchParams.set('dateTo', params.dateTo)
549
+ if (continuationToken) sessionUrl.searchParams.set('continuationToken', continuationToken)
550
+
551
+ const res = await ksefFetch(sessionUrl.toString(), {
552
+ method: 'GET',
553
+ headers: { 'Accept': 'application/json', 'Authorization': `Bearer ${accessToken}` },
554
+ })
555
+
556
+ if (!res.ok) {
557
+ const body = await res.text().catch(() => '')
558
+ throw new KsefNetworkError(`Query received sessions failed: HTTP ${res.status}${body ? ` — ${body}` : ''}`)
559
+ }
560
+
561
+ const sessionData = SessionListResponseSchema.parse(await res.json())
562
+ continuationToken = sessionData.continuationToken ?? null
563
+
564
+ for (const session of sessionData.sessions) {
565
+ const invoices = await fetchAllSessionInvoices(baseUrl, accessToken, session.referenceNumber)
566
+ allItems.push(...invoices)
567
+ }
568
+
569
+ if (!continuationToken) break
570
+ }
571
+
572
+ return { items: allItems, totalCount: allItems.length }
573
+ }
574
+
575
+ async function downloadFromUrl(url: string): Promise<string> {
576
+ const res = await fetch(url, {
577
+ method: 'GET',
578
+ signal: AbortSignal.timeout(30_000),
579
+ })
580
+
581
+ if (!res.ok) {
582
+ const body = await res.text().catch(() => '')
583
+ throw new KsefNetworkError(`Invoice download failed: HTTP ${res.status}${body ? ` — ${body}` : ''}`)
584
+ }
585
+
586
+ return res.text()
587
+ }
588
+
589
+ export async function downloadInvoiceFromUrl(url: string): Promise<string> {
590
+ return downloadFromUrl(url)
591
+ }
592
+
593
+ export async function downloadInvoice(
594
+ credentials: KsefCredentials,
595
+ ksefReferenceNumber: string,
596
+ ): Promise<{ rawContent: string; upoDownloadUrl: string | null; invoiceDownloadUrl: string | null }> {
597
+ const baseUrl = BASE_URLS[credentials.environment]
598
+ const accessToken = await getValidAccessToken(credentials)
599
+
600
+ const res = await ksefFetch(`${baseUrl}/invoices/ksef/${encodeURIComponent(ksefReferenceNumber)}`, {
601
+ method: 'GET',
602
+ headers: { 'Accept': 'application/octet-stream', 'Authorization': `Bearer ${accessToken}` },
603
+ })
604
+
605
+ if (!res.ok) {
606
+ const body = await res.text().catch(() => '')
607
+ throw new KsefNetworkError(`Invoice fetch failed: HTTP ${res.status}${body ? ` — ${body}` : ''}`)
608
+ }
609
+
610
+ const contentType = res.headers.get('content-type') ?? ''
611
+ let rawContent: string
612
+
613
+ if (contentType.includes('application/json')) {
614
+ const data = await res.json() as Record<string, unknown>
615
+ const downloadUrl = (data['invoiceDownloadUrl'] ?? data['downloadUrl'] ?? data['url']) as string | undefined
616
+ if (downloadUrl) {
617
+ rawContent = await downloadFromUrl(downloadUrl)
618
+ } else if (typeof data['content'] === 'string') {
619
+ rawContent = data['content'] as string
620
+ } else {
621
+ throw new KsefNetworkError(`Unexpected JSON response from /invoices/ksef — keys: ${Object.keys(data).join(', ')}`)
622
+ }
623
+ return {
624
+ rawContent,
625
+ upoDownloadUrl: (data['upoDownloadUrl'] as string | undefined) ?? null,
626
+ invoiceDownloadUrl: (data['invoiceDownloadUrl'] as string | undefined) ?? null,
627
+ }
628
+ }
629
+
630
+ rawContent = await res.text()
631
+ return { rawContent, upoDownloadUrl: null, invoiceDownloadUrl: null }
632
+ }
633
+
634
+ export async function checkInvoiceStatus(
635
+ credentials: KsefCredentials,
636
+ referenceNumber: string,
637
+ ): Promise<{ processingCode: number; ksefReferenceNumber?: string; errorDescription?: string }> {
638
+ const baseUrl = BASE_URLS[credentials.environment]
639
+ const accessToken = await getValidAccessToken(credentials)
640
+
641
+ const res = await ksefFetch(`${baseUrl}/sessions/${encodeURIComponent(referenceNumber)}`, {
642
+ method: 'GET',
643
+ headers: { 'Accept': 'application/json', 'Authorization': `Bearer ${accessToken}` },
644
+ })
645
+
646
+ if (!res.ok) {
647
+ const body = await res.text().catch(() => '')
648
+ throw new KsefNetworkError(`Session status check failed: HTTP ${res.status}${body ? ` — ${body}` : ''}`)
649
+ }
650
+
651
+ const data = SessionStatusResponseSchema.parse(await res.json())
652
+ const code = data.status.code
653
+
654
+ if (code === 200) {
655
+ if ((data.failedInvoiceCount ?? 0) > 0) {
656
+ const details = data.status.details?.join('; ') ?? ''
657
+ return { processingCode: 400, errorDescription: `KSeF rejected ${data.failedInvoiceCount} invoice(s): ${data.status.description}${details ? ` — ${details}` : ''}` }
658
+ }
659
+ return { processingCode: 200 }
660
+ }
661
+
662
+ if (code >= 400) {
663
+ const details = data.status.details?.join('; ') ?? ''
664
+ return { processingCode: code, errorDescription: `${data.status.description}${details ? ` — ${details}` : ''}` }
665
+ }
666
+
667
+ return { processingCode: 100 }
668
+ }