@moatless/bookkeeping 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 (93) hide show
  1. package/dist/accounting/index.d.ts +9 -0
  2. package/dist/accounting/index.js +14 -0
  3. package/dist/accounting/line-generator.d.ts +34 -0
  4. package/dist/accounting/line-generator.js +136 -0
  5. package/dist/accounting/tax-codes.d.ts +32 -0
  6. package/dist/accounting/tax-codes.js +279 -0
  7. package/dist/accounting/traktamente-rates.d.ts +48 -0
  8. package/dist/accounting/traktamente-rates.js +325 -0
  9. package/dist/accounting/types.d.ts +69 -0
  10. package/dist/accounting/types.js +5 -0
  11. package/dist/accounting/validation.d.ts +41 -0
  12. package/dist/accounting/validation.js +118 -0
  13. package/dist/auth/fortnox-login.d.ts +15 -0
  14. package/dist/auth/fortnox-login.js +170 -0
  15. package/dist/auth/index.d.ts +3 -0
  16. package/dist/auth/index.js +3 -0
  17. package/dist/auth/prompts.d.ts +6 -0
  18. package/dist/auth/prompts.js +56 -0
  19. package/dist/auth/token-store.d.ts +19 -0
  20. package/dist/auth/token-store.js +54 -0
  21. package/dist/index.d.ts +10 -0
  22. package/dist/index.js +21 -0
  23. package/dist/progress/index.d.ts +1 -0
  24. package/dist/progress/index.js +1 -0
  25. package/dist/progress/sync-progress.d.ts +31 -0
  26. package/dist/progress/sync-progress.js +65 -0
  27. package/dist/services/bokio-journal.d.ts +29 -0
  28. package/dist/services/bokio-journal.js +175 -0
  29. package/dist/services/document-download.d.ts +46 -0
  30. package/dist/services/document-download.js +105 -0
  31. package/dist/services/fortnox-inbox.d.ts +18 -0
  32. package/dist/services/fortnox-inbox.js +150 -0
  33. package/dist/services/fortnox-journal.d.ts +22 -0
  34. package/dist/services/fortnox-journal.js +166 -0
  35. package/dist/services/index.d.ts +6 -0
  36. package/dist/services/index.js +6 -0
  37. package/dist/services/journal-sync.d.ts +23 -0
  38. package/dist/services/journal-sync.js +124 -0
  39. package/dist/services/journal.service.d.ts +45 -0
  40. package/dist/services/journal.service.js +204 -0
  41. package/dist/storage/filesystem.d.ts +49 -0
  42. package/dist/storage/filesystem.js +122 -0
  43. package/dist/storage/index.d.ts +2 -0
  44. package/dist/storage/index.js +1 -0
  45. package/dist/storage/interface.d.ts +48 -0
  46. package/dist/storage/interface.js +5 -0
  47. package/dist/sync-types.d.ts +61 -0
  48. package/dist/sync-types.js +1 -0
  49. package/dist/transformers/bokio.d.ts +10 -0
  50. package/dist/transformers/bokio.js +56 -0
  51. package/dist/transformers/fortnox.d.ts +6 -0
  52. package/dist/transformers/fortnox.js +39 -0
  53. package/dist/transformers/index.d.ts +3 -0
  54. package/dist/transformers/index.js +2 -0
  55. package/dist/types/discarded-item.d.ts +29 -0
  56. package/dist/types/discarded-item.js +1 -0
  57. package/dist/types/document.d.ts +63 -0
  58. package/dist/types/document.js +9 -0
  59. package/dist/types/exported-document.d.ts +61 -0
  60. package/dist/types/exported-document.js +9 -0
  61. package/dist/types/exported-fiscal-year.d.ts +10 -0
  62. package/dist/types/exported-fiscal-year.js +1 -0
  63. package/dist/types/exported-inbox-document.d.ts +14 -0
  64. package/dist/types/exported-inbox-document.js +10 -0
  65. package/dist/types/fiscal-year.d.ts +10 -0
  66. package/dist/types/fiscal-year.js +1 -0
  67. package/dist/types/index.d.ts +10 -0
  68. package/dist/types/index.js +10 -0
  69. package/dist/types/journal-entry.d.ts +79 -0
  70. package/dist/types/journal-entry.js +12 -0
  71. package/dist/types/ledger-account.d.ts +5 -0
  72. package/dist/types/ledger-account.js +1 -0
  73. package/dist/utils/file-namer.d.ts +48 -0
  74. package/dist/utils/file-namer.js +80 -0
  75. package/dist/utils/git.d.ts +9 -0
  76. package/dist/utils/git.js +41 -0
  77. package/dist/utils/index.d.ts +6 -0
  78. package/dist/utils/index.js +6 -0
  79. package/dist/utils/paths.d.ts +17 -0
  80. package/dist/utils/paths.js +24 -0
  81. package/dist/utils/retry.d.ts +17 -0
  82. package/dist/utils/retry.js +48 -0
  83. package/dist/utils/templates.d.ts +12 -0
  84. package/dist/utils/templates.js +222 -0
  85. package/dist/utils/yaml.d.ts +12 -0
  86. package/dist/utils/yaml.js +47 -0
  87. package/dist/yaml/entry-helpers.d.ts +57 -0
  88. package/dist/yaml/entry-helpers.js +125 -0
  89. package/dist/yaml/index.d.ts +2 -0
  90. package/dist/yaml/index.js +2 -0
  91. package/dist/yaml/yaml-serializer.d.ts +21 -0
  92. package/dist/yaml/yaml-serializer.js +60 -0
  93. package/package.json +37 -0
