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.
@@ -0,0 +1,48 @@
1
+ import ora from "ora";
2
+ import { loadConfig } from "../lib/env";
3
+ import { createBokioClient } from "../lib/client";
4
+ export async function companyInfoCommand() {
5
+ const cwd = process.cwd();
6
+ // Load config
7
+ const spinner = ora("Loading configuration...").start();
8
+ let config;
9
+ try {
10
+ config = loadConfig(cwd);
11
+ spinner.succeed(`Provider: ${config.provider}`);
12
+ }
13
+ catch (error) {
14
+ spinner.fail(error instanceof Error ? error.message : "Unknown error");
15
+ process.exit(1);
16
+ }
17
+ // Create client
18
+ const client = createBokioClient(config);
19
+ // Fetch company info
20
+ const infoSpinner = ora("Fetching company information...").start();
21
+ try {
22
+ const companyInfo = await client.getCompanyInformation();
23
+ infoSpinner.stop();
24
+ console.log(`
25
+ Company Information
26
+ -------------------
27
+ Name: ${companyInfo.name}
28
+ Org Number: ${companyInfo.organizationNumber ?? "N/A"}
29
+ VAT Number: ${companyInfo.vatNumber ?? "N/A"}
30
+ Address: ${formatAddress(companyInfo.address)}
31
+ `);
32
+ }
33
+ catch (error) {
34
+ infoSpinner.fail("Failed to fetch company information");
35
+ console.error(error instanceof Error ? error.message : "Unknown error");
36
+ process.exit(1);
37
+ }
38
+ }
39
+ function formatAddress(address) {
40
+ if (!address)
41
+ return "N/A";
42
+ const parts = [
43
+ address.street,
44
+ [address.zipCode, address.city].filter(Boolean).join(" "),
45
+ address.country,
46
+ ].filter(Boolean);
47
+ return parts.length > 0 ? parts.join(", ") : "N/A";
48
+ }
@@ -0,0 +1,76 @@
1
+ import { RiksbankClient } from "integrations-riksbank";
2
+ import { getCachedRate, setCachedRate } from "../lib/currency-cache";
3
+ function getToday() {
4
+ return new Date().toISOString().slice(0, 10);
5
+ }
6
+ export async function convertCurrencyCommand(options) {
7
+ const cwd = process.cwd();
8
+ const amount = parseFloat(options.amount);
9
+ const currency = options.currency.toUpperCase();
10
+ const date = options.date ?? getToday();
11
+ // Validate amount
12
+ if (Number.isNaN(amount) || amount <= 0) {
13
+ console.error("Error: --amount must be a positive number");
14
+ process.exit(1);
15
+ }
16
+ // Validate date format
17
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
18
+ console.error("Error: --date must be in YYYY-MM-DD format");
19
+ process.exit(1);
20
+ }
21
+ // Handle SEK (no conversion needed)
22
+ if (currency === "SEK") {
23
+ console.log("Currency Conversion");
24
+ console.log("===================");
25
+ console.log(`Amount: ${amount.toFixed(2)} SEK`);
26
+ console.log(`Date: ${date}`);
27
+ console.log("");
28
+ console.log("No conversion needed (already SEK)");
29
+ console.log("");
30
+ console.log(`Result: ${amount.toFixed(2)} SEK`);
31
+ return;
32
+ }
33
+ // Initialize Riksbank client
34
+ const riksbank = new RiksbankClient();
35
+ // Validate currency is supported
36
+ if (!riksbank.isSupportedCurrency(currency)) {
37
+ console.error(`Error: Unsupported currency: ${currency}`);
38
+ console.error(`Supported currencies: ${riksbank.getSupportedCurrencies().join(", ")}`);
39
+ process.exit(1);
40
+ }
41
+ console.log("Currency Conversion");
42
+ console.log("===================");
43
+ console.log(`Amount: ${amount.toFixed(2)} ${currency}`);
44
+ console.log(`Date: ${date}`);
45
+ console.log("");
46
+ // Check cache first
47
+ const cached = getCachedRate(cwd, currency, date);
48
+ let rate;
49
+ let rateDate;
50
+ let source;
51
+ if (cached) {
52
+ rate = cached.rate;
53
+ rateDate = cached.rateDate;
54
+ source = "CACHE";
55
+ }
56
+ else {
57
+ // Fetch from Riksbank
58
+ const result = await riksbank.getRate(currency, date);
59
+ rate = result.value;
60
+ rateDate = result.date;
61
+ source = "RIKSBANK";
62
+ // Save to cache
63
+ setCachedRate(cwd, currency, date, rate, rateDate);
64
+ }
65
+ const convertedAmount = amount * rate;
66
+ console.log(`Exchange Rate: ${rate.toFixed(4)} SEK/${currency}`);
67
+ if (rateDate !== date) {
68
+ console.log(`Rate Date: ${rateDate} (closest available)`);
69
+ }
70
+ else {
71
+ console.log(`Rate Date: ${rateDate}`);
72
+ }
73
+ console.log(`Source: ${source}`);
74
+ console.log("");
75
+ console.log(`Converted Amount: ${convertedAmount.toFixed(2)} SEK`);
76
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Create a complete journal entry from an inbox document
3
+ * Reads document.yaml, generates lines, and creates entry.yaml
4
+ */
5
+ import { RiksbankClient } from "integrations-riksbank";
6
+ import { resolveTaxCode, createJournalLinesFromTaxCode, getAllTaxCodes, } from "shared/dist/accounting";
7
+ import { readAccountsYaml, getAccountName, toYaml } from "shared/dist/yaml";
8
+ import * as path from "node:path";
9
+ import * as fs from "node:fs/promises";
10
+ import * as yaml from "yaml";
11
+ export async function createEntryCommand(options) {
12
+ // Handle --list-tax-codes flag
13
+ if (options.listTaxCodes) {
14
+ console.log("Available Tax Codes");
15
+ console.log("===================");
16
+ for (const tc of getAllTaxCodes()) {
17
+ console.log(` ${tc.code}`);
18
+ console.log(` ${tc.description}`);
19
+ console.log(` Direction: ${tc.direction} | Territory: ${tc.territory} | Rate: ${tc.ratePercent}%`);
20
+ console.log("");
21
+ }
22
+ return;
23
+ }
24
+ const { inboxDir, taxCode: taxCodeStr, baseAccount: baseAccountCode, balancingAccount: balancingAccountCode, series: seriesInput, entryNumber: entryNumberStr, memo, dryRun = false, } = options;
25
+ const series = seriesInput.toUpperCase();
26
+ const entryNumber = Number.parseInt(entryNumberStr, 10);
27
+ if (Number.isNaN(entryNumber) || entryNumber < 1) {
28
+ console.error("Error: --entry-number must be a positive integer");
29
+ process.exit(1);
30
+ }
31
+ // File paths
32
+ const documentPath = path.join(inboxDir, "document.yaml");
33
+ const accountsPath = path.join(path.dirname(inboxDir), "accounts.yaml");
34
+ // Read document.yaml
35
+ let documentContent;
36
+ try {
37
+ documentContent = await fs.readFile(documentPath, "utf-8");
38
+ }
39
+ catch {
40
+ console.error(`Error: document.yaml not found at ${documentPath}`);
41
+ process.exit(1);
42
+ }
43
+ const document = yaml.parse(documentContent);
44
+ // Extract data from document
45
+ const description = document.description || "Unknown vendor";
46
+ const documentDate = document.documentDate;
47
+ const totalAmount = document.totalAmount;
48
+ const subTotal = document.subTotal;
49
+ if (!documentDate) {
50
+ console.error("Error: documentDate not found in document.yaml");
51
+ process.exit(1);
52
+ }
53
+ // Determine base amount - use subTotal (net) if available, otherwise totalAmount
54
+ const amountSource = subTotal || totalAmount;
55
+ if (!amountSource) {
56
+ console.error("Error: No amount found in document.yaml (need totalAmount or subTotal)");
57
+ process.exit(1);
58
+ }
59
+ const baseAmount = Number.parseFloat(amountSource.amount);
60
+ const currency = amountSource.currency || "SEK";
61
+ if (Number.isNaN(baseAmount) || baseAmount <= 0) {
62
+ console.error("Error: Invalid amount in document.yaml");
63
+ process.exit(1);
64
+ }
65
+ console.log("Creating Journal Entry");
66
+ console.log("======================");
67
+ console.log(`Document: ${document.fileName}`);
68
+ console.log(`Vendor: ${description}`);
69
+ console.log(`Date: ${documentDate}`);
70
+ console.log(`Amount: ${baseAmount.toFixed(2)} ${currency}`);
71
+ console.log("");
72
+ // Resolve tax code
73
+ const taxCode = resolveTaxCode(taxCodeStr);
74
+ if (!taxCode) {
75
+ console.error(`Error: Invalid tax code: ${taxCodeStr}`);
76
+ console.error("\nRun with --list-tax-codes to see available codes");
77
+ process.exit(1);
78
+ }
79
+ console.log(`Tax Code: ${taxCode.code}`);
80
+ console.log(` ${taxCode.description}`);
81
+ console.log(` VAT Rate: ${taxCode.ratePercent}%`);
82
+ console.log("");
83
+ // Currency conversion if needed
84
+ let amountInSEK = baseAmount;
85
+ if (currency !== "SEK") {
86
+ console.log(`Converting ${baseAmount.toFixed(2)} ${currency} to SEK...`);
87
+ const riksbank = new RiksbankClient();
88
+ if (!riksbank.isSupportedCurrency(currency)) {
89
+ console.error(`Error: Unsupported currency: ${currency}`);
90
+ process.exit(1);
91
+ }
92
+ const rate = await riksbank.getRate(currency, documentDate);
93
+ amountInSEK = baseAmount * rate.value;
94
+ console.log(` Rate: ${rate.value.toFixed(4)} SEK/${currency} (${rate.date})`);
95
+ console.log(` Converted: ${amountInSEK.toFixed(2)} SEK`);
96
+ console.log("");
97
+ }
98
+ // Load accounts for memo lookup
99
+ let accounts = [];
100
+ try {
101
+ accounts = await readAccountsYaml(accountsPath);
102
+ }
103
+ catch {
104
+ // accounts.yaml not found, continue without account names
105
+ }
106
+ // Generate journal lines
107
+ const result = createJournalLinesFromTaxCode({
108
+ taxCode,
109
+ baseAmount: amountInSEK,
110
+ baseAccountCode,
111
+ balancingAccountCode,
112
+ memo,
113
+ startingLineNumber: 1,
114
+ });
115
+ // Build entry.yaml content
116
+ const entryLines = result.lines.map((line) => ({
117
+ lineNumber: line.line_number,
118
+ account: line.ledger_account_code,
119
+ debit: {
120
+ amount: line.debit ?? 0,
121
+ currency: "SEK",
122
+ },
123
+ credit: {
124
+ amount: line.credit ?? 0,
125
+ currency: "SEK",
126
+ },
127
+ memo: line.memo ||
128
+ getAccountName(line.ledger_account_code, accounts) ||
129
+ undefined,
130
+ }));
131
+ const totalDebit = result.lines.reduce((sum, l) => sum + (l.debit ?? 0), 0);
132
+ const totalCredit = result.lines.reduce((sum, l) => sum + (l.credit ?? 0), 0);
133
+ const entry = {
134
+ series,
135
+ entryNumber,
136
+ entryDate: documentDate,
137
+ description,
138
+ status: "DRAFT",
139
+ currency: "SEK",
140
+ totalDebit: {
141
+ amount: Math.round(totalDebit * 100) / 100,
142
+ currency: "SEK",
143
+ },
144
+ totalCredit: {
145
+ amount: Math.round(totalCredit * 100) / 100,
146
+ currency: "SEK",
147
+ },
148
+ lines: entryLines,
149
+ };
150
+ // Output the entry
151
+ console.log("Generated Entry:");
152
+ console.log("-".repeat(40));
153
+ const entryYaml = toYaml(entry);
154
+ console.log(entryYaml);
155
+ console.log("-".repeat(40));
156
+ console.log(`Total Debit: ${totalDebit.toFixed(2)} SEK`);
157
+ console.log(`Total Credit: ${totalCredit.toFixed(2)} SEK`);
158
+ console.log(`Balanced: ${Math.abs(totalDebit - totalCredit) < 0.01 ? "✓" : "✗"}`);
159
+ // Calculate drafts directory name: {date} - {title}
160
+ const titleSlug = slugify(description, 30);
161
+ const draftsDirName = `${documentDate} - ${titleSlug}`;
162
+ const draftsDir = path.join(path.dirname(inboxDir), "drafts", draftsDirName);
163
+ // Write entry.yaml and move to drafts unless dry-run
164
+ if (dryRun) {
165
+ console.log("\n[Dry run - files not moved]");
166
+ console.log(`Would move to: ${draftsDir}`);
167
+ }
168
+ else {
169
+ // Create drafts directory
170
+ await fs.mkdir(draftsDir, { recursive: true });
171
+ // Copy all files from inbox to drafts
172
+ const inboxFiles = await fs.readdir(inboxDir);
173
+ for (const file of inboxFiles) {
174
+ const srcPath = path.join(inboxDir, file);
175
+ const destPath = path.join(draftsDir, file);
176
+ await fs.copyFile(srcPath, destPath);
177
+ }
178
+ // Write entry.yaml to drafts
179
+ const entryPath = path.join(draftsDir, "entry.yaml");
180
+ await fs.writeFile(entryPath, entryYaml, "utf-8");
181
+ // Delete inbox directory
182
+ await fs.rm(inboxDir, { recursive: true });
183
+ console.log(`\n✓ Moved to ${draftsDir}`);
184
+ console.log(`✓ Entry written to ${entryPath}`);
185
+ }
186
+ }
187
+ /**
188
+ * Convert text to a URL-friendly slug
189
+ */
190
+ function slugify(text, maxLength = 30) {
191
+ return text
192
+ .toLowerCase()
193
+ .slice(0, maxLength)
194
+ .replace(/[^a-z0-9\s-]/g, "")
195
+ .replace(/\s+/g, "-")
196
+ .replace(/-+/g, "-")
197
+ .replace(/^-|-$/g, "");
198
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Generate journal entry lines from tax codes
3
+ * Appends lines to an existing entry.yaml
4
+ */
5
+ import { createClient } from "@supabase/supabase-js";
6
+ import { resolveTaxCode, createJournalLinesFromTaxCode, convertToSEK, calculateLineTotals, getAllTaxCodes, } from "shared/dist/accounting";
7
+ import { readEntryYaml, readDocumentYaml, appendLinesToEntry, readAccountsYaml, validateAccountExists, getAccountName, } from "shared/dist/yaml";
8
+ import * as path from "node:path";
9
+ import * as fs from "node:fs/promises";
10
+ export async function generateLinesCommand(options) {
11
+ const { inboxDir, taxCode: taxCodeStr, baseAmount: baseAmountStr, baseAccount: baseAccountCode, balancingAccount: balancingAccountCode, memo, amountCurrency = "SEK", } = options;
12
+ const baseAmount = Number.parseFloat(baseAmountStr);
13
+ // Validate base amount
14
+ if (Number.isNaN(baseAmount) || baseAmount <= 0) {
15
+ console.error("Error: --base-amount must be a positive number");
16
+ process.exit(1);
17
+ }
18
+ // File paths
19
+ const entryPath = path.join(inboxDir, "entry.yaml");
20
+ const documentPath = path.join(inboxDir, "document.yaml");
21
+ const accountsPath = path.join(path.dirname(path.dirname(inboxDir)), "accounts.yaml");
22
+ // Check if files exist
23
+ try {
24
+ await fs.access(entryPath);
25
+ }
26
+ catch {
27
+ console.error(`Error: entry.yaml not found at ${entryPath}`);
28
+ process.exit(1);
29
+ }
30
+ try {
31
+ await fs.access(documentPath);
32
+ }
33
+ catch {
34
+ console.error(`Error: document.yaml not found at ${documentPath}`);
35
+ process.exit(1);
36
+ }
37
+ // Read entry and document
38
+ console.log("Reading entry and document files...");
39
+ const entry = await readEntryYaml(entryPath);
40
+ // Document is read to validate it exists but not directly used
41
+ await readDocumentYaml(documentPath);
42
+ console.log(`Entry date: ${entry.entryDate}`);
43
+ console.log(`Description: ${entry.description}`);
44
+ // Resolve tax code
45
+ console.log(`\nResolving tax code: ${taxCodeStr}`);
46
+ const taxCode = resolveTaxCode(taxCodeStr);
47
+ if (!taxCode) {
48
+ console.error(`Error: Invalid tax code: ${taxCodeStr}`);
49
+ console.error("\nAvailable tax codes:");
50
+ for (const tc of getAllTaxCodes()) {
51
+ console.error(` ${tc.code} - ${tc.description}`);
52
+ }
53
+ process.exit(1);
54
+ }
55
+ console.log(`✓ Tax code resolved: ${taxCode.description}`);
56
+ console.log(` Direction: ${taxCode.direction}`);
57
+ console.log(` Territory: ${taxCode.territory}`);
58
+ console.log(` VAT Rate: ${taxCode.ratePercent}%`);
59
+ // Currency conversion if needed
60
+ let amountInSEK = baseAmount;
61
+ if (amountCurrency !== "SEK") {
62
+ console.log(`\nCurrency conversion: ${baseAmount} ${amountCurrency} → SEK`);
63
+ // Initialize Supabase client
64
+ const supabaseUrl = process.env.SUPABASE_URL;
65
+ const supabaseKey = process.env.SUPABASE_SECRET_KEY;
66
+ if (!supabaseUrl || !supabaseKey) {
67
+ console.error("Error: Missing Supabase credentials");
68
+ console.error("Ensure SUPABASE_URL and SUPABASE_SECRET_KEY are set in environment");
69
+ process.exit(1);
70
+ }
71
+ const supabase = createClient(supabaseUrl, supabaseKey);
72
+ const converted = await convertToSEK(supabase, baseAmount, amountCurrency, entry.entryDate);
73
+ if (converted === null) {
74
+ console.error(`Error: No currency rate found for ${amountCurrency} on ${entry.entryDate}`);
75
+ console.error("Please sync currency rates from Riksbank or provide amount already in SEK");
76
+ process.exit(1);
77
+ }
78
+ amountInSEK = converted;
79
+ console.log(`✓ Converted to SEK: ${amountInSEK.toFixed(2)}`);
80
+ }
81
+ // Validate accounts exist (if accounts.yaml is available)
82
+ let accounts = [];
83
+ try {
84
+ await fs.access(accountsPath);
85
+ accounts = await readAccountsYaml(accountsPath);
86
+ console.log(`\nValidating accounts against ${accountsPath}`);
87
+ if (!validateAccountExists(baseAccountCode, accounts)) {
88
+ console.error(`Error: Base account ${baseAccountCode} not found`);
89
+ console.error("Run export-accounts first to create accounts.yaml");
90
+ process.exit(1);
91
+ }
92
+ if (!validateAccountExists(balancingAccountCode, accounts)) {
93
+ console.error(`Error: Balancing account ${balancingAccountCode} not found`);
94
+ console.error("Run export-accounts first to create accounts.yaml");
95
+ process.exit(1);
96
+ }
97
+ console.log(`✓ Base account: ${baseAccountCode} - ${getAccountName(baseAccountCode, accounts)}`);
98
+ console.log(`✓ Balancing account: ${balancingAccountCode} - ${getAccountName(balancingAccountCode, accounts)}`);
99
+ }
100
+ catch {
101
+ console.warn(`\nWarning: accounts.yaml not found at ${accountsPath}, skipping account validation`);
102
+ }
103
+ // Generate lines
104
+ console.log("\nGenerating journal lines...");
105
+ const result = createJournalLinesFromTaxCode({
106
+ taxCode,
107
+ baseAmount: amountInSEK,
108
+ baseAccountCode,
109
+ balancingAccountCode,
110
+ memo,
111
+ startingLineNumber: (entry.lines?.length || 0) + 1,
112
+ });
113
+ console.log(`✓ Generated ${result.lines.length} lines:`);
114
+ console.log(` Net amount: ${result.totals.net.toFixed(2)} SEK`);
115
+ console.log(` VAT amount: ${result.totals.vat.toFixed(2)} SEK`);
116
+ console.log(` Gross amount: ${result.totals.gross.toFixed(2)} SEK`);
117
+ for (const line of result.lines) {
118
+ const accountName = getAccountName(line.ledger_account_code, accounts);
119
+ const debit = line.debit ? `${line.debit.toFixed(2)} SEK` : "-";
120
+ const credit = line.credit ? `${line.credit.toFixed(2)} SEK` : "-";
121
+ console.log(` Line ${line.line_number}: ${line.ledger_account_code} ${accountName ? `(${accountName})` : ""} | Debit: ${debit} | Credit: ${credit}`);
122
+ }
123
+ // Calculate total debits and credits across ALL lines (existing + new)
124
+ const allLinesForTotal = [
125
+ ...(entry.lines || []).map((l) => ({
126
+ line_number: l.lineNumber,
127
+ ledger_account_code: l.account,
128
+ debit: l.debit.amount,
129
+ credit: l.credit.amount,
130
+ })),
131
+ ...result.lines,
132
+ ];
133
+ const totals = calculateLineTotals(allLinesForTotal);
134
+ console.log("\nUpdating entry.yaml...");
135
+ await appendLinesToEntry(entryPath, result.lines, totals);
136
+ console.log(`✓ Successfully appended lines to ${entryPath} and updated totals`);
137
+ console.log(` Total Debit: ${totals.totalDebit.toFixed(2)} SEK`);
138
+ console.log(` Total Credit: ${totals.totalCredit.toFixed(2)} SEK`);
139
+ console.log(` Balanced: ${totals.totalDebit === totals.totalCredit ? "✓" : "✗"}`);
140
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Extract text from a PDF file
3
+ */
4
+ import pdf from "pdf-parse";
5
+ import { readFile } from "node:fs/promises";
6
+ export async function parsePdfCommand(options) {
7
+ const { file: filePath } = options;
8
+ try {
9
+ const buffer = await readFile(filePath);
10
+ const data = await pdf(buffer);
11
+ console.log(data.text);
12
+ }
13
+ catch (err) {
14
+ if (err instanceof Error && "code" in err && err.code === "ENOENT") {
15
+ console.error(`Error: File not found: ${filePath}`);
16
+ process.exit(1);
17
+ }
18
+ throw err;
19
+ }
20
+ }
@@ -0,0 +1,186 @@
1
+ import ora from "ora";
2
+ import * as fs from "node:fs/promises";
3
+ import * as path from "node:path";
4
+ import { toYaml, sanitizeOrgName } from "sync";
5
+ import { loadConfig } from "../lib/env";
6
+ import { createBokioClient } from "../lib/client";
7
+ /**
8
+ * Slugify text for terminal-friendly filenames
9
+ */
10
+ function slugify(text, maxLength = 30) {
11
+ return text
12
+ .toLowerCase()
13
+ .slice(0, maxLength)
14
+ .replace(/[^a-z0-9\s-]/g, "")
15
+ .replace(/\s+/g, "-")
16
+ .replace(/-+/g, "-")
17
+ .replace(/^-|-$/g, "");
18
+ }
19
+ export async function syncInboxCommand() {
20
+ const cwd = process.cwd();
21
+ // Load config
22
+ const spinner = ora("Loading configuration...").start();
23
+ let config;
24
+ try {
25
+ config = loadConfig(cwd);
26
+ spinner.succeed(`Provider: ${config.provider}`);
27
+ }
28
+ catch (error) {
29
+ spinner.fail(error instanceof Error ? error.message : "Unknown error");
30
+ process.exit(1);
31
+ }
32
+ // Create client
33
+ const client = createBokioClient(config);
34
+ // Validate connection
35
+ const validateSpinner = ora("Connecting to Bokio...").start();
36
+ try {
37
+ const companyInfo = await client.getCompanyInformation();
38
+ validateSpinner.succeed(`Connected to: ${companyInfo.name}`);
39
+ }
40
+ catch (error) {
41
+ validateSpinner.fail("Failed to connect to Bokio");
42
+ console.error(error instanceof Error ? error.message : "Unknown error");
43
+ process.exit(1);
44
+ }
45
+ // Fetch uploads
46
+ const fetchSpinner = ora("Fetching inbox...").start();
47
+ const allUploads = [];
48
+ let page = 1;
49
+ const pageSize = 100;
50
+ while (true) {
51
+ const response = await client.getUploads({ page, pageSize });
52
+ allUploads.push(...response.data);
53
+ if (!response.pagination.hasNextPage)
54
+ break;
55
+ page++;
56
+ }
57
+ // Filter to unprocessed items (not linked to journal entry)
58
+ const unprocessedUploads = allUploads.filter((upload) => !upload.journalEntryId);
59
+ fetchSpinner.succeed(`Found ${unprocessedUploads.length} unprocessed inbox items (${allUploads.length} total)`);
60
+ if (unprocessedUploads.length === 0) {
61
+ console.log("\n No new inbox items to download.\n");
62
+ return;
63
+ }
64
+ // Ensure inbox directory exists
65
+ const inboxDir = path.join(cwd, "inbox");
66
+ await fs.mkdir(inboxDir, { recursive: true });
67
+ // Download each unprocessed upload
68
+ let newCount = 0;
69
+ let existingCount = 0;
70
+ const today = new Date().toISOString().slice(0, 10);
71
+ // Track used directory names to handle duplicates
72
+ const usedDirNames = new Set();
73
+ // First, scan existing directories to avoid duplicates
74
+ try {
75
+ const existingDirs = await fs.readdir(inboxDir);
76
+ for (const dir of existingDirs) {
77
+ usedDirNames.add(dir);
78
+ }
79
+ }
80
+ catch {
81
+ // Inbox directory doesn't exist yet
82
+ }
83
+ for (const upload of unprocessedUploads) {
84
+ // Generate directory name: {date}-{description-slugified}
85
+ const slug = upload.description
86
+ ? slugify(upload.description)
87
+ : upload.id.slice(0, 8);
88
+ let dirName = `${today}-${slug}`;
89
+ // Check if this upload was already downloaded (by sourceId in existing document.yaml files)
90
+ const alreadyDownloaded = await isAlreadyDownloaded(inboxDir, upload.id);
91
+ if (alreadyDownloaded) {
92
+ existingCount++;
93
+ continue;
94
+ }
95
+ // Handle duplicate directory names by appending short ID
96
+ if (usedDirNames.has(dirName)) {
97
+ dirName = `${dirName}-${upload.id.slice(0, 8)}`;
98
+ }
99
+ usedDirNames.add(dirName);
100
+ const uploadDir = path.join(inboxDir, dirName);
101
+ process.stdout.write(`\r Downloading: ${newCount + existingCount + 1}/${unprocessedUploads.length}`);
102
+ try {
103
+ // Create upload directory
104
+ await fs.mkdir(uploadDir, { recursive: true });
105
+ // Download file
106
+ const result = await client.downloadFile(upload.id);
107
+ // Determine file extension from content type
108
+ const ext = getExtensionFromContentType(upload.contentType || result.contentType);
109
+ // Generate filename: {description-slugified}.ext or document.ext
110
+ const filename = upload.description
111
+ ? `${slugify(upload.description)}${ext}`
112
+ : `document${ext}`;
113
+ // Save file
114
+ const filePath = path.join(uploadDir, filename);
115
+ const buffer = Buffer.from(result.data);
116
+ await fs.writeFile(filePath, buffer);
117
+ // Write document.yaml (ExportedInboxDocument format)
118
+ const document = {
119
+ kind: "OTHER",
120
+ status: "DRAFT",
121
+ fileName: filename,
122
+ mimeType: upload.contentType,
123
+ description: upload.description ?? undefined,
124
+ sourceIntegration: "bokio",
125
+ sourceId: upload.id,
126
+ uploadedAt: new Date().toISOString(),
127
+ };
128
+ const documentPath = path.join(uploadDir, "document.yaml");
129
+ await fs.writeFile(documentPath, toYaml(document));
130
+ newCount++;
131
+ }
132
+ catch (error) {
133
+ console.error(`\n Failed to download ${upload.id}: ${error instanceof Error ? error.message : "Unknown error"}`);
134
+ // Clean up failed directory
135
+ try {
136
+ await fs.rm(uploadDir, { recursive: true });
137
+ }
138
+ catch {
139
+ // Ignore cleanup errors
140
+ }
141
+ }
142
+ }
143
+ console.log(`\n
144
+ Sync complete!
145
+ - ${newCount} new inbox items downloaded
146
+ - ${existingCount} already existed
147
+ `);
148
+ }
149
+ function getExtensionFromContentType(contentType) {
150
+ const map = {
151
+ "application/pdf": ".pdf",
152
+ "image/jpeg": ".jpg",
153
+ "image/png": ".png",
154
+ "image/gif": ".gif",
155
+ "image/webp": ".webp",
156
+ "image/tiff": ".tiff",
157
+ "application/octet-stream": ".bin",
158
+ };
159
+ return map[contentType] || ".bin";
160
+ }
161
+ /**
162
+ * Check if an upload was already downloaded by scanning existing document.yaml files
163
+ */
164
+ async function isAlreadyDownloaded(inboxDir, sourceId) {
165
+ try {
166
+ const dirs = await fs.readdir(inboxDir, { withFileTypes: true });
167
+ for (const dir of dirs) {
168
+ if (!dir.isDirectory())
169
+ continue;
170
+ const documentPath = path.join(inboxDir, dir.name, "document.yaml");
171
+ try {
172
+ const content = await fs.readFile(documentPath, "utf-8");
173
+ if (content.includes(`sourceId: ${sourceId}`)) {
174
+ return true;
175
+ }
176
+ }
177
+ catch {
178
+ // document.yaml doesn't exist or can't be read, skip
179
+ }
180
+ }
181
+ }
182
+ catch {
183
+ // Inbox directory doesn't exist
184
+ }
185
+ return false;
186
+ }