@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.
- package/dist/accounting/index.d.ts +9 -0
- package/dist/accounting/index.js +14 -0
- package/dist/accounting/line-generator.d.ts +34 -0
- package/dist/accounting/line-generator.js +136 -0
- package/dist/accounting/tax-codes.d.ts +32 -0
- package/dist/accounting/tax-codes.js +279 -0
- package/dist/accounting/traktamente-rates.d.ts +48 -0
- package/dist/accounting/traktamente-rates.js +325 -0
- package/dist/accounting/types.d.ts +69 -0
- package/dist/accounting/types.js +5 -0
- package/dist/accounting/validation.d.ts +41 -0
- package/dist/accounting/validation.js +118 -0
- package/dist/auth/fortnox-login.d.ts +15 -0
- package/dist/auth/fortnox-login.js +170 -0
- package/dist/auth/index.d.ts +3 -0
- package/dist/auth/index.js +3 -0
- package/dist/auth/prompts.d.ts +6 -0
- package/dist/auth/prompts.js +56 -0
- package/dist/auth/token-store.d.ts +19 -0
- package/dist/auth/token-store.js +54 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +21 -0
- package/dist/progress/index.d.ts +1 -0
- package/dist/progress/index.js +1 -0
- package/dist/progress/sync-progress.d.ts +31 -0
- package/dist/progress/sync-progress.js +65 -0
- package/dist/services/bokio-journal.d.ts +29 -0
- package/dist/services/bokio-journal.js +175 -0
- package/dist/services/document-download.d.ts +46 -0
- package/dist/services/document-download.js +105 -0
- package/dist/services/fortnox-inbox.d.ts +18 -0
- package/dist/services/fortnox-inbox.js +150 -0
- package/dist/services/fortnox-journal.d.ts +22 -0
- package/dist/services/fortnox-journal.js +166 -0
- package/dist/services/index.d.ts +6 -0
- package/dist/services/index.js +6 -0
- package/dist/services/journal-sync.d.ts +23 -0
- package/dist/services/journal-sync.js +124 -0
- package/dist/services/journal.service.d.ts +45 -0
- package/dist/services/journal.service.js +204 -0
- package/dist/storage/filesystem.d.ts +49 -0
- package/dist/storage/filesystem.js +122 -0
- package/dist/storage/index.d.ts +2 -0
- package/dist/storage/index.js +1 -0
- package/dist/storage/interface.d.ts +48 -0
- package/dist/storage/interface.js +5 -0
- package/dist/sync-types.d.ts +61 -0
- package/dist/sync-types.js +1 -0
- package/dist/transformers/bokio.d.ts +10 -0
- package/dist/transformers/bokio.js +56 -0
- package/dist/transformers/fortnox.d.ts +6 -0
- package/dist/transformers/fortnox.js +39 -0
- package/dist/transformers/index.d.ts +3 -0
- package/dist/transformers/index.js +2 -0
- package/dist/types/discarded-item.d.ts +29 -0
- package/dist/types/discarded-item.js +1 -0
- package/dist/types/document.d.ts +63 -0
- package/dist/types/document.js +9 -0
- package/dist/types/exported-document.d.ts +61 -0
- package/dist/types/exported-document.js +9 -0
- package/dist/types/exported-fiscal-year.d.ts +10 -0
- package/dist/types/exported-fiscal-year.js +1 -0
- package/dist/types/exported-inbox-document.d.ts +14 -0
- package/dist/types/exported-inbox-document.js +10 -0
- package/dist/types/fiscal-year.d.ts +10 -0
- package/dist/types/fiscal-year.js +1 -0
- package/dist/types/index.d.ts +10 -0
- package/dist/types/index.js +10 -0
- package/dist/types/journal-entry.d.ts +79 -0
- package/dist/types/journal-entry.js +12 -0
- package/dist/types/ledger-account.d.ts +5 -0
- package/dist/types/ledger-account.js +1 -0
- package/dist/utils/file-namer.d.ts +48 -0
- package/dist/utils/file-namer.js +80 -0
- package/dist/utils/git.d.ts +9 -0
- package/dist/utils/git.js +41 -0
- package/dist/utils/index.d.ts +6 -0
- package/dist/utils/index.js +6 -0
- package/dist/utils/paths.d.ts +17 -0
- package/dist/utils/paths.js +24 -0
- package/dist/utils/retry.d.ts +17 -0
- package/dist/utils/retry.js +48 -0
- package/dist/utils/templates.d.ts +12 -0
- package/dist/utils/templates.js +222 -0
- package/dist/utils/yaml.d.ts +12 -0
- package/dist/utils/yaml.js +47 -0
- package/dist/yaml/entry-helpers.d.ts +57 -0
- package/dist/yaml/entry-helpers.js +125 -0
- package/dist/yaml/index.d.ts +2 -0
- package/dist/yaml/index.js +2 -0
- package/dist/yaml/yaml-serializer.d.ts +21 -0
- package/dist/yaml/yaml-serializer.js +60 -0
- package/package.json +37 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { JournalEntry } from "../types";
|
|
2
|
+
import type { IStorageService } from "../storage/interface";
|
|
3
|
+
export interface JournalEntryRecord {
|
|
4
|
+
entry: JournalEntry;
|
|
5
|
+
entryPath: string;
|
|
6
|
+
entryDir: string;
|
|
7
|
+
}
|
|
8
|
+
export interface UpsertJournalEntryResult {
|
|
9
|
+
action: "created" | "updated" | "unchanged";
|
|
10
|
+
entryDir: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Journal entry service - handles journal entries in storage
|
|
14
|
+
* Works with both GitHub and filesystem storage backends
|
|
15
|
+
*/
|
|
16
|
+
export declare class JournalService {
|
|
17
|
+
private storage;
|
|
18
|
+
constructor(storage: IStorageService);
|
|
19
|
+
private static getEntryKey;
|
|
20
|
+
/**
|
|
21
|
+
* List all journal entries from a fiscal year
|
|
22
|
+
* Returns entries with their externalId for sync matching
|
|
23
|
+
*/
|
|
24
|
+
listJournalEntries(fiscalYear: number): Promise<Map<string, JournalEntry>>;
|
|
25
|
+
/**
|
|
26
|
+
* List all journal entries from a fiscal year with their paths
|
|
27
|
+
*/
|
|
28
|
+
listJournalEntryRecords(fiscalYear: number): Promise<Map<string, JournalEntryRecord>>;
|
|
29
|
+
/**
|
|
30
|
+
* Create a new journal entry
|
|
31
|
+
*/
|
|
32
|
+
createJournalEntry(fiscalYear: number, entry: JournalEntry): Promise<void>;
|
|
33
|
+
/**
|
|
34
|
+
* Update an existing journal entry
|
|
35
|
+
*/
|
|
36
|
+
updateJournalEntry(fiscalYear: number, existingEntry: JournalEntry, updatedEntry: JournalEntry): Promise<void>;
|
|
37
|
+
/**
|
|
38
|
+
* Create or update a journal entry, returning the entry directory path used.
|
|
39
|
+
*/
|
|
40
|
+
upsertJournalEntry(fiscalYear: number, entry: JournalEntry, existingRecord?: JournalEntryRecord): Promise<UpsertJournalEntryResult>;
|
|
41
|
+
/**
|
|
42
|
+
* Check if two entries are equal (for change detection)
|
|
43
|
+
*/
|
|
44
|
+
static entriesEqual(a: JournalEntry, b: JournalEntry): boolean;
|
|
45
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { journalEntryDirFromPath, journalEntryPath } from "../utils/file-namer";
|
|
2
|
+
import { parseYaml, toYaml } from "../utils/yaml";
|
|
3
|
+
/**
|
|
4
|
+
* Journal entry service - handles journal entries in storage
|
|
5
|
+
* Works with both GitHub and filesystem storage backends
|
|
6
|
+
*/
|
|
7
|
+
export class JournalService {
|
|
8
|
+
storage;
|
|
9
|
+
constructor(storage) {
|
|
10
|
+
this.storage = storage;
|
|
11
|
+
}
|
|
12
|
+
static getEntryKey(entry) {
|
|
13
|
+
return (entry.externalId ??
|
|
14
|
+
`${entry.series ?? ""}-${entry.entryNumber}`);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* List all journal entries from a fiscal year
|
|
18
|
+
* Returns entries with their externalId for sync matching
|
|
19
|
+
*/
|
|
20
|
+
async listJournalEntries(fiscalYear) {
|
|
21
|
+
const records = await this.listJournalEntryRecords(fiscalYear);
|
|
22
|
+
const entriesMap = new Map();
|
|
23
|
+
for (const [key, record] of records) {
|
|
24
|
+
entriesMap.set(key, record.entry);
|
|
25
|
+
}
|
|
26
|
+
return entriesMap;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* List all journal entries from a fiscal year with their paths
|
|
30
|
+
*/
|
|
31
|
+
async listJournalEntryRecords(fiscalYear) {
|
|
32
|
+
const fiscalYearDir = `journal-entries/FY-${fiscalYear}`;
|
|
33
|
+
const entriesMap = new Map();
|
|
34
|
+
try {
|
|
35
|
+
const directories = await this.storage.listDirectory(fiscalYearDir);
|
|
36
|
+
for (const dir of directories) {
|
|
37
|
+
if (dir.type !== "dir" || dir.name.startsWith("_")) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const entryPath = `${dir.path}/entry.yaml`;
|
|
41
|
+
try {
|
|
42
|
+
const { content } = await this.storage.readFile(entryPath);
|
|
43
|
+
const journalEntry = parseYaml(content);
|
|
44
|
+
const key = JournalService.getEntryKey(journalEntry);
|
|
45
|
+
entriesMap.set(key, {
|
|
46
|
+
entry: journalEntry,
|
|
47
|
+
entryPath,
|
|
48
|
+
entryDir: journalEntryDirFromPath(entryPath),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
console.error(`Failed to read ${entryPath}:`, error);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
catch (_error) {
|
|
57
|
+
// Directory might not exist yet
|
|
58
|
+
console.warn(`Fiscal year directory not found: ${fiscalYearDir}`);
|
|
59
|
+
}
|
|
60
|
+
return entriesMap;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Create a new journal entry
|
|
64
|
+
*/
|
|
65
|
+
async createJournalEntry(fiscalYear, entry) {
|
|
66
|
+
const yaml = toYaml(entry);
|
|
67
|
+
const filePath = journalEntryPath(fiscalYear, entry.series ?? null, entry.entryNumber, entry.entryDate, entry.description);
|
|
68
|
+
const message = `Create journal entry: ${entry.series ?? ""}${entry.entryNumber} - ${entry.description}`;
|
|
69
|
+
await this.storage.writeFile(filePath, yaml, { message });
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Update an existing journal entry
|
|
73
|
+
*/
|
|
74
|
+
async updateJournalEntry(fiscalYear, existingEntry, updatedEntry) {
|
|
75
|
+
const yaml = toYaml(updatedEntry);
|
|
76
|
+
// Use existing entry's path info (series, number, date, description)
|
|
77
|
+
const existingPath = journalEntryPath(fiscalYear, existingEntry.series ?? null, existingEntry.entryNumber, existingEntry.entryDate, existingEntry.description);
|
|
78
|
+
const updatedPath = journalEntryPath(fiscalYear, updatedEntry.series ?? null, updatedEntry.entryNumber, updatedEntry.entryDate, updatedEntry.description);
|
|
79
|
+
let filePath = existingPath;
|
|
80
|
+
if (updatedPath !== existingPath) {
|
|
81
|
+
const renameDirectory = this.storage.renameDirectory;
|
|
82
|
+
if (renameDirectory) {
|
|
83
|
+
const existingDir = journalEntryDirFromPath(existingPath);
|
|
84
|
+
const updatedDir = journalEntryDirFromPath(updatedPath);
|
|
85
|
+
if (existingDir !== updatedDir) {
|
|
86
|
+
try {
|
|
87
|
+
await renameDirectory(existingDir, updatedDir);
|
|
88
|
+
filePath = updatedPath;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
filePath = existingPath;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
const { sha } = await this.storage.readFile(filePath);
|
|
98
|
+
const message = `Update journal entry: ${updatedEntry.series ?? ""}${updatedEntry.entryNumber} - ${updatedEntry.description}`;
|
|
99
|
+
await this.storage.writeFile(filePath, yaml, { message, sha });
|
|
100
|
+
}
|
|
101
|
+
catch (_error) {
|
|
102
|
+
// If file doesn't exist at expected path, create it
|
|
103
|
+
const message = `Update journal entry: ${updatedEntry.series ?? ""}${updatedEntry.entryNumber} - ${updatedEntry.description}`;
|
|
104
|
+
await this.storage.writeFile(filePath, yaml, { message });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Create or update a journal entry, returning the entry directory path used.
|
|
109
|
+
*/
|
|
110
|
+
async upsertJournalEntry(fiscalYear, entry, existingRecord) {
|
|
111
|
+
const entryPath = journalEntryPath(fiscalYear, entry.series ?? null, entry.entryNumber, entry.entryDate, entry.description);
|
|
112
|
+
const entryDir = journalEntryDirFromPath(entryPath);
|
|
113
|
+
if (!existingRecord) {
|
|
114
|
+
const message = `Create journal entry: ${entry.series ?? ""}${entry.entryNumber} - ${entry.description}`;
|
|
115
|
+
await this.storage.writeFile(entryPath, toYaml(entry), { message });
|
|
116
|
+
return { action: "created", entryDir };
|
|
117
|
+
}
|
|
118
|
+
if (JournalService.entriesEqual(existingRecord.entry, entry)) {
|
|
119
|
+
return { action: "unchanged", entryDir: existingRecord.entryDir };
|
|
120
|
+
}
|
|
121
|
+
let targetPath = existingRecord.entryPath;
|
|
122
|
+
let targetDir = existingRecord.entryDir;
|
|
123
|
+
if (existingRecord.entryPath !== entryPath) {
|
|
124
|
+
const renameDirectory = this.storage.renameDirectory;
|
|
125
|
+
if (renameDirectory) {
|
|
126
|
+
if (existingRecord.entryDir !== entryDir) {
|
|
127
|
+
try {
|
|
128
|
+
await renameDirectory(existingRecord.entryDir, entryDir);
|
|
129
|
+
targetPath = entryPath;
|
|
130
|
+
targetDir = entryDir;
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
targetPath = existingRecord.entryPath;
|
|
134
|
+
targetDir = existingRecord.entryDir;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const yaml = toYaml(entry);
|
|
140
|
+
const message = `Update journal entry: ${entry.series ?? ""}${entry.entryNumber} - ${entry.description}`;
|
|
141
|
+
try {
|
|
142
|
+
const { sha } = await this.storage.readFile(targetPath);
|
|
143
|
+
await this.storage.writeFile(targetPath, yaml, { message, sha });
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
await this.storage.writeFile(targetPath, yaml, { message });
|
|
147
|
+
}
|
|
148
|
+
return { action: "updated", entryDir: targetDir };
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Check if two entries are equal (for change detection)
|
|
152
|
+
*/
|
|
153
|
+
static entriesEqual(a, b) {
|
|
154
|
+
// Compare key fields
|
|
155
|
+
if ((a.series ?? null) !== (b.series ?? null) ||
|
|
156
|
+
a.entryNumber !== b.entryNumber ||
|
|
157
|
+
a.description !== b.description ||
|
|
158
|
+
a.entryDate !== b.entryDate ||
|
|
159
|
+
a.status !== b.status ||
|
|
160
|
+
a.currency !== b.currency ||
|
|
161
|
+
a.totalDebit.amount !== b.totalDebit.amount ||
|
|
162
|
+
a.totalDebit.currency !== b.totalDebit.currency ||
|
|
163
|
+
(a.totalDebit.originalAmount ?? null) !== (b.totalDebit.originalAmount ?? null) ||
|
|
164
|
+
(a.totalDebit.originalCurrency ?? null) !== (b.totalDebit.originalCurrency ?? null) ||
|
|
165
|
+
a.totalCredit.amount !== b.totalCredit.amount ||
|
|
166
|
+
a.totalCredit.currency !== b.totalCredit.currency ||
|
|
167
|
+
(a.totalCredit.originalAmount ?? null) !== (b.totalCredit.originalAmount ?? null) ||
|
|
168
|
+
(a.totalCredit.originalCurrency ?? null) !== (b.totalCredit.originalCurrency ?? null) ||
|
|
169
|
+
(a.voucherNumber ?? null) !== (b.voucherNumber ?? null) ||
|
|
170
|
+
(a.voucherSeriesCode ?? null) !== (b.voucherSeriesCode ?? null) ||
|
|
171
|
+
(a.reversingEntryExternalId ?? null) !== (b.reversingEntryExternalId ?? null) ||
|
|
172
|
+
(a.reversedByEntryExternalId ?? null) !== (b.reversedByEntryExternalId ?? null)) {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
// Compare lines count
|
|
176
|
+
if (a.lines.length !== b.lines.length) {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
// Compare each line
|
|
180
|
+
for (let i = 0; i < a.lines.length; i++) {
|
|
181
|
+
const lineA = a.lines[i];
|
|
182
|
+
const lineB = b.lines[i];
|
|
183
|
+
if (!lineA || !lineB)
|
|
184
|
+
return false;
|
|
185
|
+
if (lineA.lineNumber !== lineB.lineNumber ||
|
|
186
|
+
lineA.account !== lineB.account ||
|
|
187
|
+
lineA.debit.amount !== lineB.debit.amount ||
|
|
188
|
+
lineA.debit.currency !== lineB.debit.currency ||
|
|
189
|
+
(lineA.debit.originalAmount ?? null) !== (lineB.debit.originalAmount ?? null) ||
|
|
190
|
+
(lineA.debit.originalCurrency ?? null) !== (lineB.debit.originalCurrency ?? null) ||
|
|
191
|
+
lineA.credit.amount !== lineB.credit.amount ||
|
|
192
|
+
lineA.credit.currency !== lineB.credit.currency ||
|
|
193
|
+
(lineA.credit.originalAmount ?? null) !== (lineB.credit.originalAmount ?? null) ||
|
|
194
|
+
(lineA.credit.originalCurrency ?? null) !== (lineB.credit.originalCurrency ?? null) ||
|
|
195
|
+
(lineA.memo ?? null) !== (lineB.memo ?? null) ||
|
|
196
|
+
(lineA.taxCode ?? null) !== (lineB.taxCode ?? null) ||
|
|
197
|
+
(lineA.costCenter ?? null) !== (lineB.costCenter ?? null) ||
|
|
198
|
+
(lineA.ledgerAccountName ?? null) !== (lineB.ledgerAccountName ?? null)) {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { IStorageService, FileContent, StorageEntry, WriteOptions } from "./interface";
|
|
2
|
+
/**
|
|
3
|
+
* Filesystem implementation of storage service.
|
|
4
|
+
* Uses local filesystem for file operations (for CLI tools like gitops).
|
|
5
|
+
*/
|
|
6
|
+
export declare class FilesystemStorageService implements IStorageService {
|
|
7
|
+
private basePath;
|
|
8
|
+
constructor(basePath: string);
|
|
9
|
+
/**
|
|
10
|
+
* Resolve a relative path to an absolute path
|
|
11
|
+
*/
|
|
12
|
+
private resolvePath;
|
|
13
|
+
/**
|
|
14
|
+
* Generate a SHA-like hash from file content for version tracking
|
|
15
|
+
*/
|
|
16
|
+
private contentHash;
|
|
17
|
+
/**
|
|
18
|
+
* Read a file from the filesystem
|
|
19
|
+
*/
|
|
20
|
+
readFile(filePath: string): Promise<FileContent>;
|
|
21
|
+
/**
|
|
22
|
+
* Write a file to the filesystem
|
|
23
|
+
*/
|
|
24
|
+
writeFile(filePath: string, content: string, _options?: WriteOptions): Promise<void>;
|
|
25
|
+
/**
|
|
26
|
+
* Write a binary file to the filesystem
|
|
27
|
+
*/
|
|
28
|
+
writeBinaryFile(filePath: string, content: Uint8Array): Promise<void>;
|
|
29
|
+
/**
|
|
30
|
+
* List contents of a directory
|
|
31
|
+
*/
|
|
32
|
+
listDirectory(dirPath: string): Promise<StorageEntry[]>;
|
|
33
|
+
/**
|
|
34
|
+
* Delete a file from the filesystem
|
|
35
|
+
*/
|
|
36
|
+
deleteFile(filePath: string, _sha: string, _message?: string): Promise<void>;
|
|
37
|
+
/**
|
|
38
|
+
* Filesystem doesn't support public URLs
|
|
39
|
+
*/
|
|
40
|
+
getPublicUrl(_path: string): string | null;
|
|
41
|
+
/**
|
|
42
|
+
* Check if a file exists
|
|
43
|
+
*/
|
|
44
|
+
exists(filePath: string): Promise<boolean>;
|
|
45
|
+
/**
|
|
46
|
+
* Rename a directory
|
|
47
|
+
*/
|
|
48
|
+
renameDirectory(oldPath: string, newPath: string): Promise<void>;
|
|
49
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import * as crypto from "node:crypto";
|
|
4
|
+
/**
|
|
5
|
+
* Filesystem implementation of storage service.
|
|
6
|
+
* Uses local filesystem for file operations (for CLI tools like gitops).
|
|
7
|
+
*/
|
|
8
|
+
export class FilesystemStorageService {
|
|
9
|
+
basePath;
|
|
10
|
+
constructor(basePath) {
|
|
11
|
+
this.basePath = basePath;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Resolve a relative path to an absolute path
|
|
15
|
+
*/
|
|
16
|
+
resolvePath(relativePath) {
|
|
17
|
+
return path.join(this.basePath, relativePath);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Generate a SHA-like hash from file content for version tracking
|
|
21
|
+
*/
|
|
22
|
+
contentHash(content) {
|
|
23
|
+
return crypto.createHash("sha1").update(content).digest("hex");
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Read a file from the filesystem
|
|
27
|
+
*/
|
|
28
|
+
async readFile(filePath) {
|
|
29
|
+
const absolutePath = this.resolvePath(filePath);
|
|
30
|
+
const content = await fs.readFile(absolutePath, "utf-8");
|
|
31
|
+
return {
|
|
32
|
+
content,
|
|
33
|
+
sha: this.contentHash(content),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Write a file to the filesystem
|
|
38
|
+
*/
|
|
39
|
+
async writeFile(filePath, content, _options) {
|
|
40
|
+
const absolutePath = this.resolvePath(filePath);
|
|
41
|
+
// Ensure directory exists
|
|
42
|
+
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
|
43
|
+
await fs.writeFile(absolutePath, content, "utf-8");
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Write a binary file to the filesystem
|
|
47
|
+
*/
|
|
48
|
+
async writeBinaryFile(filePath, content) {
|
|
49
|
+
const absolutePath = this.resolvePath(filePath);
|
|
50
|
+
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
|
51
|
+
await fs.writeFile(absolutePath, content);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* List contents of a directory
|
|
55
|
+
*/
|
|
56
|
+
async listDirectory(dirPath) {
|
|
57
|
+
const absolutePath = this.resolvePath(dirPath);
|
|
58
|
+
const entries = [];
|
|
59
|
+
try {
|
|
60
|
+
const items = await fs.readdir(absolutePath, { withFileTypes: true });
|
|
61
|
+
for (const item of items) {
|
|
62
|
+
if (item.isFile()) {
|
|
63
|
+
entries.push({
|
|
64
|
+
name: item.name,
|
|
65
|
+
type: "file",
|
|
66
|
+
path: path.join(dirPath, item.name),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
else if (item.isDirectory()) {
|
|
70
|
+
entries.push({
|
|
71
|
+
name: item.name,
|
|
72
|
+
type: "dir",
|
|
73
|
+
path: path.join(dirPath, item.name),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
// Directory doesn't exist - return empty array
|
|
80
|
+
if (error.code === "ENOENT") {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
return entries;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Delete a file from the filesystem
|
|
89
|
+
*/
|
|
90
|
+
async deleteFile(filePath, _sha, _message) {
|
|
91
|
+
const absolutePath = this.resolvePath(filePath);
|
|
92
|
+
await fs.unlink(absolutePath);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Filesystem doesn't support public URLs
|
|
96
|
+
*/
|
|
97
|
+
getPublicUrl(_path) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Check if a file exists
|
|
102
|
+
*/
|
|
103
|
+
async exists(filePath) {
|
|
104
|
+
try {
|
|
105
|
+
await fs.access(this.resolvePath(filePath));
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Rename a directory
|
|
114
|
+
*/
|
|
115
|
+
async renameDirectory(oldPath, newPath) {
|
|
116
|
+
const absoluteOldPath = this.resolvePath(oldPath);
|
|
117
|
+
const absoluteNewPath = this.resolvePath(newPath);
|
|
118
|
+
// Ensure parent directory of new path exists
|
|
119
|
+
await fs.mkdir(path.dirname(absoluteNewPath), { recursive: true });
|
|
120
|
+
await fs.rename(absoluteOldPath, absoluteNewPath);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { FilesystemStorageService } from "./filesystem";
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage abstraction interface.
|
|
3
|
+
* Allows swapping between different storage backends (GitHub, Filesystem, Azure Blob, etc.)
|
|
4
|
+
*/
|
|
5
|
+
export interface StorageEntry {
|
|
6
|
+
name: string;
|
|
7
|
+
type: "file" | "dir";
|
|
8
|
+
path: string;
|
|
9
|
+
}
|
|
10
|
+
export interface FileContent {
|
|
11
|
+
/** Text content (binary files should be Base64-encoded by caller) */
|
|
12
|
+
content: string;
|
|
13
|
+
/** Version identifier for optimistic locking (e.g., Git SHA, ETag, mtime) */
|
|
14
|
+
sha: string;
|
|
15
|
+
}
|
|
16
|
+
export interface WriteOptions {
|
|
17
|
+
/** Commit message (GitHub) or audit log entry (other backends) */
|
|
18
|
+
message?: string;
|
|
19
|
+
/** Required for updates - ensures no concurrent modifications */
|
|
20
|
+
sha?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface IStorageService {
|
|
23
|
+
/**
|
|
24
|
+
* Read a file's content and version identifier.
|
|
25
|
+
* @throws Error if file does not exist
|
|
26
|
+
*/
|
|
27
|
+
readFile(path: string): Promise<FileContent>;
|
|
28
|
+
/**
|
|
29
|
+
* Write a file (create or update).
|
|
30
|
+
* For updates, provide sha in options for optimistic locking.
|
|
31
|
+
*/
|
|
32
|
+
writeFile(path: string, content: string, options?: WriteOptions): Promise<void>;
|
|
33
|
+
/**
|
|
34
|
+
* List contents of a directory.
|
|
35
|
+
* Returns only files and directories (no symlinks or submodules).
|
|
36
|
+
*/
|
|
37
|
+
listDirectory(path: string): Promise<StorageEntry[]>;
|
|
38
|
+
/**
|
|
39
|
+
* Delete a file.
|
|
40
|
+
* Requires sha for optimistic locking.
|
|
41
|
+
*/
|
|
42
|
+
deleteFile(path: string, sha: string, message?: string): Promise<void>;
|
|
43
|
+
/**
|
|
44
|
+
* Get a public URL for a file, if supported.
|
|
45
|
+
* Returns null if the storage backend doesn't support public URLs.
|
|
46
|
+
*/
|
|
47
|
+
getPublicUrl(path: string): string | null;
|
|
48
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Result stats for sync operations
|
|
3
|
+
*/
|
|
4
|
+
export interface SyncStats {
|
|
5
|
+
fetched: number;
|
|
6
|
+
written: number;
|
|
7
|
+
skipped: number;
|
|
8
|
+
errors: number;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Stats per fiscal year (more detailed)
|
|
12
|
+
*/
|
|
13
|
+
export interface FiscalYearStats {
|
|
14
|
+
year: number;
|
|
15
|
+
fetched: number;
|
|
16
|
+
created: number;
|
|
17
|
+
updated: number;
|
|
18
|
+
unchanged: number;
|
|
19
|
+
failed: number;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Error entry for tracking issues
|
|
23
|
+
*/
|
|
24
|
+
export interface SyncError {
|
|
25
|
+
year: number;
|
|
26
|
+
externalId: string;
|
|
27
|
+
message: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Complete sync result
|
|
31
|
+
*/
|
|
32
|
+
export interface SyncJournalResult {
|
|
33
|
+
success: boolean;
|
|
34
|
+
fiscalYears: FiscalYearStats[];
|
|
35
|
+
totals: {
|
|
36
|
+
fetched: number;
|
|
37
|
+
created: number;
|
|
38
|
+
updated: number;
|
|
39
|
+
unchanged: number;
|
|
40
|
+
failed: number;
|
|
41
|
+
};
|
|
42
|
+
errors: SyncError[];
|
|
43
|
+
durationMs: number;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Fiscal year info for sync operations
|
|
47
|
+
*/
|
|
48
|
+
export interface FiscalYearInfo {
|
|
49
|
+
/** External ID from provider (used for API calls) */
|
|
50
|
+
externalId: number;
|
|
51
|
+
/** Calendar year (used for file paths) */
|
|
52
|
+
year: number;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Fiscal year with dates (from database)
|
|
56
|
+
*/
|
|
57
|
+
export interface FiscalYearWithDates {
|
|
58
|
+
name: string | null;
|
|
59
|
+
start_date: string;
|
|
60
|
+
end_date: string;
|
|
61
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { CreateJournalEntryRequest, JournalEntry as BokioJournalEntry } from "@moatless/bokio-client";
|
|
2
|
+
import type { JournalEntry } from "../types";
|
|
3
|
+
/**
|
|
4
|
+
* Transform a Bokio journal entry to a JournalEntry
|
|
5
|
+
*/
|
|
6
|
+
export declare function mapBokioEntryToJournalEntry(entry: BokioJournalEntry): JournalEntry;
|
|
7
|
+
/**
|
|
8
|
+
* Transform a local JournalEntry to a Bokio CreateJournalEntryRequest
|
|
9
|
+
*/
|
|
10
|
+
export declare function mapJournalEntryToBokioRequest(entry: JournalEntry): CreateJournalEntryRequest;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transform a Bokio journal entry to a JournalEntry
|
|
3
|
+
*/
|
|
4
|
+
export function mapBokioEntryToJournalEntry(entry) {
|
|
5
|
+
// Parse voucher number and series from journalEntryNumber (e.g., "A123" -> series: "A", number: 123)
|
|
6
|
+
const voucherNumberMatch = entry.journalEntryNumber.match(/\d+/);
|
|
7
|
+
const voucherNumber = voucherNumberMatch
|
|
8
|
+
? parseInt(voucherNumberMatch[0], 10)
|
|
9
|
+
: 0;
|
|
10
|
+
const seriesCode = entry.journalEntryNumber.replace(/\d+/g, "") || undefined;
|
|
11
|
+
const lines = entry.items.map((item, idx) => ({
|
|
12
|
+
lineNumber: idx + 1,
|
|
13
|
+
account: String(item.account),
|
|
14
|
+
debit: { amount: item.debit, currency: "SEK" },
|
|
15
|
+
credit: { amount: item.credit, currency: "SEK" },
|
|
16
|
+
}));
|
|
17
|
+
const totalDebit = entry.items.reduce((sum, item) => sum + item.debit, 0);
|
|
18
|
+
const totalCredit = entry.items.reduce((sum, item) => sum + item.credit, 0);
|
|
19
|
+
return {
|
|
20
|
+
series: seriesCode,
|
|
21
|
+
entryNumber: voucherNumber,
|
|
22
|
+
entryDate: entry.date,
|
|
23
|
+
description: entry.title,
|
|
24
|
+
status: "POSTED",
|
|
25
|
+
currency: "SEK",
|
|
26
|
+
externalId: entry.id,
|
|
27
|
+
totalDebit: { amount: totalDebit, currency: "SEK" },
|
|
28
|
+
totalCredit: { amount: totalCredit, currency: "SEK" },
|
|
29
|
+
lines,
|
|
30
|
+
sourceIntegration: "bokio",
|
|
31
|
+
sourceSyncedAt: new Date().toISOString(),
|
|
32
|
+
reversingEntryExternalId: entry.reversingJournalEntryId ?? undefined,
|
|
33
|
+
reversedByEntryExternalId: entry.reversedByJournalEntryId ?? undefined,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Helper to handle both plain number and MoneyAmount object formats.
|
|
38
|
+
* This provides backwards compatibility with YAML files that have plain numbers.
|
|
39
|
+
*/
|
|
40
|
+
function getAmount(value) {
|
|
41
|
+
return typeof value === "number" ? value : value.amount;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Transform a local JournalEntry to a Bokio CreateJournalEntryRequest
|
|
45
|
+
*/
|
|
46
|
+
export function mapJournalEntryToBokioRequest(entry) {
|
|
47
|
+
return {
|
|
48
|
+
title: entry.description,
|
|
49
|
+
date: entry.entryDate,
|
|
50
|
+
items: entry.lines.map((line) => ({
|
|
51
|
+
debit: getAmount(line.debit),
|
|
52
|
+
credit: getAmount(line.credit),
|
|
53
|
+
account: parseInt(line.account, 10),
|
|
54
|
+
})),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Voucher } from "@moatless/fortnox-client";
|
|
2
|
+
import type { JournalEntry } from "../types";
|
|
3
|
+
/**
|
|
4
|
+
* Transform a Fortnox voucher to a JournalEntry
|
|
5
|
+
*/
|
|
6
|
+
export declare function mapFortnoxVoucherToJournalEntry(voucher: Voucher, _fiscalYear?: number): JournalEntry;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transform a Fortnox voucher to a JournalEntry
|
|
3
|
+
*/
|
|
4
|
+
export function mapFortnoxVoucherToJournalEntry(voucher, _fiscalYear) {
|
|
5
|
+
const series = voucher.VoucherSeries;
|
|
6
|
+
const externalId = `${voucher.VoucherSeries}-${voucher.VoucherNumber}`;
|
|
7
|
+
// Calculate totals from voucher rows
|
|
8
|
+
let totalDebit = 0;
|
|
9
|
+
let totalCredit = 0;
|
|
10
|
+
const lines = (voucher.VoucherRows ?? []).map((row, idx) => {
|
|
11
|
+
totalDebit += row.Debit ?? 0;
|
|
12
|
+
totalCredit += row.Credit ?? 0;
|
|
13
|
+
const memo = row.TransactionInformation || row.Description || undefined;
|
|
14
|
+
return {
|
|
15
|
+
lineNumber: idx + 1,
|
|
16
|
+
account: String(row.Account),
|
|
17
|
+
debit: { amount: row.Debit ?? 0, currency: "SEK" },
|
|
18
|
+
credit: { amount: row.Credit ?? 0, currency: "SEK" },
|
|
19
|
+
memo: memo ? String(memo) : undefined,
|
|
20
|
+
costCenter: row.CostCenter ?? undefined,
|
|
21
|
+
};
|
|
22
|
+
});
|
|
23
|
+
return {
|
|
24
|
+
series,
|
|
25
|
+
entryNumber: voucher.VoucherNumber ?? 0,
|
|
26
|
+
entryDate: voucher.TransactionDate ?? voucher.VoucherDate ?? "",
|
|
27
|
+
description: voucher.Description ?? "",
|
|
28
|
+
status: "POSTED",
|
|
29
|
+
currency: "SEK",
|
|
30
|
+
externalId,
|
|
31
|
+
totalDebit: { amount: totalDebit, currency: "SEK" },
|
|
32
|
+
totalCredit: { amount: totalCredit, currency: "SEK" },
|
|
33
|
+
lines,
|
|
34
|
+
sourceIntegration: "fortnox",
|
|
35
|
+
sourceSyncedAt: new Date().toISOString(),
|
|
36
|
+
voucherNumber: voucher.VoucherNumber,
|
|
37
|
+
voucherSeriesCode: voucher.VoucherSeries,
|
|
38
|
+
};
|
|
39
|
+
}
|