@@ -0,0 +1,170 @@
1
+ import * as http from "node:http";
2
+ import open from "open";
3
+ import { buildAuthorizationUrl, exchangeCodeForToken, FORTNOX_SCOPES, } from "@moatless/fortnox-client";
4
+ import { saveFortnoxToken, loadFortnoxToken, tokenExistsAt, } from "./token-store";
5
+ import { promptForCredentials, promptConfirm } from "./prompts";
6
+ const DEFAULT_PORT = 8585;
7
+ const DEFAULT_SCOPES = [
8
+ FORTNOX_SCOPES.BOOKKEEPING,
9
+ FORTNOX_SCOPES.COMPANYINFORMATION,
10
+ FORTNOX_SCOPES.INBOX,
11
+ FORTNOX_SCOPES.ARCHIVE,
12
+ FORTNOX_SCOPES.CONNECTFILE,
13
+ ];
14
+ const SUCCESS_HTML = `
15
+ <!DOCTYPE html>
16
+ <html>
17
+ <head>
18
+ <title>Authentication Complete</title>
19
+ <style>
20
+ body {
21
+ font-family: system-ui, sans-serif;
22
+ display: flex;
23
+ justify-content: center;
24
+ align-items: center;
25
+ height: 100vh;
26
+ margin: 0;
27
+ background: #fff;
28
+ color: #111;
29
+ }
30
+ .container { text-align: center; }
31
+ h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
32
+ p { color: #666; }
33
+ </style>
34
+ </head>
35
+ <body>
36
+ <div class="container">
37
+ <h1>&#10003; Authentication complete</h1>
38
+ <p>You can close this window.</p>
39
+ </div>
40
+ </body>
41
+ </html>
42
+ `;
43
+ const ERROR_HTML = (error) => `
44
+ <!DOCTYPE html>
45
+ <html>
46
+ <head>
47
+ <title>Authentication Failed</title>
48
+ <style>
49
+ body {
50
+ font-family: system-ui, sans-serif;
51
+ display: flex;
52
+ justify-content: center;
53
+ align-items: center;
54
+ height: 100vh;
55
+ margin: 0;
56
+ background: #fff;
57
+ color: #111;
58
+ }
59
+ .container { text-align: center; }
60
+ h1 { font-size: 1.5rem; margin-bottom: 0.5rem; color: #c00; }
61
+ p { color: #666; }
62
+ </style>
63
+ </head>
64
+ <body>
65
+ <div class="container">
66
+ <h1>Authentication failed</h1>
67
+ <p>${error}</p>
68
+ </div>
69
+ </body>
70
+ </html>
71
+ `;
72
+ async function startCallbackServer(port) {
73
+ let resolveCallback;
74
+ const resultPromise = new Promise((resolve) => {
75
+ resolveCallback = resolve;
76
+ });
77
+ const server = http.createServer((req, res) => {
78
+ const url = new URL(req.url ?? "/", `http://localhost:${port}`);
79
+ if (url.pathname === "/callback") {
80
+ const code = url.searchParams.get("code");
81
+ const error = url.searchParams.get("error");
82
+ const errorDescription = url.searchParams.get("error_description");
83
+ res.setHeader("Content-Type", "text/html");
84
+ if (error) {
85
+ resolveCallback({ error, errorDescription: errorDescription ?? undefined });
86
+ res.statusCode = 200;
87
+ res.end(ERROR_HTML(errorDescription ?? error));
88
+ return;
89
+ }
90
+ if (code) {
91
+ resolveCallback({ code });
92
+ res.statusCode = 200;
93
+ res.end(SUCCESS_HTML);
94
+ return;
95
+ }
96
+ resolveCallback({ error: "No authorization code received" });
97
+ res.statusCode = 200;
98
+ res.end(ERROR_HTML("No authorization code received"));
99
+ return;
100
+ }
101
+ res.statusCode = 404;
102
+ res.end("Not found");
103
+ });
104
+ await new Promise((resolve) => {
105
+ server.listen(port, () => resolve());
106
+ });
107
+ return {
108
+ result: resultPromise,
109
+ stop: () => server.close(),
110
+ };
111
+ }
112
+ export async function loginFortnox(options) {
113
+ const { cwd, scopes = DEFAULT_SCOPES, port = DEFAULT_PORT, force = false, onStatus = console.log, } = options;
114
+ // Check for existing token
115
+ if (!force && tokenExistsAt(cwd)) {
116
+ const existingToken = loadFortnoxToken(cwd);
117
+ if (existingToken) {
118
+ const overwrite = await promptConfirm("Fortnox token already exists. Overwrite?");
119
+ if (!overwrite) {
120
+ onStatus("Login cancelled.");
121
+ return existingToken;
122
+ }
123
+ }
124
+ }
125
+ // Get credentials
126
+ let clientId = options.clientId;
127
+ let clientSecret = options.clientSecret;
128
+ if (!clientId || !clientSecret) {
129
+ const credentials = await promptForCredentials();
130
+ clientId = credentials.clientId;
131
+ clientSecret = credentials.clientSecret;
132
+ }
133
+ const redirectUri = `http://localhost:${port}/callback`;
134
+ const config = {
135
+ clientId,
136
+ clientSecret,
137
+ redirectUri,
138
+ scopes,
139
+ };
140
+ // Start callback server
141
+ onStatus(`Starting OAuth callback server on port ${port}...`);
142
+ const { result, stop } = await startCallbackServer(port);
143
+ try {
144
+ // Build auth URL and open browser
145
+ const state = crypto.randomUUID();
146
+ const authUrl = buildAuthorizationUrl(config, state);
147
+ onStatus("Opening browser for Fortnox authorization...");
148
+ await open(authUrl);
149
+ onStatus("Waiting for authorization (press Ctrl+C to cancel)...");
150
+ // Wait for callback
151
+ const callbackResult = await result;
152
+ if (callbackResult.error) {
153
+ throw new Error(callbackResult.errorDescription ?? callbackResult.error);
154
+ }
155
+ if (!callbackResult.code) {
156
+ throw new Error("No authorization code received");
157
+ }
158
+ // Exchange code for token
159
+ onStatus("Exchanging authorization code for token...");
160
+ const tokenResponse = await exchangeCodeForToken(config, callbackResult.code);
161
+ // Save token
162
+ const storedToken = saveFortnoxToken(cwd, tokenResponse);
163
+ onStatus("Token saved to .kvitton/tokens/fortnox.json");
164
+ return storedToken;
165
+ }
166
+ finally {
167
+ stop();
168
+ }
169
+ }
170
+ export { DEFAULT_SCOPES as FORTNOX_DEFAULT_SCOPES };
@@ -0,0 +1,3 @@
1
+ export { loginFortnox, FORTNOX_DEFAULT_SCOPES, type LoginOptions, } from "./fortnox-login";
2
+ export { saveFortnoxToken, loadFortnoxToken, deleteFortnoxToken, isTokenExpired, tokenExistsAt, type StoredFortnoxToken, type FortnoxTokenResponse, } from "./token-store";
3
+ export { promptForCredentials, promptConfirm, promptInput, } from "./prompts";
@@ -0,0 +1,3 @@
1
+ export { loginFortnox, FORTNOX_DEFAULT_SCOPES, } from "./fortnox-login";
2
+ export { saveFortnoxToken, loadFortnoxToken, deleteFortnoxToken, isTokenExpired, tokenExistsAt, } from "./token-store";
3
+ export { promptForCredentials, promptConfirm, promptInput, } from "./prompts";
@@ -0,0 +1,6 @@
1
+ export declare function promptForCredentials(): Promise<{
2
+ clientId: string;
3
+ clientSecret: string;
4
+ }>;
5
+ export declare function promptConfirm(message: string): Promise<boolean>;
6
+ export declare function promptInput(message: string): Promise<string>;
@@ -0,0 +1,56 @@
1
+ import * as readline from "node:readline";
2
+ function createInterface() {
3
+ return readline.createInterface({
4
+ input: process.stdin,
5
+ output: process.stdout,
6
+ });
7
+ }
8
+ function question(rl, prompt) {
9
+ return new Promise((resolve) => {
10
+ rl.question(prompt, (answer) => {
11
+ resolve(answer);
12
+ });
13
+ });
14
+ }
15
+ export async function promptForCredentials() {
16
+ const rl = createInterface();
17
+ try {
18
+ console.log("\nFortnox OAuth credentials required.");
19
+ console.log("Get these from: https://developer.fortnox.se/my-apps\n");
20
+ const clientId = await question(rl, "FORTNOX_CLIENT_ID: ");
21
+ if (!clientId.trim()) {
22
+ throw new Error("Client ID is required");
23
+ }
24
+ const clientSecret = await question(rl, "FORTNOX_CLIENT_SECRET: ");
25
+ if (!clientSecret.trim()) {
26
+ throw new Error("Client Secret is required");
27
+ }
28
+ return {
29
+ clientId: clientId.trim(),
30
+ clientSecret: clientSecret.trim(),
31
+ };
32
+ }
33
+ finally {
34
+ rl.close();
35
+ }
36
+ }
37
+ export async function promptConfirm(message) {
38
+ const rl = createInterface();
39
+ try {
40
+ const answer = await question(rl, `${message} (y/N): `);
41
+ return answer.toLowerCase() === "y" || answer.toLowerCase() === "yes";
42
+ }
43
+ finally {
44
+ rl.close();
45
+ }
46
+ }
47
+ export async function promptInput(message) {
48
+ const rl = createInterface();
49
+ try {
50
+ const answer = await question(rl, `${message}: `);
51
+ return answer.trim();
52
+ }
53
+ finally {
54
+ rl.close();
55
+ }
56
+ }
@@ -0,0 +1,19 @@
1
+ export interface StoredFortnoxToken {
2
+ access_token: string;
3
+ refresh_token: string;
4
+ scope: string;
5
+ expires_at: string;
6
+ created_at: string;
7
+ }
8
+ export interface FortnoxTokenResponse {
9
+ access_token: string;
10
+ refresh_token: string;
11
+ scope: string;
12
+ expires_in: number;
13
+ token_type: "bearer";
14
+ }
15
+ export declare function saveFortnoxToken(cwd: string, token: FortnoxTokenResponse): StoredFortnoxToken;
16
+ export declare function loadFortnoxToken(cwd: string): StoredFortnoxToken | null;
17
+ export declare function deleteFortnoxToken(cwd: string): void;
18
+ export declare function isTokenExpired(token: StoredFortnoxToken): boolean;
19
+ export declare function tokenExistsAt(cwd: string): boolean;
@@ -0,0 +1,54 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { getTokensDir } from "../utils/paths";
4
+ const FORTNOX_TOKEN_FILE = "fortnox.json";
5
+ function getTokenPath(cwd) {
6
+ return path.join(getTokensDir(cwd), FORTNOX_TOKEN_FILE);
7
+ }
8
+ function ensureTokenDir(cwd) {
9
+ const dir = getTokensDir(cwd);
10
+ if (!fs.existsSync(dir)) {
11
+ fs.mkdirSync(dir, { recursive: true });
12
+ }
13
+ }
14
+ export function saveFortnoxToken(cwd, token) {
15
+ ensureTokenDir(cwd);
16
+ const expiresAt = new Date(Date.now() + token.expires_in * 1000);
17
+ const stored = {
18
+ access_token: token.access_token,
19
+ refresh_token: token.refresh_token,
20
+ scope: token.scope,
21
+ expires_at: expiresAt.toISOString(),
22
+ created_at: new Date().toISOString(),
23
+ };
24
+ fs.writeFileSync(getTokenPath(cwd), JSON.stringify(stored, null, 2), "utf-8");
25
+ return stored;
26
+ }
27
+ export function loadFortnoxToken(cwd) {
28
+ const tokenPath = getTokenPath(cwd);
29
+ if (!fs.existsSync(tokenPath)) {
30
+ return null;
31
+ }
32
+ try {
33
+ const content = fs.readFileSync(tokenPath, "utf-8");
34
+ return JSON.parse(content);
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ }
40
+ export function deleteFortnoxToken(cwd) {
41
+ const tokenPath = getTokenPath(cwd);
42
+ if (fs.existsSync(tokenPath)) {
43
+ fs.unlinkSync(tokenPath);
44
+ }
45
+ }
46
+ export function isTokenExpired(token) {
47
+ const expiresAt = new Date(token.expires_at);
48
+ // Consider token expired 5 minutes before actual expiry for safety
49
+ const safetyMargin = 5 * 60 * 1000;
50
+ return expiresAt.getTime() - safetyMargin <= Date.now();
51
+ }
52
+ export function tokenExistsAt(cwd) {
53
+ return fs.existsSync(getTokenPath(cwd));
54
+ }
@@ -0,0 +1,10 @@
1
+ export * from "./types";
2
+ export * from "./sync-types";
3
+ export * from "./accounting";
4
+ export { readEntryYaml, readDocumentsYaml, readDocumentYaml, writeEntryYaml, appendLinesToEntry, readAccountsYaml, validateAccountExists, getAccountName, } from "./yaml";
5
+ export * from "./transformers";
6
+ export * from "./storage";
7
+ export * from "./utils";
8
+ export * from "./services";
9
+ export * from "./auth";
10
+ export * from "./progress";
package/dist/index.js ADDED
@@ -0,0 +1,21 @@
1
+ // Types (journal entries, documents, fiscal years, etc.)
2
+ export * from "./types";
3
+ // Sync types (sync stats, errors, results)
4
+ export * from "./sync-types";
5
+ // Accounting logic
6
+ export * from "./accounting";
7
+ // YAML utilities (from yaml module - entry helpers, etc.)
8
+ // Note: parseYaml and toYaml are also exported from ./utils
9
+ export { readEntryYaml, readDocumentsYaml, readDocumentYaml, writeEntryYaml, appendLinesToEntry, readAccountsYaml, validateAccountExists, getAccountName, } from "./yaml";
10
+ // Transformers
11
+ export * from "./transformers";
12
+ // Storage
13
+ export * from "./storage";
14
+ // Utils (includes parseYaml, toYaml)
15
+ export * from "./utils";
16
+ // Services
17
+ export * from "./services";
18
+ // Auth utilities
19
+ export * from "./auth";
20
+ // Progress bar utilities
21
+ export * from "./progress";
@@ -0,0 +1 @@
1
+ export { SyncProgressBar, type SyncProgress, type FiscalYearSummary, } from "./sync-progress";
@@ -0,0 +1 @@
1
+ export { SyncProgressBar, } from "./sync-progress";
@@ -0,0 +1,31 @@
1
+ export interface SyncProgress {
2
+ current: number;
3
+ total: number;
4
+ message?: string;
5
+ /** Phase of sync: discovering (listing) or fetching (getting details) */
6
+ phase?: "discovering" | "fetching";
7
+ }
8
+ export interface FiscalYearSummary {
9
+ id: number;
10
+ year: number;
11
+ fromDate: string;
12
+ toDate: string;
13
+ entryCount: number;
14
+ }
15
+ export declare class SyncProgressBar {
16
+ private bar;
17
+ private currentPhase;
18
+ private started;
19
+ constructor();
20
+ start(total: number, phase?: string): void;
21
+ update(current: number, phase?: string): void;
22
+ stop(): void;
23
+ /**
24
+ * Create an adapter for the existing SyncProgress callback pattern.
25
+ * Handles both discovery phase (no bar, just count) and fetching phase (progress bar).
26
+ */
27
+ static createCallback(): {
28
+ bar: SyncProgressBar;
29
+ onProgress: (progress: SyncProgress) => void;
30
+ };
31
+ }
@@ -0,0 +1,65 @@
1
+ import cliProgress from "cli-progress";
2
+ export class SyncProgressBar {
3
+ bar;
4
+ currentPhase = "";
5
+ started = false;
6
+ constructor() {
7
+ this.bar = new cliProgress.SingleBar({
8
+ format: " {phase} [{bar}] {percentage}% | {value}/{total}",
9
+ barCompleteChar: "\u2588",
10
+ barIncompleteChar: "\u2591",
11
+ barsize: 20,
12
+ hideCursor: true,
13
+ clearOnComplete: false,
14
+ });
15
+ }
16
+ start(total, phase) {
17
+ this.currentPhase = phase ?? "Syncing...";
18
+ this.started = true;
19
+ this.bar.start(total, 0, { phase: this.currentPhase });
20
+ }
21
+ update(current, phase) {
22
+ if (phase && phase !== this.currentPhase) {
23
+ this.currentPhase = phase;
24
+ }
25
+ this.bar.update(current, { phase: this.currentPhase });
26
+ }
27
+ stop() {
28
+ if (this.started) {
29
+ this.bar.stop();
30
+ this.started = false;
31
+ }
32
+ }
33
+ /**
34
+ * Create an adapter for the existing SyncProgress callback pattern.
35
+ * Handles both discovery phase (no bar, just count) and fetching phase (progress bar).
36
+ */
37
+ static createCallback() {
38
+ const bar = new SyncProgressBar();
39
+ let barStarted = false;
40
+ let discoveryShown = false;
41
+ return {
42
+ bar,
43
+ onProgress: (progress) => {
44
+ if (progress.phase === "discovering") {
45
+ // Discovery phase - show count without bar (total unknown)
46
+ process.stdout.write(`\r ${progress.message ?? "Discovering..."} ${progress.current.toLocaleString()} found`);
47
+ discoveryShown = true;
48
+ }
49
+ else {
50
+ // Fetching phase - show progress bar
51
+ if (!barStarted && progress.total > 0) {
52
+ if (discoveryShown) {
53
+ console.log(); // Newline after discovery
54
+ }
55
+ bar.start(progress.total, progress.message);
56
+ barStarted = true;
57
+ }
58
+ if (barStarted) {
59
+ bar.update(progress.current, progress.message);
60
+ }
61
+ }
62
+ },
63
+ };
64
+ }
65
+ }
@@ -0,0 +1,29 @@
1
+ import type { BokioClient } from "@moatless/bokio-client";
2
+ import type { FilesystemStorageService } from "../storage/filesystem";
3
+ export interface BokioSyncProgress {
4
+ current: number;
5
+ total: number;
6
+ message?: string;
7
+ }
8
+ export interface BokioJournalSyncOptions {
9
+ downloadFiles?: boolean;
10
+ targetYear?: number;
11
+ onProgress?: (progress: BokioSyncProgress) => void;
12
+ }
13
+ export interface BokioJournalSyncResult {
14
+ entriesCount: number;
15
+ newEntries: number;
16
+ existingEntries: number;
17
+ fiscalYearsCount: number;
18
+ entriesWithFilesDownloaded: number;
19
+ }
20
+ /**
21
+ * Sync journal entries from Bokio
22
+ */
23
+ export declare function syncBokioJournalEntries(client: BokioClient, storage: FilesystemStorageService, options?: BokioJournalSyncOptions): Promise<BokioJournalSyncResult>;
24
+ /**
25
+ * Sync chart of accounts from Bokio to accounts.yaml
26
+ */
27
+ export declare function syncBokioChartOfAccounts(client: BokioClient, storage: FilesystemStorageService): Promise<{
28
+ accountsCount: number;
29
+ }>;
@@ -0,0 +1,175 @@
1
+ import { fiscalYearDirName } from "../utils/file-namer";
2
+ import { toYaml } from "../utils/yaml";
3
+ import { mapBokioEntryToJournalEntry } from "../transformers";
4
+ import { downloadFilesForEntry } from "./document-download";
5
+ import { JournalService } from "./journal.service";
6
+ /**
7
+ * Create a FileDownloader adapter for BokioClient
8
+ */
9
+ function createBokioDownloader(client) {
10
+ return {
11
+ async getFilesForEntry(journalEntryId) {
12
+ const response = await client.getUploads({
13
+ query: `journalEntryId==${journalEntryId}`,
14
+ });
15
+ return response.data.map((upload) => ({
16
+ id: upload.id,
17
+ contentType: upload.contentType,
18
+ description: upload.description ?? undefined,
19
+ }));
20
+ },
21
+ async downloadFile(id) {
22
+ return client.downloadFile(id);
23
+ },
24
+ };
25
+ }
26
+ function findFiscalYear(date, fiscalYears) {
27
+ return fiscalYears.find((fy) => date >= fy.startDate && date <= fy.endDate);
28
+ }
29
+ /**
30
+ * Sync journal entries from Bokio
31
+ */
32
+ export async function syncBokioJournalEntries(client, storage, options = {}) {
33
+ const { downloadFiles = false, targetYear, onProgress = () => { } } = options;
34
+ const journalService = new JournalService(storage);
35
+ // 1. Fetch fiscal years
36
+ const fiscalYearsResponse = await client.getFiscalYears();
37
+ const allFiscalYears = fiscalYearsResponse.data;
38
+ // Filter fiscal years if targeting a specific year
39
+ const fiscalYears = targetYear
40
+ ? allFiscalYears.filter((fy) => parseInt(fy.startDate.slice(0, 4), 10) === targetYear)
41
+ : allFiscalYears;
42
+ if (targetYear && fiscalYears.length === 0) {
43
+ return {
44
+ entriesCount: 0,
45
+ newEntries: 0,
46
+ existingEntries: 0,
47
+ fiscalYearsCount: 0,
48
+ entriesWithFilesDownloaded: 0,
49
+ };
50
+ }
51
+ // 2. Fetch total count first for progress display
52
+ const firstPage = await client.getJournalEntries({ page: 1, pageSize: 1 });
53
+ const totalFromApi = firstPage.pagination.totalItems;
54
+ onProgress({ current: 0, total: totalFromApi, message: "Starting sync..." });
55
+ if (totalFromApi === 0) {
56
+ await writeBokioFiscalYearsMetadata(storage, fiscalYears);
57
+ return {
58
+ entriesCount: 0,
59
+ newEntries: 0,
60
+ existingEntries: 0,
61
+ fiscalYearsCount: fiscalYears.length,
62
+ entriesWithFilesDownloaded: 0,
63
+ };
64
+ }
65
+ // 3. Fetch all entries with pagination
66
+ const allEntries = [];
67
+ let page = 1;
68
+ const pageSize = 100;
69
+ while (true) {
70
+ const response = await client.getJournalEntries({ page, pageSize });
71
+ allEntries.push(...response.data);
72
+ onProgress({ current: allEntries.length, total: totalFromApi });
73
+ if (!response.pagination.hasNextPage)
74
+ break;
75
+ page++;
76
+ }
77
+ // 4. Filter entries by target fiscal year(s)
78
+ const entriesToSync = targetYear
79
+ ? allEntries.filter((entry) => {
80
+ const fy = findFiscalYear(entry.date, fiscalYears);
81
+ return fy !== undefined;
82
+ })
83
+ : allEntries;
84
+ const existingEntriesByYear = new Map();
85
+ for (const fy of fiscalYears) {
86
+ const fyYear = parseInt(fy.startDate.slice(0, 4), 10);
87
+ const existingRecords = await journalService.listJournalEntryRecords(fyYear);
88
+ existingEntriesByYear.set(fyYear, existingRecords);
89
+ }
90
+ // 5. Write all entries first (so entries are synced even if file downloads fail)
91
+ let newEntries = 0;
92
+ let existingEntries = 0;
93
+ const entryDirs = new Map();
94
+ for (const entry of entriesToSync) {
95
+ const fiscalYear = findFiscalYear(entry.date, allFiscalYears);
96
+ if (!fiscalYear)
97
+ continue;
98
+ const fyYear = parseInt(fiscalYear.startDate.slice(0, 4), 10);
99
+ const journalEntry = mapBokioEntryToJournalEntry(entry);
100
+ const entryKey = journalEntry.externalId ??
101
+ `${journalEntry.series ?? ""}-${journalEntry.entryNumber}`;
102
+ const existingRecords = existingEntriesByYear.get(fyYear);
103
+ const existingRecord = existingRecords?.get(entryKey);
104
+ const result = await journalService.upsertJournalEntry(fyYear, journalEntry, existingRecord);
105
+ if (result.action === "created") {
106
+ newEntries++;
107
+ }
108
+ else {
109
+ existingEntries++;
110
+ }
111
+ entryDirs.set(entry.id, result.entryDir);
112
+ }
113
+ // 6. Write fiscal year metadata
114
+ await writeBokioFiscalYearsMetadata(storage, fiscalYears);
115
+ // 7. Download files for entries (if enabled)
116
+ let entriesWithFilesDownloaded = 0;
117
+ if (downloadFiles) {
118
+ const downloader = createBokioDownloader(client);
119
+ for (const entry of entriesToSync) {
120
+ const entryDir = entryDirs.get(entry.id);
121
+ if (!entryDir)
122
+ continue;
123
+ const filesDownloaded = await downloadFilesForEntry({
124
+ storage,
125
+ repoPath: "",
126
+ entryDir,
127
+ journalEntryId: entry.id,
128
+ downloader,
129
+ sourceIntegration: "bokio",
130
+ });
131
+ if (filesDownloaded > 0) {
132
+ entriesWithFilesDownloaded++;
133
+ }
134
+ }
135
+ }
136
+ return {
137
+ entriesCount: entriesToSync.length,
138
+ newEntries,
139
+ existingEntries,
140
+ fiscalYearsCount: fiscalYears.length,
141
+ entriesWithFilesDownloaded,
142
+ };
143
+ }
144
+ async function writeBokioFiscalYearsMetadata(storage, fiscalYears) {
145
+ for (const fy of fiscalYears) {
146
+ const fyDir = fiscalYearDirName({ start_date: fy.startDate });
147
+ const metadataPath = `journal-entries/${fyDir}/_fiscal-year.yaml`;
148
+ const metadata = {
149
+ id: fy.id,
150
+ startDate: fy.startDate,
151
+ endDate: fy.endDate,
152
+ status: fy.status,
153
+ };
154
+ const yamlContent = toYaml(metadata);
155
+ await storage.writeFile(metadataPath, yamlContent);
156
+ }
157
+ }
158
+ /**
159
+ * Sync chart of accounts from Bokio to accounts.yaml
160
+ */
161
+ export async function syncBokioChartOfAccounts(client, storage) {
162
+ const bokioAccounts = await client.getChartOfAccounts();
163
+ // Sort by account number and transform to expected format
164
+ const accounts = [...bokioAccounts]
165
+ .sort((a, b) => a.account - b.account)
166
+ .map((account) => ({
167
+ code: account.account.toString(),
168
+ name: account.name,
169
+ description: account.name,
170
+ }));
171
+ // Write accounts.yaml
172
+ const yamlContent = toYaml({ accounts });
173
+ await storage.writeFile("accounts.yaml", yamlContent);
174
+ return { accountsCount: accounts.length };
175
+ }