create-kvitton 0.4.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.
- package/dist/commands/create.js +238 -0
- package/dist/index.js +1283 -0
- package/dist/lib/env.js +8 -0
- package/dist/lib/git.js +32 -0
- package/dist/sync/bokio-sync.js +142 -0
- package/dist/templates/AGENTS.md +95 -0
- package/package.json +36 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1283 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/commands/create.ts
|
|
7
|
+
import { select, input, password, confirm } from "@inquirer/prompts";
|
|
8
|
+
import ora from "ora";
|
|
9
|
+
import * as path5 from "node:path";
|
|
10
|
+
import * as fs5 from "node:fs/promises";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
|
|
13
|
+
// ../../integrations/bokio/dist/config.js
|
|
14
|
+
var BOKIO_CONFIG = {
|
|
15
|
+
API_BASE_URL: "https://api.bokio.se",
|
|
16
|
+
API_VERSION: "v1",
|
|
17
|
+
RATE_LIMIT: {
|
|
18
|
+
MAX_REQUESTS: 200,
|
|
19
|
+
WINDOW_SECONDS: 60
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
// ../../integrations/bokio/dist/schemas.js
|
|
23
|
+
import { z } from "zod";
|
|
24
|
+
var BokioPaginationSchema = z.object({
|
|
25
|
+
totalItems: z.number(),
|
|
26
|
+
totalPages: z.number(),
|
|
27
|
+
currentPage: z.number()
|
|
28
|
+
}).passthrough();
|
|
29
|
+
var JournalEntryItemSchema = z.object({
|
|
30
|
+
id: z.number(),
|
|
31
|
+
debit: z.number(),
|
|
32
|
+
credit: z.number(),
|
|
33
|
+
account: z.number()
|
|
34
|
+
}).passthrough();
|
|
35
|
+
var JournalEntrySchema = z.object({
|
|
36
|
+
id: z.string().uuid(),
|
|
37
|
+
title: z.string(),
|
|
38
|
+
journalEntryNumber: z.string(),
|
|
39
|
+
date: z.string(),
|
|
40
|
+
items: z.array(JournalEntryItemSchema),
|
|
41
|
+
reversingJournalEntryId: z.string().uuid().nullable().optional(),
|
|
42
|
+
reversedByJournalEntryId: z.string().uuid().nullable().optional()
|
|
43
|
+
}).passthrough();
|
|
44
|
+
var JournalEntriesResponseSchema = z.object({
|
|
45
|
+
totalItems: z.number(),
|
|
46
|
+
totalPages: z.number(),
|
|
47
|
+
currentPage: z.number(),
|
|
48
|
+
items: z.array(JournalEntrySchema)
|
|
49
|
+
}).passthrough();
|
|
50
|
+
var BokioErrorSchema = z.object({
|
|
51
|
+
status: z.number(),
|
|
52
|
+
message: z.string(),
|
|
53
|
+
errorId: z.string().optional(),
|
|
54
|
+
errors: z.array(z.object({
|
|
55
|
+
field: z.string().optional(),
|
|
56
|
+
message: z.string()
|
|
57
|
+
})).optional()
|
|
58
|
+
}).passthrough();
|
|
59
|
+
var CompanyInfoSchema = z.object({
|
|
60
|
+
name: z.string(),
|
|
61
|
+
organizationNumber: z.string().nullable().optional(),
|
|
62
|
+
vatNumber: z.string().nullable().optional(),
|
|
63
|
+
address: z.object({
|
|
64
|
+
street: z.string().nullable().optional(),
|
|
65
|
+
zipCode: z.string().nullable().optional(),
|
|
66
|
+
city: z.string().nullable().optional(),
|
|
67
|
+
country: z.string().nullable().optional()
|
|
68
|
+
}).nullable().optional()
|
|
69
|
+
}).passthrough();
|
|
70
|
+
var UploadSchema = z.object({
|
|
71
|
+
id: z.string().uuid(),
|
|
72
|
+
description: z.string().nullable(),
|
|
73
|
+
contentType: z.string(),
|
|
74
|
+
journalEntryId: z.string().uuid().nullable().optional()
|
|
75
|
+
}).passthrough();
|
|
76
|
+
var UploadsResponseSchema = z.object({
|
|
77
|
+
totalItems: z.number(),
|
|
78
|
+
totalPages: z.number(),
|
|
79
|
+
currentPage: z.number(),
|
|
80
|
+
items: z.array(UploadSchema)
|
|
81
|
+
}).passthrough();
|
|
82
|
+
var CreateJournalEntryItemSchema = z.object({
|
|
83
|
+
debit: z.number(),
|
|
84
|
+
credit: z.number(),
|
|
85
|
+
account: z.number()
|
|
86
|
+
});
|
|
87
|
+
var CreateJournalEntryRequestSchema = z.object({
|
|
88
|
+
title: z.string(),
|
|
89
|
+
date: z.string(),
|
|
90
|
+
items: z.array(CreateJournalEntryItemSchema)
|
|
91
|
+
});
|
|
92
|
+
var FiscalYearSchema = z.object({
|
|
93
|
+
id: z.string().uuid(),
|
|
94
|
+
startDate: z.string(),
|
|
95
|
+
endDate: z.string(),
|
|
96
|
+
accountingMethod: z.enum(["cash", "accrual"]),
|
|
97
|
+
status: z.enum(["open", "closed"])
|
|
98
|
+
}).passthrough();
|
|
99
|
+
var FiscalYearsResponseSchema = z.object({
|
|
100
|
+
totalItems: z.number(),
|
|
101
|
+
totalPages: z.number(),
|
|
102
|
+
currentPage: z.number(),
|
|
103
|
+
items: z.array(FiscalYearSchema)
|
|
104
|
+
}).passthrough();
|
|
105
|
+
var AccountSchema = z.object({
|
|
106
|
+
account: z.number(),
|
|
107
|
+
name: z.string(),
|
|
108
|
+
accountType: z.enum(["basePlanAccount", "customAccount"])
|
|
109
|
+
}).passthrough();
|
|
110
|
+
var ChartOfAccountsResponseSchema = z.array(AccountSchema);
|
|
111
|
+
// ../../integrations/core/dist/errors.js
|
|
112
|
+
class IntegrationError extends Error {
|
|
113
|
+
code;
|
|
114
|
+
statusCode;
|
|
115
|
+
cause;
|
|
116
|
+
constructor(message, code, statusCode, cause) {
|
|
117
|
+
super(message);
|
|
118
|
+
this.code = code;
|
|
119
|
+
this.statusCode = statusCode;
|
|
120
|
+
this.cause = cause;
|
|
121
|
+
this.name = "IntegrationError";
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
class AuthenticationError extends IntegrationError {
|
|
126
|
+
constructor(message, cause) {
|
|
127
|
+
super(message, "AUTHENTICATION_ERROR", 401, cause);
|
|
128
|
+
this.name = "AuthenticationError";
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
class TokenExpiredError extends IntegrationError {
|
|
133
|
+
constructor(message = "Access token has expired", cause) {
|
|
134
|
+
super(message, "TOKEN_EXPIRED", 401, cause);
|
|
135
|
+
this.name = "TokenExpiredError";
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
class RateLimitError extends IntegrationError {
|
|
140
|
+
retryAfterMs;
|
|
141
|
+
constructor(retryAfterMs, cause) {
|
|
142
|
+
super(retryAfterMs ? `Rate limit exceeded. Retry after ${retryAfterMs}ms` : "Rate limit exceeded", "RATE_LIMIT_EXCEEDED", 429, cause);
|
|
143
|
+
this.retryAfterMs = retryAfterMs;
|
|
144
|
+
this.name = "RateLimitError";
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
class ApiError extends IntegrationError {
|
|
149
|
+
response;
|
|
150
|
+
constructor(message, statusCode, response, cause) {
|
|
151
|
+
super(message, "API_ERROR", statusCode, cause);
|
|
152
|
+
this.response = response;
|
|
153
|
+
this.name = "ApiError";
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
class ValidationError extends IntegrationError {
|
|
158
|
+
zodError;
|
|
159
|
+
constructor(message, zodError) {
|
|
160
|
+
super(message, "VALIDATION_ERROR", 422);
|
|
161
|
+
this.zodError = zodError;
|
|
162
|
+
this.name = "ValidationError";
|
|
163
|
+
}
|
|
164
|
+
get issues() {
|
|
165
|
+
return this.zodError.issues;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// ../../integrations/core/dist/validation.js
|
|
169
|
+
function validate(schema, data, context) {
|
|
170
|
+
const result = schema.safeParse(data);
|
|
171
|
+
if (!result.success) {
|
|
172
|
+
console.error(`Validation failed for ${context}:`, result.error.issues);
|
|
173
|
+
throw new ValidationError(`Invalid ${context}`, result.error);
|
|
174
|
+
}
|
|
175
|
+
return result.data;
|
|
176
|
+
}
|
|
177
|
+
// ../../integrations/core/dist/http-client.js
|
|
178
|
+
var DEFAULT_RETRY_CONFIG = {
|
|
179
|
+
maxRetries: 3,
|
|
180
|
+
baseDelayMs: 1000,
|
|
181
|
+
maxDelayMs: 1e4
|
|
182
|
+
};
|
|
183
|
+
var DEFAULT_TIMEOUT = 30000;
|
|
184
|
+
|
|
185
|
+
class HttpClient {
|
|
186
|
+
config;
|
|
187
|
+
retryConfig;
|
|
188
|
+
constructor(config, retryConfig) {
|
|
189
|
+
this.config = config;
|
|
190
|
+
this.retryConfig = { ...DEFAULT_RETRY_CONFIG, ...retryConfig };
|
|
191
|
+
}
|
|
192
|
+
async request(path, options = {}) {
|
|
193
|
+
const { method = "GET", headers = {}, body, timeout } = options;
|
|
194
|
+
if (this.config.rateLimiter) {
|
|
195
|
+
await this.config.rateLimiter.acquire();
|
|
196
|
+
}
|
|
197
|
+
const accessToken = await this.config.getAccessToken();
|
|
198
|
+
const url = `${this.config.baseUrl}${path}`;
|
|
199
|
+
const requestHeaders = {
|
|
200
|
+
"Content-Type": "application/json",
|
|
201
|
+
Accept: "application/json",
|
|
202
|
+
Authorization: `Bearer ${accessToken}`,
|
|
203
|
+
...this.config.defaultHeaders,
|
|
204
|
+
...headers
|
|
205
|
+
};
|
|
206
|
+
const controller = new AbortController;
|
|
207
|
+
const timeoutMs = timeout ?? this.config.timeout ?? DEFAULT_TIMEOUT;
|
|
208
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
209
|
+
let lastError;
|
|
210
|
+
for (let attempt = 0;attempt <= this.retryConfig.maxRetries; attempt++) {
|
|
211
|
+
try {
|
|
212
|
+
const response = await fetch(url, {
|
|
213
|
+
method,
|
|
214
|
+
headers: requestHeaders,
|
|
215
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
216
|
+
signal: controller.signal
|
|
217
|
+
});
|
|
218
|
+
clearTimeout(timeoutId);
|
|
219
|
+
if (response.ok) {
|
|
220
|
+
return await response.json();
|
|
221
|
+
}
|
|
222
|
+
if (response.status === 401) {
|
|
223
|
+
throw new AuthenticationError("Authentication failed");
|
|
224
|
+
}
|
|
225
|
+
if (response.status === 429) {
|
|
226
|
+
const retryAfter = response.headers.get("Retry-After");
|
|
227
|
+
const retryAfterMs = retryAfter ? parseInt(retryAfter, 10) * 1000 : 1000;
|
|
228
|
+
if (attempt < this.retryConfig.maxRetries) {
|
|
229
|
+
console.log(`[HttpClient] Rate limited, waiting ${retryAfterMs}ms before retry ${attempt + 1}/${this.retryConfig.maxRetries}`);
|
|
230
|
+
await new Promise((resolve) => setTimeout(resolve, retryAfterMs));
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
throw new RateLimitError(retryAfterMs);
|
|
234
|
+
}
|
|
235
|
+
if (response.status >= 500 && attempt < this.retryConfig.maxRetries) {
|
|
236
|
+
lastError = new ApiError(`Server error: ${response.status}`, response.status, await response.text().catch(() => {
|
|
237
|
+
return;
|
|
238
|
+
}));
|
|
239
|
+
await this.delay(attempt);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
const errorBody = await response.text().catch(() => {
|
|
243
|
+
return;
|
|
244
|
+
});
|
|
245
|
+
throw new ApiError(`Request failed: ${response.status}`, response.status, errorBody);
|
|
246
|
+
} catch (error) {
|
|
247
|
+
clearTimeout(timeoutId);
|
|
248
|
+
if (error instanceof AuthenticationError || error instanceof RateLimitError || error instanceof ApiError) {
|
|
249
|
+
throw error;
|
|
250
|
+
}
|
|
251
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
252
|
+
throw new ApiError("Request timeout", 408);
|
|
253
|
+
}
|
|
254
|
+
if (attempt < this.retryConfig.maxRetries) {
|
|
255
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
256
|
+
await this.delay(attempt);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
throw error;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
throw lastError ?? new ApiError("Request failed after retries", 500);
|
|
263
|
+
}
|
|
264
|
+
async delay(attempt) {
|
|
265
|
+
const delayMs = Math.min(this.retryConfig.baseDelayMs * 2 ** attempt, this.retryConfig.maxDelayMs);
|
|
266
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
267
|
+
}
|
|
268
|
+
async get(path, headers) {
|
|
269
|
+
return this.request(path, { method: "GET", headers });
|
|
270
|
+
}
|
|
271
|
+
async post(path, body, headers) {
|
|
272
|
+
return this.request(path, { method: "POST", body, headers });
|
|
273
|
+
}
|
|
274
|
+
async put(path, body, headers) {
|
|
275
|
+
return this.request(path, { method: "PUT", body, headers });
|
|
276
|
+
}
|
|
277
|
+
async patch(path, body, headers) {
|
|
278
|
+
return this.request(path, { method: "PATCH", body, headers });
|
|
279
|
+
}
|
|
280
|
+
async delete(path, headers) {
|
|
281
|
+
return this.request(path, { method: "DELETE", headers });
|
|
282
|
+
}
|
|
283
|
+
async uploadFormData(path, formData, headers) {
|
|
284
|
+
if (this.config.rateLimiter) {
|
|
285
|
+
await this.config.rateLimiter.acquire();
|
|
286
|
+
}
|
|
287
|
+
const accessToken = await this.config.getAccessToken();
|
|
288
|
+
const url = `${this.config.baseUrl}${path}`;
|
|
289
|
+
const requestHeaders = {
|
|
290
|
+
Accept: "application/json",
|
|
291
|
+
Authorization: `Bearer ${accessToken}`,
|
|
292
|
+
...this.config.defaultHeaders,
|
|
293
|
+
...headers
|
|
294
|
+
};
|
|
295
|
+
delete requestHeaders["Content-Type"];
|
|
296
|
+
const controller = new AbortController;
|
|
297
|
+
const timeoutMs = this.config.timeout ?? DEFAULT_TIMEOUT;
|
|
298
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
299
|
+
try {
|
|
300
|
+
const response = await fetch(url, {
|
|
301
|
+
method: "POST",
|
|
302
|
+
headers: requestHeaders,
|
|
303
|
+
body: formData,
|
|
304
|
+
signal: controller.signal
|
|
305
|
+
});
|
|
306
|
+
clearTimeout(timeoutId);
|
|
307
|
+
if (response.ok) {
|
|
308
|
+
return await response.json();
|
|
309
|
+
}
|
|
310
|
+
if (response.status === 401) {
|
|
311
|
+
throw new AuthenticationError("Authentication failed");
|
|
312
|
+
}
|
|
313
|
+
if (response.status === 429) {
|
|
314
|
+
const retryAfter = response.headers.get("Retry-After");
|
|
315
|
+
const retryAfterMs = retryAfter ? parseInt(retryAfter, 10) * 1000 : 1000;
|
|
316
|
+
throw new RateLimitError(retryAfterMs);
|
|
317
|
+
}
|
|
318
|
+
const errorBody = await response.text().catch(() => {
|
|
319
|
+
return;
|
|
320
|
+
});
|
|
321
|
+
throw new ApiError(`Upload failed: ${response.status}`, response.status, errorBody);
|
|
322
|
+
} catch (error) {
|
|
323
|
+
clearTimeout(timeoutId);
|
|
324
|
+
if (error instanceof AuthenticationError || error instanceof RateLimitError || error instanceof ApiError) {
|
|
325
|
+
throw error;
|
|
326
|
+
}
|
|
327
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
328
|
+
throw new ApiError("Upload timeout", 408);
|
|
329
|
+
}
|
|
330
|
+
throw error;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
async downloadBinary(path, headers) {
|
|
334
|
+
if (this.config.rateLimiter) {
|
|
335
|
+
await this.config.rateLimiter.acquire();
|
|
336
|
+
}
|
|
337
|
+
const accessToken = await this.config.getAccessToken();
|
|
338
|
+
const url = `${this.config.baseUrl}${path}`;
|
|
339
|
+
const requestHeaders = {
|
|
340
|
+
Authorization: `Bearer ${accessToken}`,
|
|
341
|
+
...this.config.defaultHeaders,
|
|
342
|
+
...headers
|
|
343
|
+
};
|
|
344
|
+
const controller = new AbortController;
|
|
345
|
+
const timeoutMs = this.config.timeout ?? DEFAULT_TIMEOUT;
|
|
346
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
347
|
+
try {
|
|
348
|
+
const response = await fetch(url, {
|
|
349
|
+
method: "GET",
|
|
350
|
+
headers: requestHeaders,
|
|
351
|
+
signal: controller.signal
|
|
352
|
+
});
|
|
353
|
+
clearTimeout(timeoutId);
|
|
354
|
+
if (!response.ok) {
|
|
355
|
+
if (response.status === 401) {
|
|
356
|
+
throw new AuthenticationError("Authentication failed");
|
|
357
|
+
}
|
|
358
|
+
if (response.status === 429) {
|
|
359
|
+
const retryAfter = response.headers.get("Retry-After");
|
|
360
|
+
const retryAfterMs = retryAfter ? parseInt(retryAfter, 10) * 1000 : undefined;
|
|
361
|
+
throw new RateLimitError(retryAfterMs);
|
|
362
|
+
}
|
|
363
|
+
const errorBody = await response.text().catch(() => {
|
|
364
|
+
return;
|
|
365
|
+
});
|
|
366
|
+
throw new ApiError(`Download failed: ${response.status}`, response.status, errorBody);
|
|
367
|
+
}
|
|
368
|
+
const contentDisposition = response.headers.get("Content-Disposition");
|
|
369
|
+
const filenameMatch = contentDisposition?.match(/filename="?([^";\n]+)"?/);
|
|
370
|
+
return {
|
|
371
|
+
data: await response.arrayBuffer(),
|
|
372
|
+
contentType: response.headers.get("Content-Type") ?? "application/octet-stream",
|
|
373
|
+
filename: filenameMatch?.[1]
|
|
374
|
+
};
|
|
375
|
+
} catch (error) {
|
|
376
|
+
clearTimeout(timeoutId);
|
|
377
|
+
if (error instanceof AuthenticationError || error instanceof RateLimitError || error instanceof ApiError) {
|
|
378
|
+
throw error;
|
|
379
|
+
}
|
|
380
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
381
|
+
throw new ApiError("Download timeout", 408);
|
|
382
|
+
}
|
|
383
|
+
throw error;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
// ../../integrations/core/dist/rate-limiter.js
|
|
388
|
+
class TokenBucketRateLimiter {
|
|
389
|
+
config;
|
|
390
|
+
tokens;
|
|
391
|
+
lastRefill;
|
|
392
|
+
waitQueue = [];
|
|
393
|
+
constructor(config) {
|
|
394
|
+
this.config = config;
|
|
395
|
+
this.tokens = config.maxTokens;
|
|
396
|
+
this.lastRefill = Date.now();
|
|
397
|
+
}
|
|
398
|
+
async acquire() {
|
|
399
|
+
this.refill();
|
|
400
|
+
if (this.tokens >= 1) {
|
|
401
|
+
this.tokens -= 1;
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
return new Promise((resolve) => {
|
|
405
|
+
this.waitQueue.push(resolve);
|
|
406
|
+
this.scheduleRefill();
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
refill() {
|
|
410
|
+
const now = Date.now();
|
|
411
|
+
const elapsed = now - this.lastRefill;
|
|
412
|
+
const tokensToAdd = Math.floor(elapsed / this.config.refillIntervalMs) * this.config.refillAmount;
|
|
413
|
+
if (tokensToAdd > 0) {
|
|
414
|
+
this.tokens = Math.min(this.config.maxTokens, this.tokens + tokensToAdd);
|
|
415
|
+
this.lastRefill = now;
|
|
416
|
+
while (this.tokens >= 1 && this.waitQueue.length > 0) {
|
|
417
|
+
this.tokens -= 1;
|
|
418
|
+
const resolve = this.waitQueue.shift();
|
|
419
|
+
resolve?.();
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
if (this.waitQueue.length > 0) {
|
|
423
|
+
this.scheduleRefill();
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
scheduleRefill() {
|
|
427
|
+
const timeUntilNextToken = this.config.refillIntervalMs - (Date.now() - this.lastRefill) % this.config.refillIntervalMs;
|
|
428
|
+
setTimeout(() => {
|
|
429
|
+
this.refill();
|
|
430
|
+
}, timeUntilNextToken);
|
|
431
|
+
}
|
|
432
|
+
static forFortnox() {
|
|
433
|
+
return new TokenBucketRateLimiter({
|
|
434
|
+
maxTokens: 20,
|
|
435
|
+
refillIntervalMs: 250,
|
|
436
|
+
refillAmount: 1
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
static forBokio() {
|
|
440
|
+
return new TokenBucketRateLimiter({
|
|
441
|
+
maxTokens: 200,
|
|
442
|
+
refillIntervalMs: 300,
|
|
443
|
+
refillAmount: 1
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
// ../../integrations/core/dist/auth/token-manager.js
|
|
448
|
+
var DEFAULT_REFRESH_BUFFER_SECONDS = 300;
|
|
449
|
+
|
|
450
|
+
class TokenManager {
|
|
451
|
+
tokenStore;
|
|
452
|
+
tokenRefresher;
|
|
453
|
+
refreshBufferSeconds;
|
|
454
|
+
tokenCache = new Map;
|
|
455
|
+
refreshLocks = new Map;
|
|
456
|
+
constructor(config) {
|
|
457
|
+
this.tokenStore = config.tokenStore;
|
|
458
|
+
this.tokenRefresher = config.tokenRefresher;
|
|
459
|
+
this.refreshBufferSeconds = config.refreshBufferSeconds ?? DEFAULT_REFRESH_BUFFER_SECONDS;
|
|
460
|
+
}
|
|
461
|
+
getCacheKey(integration, userId, organizationId) {
|
|
462
|
+
return `${integration}:${userId}:${organizationId}`;
|
|
463
|
+
}
|
|
464
|
+
async getValidAccessToken(integration, userId, organizationId) {
|
|
465
|
+
const cacheKey = this.getCacheKey(integration, userId, organizationId);
|
|
466
|
+
const cached = this.tokenCache.get(cacheKey);
|
|
467
|
+
if (cached && !this.isTokenExpiringSoon(cached.expiresAt)) {
|
|
468
|
+
return cached.accessToken;
|
|
469
|
+
}
|
|
470
|
+
const storedToken = await this.tokenStore.getToken(integration, userId, organizationId);
|
|
471
|
+
if (!storedToken) {
|
|
472
|
+
throw new TokenExpiredError("No token found for this integration");
|
|
473
|
+
}
|
|
474
|
+
if (this.isTokenExpiringSoon(storedToken.expiresAt)) {
|
|
475
|
+
const refreshedToken = await this.refreshTokenWithLock(integration, userId, organizationId, storedToken);
|
|
476
|
+
return refreshedToken.accessToken;
|
|
477
|
+
}
|
|
478
|
+
this.tokenCache.set(cacheKey, {
|
|
479
|
+
accessToken: storedToken.accessToken,
|
|
480
|
+
expiresAt: storedToken.expiresAt,
|
|
481
|
+
version: storedToken.version
|
|
482
|
+
});
|
|
483
|
+
return storedToken.accessToken;
|
|
484
|
+
}
|
|
485
|
+
isTokenExpiringSoon(expiresAt) {
|
|
486
|
+
const bufferMs = this.refreshBufferSeconds * 1000;
|
|
487
|
+
return expiresAt.getTime() - bufferMs <= Date.now();
|
|
488
|
+
}
|
|
489
|
+
async refreshTokenWithLock(integration, userId, organizationId, storedToken) {
|
|
490
|
+
const cacheKey = this.getCacheKey(integration, userId, organizationId);
|
|
491
|
+
const existingRefresh = this.refreshLocks.get(cacheKey);
|
|
492
|
+
if (existingRefresh) {
|
|
493
|
+
return existingRefresh;
|
|
494
|
+
}
|
|
495
|
+
const refreshPromise = this.performRefresh(integration, userId, organizationId, storedToken);
|
|
496
|
+
this.refreshLocks.set(cacheKey, refreshPromise);
|
|
497
|
+
try {
|
|
498
|
+
const result = await refreshPromise;
|
|
499
|
+
return result;
|
|
500
|
+
} finally {
|
|
501
|
+
this.refreshLocks.delete(cacheKey);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
async performRefresh(integration, userId, organizationId, storedToken) {
|
|
505
|
+
const cacheKey = this.getCacheKey(integration, userId, organizationId);
|
|
506
|
+
try {
|
|
507
|
+
const newTokenResponse = await this.tokenRefresher.refreshToken(storedToken.refreshToken);
|
|
508
|
+
const updatedToken = await this.tokenStore.updateToken(storedToken.id, newTokenResponse, storedToken.version);
|
|
509
|
+
this.tokenCache.set(cacheKey, {
|
|
510
|
+
accessToken: updatedToken.accessToken,
|
|
511
|
+
expiresAt: updatedToken.expiresAt,
|
|
512
|
+
version: updatedToken.version
|
|
513
|
+
});
|
|
514
|
+
return updatedToken;
|
|
515
|
+
} catch (error) {
|
|
516
|
+
this.tokenCache.delete(cacheKey);
|
|
517
|
+
throw error;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
async storeNewToken(integration, userId, organizationId, tokenResponse) {
|
|
521
|
+
const storedToken = await this.tokenStore.saveToken(integration, userId, organizationId, tokenResponse);
|
|
522
|
+
const cacheKey = this.getCacheKey(integration, userId, organizationId);
|
|
523
|
+
this.tokenCache.set(cacheKey, {
|
|
524
|
+
accessToken: storedToken.accessToken,
|
|
525
|
+
expiresAt: storedToken.expiresAt,
|
|
526
|
+
version: storedToken.version
|
|
527
|
+
});
|
|
528
|
+
return storedToken;
|
|
529
|
+
}
|
|
530
|
+
async revokeToken(integration, userId, organizationId) {
|
|
531
|
+
await this.tokenStore.deleteToken(integration, userId, organizationId);
|
|
532
|
+
const cacheKey = this.getCacheKey(integration, userId, organizationId);
|
|
533
|
+
this.tokenCache.delete(cacheKey);
|
|
534
|
+
}
|
|
535
|
+
clearCache() {
|
|
536
|
+
this.tokenCache.clear();
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
// ../../integrations/bokio/dist/client.js
|
|
540
|
+
class BokioClient {
|
|
541
|
+
httpClient;
|
|
542
|
+
_companyId;
|
|
543
|
+
constructor(config) {
|
|
544
|
+
this._companyId = config.companyId;
|
|
545
|
+
this.httpClient = new HttpClient({
|
|
546
|
+
baseUrl: BOKIO_CONFIG.API_BASE_URL,
|
|
547
|
+
getAccessToken: async () => config.token,
|
|
548
|
+
rateLimiter: TokenBucketRateLimiter.forBokio(),
|
|
549
|
+
defaultHeaders: {
|
|
550
|
+
Accept: "application/json"
|
|
551
|
+
}
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
get companyId() {
|
|
555
|
+
return this._companyId;
|
|
556
|
+
}
|
|
557
|
+
buildPath(endpoint) {
|
|
558
|
+
return `/${BOKIO_CONFIG.API_VERSION}/companies/${this._companyId}${endpoint}`;
|
|
559
|
+
}
|
|
560
|
+
async get(endpoint) {
|
|
561
|
+
return this.httpClient.get(this.buildPath(endpoint));
|
|
562
|
+
}
|
|
563
|
+
async post(endpoint, body) {
|
|
564
|
+
return this.httpClient.post(this.buildPath(endpoint), body);
|
|
565
|
+
}
|
|
566
|
+
async validateConnection() {
|
|
567
|
+
try {
|
|
568
|
+
await this.getJournalEntries({ page: 1, pageSize: 1 });
|
|
569
|
+
return { valid: true };
|
|
570
|
+
} catch (error) {
|
|
571
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
572
|
+
return { valid: false, error: message };
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
async getJournalEntries(params) {
|
|
576
|
+
const page = params?.page ?? 1;
|
|
577
|
+
const pageSize = params?.pageSize ?? 100;
|
|
578
|
+
const query = `?page=${page}&pageSize=${pageSize}`;
|
|
579
|
+
const raw = await this.get(`/journal-entries${query}`);
|
|
580
|
+
const response = validate(JournalEntriesResponseSchema, raw, "JournalEntriesResponse");
|
|
581
|
+
return {
|
|
582
|
+
data: response.items,
|
|
583
|
+
pagination: {
|
|
584
|
+
currentPage: response.currentPage,
|
|
585
|
+
pageSize,
|
|
586
|
+
totalItems: response.totalItems,
|
|
587
|
+
totalPages: response.totalPages,
|
|
588
|
+
hasNextPage: response.currentPage < response.totalPages,
|
|
589
|
+
hasPreviousPage: response.currentPage > 1
|
|
590
|
+
}
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
async getJournalEntry(id) {
|
|
594
|
+
const raw = await this.get(`/journal-entries/${id}`);
|
|
595
|
+
return validate(JournalEntrySchema, raw, "JournalEntry");
|
|
596
|
+
}
|
|
597
|
+
async getCompanyInformation() {
|
|
598
|
+
const raw = await this.get("/company-information");
|
|
599
|
+
return validate(CompanyInfoSchema, raw, "CompanyInfo");
|
|
600
|
+
}
|
|
601
|
+
async getFiscalYears(params) {
|
|
602
|
+
const page = params?.page ?? 1;
|
|
603
|
+
const pageSize = params?.pageSize ?? 100;
|
|
604
|
+
const query = `?page=${page}&pageSize=${pageSize}`;
|
|
605
|
+
const raw = await this.get(`/fiscal-years${query}`);
|
|
606
|
+
const response = validate(FiscalYearsResponseSchema, raw, "FiscalYearsResponse");
|
|
607
|
+
return {
|
|
608
|
+
data: response.items,
|
|
609
|
+
pagination: {
|
|
610
|
+
currentPage: response.currentPage,
|
|
611
|
+
pageSize,
|
|
612
|
+
totalItems: response.totalItems,
|
|
613
|
+
totalPages: response.totalPages,
|
|
614
|
+
hasNextPage: response.currentPage < response.totalPages,
|
|
615
|
+
hasPreviousPage: response.currentPage > 1
|
|
616
|
+
}
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
async getChartOfAccounts() {
|
|
620
|
+
const raw = await this.get("/chart-of-accounts");
|
|
621
|
+
return validate(ChartOfAccountsResponseSchema, raw, "ChartOfAccounts");
|
|
622
|
+
}
|
|
623
|
+
async getUploads(params) {
|
|
624
|
+
const page = params?.page ?? 1;
|
|
625
|
+
const pageSize = params?.pageSize ?? 25;
|
|
626
|
+
const queryParts = [`page=${page}`, `pageSize=${pageSize}`];
|
|
627
|
+
if (params?.query) {
|
|
628
|
+
queryParts.push(`query=${encodeURIComponent(params.query)}`);
|
|
629
|
+
}
|
|
630
|
+
const queryString = `?${queryParts.join("&")}`;
|
|
631
|
+
const raw = await this.get(`/uploads${queryString}`);
|
|
632
|
+
const response = validate(UploadsResponseSchema, raw, "UploadsResponse");
|
|
633
|
+
return {
|
|
634
|
+
data: response.items,
|
|
635
|
+
pagination: {
|
|
636
|
+
currentPage: response.currentPage,
|
|
637
|
+
pageSize,
|
|
638
|
+
totalItems: response.totalItems,
|
|
639
|
+
totalPages: response.totalPages,
|
|
640
|
+
hasNextPage: response.currentPage < response.totalPages,
|
|
641
|
+
hasPreviousPage: response.currentPage > 1
|
|
642
|
+
}
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
async getUpload(id) {
|
|
646
|
+
const raw = await this.get(`/uploads/${id}`);
|
|
647
|
+
return validate(UploadSchema, raw, "Upload");
|
|
648
|
+
}
|
|
649
|
+
async downloadFile(id) {
|
|
650
|
+
return this.httpClient.downloadBinary(this.buildPath(`/uploads/${id}/download`));
|
|
651
|
+
}
|
|
652
|
+
async createJournalEntry(request) {
|
|
653
|
+
const raw = await this.post("/journal-entries", request);
|
|
654
|
+
return validate(JournalEntrySchema, raw, "JournalEntry");
|
|
655
|
+
}
|
|
656
|
+
async uploadFile(options) {
|
|
657
|
+
const formData = new FormData;
|
|
658
|
+
formData.append("file", options.file, options.filename);
|
|
659
|
+
if (options.description) {
|
|
660
|
+
formData.append("description", options.description);
|
|
661
|
+
}
|
|
662
|
+
if (options.journalEntryId) {
|
|
663
|
+
formData.append("journalEntryId", options.journalEntryId);
|
|
664
|
+
}
|
|
665
|
+
const raw = await this.httpClient.uploadFormData(this.buildPath("/uploads"), formData);
|
|
666
|
+
return validate(UploadSchema, raw, "Upload");
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
// ../sync/dist/transformers/bokio.js
|
|
670
|
+
function mapBokioEntryToJournalEntry(entry) {
|
|
671
|
+
const voucherNumberMatch = entry.journalEntryNumber.match(/\d+/);
|
|
672
|
+
const voucherNumber = voucherNumberMatch ? parseInt(voucherNumberMatch[0], 10) : 0;
|
|
673
|
+
const seriesCode = entry.journalEntryNumber.replace(/\d+/g, "") || undefined;
|
|
674
|
+
const lines = entry.items.map((item, idx) => ({
|
|
675
|
+
lineNumber: idx + 1,
|
|
676
|
+
account: String(item.account),
|
|
677
|
+
debit: { amount: item.debit, currency: "SEK" },
|
|
678
|
+
credit: { amount: item.credit, currency: "SEK" }
|
|
679
|
+
}));
|
|
680
|
+
const totalDebit = entry.items.reduce((sum, item) => sum + item.debit, 0);
|
|
681
|
+
const totalCredit = entry.items.reduce((sum, item) => sum + item.credit, 0);
|
|
682
|
+
return {
|
|
683
|
+
series: seriesCode,
|
|
684
|
+
entryNumber: voucherNumber,
|
|
685
|
+
entryDate: entry.date,
|
|
686
|
+
description: entry.title,
|
|
687
|
+
status: "POSTED",
|
|
688
|
+
currency: "SEK",
|
|
689
|
+
externalId: entry.id,
|
|
690
|
+
totalDebit: { amount: totalDebit, currency: "SEK" },
|
|
691
|
+
totalCredit: { amount: totalCredit, currency: "SEK" },
|
|
692
|
+
lines,
|
|
693
|
+
sourceIntegration: "bokio",
|
|
694
|
+
sourceSyncedAt: new Date().toISOString()
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
// ../sync/dist/storage/filesystem.js
|
|
698
|
+
import * as fs from "node:fs/promises";
|
|
699
|
+
import * as path from "node:path";
|
|
700
|
+
import * as crypto from "node:crypto";
|
|
701
|
+
|
|
702
|
+
class FilesystemStorageService {
|
|
703
|
+
basePath;
|
|
704
|
+
constructor(basePath) {
|
|
705
|
+
this.basePath = basePath;
|
|
706
|
+
}
|
|
707
|
+
resolvePath(relativePath) {
|
|
708
|
+
return path.join(this.basePath, relativePath);
|
|
709
|
+
}
|
|
710
|
+
contentHash(content) {
|
|
711
|
+
return crypto.createHash("sha1").update(content).digest("hex");
|
|
712
|
+
}
|
|
713
|
+
async readFile(filePath) {
|
|
714
|
+
const absolutePath = this.resolvePath(filePath);
|
|
715
|
+
const content = await fs.readFile(absolutePath, "utf-8");
|
|
716
|
+
return {
|
|
717
|
+
content,
|
|
718
|
+
sha: this.contentHash(content)
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
async writeFile(filePath, content, _options) {
|
|
722
|
+
const absolutePath = this.resolvePath(filePath);
|
|
723
|
+
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
|
724
|
+
await fs.writeFile(absolutePath, content, "utf-8");
|
|
725
|
+
}
|
|
726
|
+
async listDirectory(dirPath) {
|
|
727
|
+
const absolutePath = this.resolvePath(dirPath);
|
|
728
|
+
const entries = [];
|
|
729
|
+
try {
|
|
730
|
+
const items = await fs.readdir(absolutePath, { withFileTypes: true });
|
|
731
|
+
for (const item of items) {
|
|
732
|
+
if (item.isFile()) {
|
|
733
|
+
entries.push({
|
|
734
|
+
name: item.name,
|
|
735
|
+
type: "file",
|
|
736
|
+
path: path.join(dirPath, item.name)
|
|
737
|
+
});
|
|
738
|
+
} else if (item.isDirectory()) {
|
|
739
|
+
entries.push({
|
|
740
|
+
name: item.name,
|
|
741
|
+
type: "dir",
|
|
742
|
+
path: path.join(dirPath, item.name)
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
} catch (error) {
|
|
747
|
+
if (error.code === "ENOENT") {
|
|
748
|
+
return [];
|
|
749
|
+
}
|
|
750
|
+
throw error;
|
|
751
|
+
}
|
|
752
|
+
return entries;
|
|
753
|
+
}
|
|
754
|
+
async deleteFile(filePath, _sha, _message) {
|
|
755
|
+
const absolutePath = this.resolvePath(filePath);
|
|
756
|
+
await fs.unlink(absolutePath);
|
|
757
|
+
}
|
|
758
|
+
getPublicUrl(_path) {
|
|
759
|
+
return null;
|
|
760
|
+
}
|
|
761
|
+
async exists(filePath) {
|
|
762
|
+
try {
|
|
763
|
+
await fs.access(this.resolvePath(filePath));
|
|
764
|
+
return true;
|
|
765
|
+
} catch {
|
|
766
|
+
return false;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
async renameDirectory(oldPath, newPath) {
|
|
770
|
+
const absoluteOldPath = this.resolvePath(oldPath);
|
|
771
|
+
const absoluteNewPath = this.resolvePath(newPath);
|
|
772
|
+
await fs.mkdir(path.dirname(absoluteNewPath), { recursive: true });
|
|
773
|
+
await fs.rename(absoluteOldPath, absoluteNewPath);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
// ../sync/dist/utils/file-namer.js
|
|
777
|
+
function journalEntryDirName(entry) {
|
|
778
|
+
const series = entry.voucher_series_code || "";
|
|
779
|
+
const voucher = String(entry.voucher_number || "000").padStart(3, "0");
|
|
780
|
+
const date = entry.entry_date;
|
|
781
|
+
const description = slugify(entry.description || entry.id?.slice(0, 8) || "entry");
|
|
782
|
+
const parts = [series, voucher, date, description].filter(Boolean);
|
|
783
|
+
return parts.join("-");
|
|
784
|
+
}
|
|
785
|
+
function journalEntryPath(fiscalYear, series, entryNumber, entryDate, description) {
|
|
786
|
+
const fyDir = fiscalYearDirName(fiscalYear);
|
|
787
|
+
const entryDir = journalEntryDirName({
|
|
788
|
+
voucher_series_code: series,
|
|
789
|
+
voucher_number: entryNumber,
|
|
790
|
+
entry_date: entryDate,
|
|
791
|
+
description
|
|
792
|
+
});
|
|
793
|
+
return `journal-entries/${fyDir}/${entryDir}/entry.yaml`;
|
|
794
|
+
}
|
|
795
|
+
function journalEntryDirFromPath(entryPath) {
|
|
796
|
+
return entryPath.replace(/\/entry\.yaml$/, "");
|
|
797
|
+
}
|
|
798
|
+
function fiscalYearDirName(year) {
|
|
799
|
+
if (typeof year === "number") {
|
|
800
|
+
return `FY-${year}`;
|
|
801
|
+
}
|
|
802
|
+
return `FY-${year.start_date.slice(0, 4)}`;
|
|
803
|
+
}
|
|
804
|
+
function slugify(text, maxLength = 30) {
|
|
805
|
+
return text.toLowerCase().slice(0, maxLength).replace(/[^a-z0-9\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
806
|
+
}
|
|
807
|
+
function sanitizeOrgName(name) {
|
|
808
|
+
return slugify(name, 50);
|
|
809
|
+
}
|
|
810
|
+
// ../sync/dist/utils/yaml.js
|
|
811
|
+
import YAML from "yaml";
|
|
812
|
+
function parseYaml(yaml) {
|
|
813
|
+
return YAML.parse(yaml);
|
|
814
|
+
}
|
|
815
|
+
function toYaml(data) {
|
|
816
|
+
const cleanData = removeNulls(data);
|
|
817
|
+
return YAML.stringify(cleanData, {
|
|
818
|
+
indent: 2,
|
|
819
|
+
lineWidth: 120,
|
|
820
|
+
minContentWidth: 0,
|
|
821
|
+
doubleQuotedAsJSON: true
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
function removeNulls(obj) {
|
|
825
|
+
if (obj === null || obj === undefined) {
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
if (Array.isArray(obj)) {
|
|
829
|
+
return obj.map(removeNulls).filter((item) => item !== undefined);
|
|
830
|
+
}
|
|
831
|
+
if (typeof obj === "object" && obj !== null) {
|
|
832
|
+
const result = {};
|
|
833
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
834
|
+
const cleanValue = removeNulls(value);
|
|
835
|
+
if (cleanValue !== undefined) {
|
|
836
|
+
result[key] = cleanValue;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
return result;
|
|
840
|
+
}
|
|
841
|
+
return obj;
|
|
842
|
+
}
|
|
843
|
+
// ../sync/dist/services/document-download.js
|
|
844
|
+
import * as fs2 from "node:fs/promises";
|
|
845
|
+
import * as path2 from "node:path";
|
|
846
|
+
function slugify2(text, maxLength = 40) {
|
|
847
|
+
return text.toLowerCase().slice(0, maxLength).replace(/[^a-z0-9\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
848
|
+
}
|
|
849
|
+
var EXTENSION_MAP = {
|
|
850
|
+
"application/pdf": ".pdf",
|
|
851
|
+
"image/jpeg": ".jpg",
|
|
852
|
+
"image/png": ".png",
|
|
853
|
+
"image/gif": ".gif",
|
|
854
|
+
"image/webp": ".webp",
|
|
855
|
+
"image/tiff": ".tiff",
|
|
856
|
+
"application/octet-stream": ".bin"
|
|
857
|
+
};
|
|
858
|
+
async function downloadFilesForEntry(options) {
|
|
859
|
+
const { storage, repoPath, entryDir, journalEntryId, downloader, sourceIntegration } = options;
|
|
860
|
+
const linkedFiles = await downloader.getFilesForEntry(journalEntryId);
|
|
861
|
+
if (linkedFiles.length === 0) {
|
|
862
|
+
return 0;
|
|
863
|
+
}
|
|
864
|
+
const documentsPath = `${entryDir}/documents.yaml`;
|
|
865
|
+
let existingDocs = [];
|
|
866
|
+
try {
|
|
867
|
+
const { content } = await storage.readFile(documentsPath);
|
|
868
|
+
existingDocs = parseYaml(content) || [];
|
|
869
|
+
} catch {}
|
|
870
|
+
const downloadedSourceIds = new Set(existingDocs.filter((d) => d.sourceIntegration === sourceIntegration).map((d) => d.sourceId));
|
|
871
|
+
let filesDownloaded = 0;
|
|
872
|
+
const absoluteDir = path2.join(repoPath, entryDir);
|
|
873
|
+
await fs2.mkdir(absoluteDir, { recursive: true });
|
|
874
|
+
for (const file of linkedFiles) {
|
|
875
|
+
if (downloadedSourceIds.has(file.id)) {
|
|
876
|
+
continue;
|
|
877
|
+
}
|
|
878
|
+
try {
|
|
879
|
+
const result = await downloader.downloadFile(file.id);
|
|
880
|
+
const ext = EXTENSION_MAP[file.contentType || result.contentType] || ".bin";
|
|
881
|
+
const baseName = file.description ? slugify2(file.description) : file.id.slice(0, 8);
|
|
882
|
+
const existingFilenames = existingDocs.map((d) => d.fileName);
|
|
883
|
+
let filename = `${baseName}${ext}`;
|
|
884
|
+
let counter = 1;
|
|
885
|
+
while (existingFilenames.includes(filename)) {
|
|
886
|
+
filename = `${baseName}-${counter}${ext}`;
|
|
887
|
+
counter++;
|
|
888
|
+
}
|
|
889
|
+
const filePath = path2.join(absoluteDir, filename);
|
|
890
|
+
const buffer = Buffer.from(result.data);
|
|
891
|
+
await fs2.writeFile(filePath, buffer);
|
|
892
|
+
existingDocs.push({
|
|
893
|
+
fileName: filename,
|
|
894
|
+
mimeType: file.contentType || result.contentType,
|
|
895
|
+
sourceIntegration,
|
|
896
|
+
sourceId: file.id
|
|
897
|
+
});
|
|
898
|
+
filesDownloaded++;
|
|
899
|
+
} catch (error) {
|
|
900
|
+
console.error(`
|
|
901
|
+
Warning: Failed to download file ${file.id}: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
if (filesDownloaded > 0) {
|
|
905
|
+
await storage.writeFile(documentsPath, toYaml(existingDocs));
|
|
906
|
+
}
|
|
907
|
+
return filesDownloaded;
|
|
908
|
+
}
|
|
909
|
+
// src/lib/git.ts
|
|
910
|
+
import simpleGit from "simple-git";
|
|
911
|
+
import * as fs3 from "node:fs/promises";
|
|
912
|
+
import * as path3 from "node:path";
|
|
913
|
+
var GITIGNORE = `# Environment files with secrets
|
|
914
|
+
.env
|
|
915
|
+
.env.*
|
|
916
|
+
|
|
917
|
+
# CLI cache
|
|
918
|
+
.kvitton/
|
|
919
|
+
|
|
920
|
+
# OS files
|
|
921
|
+
.DS_Store
|
|
922
|
+
Thumbs.db
|
|
923
|
+
|
|
924
|
+
# Editor files
|
|
925
|
+
.vscode/
|
|
926
|
+
.idea/
|
|
927
|
+
*.swp
|
|
928
|
+
`;
|
|
929
|
+
async function initGitRepo(repoPath) {
|
|
930
|
+
const git = simpleGit(repoPath);
|
|
931
|
+
await git.init();
|
|
932
|
+
await fs3.writeFile(path3.join(repoPath, ".gitignore"), GITIGNORE);
|
|
933
|
+
}
|
|
934
|
+
async function commitAll(repoPath, message) {
|
|
935
|
+
const git = simpleGit(repoPath);
|
|
936
|
+
await git.add(".");
|
|
937
|
+
const status = await git.status();
|
|
938
|
+
if (status.files.length > 0) {
|
|
939
|
+
await git.commit(message);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// src/lib/env.ts
|
|
944
|
+
import * as fs4 from "node:fs/promises";
|
|
945
|
+
import * as path4 from "node:path";
|
|
946
|
+
async function createEnvFile(repoPath, variables) {
|
|
947
|
+
const lines = Object.entries(variables).map(([key, value]) => `${key}=${value}`).join(`
|
|
948
|
+
`);
|
|
949
|
+
await fs4.writeFile(path4.join(repoPath, ".env"), `${lines}
|
|
950
|
+
`);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// src/sync/bokio-sync.ts
|
|
954
|
+
function createBokioDownloader(client2) {
|
|
955
|
+
return {
|
|
956
|
+
async getFilesForEntry(journalEntryId) {
|
|
957
|
+
const response = await client2.getUploads({
|
|
958
|
+
query: `journalEntryId==${journalEntryId}`
|
|
959
|
+
});
|
|
960
|
+
return response.data.map((upload) => ({
|
|
961
|
+
id: upload.id,
|
|
962
|
+
contentType: upload.contentType,
|
|
963
|
+
description: upload.description ?? undefined
|
|
964
|
+
}));
|
|
965
|
+
},
|
|
966
|
+
async downloadFile(id) {
|
|
967
|
+
return client2.downloadFile(id);
|
|
968
|
+
}
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
async function syncJournalEntries2(client2, repoPath, options, onProgress) {
|
|
972
|
+
const storage2 = new FilesystemStorageService(repoPath);
|
|
973
|
+
const fiscalYearsResponse = await client2.getFiscalYears();
|
|
974
|
+
const fiscalYears = fiscalYearsResponse.data;
|
|
975
|
+
const firstPage = await client2.getJournalEntries({ page: 1, pageSize: 1 });
|
|
976
|
+
const totalEntries = firstPage.pagination.totalItems;
|
|
977
|
+
onProgress({ current: 0, total: totalEntries, message: "Starting sync..." });
|
|
978
|
+
if (totalEntries === 0) {
|
|
979
|
+
await writeFiscalYearsMetadata(storage2, fiscalYears);
|
|
980
|
+
return {
|
|
981
|
+
entriesCount: 0,
|
|
982
|
+
fiscalYearsCount: fiscalYears.length,
|
|
983
|
+
entriesWithFilesDownloaded: 0
|
|
984
|
+
};
|
|
985
|
+
}
|
|
986
|
+
const allEntries = [];
|
|
987
|
+
let page = 1;
|
|
988
|
+
const pageSize = 100;
|
|
989
|
+
while (true) {
|
|
990
|
+
const response = await client2.getJournalEntries({ page, pageSize });
|
|
991
|
+
allEntries.push(...response.data);
|
|
992
|
+
onProgress({ current: allEntries.length, total: totalEntries });
|
|
993
|
+
if (!response.pagination.hasNextPage)
|
|
994
|
+
break;
|
|
995
|
+
page++;
|
|
996
|
+
}
|
|
997
|
+
let entriesWithFilesDownloaded = 0;
|
|
998
|
+
const downloader = createBokioDownloader(client2);
|
|
999
|
+
for (const entry of allEntries) {
|
|
1000
|
+
const fiscalYear = findFiscalYear(entry.date, fiscalYears);
|
|
1001
|
+
if (!fiscalYear)
|
|
1002
|
+
continue;
|
|
1003
|
+
const fyYear = parseInt(fiscalYear.startDate.slice(0, 4), 10);
|
|
1004
|
+
const entryDir = await writeJournalEntry(storage2, fyYear, entry);
|
|
1005
|
+
if (options.downloadFiles !== false && entryDir) {
|
|
1006
|
+
const filesDownloaded = await downloadFilesForEntry({
|
|
1007
|
+
storage: storage2,
|
|
1008
|
+
repoPath,
|
|
1009
|
+
entryDir,
|
|
1010
|
+
journalEntryId: entry.id,
|
|
1011
|
+
downloader,
|
|
1012
|
+
sourceIntegration: "bokio"
|
|
1013
|
+
});
|
|
1014
|
+
if (filesDownloaded > 0) {
|
|
1015
|
+
entriesWithFilesDownloaded++;
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
await writeFiscalYearsMetadata(storage2, fiscalYears);
|
|
1020
|
+
return {
|
|
1021
|
+
entriesCount: allEntries.length,
|
|
1022
|
+
fiscalYearsCount: fiscalYears.length,
|
|
1023
|
+
entriesWithFilesDownloaded
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
function findFiscalYear(date, fiscalYears) {
|
|
1027
|
+
return fiscalYears.find((fy) => date >= fy.startDate && date <= fy.endDate);
|
|
1028
|
+
}
|
|
1029
|
+
async function writeJournalEntry(storage2, fyYear, entry) {
|
|
1030
|
+
const journalEntry = mapBokioEntryToJournalEntry({
|
|
1031
|
+
id: entry.id,
|
|
1032
|
+
journalEntryNumber: entry.journalEntryNumber,
|
|
1033
|
+
date: entry.date,
|
|
1034
|
+
title: entry.title,
|
|
1035
|
+
items: entry.items.map((item) => ({
|
|
1036
|
+
account: item.account,
|
|
1037
|
+
debit: item.debit,
|
|
1038
|
+
credit: item.credit
|
|
1039
|
+
}))
|
|
1040
|
+
});
|
|
1041
|
+
const entryPath = journalEntryPath(fyYear, journalEntry.series ?? null, journalEntry.entryNumber, journalEntry.entryDate, journalEntry.description);
|
|
1042
|
+
const yamlContent = toYaml(journalEntry);
|
|
1043
|
+
await storage2.writeFile(entryPath, yamlContent);
|
|
1044
|
+
return journalEntryDirFromPath(entryPath);
|
|
1045
|
+
}
|
|
1046
|
+
async function writeFiscalYearsMetadata(storage2, fiscalYears) {
|
|
1047
|
+
for (const fy of fiscalYears) {
|
|
1048
|
+
const fyDir = fiscalYearDirName({ start_date: fy.startDate });
|
|
1049
|
+
const metadataPath = `journal-entries/${fyDir}/_fiscal-year.yaml`;
|
|
1050
|
+
const metadata = {
|
|
1051
|
+
id: fy.id,
|
|
1052
|
+
startDate: fy.startDate,
|
|
1053
|
+
endDate: fy.endDate,
|
|
1054
|
+
status: fy.status
|
|
1055
|
+
};
|
|
1056
|
+
const yamlContent = toYaml(metadata);
|
|
1057
|
+
await storage2.writeFile(metadataPath, yamlContent);
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
async function syncChartOfAccounts(client2, repoPath) {
|
|
1061
|
+
const storage2 = new FilesystemStorageService(repoPath);
|
|
1062
|
+
const bokioAccounts = await client2.getChartOfAccounts();
|
|
1063
|
+
const accounts = [...bokioAccounts].sort((a, b) => a.account - b.account).map((account) => ({
|
|
1064
|
+
code: account.account.toString(),
|
|
1065
|
+
name: account.name,
|
|
1066
|
+
description: account.name
|
|
1067
|
+
}));
|
|
1068
|
+
const yamlContent = toYaml({ accounts });
|
|
1069
|
+
await storage2.writeFile("accounts.yaml", yamlContent);
|
|
1070
|
+
return { accountsCount: accounts.length };
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// src/commands/create.ts
|
|
1074
|
+
var __filename2 = fileURLToPath(import.meta.url);
|
|
1075
|
+
var __dirname2 = path5.dirname(__filename2);
|
|
1076
|
+
async function createAgentsFile(targetDir, options) {
|
|
1077
|
+
const templatePath = path5.join(__dirname2, "../templates/AGENTS.md");
|
|
1078
|
+
let template = await fs5.readFile(templatePath, "utf-8");
|
|
1079
|
+
template = template.replace(/\{\{COMPANY_NAME\}\}/g, options.companyName);
|
|
1080
|
+
template = template.replace(/\{\{PROVIDER\}\}/g, options.provider);
|
|
1081
|
+
template = template.replace(/\{\{PROVIDER_LOWER\}\}/g, options.provider.toLowerCase());
|
|
1082
|
+
const agentsPath = path5.join(targetDir, "AGENTS.md");
|
|
1083
|
+
await fs5.writeFile(agentsPath, template, "utf-8");
|
|
1084
|
+
const claudePath = path5.join(targetDir, "CLAUDE.md");
|
|
1085
|
+
await fs5.symlink("AGENTS.md", claudePath);
|
|
1086
|
+
}
|
|
1087
|
+
function isValidUUID(value) {
|
|
1088
|
+
return /^[0-9a-f-]{36}$/i.test(value.trim());
|
|
1089
|
+
}
|
|
1090
|
+
function isValidFolderName(value) {
|
|
1091
|
+
return /^[a-z0-9-]+$/.test(value.trim());
|
|
1092
|
+
}
|
|
1093
|
+
async function createBookkeepingRepo(name, options = {}) {
|
|
1094
|
+
const { yes: acceptDefaults = false } = options;
|
|
1095
|
+
console.log(`
|
|
1096
|
+
Create a new bookkeeping repository
|
|
1097
|
+
`);
|
|
1098
|
+
let provider = options.provider;
|
|
1099
|
+
if (!provider) {
|
|
1100
|
+
provider = await select({
|
|
1101
|
+
message: "Select your accounting provider:",
|
|
1102
|
+
choices: [
|
|
1103
|
+
{ name: "Bokio", value: "bokio" },
|
|
1104
|
+
{ name: "Fortnox (coming soon)", value: "fortnox", disabled: true }
|
|
1105
|
+
]
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
if (provider !== "bokio") {
|
|
1109
|
+
console.log("Only Bokio is supported at this time.");
|
|
1110
|
+
process.exit(1);
|
|
1111
|
+
}
|
|
1112
|
+
const envToken = process.env.BOKIO_TOKEN;
|
|
1113
|
+
let token;
|
|
1114
|
+
if (envToken) {
|
|
1115
|
+
token = envToken;
|
|
1116
|
+
console.log(" Using BOKIO_TOKEN from environment");
|
|
1117
|
+
} else {
|
|
1118
|
+
console.log(`
|
|
1119
|
+
To connect to Bokio, you need:`);
|
|
1120
|
+
console.log(" - API Token: Bokio app > Settings > Integrations > Create Integration");
|
|
1121
|
+
console.log(` - Company ID: From your Bokio URL (app.bokio.se/{companyId}/...)
|
|
1122
|
+
`);
|
|
1123
|
+
console.log(` Tip: Set BOKIO_TOKEN env var to avoid entering token each time
|
|
1124
|
+
`);
|
|
1125
|
+
token = await password({
|
|
1126
|
+
message: "Enter your Bokio API token:",
|
|
1127
|
+
mask: "*"
|
|
1128
|
+
});
|
|
1129
|
+
}
|
|
1130
|
+
let companyId = options.companyId;
|
|
1131
|
+
if (!companyId) {
|
|
1132
|
+
companyId = await input({
|
|
1133
|
+
message: "Enter your Bokio Company ID:",
|
|
1134
|
+
validate: (value) => {
|
|
1135
|
+
if (!value.trim())
|
|
1136
|
+
return "Company ID is required";
|
|
1137
|
+
if (!isValidUUID(value)) {
|
|
1138
|
+
return "Company ID should be a UUID (e.g., 12345678-1234-1234-1234-123456789abc)";
|
|
1139
|
+
}
|
|
1140
|
+
return true;
|
|
1141
|
+
}
|
|
1142
|
+
});
|
|
1143
|
+
} else if (!isValidUUID(companyId)) {
|
|
1144
|
+
console.error("Error: Company ID should be a UUID (e.g., 12345678-1234-1234-1234-123456789abc)");
|
|
1145
|
+
process.exit(1);
|
|
1146
|
+
}
|
|
1147
|
+
const spinner = ora("Validating credentials...").start();
|
|
1148
|
+
const client2 = new BokioClient({ token, companyId: companyId.trim() });
|
|
1149
|
+
let companyName;
|
|
1150
|
+
try {
|
|
1151
|
+
const companyInfo = await client2.getCompanyInformation();
|
|
1152
|
+
companyName = companyInfo.name;
|
|
1153
|
+
spinner.succeed(`Connected to: ${companyName}`);
|
|
1154
|
+
} catch (error) {
|
|
1155
|
+
spinner.fail("Failed to connect to Bokio");
|
|
1156
|
+
console.error(error instanceof Error ? error.message : "Unknown error");
|
|
1157
|
+
process.exit(1);
|
|
1158
|
+
}
|
|
1159
|
+
const suggestedName = `kvitton-${sanitizeOrgName(companyName)}`;
|
|
1160
|
+
let folderName;
|
|
1161
|
+
if (name) {
|
|
1162
|
+
if (!isValidFolderName(name)) {
|
|
1163
|
+
console.error("Error: Folder name should only contain lowercase letters, numbers, and hyphens");
|
|
1164
|
+
process.exit(1);
|
|
1165
|
+
}
|
|
1166
|
+
folderName = name;
|
|
1167
|
+
} else if (acceptDefaults) {
|
|
1168
|
+
folderName = suggestedName;
|
|
1169
|
+
console.log(` Using folder name: ${folderName}`);
|
|
1170
|
+
} else {
|
|
1171
|
+
folderName = await input({
|
|
1172
|
+
message: "Repository folder name:",
|
|
1173
|
+
default: suggestedName,
|
|
1174
|
+
validate: (value) => {
|
|
1175
|
+
if (!value.trim())
|
|
1176
|
+
return "Folder name is required";
|
|
1177
|
+
if (!isValidFolderName(value)) {
|
|
1178
|
+
return "Folder name should only contain lowercase letters, numbers, and hyphens";
|
|
1179
|
+
}
|
|
1180
|
+
return true;
|
|
1181
|
+
}
|
|
1182
|
+
});
|
|
1183
|
+
}
|
|
1184
|
+
const targetDir = path5.resolve(process.cwd(), folderName.trim());
|
|
1185
|
+
try {
|
|
1186
|
+
await fs5.access(targetDir);
|
|
1187
|
+
if (acceptDefaults) {
|
|
1188
|
+
console.error(`Error: Directory ${folderName} already exists`);
|
|
1189
|
+
process.exit(1);
|
|
1190
|
+
}
|
|
1191
|
+
const overwrite = await confirm({
|
|
1192
|
+
message: `Directory ${folderName} already exists. Continue anyway?`,
|
|
1193
|
+
default: false
|
|
1194
|
+
});
|
|
1195
|
+
if (!overwrite) {
|
|
1196
|
+
console.log("Aborted.");
|
|
1197
|
+
process.exit(0);
|
|
1198
|
+
}
|
|
1199
|
+
} catch {}
|
|
1200
|
+
const gitSpinner = ora("Initializing repository...").start();
|
|
1201
|
+
await fs5.mkdir(targetDir, { recursive: true });
|
|
1202
|
+
await initGitRepo(targetDir);
|
|
1203
|
+
gitSpinner.succeed("Repository initialized");
|
|
1204
|
+
await createEnvFile(targetDir, {
|
|
1205
|
+
PROVIDER: "bokio",
|
|
1206
|
+
BOKIO_TOKEN: token,
|
|
1207
|
+
BOKIO_COMPANY_ID: companyId.trim()
|
|
1208
|
+
});
|
|
1209
|
+
console.log(" Created .env with credentials");
|
|
1210
|
+
await createAgentsFile(targetDir, {
|
|
1211
|
+
companyName,
|
|
1212
|
+
provider: "Bokio"
|
|
1213
|
+
});
|
|
1214
|
+
console.log(" Created AGENTS.md and CLAUDE.md symlink");
|
|
1215
|
+
const accountsSpinner = ora("Syncing chart of accounts...").start();
|
|
1216
|
+
const { accountsCount } = await syncChartOfAccounts(client2, targetDir);
|
|
1217
|
+
accountsSpinner.succeed(`Synced ${accountsCount} accounts to accounts.yaml`);
|
|
1218
|
+
let shouldSync;
|
|
1219
|
+
if (options.sync !== undefined) {
|
|
1220
|
+
shouldSync = options.sync;
|
|
1221
|
+
} else if (acceptDefaults) {
|
|
1222
|
+
shouldSync = true;
|
|
1223
|
+
} else {
|
|
1224
|
+
shouldSync = await confirm({
|
|
1225
|
+
message: "Sync existing journal entries from Bokio?",
|
|
1226
|
+
default: true
|
|
1227
|
+
});
|
|
1228
|
+
}
|
|
1229
|
+
let entriesCount = 0;
|
|
1230
|
+
let fiscalYearsCount = 0;
|
|
1231
|
+
if (shouldSync) {
|
|
1232
|
+
const shouldDownloadFiles = options.downloadFiles !== false;
|
|
1233
|
+
const result = await syncJournalEntries2(client2, targetDir, { downloadFiles: shouldDownloadFiles }, (progress) => {
|
|
1234
|
+
process.stdout.write(`\r Syncing: ${progress.current}/${progress.total} entries`);
|
|
1235
|
+
});
|
|
1236
|
+
entriesCount = result.entriesCount;
|
|
1237
|
+
fiscalYearsCount = result.fiscalYearsCount;
|
|
1238
|
+
if (result.entriesWithFilesDownloaded > 0) {
|
|
1239
|
+
console.log(`
|
|
1240
|
+
Downloaded files for ${result.entriesWithFilesDownloaded}/${result.entriesCount} entries`);
|
|
1241
|
+
}
|
|
1242
|
+
console.log(" Sync complete!");
|
|
1243
|
+
}
|
|
1244
|
+
const commitSpinner = ora("Creating initial commit...").start();
|
|
1245
|
+
await commitAll(targetDir, "Initial sync from Bokio");
|
|
1246
|
+
commitSpinner.succeed("Initial commit created");
|
|
1247
|
+
console.log(`
|
|
1248
|
+
Success! Created ${folderName} at ${targetDir}
|
|
1249
|
+
|
|
1250
|
+
Summary:
|
|
1251
|
+
- ${accountsCount} accounts
|
|
1252
|
+
- ${fiscalYearsCount} fiscal year(s)
|
|
1253
|
+
- ${entriesCount} journal entries
|
|
1254
|
+
|
|
1255
|
+
Next steps:
|
|
1256
|
+
cd ${folderName}
|
|
1257
|
+
|
|
1258
|
+
Your bookkeeping data is stored as YAML files:
|
|
1259
|
+
journal-entries/FY-2024/A-001-2024-01-15-invoice/entry.yaml
|
|
1260
|
+
|
|
1261
|
+
Commands:
|
|
1262
|
+
kvitton sync-journal Sync journal entries from Bokio
|
|
1263
|
+
kvitton sync-inbox Download inbox files from Bokio
|
|
1264
|
+
kvitton company-info Display company information
|
|
1265
|
+
`);
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
// src/index.ts
|
|
1269
|
+
var program = new Command;
|
|
1270
|
+
program.name("create-kvitton").description("Create a new kvitton bookkeeping git repository").version("0.4.0").argument("[name]", "Repository folder name").option("-p, --provider <provider>", "Accounting provider (bokio)", "bokio").option("-c, --company-id <id>", "Company ID (UUID from provider)").option("-s, --sync", "Sync journal entries after creation", true).option("--no-sync", "Skip syncing journal entries").option("-d, --download-files", "Download files for journal entries", true).option("--no-download-files", "Skip downloading files").option("-y, --yes", "Accept defaults without prompting").addHelpText("after", `
|
|
1271
|
+
Environment Variables:
|
|
1272
|
+
BOKIO_TOKEN Bokio API token (required for non-interactive mode)
|
|
1273
|
+
|
|
1274
|
+
Examples:
|
|
1275
|
+
$ create-kvitton
|
|
1276
|
+
$ create-kvitton my-company-books
|
|
1277
|
+
$ create-kvitton my-repo --company-id abc-123-def --sync
|
|
1278
|
+
$ create-kvitton my-repo -c abc-123 --no-download-files
|
|
1279
|
+
$ BOKIO_TOKEN=xxx create-kvitton my-repo -c abc-123 -y
|
|
1280
|
+
`).action((name, options) => {
|
|
1281
|
+
createBookkeepingRepo(name, options);
|
|
1282
|
+
});
|
|
1283
|
+
program.parse();
|