kvitton-cli 0.4.2
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/company-info.js +48 -0
- package/dist/commands/convert-currency.js +76 -0
- package/dist/commands/create-entry.js +198 -0
- package/dist/commands/generate-lines.js +140 -0
- package/dist/commands/parse-pdf.js +20 -0
- package/dist/commands/sync-inbox.js +186 -0
- package/dist/commands/sync-journal.js +490 -0
- package/dist/index.js +7394 -0
- package/dist/lib/client.js +10 -0
- package/dist/lib/currency-cache.js +49 -0
- package/dist/lib/env.js +37 -0
- package/package.json +40 -0
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import ora from "ora";
|
|
4
|
+
import { FilesystemStorageService, mapBokioEntryToJournalEntry, mapJournalEntryToBokioRequest, journalEntryPath, journalEntryDirFromPath, toYaml, parseYaml, fiscalYearDirName, downloadFilesForEntry, } from "sync";
|
|
5
|
+
import { loadConfig } from "../lib/env";
|
|
6
|
+
import { createBokioClient } from "../lib/client";
|
|
7
|
+
/** File extensions that should be uploaded as documents */
|
|
8
|
+
const UPLOADABLE_EXTENSIONS = [".pdf", ".png", ".jpg", ".jpeg"];
|
|
9
|
+
/** Map file extensions to MIME types */
|
|
10
|
+
const MIME_TYPES = {
|
|
11
|
+
".pdf": "application/pdf",
|
|
12
|
+
".png": "image/png",
|
|
13
|
+
".jpg": "image/jpeg",
|
|
14
|
+
".jpeg": "image/jpeg",
|
|
15
|
+
};
|
|
16
|
+
export async function syncJournalCommand(options) {
|
|
17
|
+
const cwd = process.cwd();
|
|
18
|
+
// Load config
|
|
19
|
+
const spinner = ora("Loading configuration...").start();
|
|
20
|
+
let config;
|
|
21
|
+
try {
|
|
22
|
+
config = loadConfig(cwd);
|
|
23
|
+
spinner.succeed(`Provider: ${config.provider}`);
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
spinner.fail(error instanceof Error ? error.message : "Unknown error");
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
// Create client
|
|
30
|
+
const client = createBokioClient(config);
|
|
31
|
+
// Validate connection
|
|
32
|
+
const validateSpinner = ora("Connecting to Bokio...").start();
|
|
33
|
+
try {
|
|
34
|
+
const companyInfo = await client.getCompanyInformation();
|
|
35
|
+
validateSpinner.succeed(`Connected to: ${companyInfo.name}`);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
validateSpinner.fail("Failed to connect to Bokio");
|
|
39
|
+
console.error(error instanceof Error ? error.message : "Unknown error");
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
// Determine which fiscal year(s) to sync
|
|
43
|
+
const targetYear = options.year
|
|
44
|
+
? parseInt(options.year, 10)
|
|
45
|
+
: options.all
|
|
46
|
+
? undefined
|
|
47
|
+
: new Date().getFullYear();
|
|
48
|
+
const yearLabel = targetYear ? `FY ${targetYear}` : "all years";
|
|
49
|
+
console.log(` Syncing ${yearLabel}...\n`);
|
|
50
|
+
// Storage for file operations
|
|
51
|
+
const storage = new FilesystemStorageService(cwd);
|
|
52
|
+
// Sync chart of accounts first
|
|
53
|
+
const accountsSpinner = ora("Syncing chart of accounts...").start();
|
|
54
|
+
const accountsCount = await syncChartOfAccounts(client, storage);
|
|
55
|
+
accountsSpinner.succeed(`Synced ${accountsCount} accounts`);
|
|
56
|
+
// Sync journal entries
|
|
57
|
+
const shouldDownloadFiles = options.downloadFiles !== false;
|
|
58
|
+
const result = await syncJournalEntries(client, cwd, targetYear, shouldDownloadFiles, (progress) => {
|
|
59
|
+
process.stdout.write(`\r Syncing: ${progress.current}/${progress.total} entries`);
|
|
60
|
+
});
|
|
61
|
+
const filesDownloadedLine = result.entriesWithFilesDownloaded > 0
|
|
62
|
+
? `\n - Downloaded files for ${result.entriesWithFilesDownloaded}/${result.totalEntries} entries`
|
|
63
|
+
: "";
|
|
64
|
+
console.log(`\n
|
|
65
|
+
Sync complete! (${yearLabel})
|
|
66
|
+
- ${accountsCount} accounts
|
|
67
|
+
- ${result.fiscalYearsCount} fiscal year(s)
|
|
68
|
+
- Downloaded: ${result.totalEntries} entries (${result.newEntries} new, ${result.existingEntries} existing)
|
|
69
|
+
- Uploaded: ${result.uploadedEntries} local entries${filesDownloadedLine}
|
|
70
|
+
`);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Create a FileDownloader adapter for BokioClient
|
|
74
|
+
*/
|
|
75
|
+
function createBokioDownloader(client) {
|
|
76
|
+
return {
|
|
77
|
+
async getFilesForEntry(journalEntryId) {
|
|
78
|
+
// Bokio API requires query format: journalEntryId==UUID
|
|
79
|
+
const response = await client.getUploads({
|
|
80
|
+
query: `journalEntryId==${journalEntryId}`,
|
|
81
|
+
});
|
|
82
|
+
return response.data.map((upload) => ({
|
|
83
|
+
id: upload.id,
|
|
84
|
+
contentType: upload.contentType,
|
|
85
|
+
description: upload.description ?? undefined,
|
|
86
|
+
}));
|
|
87
|
+
},
|
|
88
|
+
async downloadFile(id) {
|
|
89
|
+
return client.downloadFile(id);
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
async function syncJournalEntries(client, repoPath, targetYear, downloadFiles, onProgress) {
|
|
94
|
+
const storage = new FilesystemStorageService(repoPath);
|
|
95
|
+
// 1. Fetch fiscal years
|
|
96
|
+
const fiscalYearsResponse = await client.getFiscalYears();
|
|
97
|
+
const allFiscalYears = fiscalYearsResponse.data;
|
|
98
|
+
// Filter fiscal years if targeting a specific year
|
|
99
|
+
const fiscalYears = targetYear
|
|
100
|
+
? allFiscalYears.filter((fy) => parseInt(fy.startDate.slice(0, 4), 10) === targetYear)
|
|
101
|
+
: allFiscalYears;
|
|
102
|
+
if (targetYear && fiscalYears.length === 0) {
|
|
103
|
+
console.log(` No fiscal year found for ${targetYear}`);
|
|
104
|
+
return {
|
|
105
|
+
totalEntries: 0,
|
|
106
|
+
newEntries: 0,
|
|
107
|
+
existingEntries: 0,
|
|
108
|
+
uploadedEntries: 0,
|
|
109
|
+
fiscalYearsCount: 0,
|
|
110
|
+
entriesWithFilesDownloaded: 0,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
// 2. Fetch all entries with pagination
|
|
114
|
+
const allEntries = [];
|
|
115
|
+
let page = 1;
|
|
116
|
+
const pageSize = 100;
|
|
117
|
+
// First fetch to get total count
|
|
118
|
+
const firstPage = await client.getJournalEntries({ page: 1, pageSize: 1 });
|
|
119
|
+
const totalFromApi = firstPage.pagination.totalItems;
|
|
120
|
+
onProgress({ current: 0, total: totalFromApi });
|
|
121
|
+
if (totalFromApi === 0) {
|
|
122
|
+
await writeFiscalYearsMetadata(storage, fiscalYears);
|
|
123
|
+
// Even with no entries from API, we might have local entries to upload
|
|
124
|
+
const uploadedEntries = await uploadLocalEntries(client, storage, repoPath, fiscalYears);
|
|
125
|
+
return {
|
|
126
|
+
totalEntries: 0,
|
|
127
|
+
newEntries: 0,
|
|
128
|
+
existingEntries: 0,
|
|
129
|
+
uploadedEntries,
|
|
130
|
+
fiscalYearsCount: fiscalYears.length,
|
|
131
|
+
entriesWithFilesDownloaded: 0,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
// Fetch all entries
|
|
135
|
+
while (true) {
|
|
136
|
+
const response = await client.getJournalEntries({ page, pageSize });
|
|
137
|
+
allEntries.push(...response.data);
|
|
138
|
+
onProgress({ current: allEntries.length, total: totalFromApi });
|
|
139
|
+
if (!response.pagination.hasNextPage)
|
|
140
|
+
break;
|
|
141
|
+
page++;
|
|
142
|
+
}
|
|
143
|
+
// 3. Filter entries by target fiscal year(s)
|
|
144
|
+
const entriesToSync = targetYear
|
|
145
|
+
? allEntries.filter((entry) => {
|
|
146
|
+
const fy = findFiscalYear(entry.date, fiscalYears);
|
|
147
|
+
return fy !== undefined;
|
|
148
|
+
})
|
|
149
|
+
: allEntries;
|
|
150
|
+
// 4. Write each entry, tracking new vs existing, and download files
|
|
151
|
+
let newEntries = 0;
|
|
152
|
+
let existingEntries = 0;
|
|
153
|
+
let entriesWithFilesDownloaded = 0;
|
|
154
|
+
const downloader = createBokioDownloader(client);
|
|
155
|
+
for (const entry of entriesToSync) {
|
|
156
|
+
const fiscalYear = findFiscalYear(entry.date, allFiscalYears);
|
|
157
|
+
if (!fiscalYear)
|
|
158
|
+
continue;
|
|
159
|
+
const fyYear = parseInt(fiscalYear.startDate.slice(0, 4), 10);
|
|
160
|
+
const result = await writeJournalEntry(storage, fyYear, entry);
|
|
161
|
+
if (result.isNew) {
|
|
162
|
+
newEntries++;
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
existingEntries++;
|
|
166
|
+
}
|
|
167
|
+
// Download files for this entry if enabled
|
|
168
|
+
if (downloadFiles && result.entryDir) {
|
|
169
|
+
const filesDownloaded = await downloadFilesForEntry({
|
|
170
|
+
storage,
|
|
171
|
+
repoPath,
|
|
172
|
+
entryDir: result.entryDir,
|
|
173
|
+
journalEntryId: entry.id,
|
|
174
|
+
downloader,
|
|
175
|
+
sourceIntegration: "bokio",
|
|
176
|
+
});
|
|
177
|
+
if (filesDownloaded > 0) {
|
|
178
|
+
entriesWithFilesDownloaded++;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// 5. Write fiscal year metadata
|
|
183
|
+
await writeFiscalYearsMetadata(storage, fiscalYears);
|
|
184
|
+
// 6. Upload local entries without externalId to Bokio
|
|
185
|
+
const uploadedEntries = await uploadLocalEntries(client, storage, repoPath, fiscalYears);
|
|
186
|
+
return {
|
|
187
|
+
totalEntries: entriesToSync.length,
|
|
188
|
+
newEntries,
|
|
189
|
+
existingEntries,
|
|
190
|
+
uploadedEntries,
|
|
191
|
+
fiscalYearsCount: fiscalYears.length,
|
|
192
|
+
entriesWithFilesDownloaded,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
function findFiscalYear(date, fiscalYears) {
|
|
196
|
+
return fiscalYears.find((fy) => date >= fy.startDate && date <= fy.endDate);
|
|
197
|
+
}
|
|
198
|
+
async function writeJournalEntry(storage, fyYear, entry) {
|
|
199
|
+
const journalEntry = mapBokioEntryToJournalEntry({
|
|
200
|
+
id: entry.id,
|
|
201
|
+
journalEntryNumber: entry.journalEntryNumber,
|
|
202
|
+
date: entry.date,
|
|
203
|
+
title: entry.title,
|
|
204
|
+
items: entry.items.map((item) => ({
|
|
205
|
+
account: item.account,
|
|
206
|
+
debit: item.debit,
|
|
207
|
+
credit: item.credit,
|
|
208
|
+
})),
|
|
209
|
+
});
|
|
210
|
+
const entryPath = journalEntryPath(fyYear, journalEntry.series ?? null, journalEntry.entryNumber, journalEntry.entryDate, journalEntry.description);
|
|
211
|
+
const entryDir = journalEntryDirFromPath(entryPath);
|
|
212
|
+
// Check if entry already exists
|
|
213
|
+
const exists = await storage.exists(entryPath);
|
|
214
|
+
if (exists) {
|
|
215
|
+
return { isNew: false, entryDir };
|
|
216
|
+
}
|
|
217
|
+
const yamlContent = toYaml(journalEntry);
|
|
218
|
+
await storage.writeFile(entryPath, yamlContent);
|
|
219
|
+
return { isNew: true, entryDir };
|
|
220
|
+
}
|
|
221
|
+
async function writeFiscalYearsMetadata(storage, fiscalYears) {
|
|
222
|
+
for (const fy of fiscalYears) {
|
|
223
|
+
const fyDir = fiscalYearDirName({ start_date: fy.startDate });
|
|
224
|
+
const metadataPath = `journal-entries/${fyDir}/_fiscal-year.yaml`;
|
|
225
|
+
const metadata = {
|
|
226
|
+
id: fy.id,
|
|
227
|
+
startDate: fy.startDate,
|
|
228
|
+
endDate: fy.endDate,
|
|
229
|
+
status: fy.status,
|
|
230
|
+
};
|
|
231
|
+
const yamlContent = toYaml(metadata);
|
|
232
|
+
await storage.writeFile(metadataPath, yamlContent);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Parse journal entry number from Bokio (e.g., "A123" -> { series: "A", entryNumber: 123 })
|
|
237
|
+
*/
|
|
238
|
+
function parseJournalEntryNumber(journalEntryNumber) {
|
|
239
|
+
const numberMatch = journalEntryNumber.match(/\d+/);
|
|
240
|
+
const entryNumber = numberMatch ? parseInt(numberMatch[0], 10) : 0;
|
|
241
|
+
const series = journalEntryNumber.replace(/\d+/g, "") || undefined;
|
|
242
|
+
return {
|
|
243
|
+
series: series,
|
|
244
|
+
entryNumber,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* List local entries without externalId (not yet synced to provider)
|
|
249
|
+
*/
|
|
250
|
+
async function listLocalEntriesWithoutExternalId(storage, fiscalYears) {
|
|
251
|
+
const result = [];
|
|
252
|
+
for (const fy of fiscalYears) {
|
|
253
|
+
const fyYear = parseInt(fy.startDate.slice(0, 4), 10);
|
|
254
|
+
const fyDir = `journal-entries/FY-${fyYear}`;
|
|
255
|
+
try {
|
|
256
|
+
const directories = await storage.listDirectory(fyDir);
|
|
257
|
+
for (const dir of directories) {
|
|
258
|
+
if (dir.type !== "dir" || dir.name.startsWith("_")) {
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
const entryPath = `${dir.path}/entry.yaml`;
|
|
262
|
+
try {
|
|
263
|
+
const { content } = await storage.readFile(entryPath);
|
|
264
|
+
const entry = parseYaml(content);
|
|
265
|
+
// Only include entries without externalId
|
|
266
|
+
if (!entry.externalId) {
|
|
267
|
+
result.push({
|
|
268
|
+
entry,
|
|
269
|
+
directoryPath: dir.path,
|
|
270
|
+
fiscalYear: fy,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
// Skip entries that can't be read
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
// Fiscal year directory doesn't exist yet
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return result;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Upload local entries without externalId to Bokio
|
|
287
|
+
*/
|
|
288
|
+
async function uploadLocalEntries(client, storage, repoPath, fiscalYears) {
|
|
289
|
+
const localEntries = await listLocalEntriesWithoutExternalId(storage, fiscalYears);
|
|
290
|
+
let uploadedCount = 0;
|
|
291
|
+
for (const { entry, directoryPath, fiscalYear } of localEntries) {
|
|
292
|
+
try {
|
|
293
|
+
// Upload to Bokio
|
|
294
|
+
const request = mapJournalEntryToBokioRequest(entry);
|
|
295
|
+
const bokioEntry = await client.createJournalEntry(request);
|
|
296
|
+
// Parse series and number from response
|
|
297
|
+
const parsed = parseJournalEntryNumber(bokioEntry.journalEntryNumber);
|
|
298
|
+
// Upload any document files in the directory
|
|
299
|
+
await uploadDocumentsForEntry(client, storage, path.join(repoPath, directoryPath), directoryPath, bokioEntry.id);
|
|
300
|
+
// Update entry with Bokio metadata (remove legacy fields)
|
|
301
|
+
const updatedEntry = {
|
|
302
|
+
...entry,
|
|
303
|
+
externalId: bokioEntry.id,
|
|
304
|
+
series: parsed.series,
|
|
305
|
+
entryNumber: parsed.entryNumber,
|
|
306
|
+
sourceIntegration: "bokio",
|
|
307
|
+
sourceSyncedAt: new Date().toISOString(),
|
|
308
|
+
// Remove legacy fields by setting to undefined
|
|
309
|
+
voucherNumber: undefined,
|
|
310
|
+
voucherSeriesCode: undefined,
|
|
311
|
+
};
|
|
312
|
+
// Calculate new directory path
|
|
313
|
+
const fyYear = parseInt(fiscalYear.startDate.slice(0, 4), 10);
|
|
314
|
+
const newEntryPath = journalEntryPath(fyYear, updatedEntry.series ?? null, updatedEntry.entryNumber, updatedEntry.entryDate, updatedEntry.description);
|
|
315
|
+
const newDirPath = journalEntryDirFromPath(newEntryPath);
|
|
316
|
+
// Write updated entry.yaml
|
|
317
|
+
const yamlContent = toYaml(updatedEntry);
|
|
318
|
+
await storage.writeFile(`${directoryPath}/entry.yaml`, yamlContent);
|
|
319
|
+
// Rename directory if path changed
|
|
320
|
+
if (directoryPath !== newDirPath) {
|
|
321
|
+
// Check if target directory already exists
|
|
322
|
+
const targetExists = await storage.exists(`${newDirPath}/entry.yaml`);
|
|
323
|
+
if (targetExists) {
|
|
324
|
+
console.warn(` Warning: Cannot rename ${directoryPath} -> ${newDirPath} (target exists)`);
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
await storage.renameDirectory(directoryPath, newDirPath);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
uploadedCount++;
|
|
331
|
+
console.log(` Uploaded: ${entry.description} -> ${bokioEntry.journalEntryNumber}`);
|
|
332
|
+
}
|
|
333
|
+
catch (error) {
|
|
334
|
+
console.error(` Failed to upload entry: ${entry.description}`, error instanceof Error ? error.message : error);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
// Also retry document uploads for entries that have externalId but failed document upload
|
|
338
|
+
const retriedDocs = await retryFailedDocumentUploads(client, storage, repoPath, fiscalYears);
|
|
339
|
+
return uploadedCount + retriedDocs;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Upload documents for a journal entry and update document.yaml with sourceIntegration/sourceId
|
|
343
|
+
*/
|
|
344
|
+
async function uploadDocumentsForEntry(client, storage, absoluteDirPath, relativeDirPath, journalEntryId) {
|
|
345
|
+
const files = await fs.readdir(absoluteDirPath);
|
|
346
|
+
for (const filename of files) {
|
|
347
|
+
const ext = path.extname(filename).toLowerCase();
|
|
348
|
+
if (!UPLOADABLE_EXTENSIONS.includes(ext)) {
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
// Check if already uploaded via document.yaml
|
|
352
|
+
const docYamlPath = `${relativeDirPath}/document.yaml`;
|
|
353
|
+
let docMetadata = {};
|
|
354
|
+
try {
|
|
355
|
+
const { content } = await storage.readFile(docYamlPath);
|
|
356
|
+
docMetadata = parseYaml(content);
|
|
357
|
+
if (docMetadata.sourceIntegration === "bokio" && docMetadata.sourceId) {
|
|
358
|
+
// Already uploaded to Bokio
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
// No document.yaml, proceed with upload
|
|
364
|
+
}
|
|
365
|
+
try {
|
|
366
|
+
const filePath = path.join(absoluteDirPath, filename);
|
|
367
|
+
let fileBuffer = await fs.readFile(filePath);
|
|
368
|
+
// Check if file is base64 encoded (stored as text, not binary)
|
|
369
|
+
// Base64 PDF starts with "JVBERi" (%PDF- encoded)
|
|
370
|
+
const firstBytes = fileBuffer.subarray(0, 10).toString("utf8");
|
|
371
|
+
if (firstBytes.startsWith("JVBERi") || firstBytes.match(/^[A-Za-z0-9+/=]+$/)) {
|
|
372
|
+
// Decode base64
|
|
373
|
+
const base64Content = fileBuffer.toString("utf8");
|
|
374
|
+
fileBuffer = Buffer.from(base64Content, "base64");
|
|
375
|
+
}
|
|
376
|
+
const mimeType = MIME_TYPES[ext] || "application/octet-stream";
|
|
377
|
+
const file = new File([fileBuffer], filename, { type: mimeType });
|
|
378
|
+
const upload = await client.uploadFile({
|
|
379
|
+
file,
|
|
380
|
+
filename,
|
|
381
|
+
journalEntryId,
|
|
382
|
+
});
|
|
383
|
+
// Update document.yaml with Bokio source info
|
|
384
|
+
const updatedDocMetadata = {
|
|
385
|
+
...docMetadata,
|
|
386
|
+
sourceIntegration: "bokio",
|
|
387
|
+
sourceId: upload.id,
|
|
388
|
+
};
|
|
389
|
+
await storage.writeFile(docYamlPath, toYaml(updatedDocMetadata));
|
|
390
|
+
console.log(` Uploaded file: ${filename}`);
|
|
391
|
+
}
|
|
392
|
+
catch (fileError) {
|
|
393
|
+
console.warn(`\n Warning: Failed to upload ${filename}`);
|
|
394
|
+
if (fileError &&
|
|
395
|
+
typeof fileError === "object" &&
|
|
396
|
+
"statusCode" in fileError) {
|
|
397
|
+
const apiErr = fileError;
|
|
398
|
+
console.warn(` Status: ${apiErr.statusCode}`);
|
|
399
|
+
if (apiErr.response) {
|
|
400
|
+
console.warn(` Response: ${JSON.stringify(apiErr.response)}`);
|
|
401
|
+
}
|
|
402
|
+
else if (apiErr.message) {
|
|
403
|
+
console.warn(` Message: ${apiErr.message}`);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
else if (fileError instanceof Error) {
|
|
407
|
+
console.warn(` Error: ${fileError.message}`);
|
|
408
|
+
}
|
|
409
|
+
else {
|
|
410
|
+
console.warn(` Error: ${fileError}`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Retry document uploads for entries synced to Bokio but with documents not yet uploaded
|
|
417
|
+
*/
|
|
418
|
+
async function retryFailedDocumentUploads(client, storage, repoPath, fiscalYears) {
|
|
419
|
+
let retriedCount = 0;
|
|
420
|
+
for (const fy of fiscalYears) {
|
|
421
|
+
const fyYear = parseInt(fy.startDate.slice(0, 4), 10);
|
|
422
|
+
const fyDir = `journal-entries/FY-${fyYear}`;
|
|
423
|
+
try {
|
|
424
|
+
const directories = await storage.listDirectory(fyDir);
|
|
425
|
+
for (const dir of directories) {
|
|
426
|
+
if (dir.type !== "dir" || dir.name.startsWith("_")) {
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
const entryPath = `${dir.path}/entry.yaml`;
|
|
430
|
+
const docPath = `${dir.path}/document.yaml`;
|
|
431
|
+
try {
|
|
432
|
+
// Check if entry has externalId (already synced to Bokio)
|
|
433
|
+
const { content: entryContent } = await storage.readFile(entryPath);
|
|
434
|
+
const entry = parseYaml(entryContent);
|
|
435
|
+
if (!entry.externalId || entry.sourceIntegration !== "bokio") {
|
|
436
|
+
continue; // Not synced to Bokio yet
|
|
437
|
+
}
|
|
438
|
+
// Check if document.yaml exists and needs upload
|
|
439
|
+
try {
|
|
440
|
+
const { content: docContent } = await storage.readFile(docPath);
|
|
441
|
+
const docMetadata = parseYaml(docContent);
|
|
442
|
+
if (docMetadata.sourceIntegration === "bokio" && docMetadata.sourceId) {
|
|
443
|
+
continue; // Already uploaded to Bokio
|
|
444
|
+
}
|
|
445
|
+
// Has document.yaml but not uploaded to Bokio - retry upload
|
|
446
|
+
console.log(`\n Retrying document upload for: ${entry.description}`);
|
|
447
|
+
await uploadDocumentsForEntry(client, storage, path.join(repoPath, dir.path), dir.path, entry.externalId);
|
|
448
|
+
retriedCount++;
|
|
449
|
+
}
|
|
450
|
+
catch {
|
|
451
|
+
// No document.yaml, check for files to upload
|
|
452
|
+
const absoluteDirPath = path.join(repoPath, dir.path);
|
|
453
|
+
const files = await fs.readdir(absoluteDirPath);
|
|
454
|
+
const hasUploadableFiles = files.some((f) => UPLOADABLE_EXTENSIONS.includes(path.extname(f).toLowerCase()));
|
|
455
|
+
if (hasUploadableFiles) {
|
|
456
|
+
console.log(`\n Uploading documents for: ${entry.description}`);
|
|
457
|
+
await uploadDocumentsForEntry(client, storage, absoluteDirPath, dir.path, entry.externalId);
|
|
458
|
+
retriedCount++;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
catch {
|
|
463
|
+
// Skip entries that can't be read
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
catch {
|
|
468
|
+
// Fiscal year directory doesn't exist
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
return retriedCount;
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Sync chart of accounts from Bokio to accounts.yaml
|
|
475
|
+
*/
|
|
476
|
+
async function syncChartOfAccounts(client, storage) {
|
|
477
|
+
const bokioAccounts = await client.getChartOfAccounts();
|
|
478
|
+
// Sort by account number and transform to expected format
|
|
479
|
+
const accounts = [...bokioAccounts]
|
|
480
|
+
.sort((a, b) => a.account - b.account)
|
|
481
|
+
.map((account) => ({
|
|
482
|
+
code: account.account.toString(),
|
|
483
|
+
name: account.name,
|
|
484
|
+
description: account.name,
|
|
485
|
+
}));
|
|
486
|
+
// Write accounts.yaml
|
|
487
|
+
const yamlContent = toYaml({ accounts });
|
|
488
|
+
await storage.writeFile("accounts.yaml", yamlContent);
|
|
489
|
+
return accounts.length;
|
|
490
|
+
}
|