@fuzzle/opencode-accountant 0.1.0 → 0.1.1
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/README.md +1 -1
- package/agent/accountant.md +6 -6
- package/dist/index.js +1585 -82
- package/docs/tools/{update-prices.md → fetch-currency-prices.md} +10 -10
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1342,28 +1342,28 @@ var require_papaparse = __commonJS((exports, module) => {
|
|
|
1342
1342
|
|
|
1343
1343
|
// node_modules/convert-csv-to-json/src/util/fileUtils.js
|
|
1344
1344
|
var require_fileUtils = __commonJS((exports, module) => {
|
|
1345
|
-
var
|
|
1345
|
+
var fs8 = __require("fs");
|
|
1346
1346
|
|
|
1347
1347
|
class FileUtils {
|
|
1348
1348
|
readFile(fileInputName, encoding) {
|
|
1349
|
-
return
|
|
1349
|
+
return fs8.readFileSync(fileInputName, encoding).toString();
|
|
1350
1350
|
}
|
|
1351
1351
|
readFileAsync(fileInputName, encoding = "utf8") {
|
|
1352
|
-
if (
|
|
1353
|
-
return
|
|
1352
|
+
if (fs8.promises && typeof fs8.promises.readFile === "function") {
|
|
1353
|
+
return fs8.promises.readFile(fileInputName, encoding).then((buf) => buf.toString());
|
|
1354
1354
|
}
|
|
1355
|
-
return new Promise((
|
|
1356
|
-
|
|
1355
|
+
return new Promise((resolve2, reject) => {
|
|
1356
|
+
fs8.readFile(fileInputName, encoding, (err, data) => {
|
|
1357
1357
|
if (err) {
|
|
1358
1358
|
reject(err);
|
|
1359
1359
|
return;
|
|
1360
1360
|
}
|
|
1361
|
-
|
|
1361
|
+
resolve2(data.toString());
|
|
1362
1362
|
});
|
|
1363
1363
|
});
|
|
1364
1364
|
}
|
|
1365
1365
|
writeFile(json3, fileOutputName) {
|
|
1366
|
-
|
|
1366
|
+
fs8.writeFile(fileOutputName, json3, function(err) {
|
|
1367
1367
|
if (err) {
|
|
1368
1368
|
throw err;
|
|
1369
1369
|
} else {
|
|
@@ -1372,14 +1372,14 @@ var require_fileUtils = __commonJS((exports, module) => {
|
|
|
1372
1372
|
});
|
|
1373
1373
|
}
|
|
1374
1374
|
writeFileAsync(json3, fileOutputName) {
|
|
1375
|
-
if (
|
|
1376
|
-
return
|
|
1375
|
+
if (fs8.promises && typeof fs8.promises.writeFile === "function") {
|
|
1376
|
+
return fs8.promises.writeFile(fileOutputName, json3);
|
|
1377
1377
|
}
|
|
1378
|
-
return new Promise((
|
|
1379
|
-
|
|
1378
|
+
return new Promise((resolve2, reject) => {
|
|
1379
|
+
fs8.writeFile(fileOutputName, json3, (err) => {
|
|
1380
1380
|
if (err)
|
|
1381
1381
|
return reject(err);
|
|
1382
|
-
|
|
1382
|
+
resolve2();
|
|
1383
1383
|
});
|
|
1384
1384
|
});
|
|
1385
1385
|
}
|
|
@@ -1800,7 +1800,7 @@ var require_browserApi = __commonJS((exports, module) => {
|
|
|
1800
1800
|
if (!file2) {
|
|
1801
1801
|
return Promise.reject(new Error("file is not defined!!!"));
|
|
1802
1802
|
}
|
|
1803
|
-
return new Promise((
|
|
1803
|
+
return new Promise((resolve2, reject) => {
|
|
1804
1804
|
if (typeof FileReader === "undefined") {
|
|
1805
1805
|
reject(new Error("FileReader is not available in this environment"));
|
|
1806
1806
|
return;
|
|
@@ -1811,7 +1811,7 @@ var require_browserApi = __commonJS((exports, module) => {
|
|
|
1811
1811
|
try {
|
|
1812
1812
|
const text = reader.result;
|
|
1813
1813
|
const result = this.csvToJson.csvToJson(String(text));
|
|
1814
|
-
|
|
1814
|
+
resolve2(result);
|
|
1815
1815
|
} catch (err) {
|
|
1816
1816
|
reject(err);
|
|
1817
1817
|
}
|
|
@@ -1941,7 +1941,7 @@ var require_convert_csv_to_json = __commonJS((exports) => {
|
|
|
1941
1941
|
});
|
|
1942
1942
|
|
|
1943
1943
|
// src/index.ts
|
|
1944
|
-
import { dirname, join as
|
|
1944
|
+
import { dirname as dirname5, join as join10 } from "path";
|
|
1945
1945
|
import { fileURLToPath } from "url";
|
|
1946
1946
|
|
|
1947
1947
|
// src/utils/agentLoader.ts
|
|
@@ -16976,10 +16976,9 @@ function tool(input) {
|
|
|
16976
16976
|
return input;
|
|
16977
16977
|
}
|
|
16978
16978
|
tool.schema = exports_external;
|
|
16979
|
-
// src/tools/
|
|
16979
|
+
// src/tools/fetch-currency-prices.ts
|
|
16980
16980
|
var {$ } = globalThis.Bun;
|
|
16981
|
-
import * as
|
|
16982
|
-
import * as fs2 from "fs";
|
|
16981
|
+
import * as path3 from "path";
|
|
16983
16982
|
|
|
16984
16983
|
// src/utils/agentRestriction.ts
|
|
16985
16984
|
function checkAccountantAgent(agent, toolPrompt, additionalFields) {
|
|
@@ -17056,15 +17055,141 @@ function loadPricesConfig(directory) {
|
|
|
17056
17055
|
return { currencies };
|
|
17057
17056
|
}
|
|
17058
17057
|
|
|
17059
|
-
// src/
|
|
17060
|
-
|
|
17061
|
-
|
|
17062
|
-
|
|
17058
|
+
// src/utils/journalUtils.ts
|
|
17059
|
+
import * as fs2 from "fs";
|
|
17060
|
+
import * as path2 from "path";
|
|
17061
|
+
function extractDateFromPriceLine(line) {
|
|
17062
|
+
return line.split(" ")[1];
|
|
17063
|
+
}
|
|
17064
|
+
function updatePriceJournal(journalPath, newPriceLines) {
|
|
17065
|
+
let existingLines = [];
|
|
17066
|
+
if (fs2.existsSync(journalPath)) {
|
|
17067
|
+
existingLines = fs2.readFileSync(journalPath, "utf-8").split(`
|
|
17068
|
+
`).filter((line) => line.trim() !== "");
|
|
17069
|
+
}
|
|
17070
|
+
const priceMap = new Map;
|
|
17071
|
+
for (const line of existingLines) {
|
|
17072
|
+
const date5 = extractDateFromPriceLine(line);
|
|
17073
|
+
if (date5)
|
|
17074
|
+
priceMap.set(date5, line);
|
|
17075
|
+
}
|
|
17076
|
+
for (const line of newPriceLines) {
|
|
17077
|
+
const date5 = extractDateFromPriceLine(line);
|
|
17078
|
+
if (date5)
|
|
17079
|
+
priceMap.set(date5, line);
|
|
17080
|
+
}
|
|
17081
|
+
const sortedLines = Array.from(priceMap.entries()).sort((a, b) => a[0].localeCompare(b[0])).map(([, line]) => line);
|
|
17082
|
+
fs2.writeFileSync(journalPath, sortedLines.join(`
|
|
17083
|
+
`) + `
|
|
17084
|
+
`);
|
|
17085
|
+
}
|
|
17086
|
+
function findCsvFiles(directory, provider, currency) {
|
|
17087
|
+
const csvFiles = [];
|
|
17088
|
+
if (!fs2.existsSync(directory)) {
|
|
17089
|
+
return csvFiles;
|
|
17090
|
+
}
|
|
17091
|
+
let searchPath = directory;
|
|
17092
|
+
if (provider) {
|
|
17093
|
+
searchPath = path2.join(searchPath, provider);
|
|
17094
|
+
if (currency) {
|
|
17095
|
+
searchPath = path2.join(searchPath, currency);
|
|
17096
|
+
}
|
|
17097
|
+
}
|
|
17098
|
+
if (!fs2.existsSync(searchPath)) {
|
|
17099
|
+
return csvFiles;
|
|
17100
|
+
}
|
|
17101
|
+
function scanDirectory(dir) {
|
|
17102
|
+
const entries = fs2.readdirSync(dir, { withFileTypes: true });
|
|
17103
|
+
for (const entry of entries) {
|
|
17104
|
+
const fullPath = path2.join(dir, entry.name);
|
|
17105
|
+
if (entry.isDirectory()) {
|
|
17106
|
+
scanDirectory(fullPath);
|
|
17107
|
+
} else if (entry.isFile() && entry.name.endsWith(".csv")) {
|
|
17108
|
+
csvFiles.push(fullPath);
|
|
17109
|
+
}
|
|
17110
|
+
}
|
|
17111
|
+
}
|
|
17112
|
+
scanDirectory(searchPath);
|
|
17113
|
+
return csvFiles.sort();
|
|
17114
|
+
}
|
|
17115
|
+
function ensureYearJournalExists(directory, year) {
|
|
17116
|
+
const ledgerDir = path2.join(directory, "ledger");
|
|
17117
|
+
const yearJournalPath = path2.join(ledgerDir, `${year}.journal`);
|
|
17118
|
+
const mainJournalPath = path2.join(directory, ".hledger.journal");
|
|
17119
|
+
if (!fs2.existsSync(ledgerDir)) {
|
|
17120
|
+
fs2.mkdirSync(ledgerDir, { recursive: true });
|
|
17121
|
+
}
|
|
17122
|
+
if (!fs2.existsSync(yearJournalPath)) {
|
|
17123
|
+
fs2.writeFileSync(yearJournalPath, `; ${year} transactions
|
|
17124
|
+
`);
|
|
17125
|
+
}
|
|
17126
|
+
if (!fs2.existsSync(mainJournalPath)) {
|
|
17127
|
+
throw new Error(`.hledger.journal not found at ${mainJournalPath}. Create it first with appropriate includes.`);
|
|
17128
|
+
}
|
|
17129
|
+
const mainJournalContent = fs2.readFileSync(mainJournalPath, "utf-8");
|
|
17130
|
+
const includeDirective = `include ledger/${year}.journal`;
|
|
17131
|
+
const lines = mainJournalContent.split(`
|
|
17132
|
+
`);
|
|
17133
|
+
const includeExists = lines.some((line) => {
|
|
17134
|
+
const trimmed = line.trim();
|
|
17135
|
+
return trimmed === includeDirective || trimmed.startsWith(includeDirective + " ");
|
|
17136
|
+
});
|
|
17137
|
+
if (!includeExists) {
|
|
17138
|
+
const newContent = mainJournalContent.trimEnd() + `
|
|
17139
|
+
` + includeDirective + `
|
|
17140
|
+
`;
|
|
17141
|
+
fs2.writeFileSync(mainJournalPath, newContent);
|
|
17142
|
+
}
|
|
17143
|
+
return yearJournalPath;
|
|
17144
|
+
}
|
|
17145
|
+
|
|
17146
|
+
// src/utils/dateUtils.ts
|
|
17147
|
+
function formatDateISO(date5) {
|
|
17148
|
+
return date5.toISOString().split("T")[0];
|
|
17063
17149
|
}
|
|
17064
17150
|
function getYesterday() {
|
|
17065
17151
|
const d = new Date;
|
|
17066
17152
|
d.setDate(d.getDate() - 1);
|
|
17067
|
-
return d
|
|
17153
|
+
return formatDateISO(d);
|
|
17154
|
+
}
|
|
17155
|
+
function getNextDay(dateStr) {
|
|
17156
|
+
const date5 = new Date(dateStr);
|
|
17157
|
+
date5.setDate(date5.getDate() + 1);
|
|
17158
|
+
return formatDateISO(date5);
|
|
17159
|
+
}
|
|
17160
|
+
|
|
17161
|
+
// src/tools/fetch-currency-prices.ts
|
|
17162
|
+
async function defaultPriceFetcher(cmdArgs) {
|
|
17163
|
+
const result = await $`pricehist ${cmdArgs}`.quiet();
|
|
17164
|
+
return result.stdout.toString().trim();
|
|
17165
|
+
}
|
|
17166
|
+
function buildPricehistArgs(startDate, endDate, currencyConfig) {
|
|
17167
|
+
const cmdArgs = [
|
|
17168
|
+
"fetch",
|
|
17169
|
+
"-o",
|
|
17170
|
+
"ledger",
|
|
17171
|
+
"-s",
|
|
17172
|
+
startDate,
|
|
17173
|
+
"-e",
|
|
17174
|
+
endDate,
|
|
17175
|
+
currencyConfig.source,
|
|
17176
|
+
currencyConfig.pair
|
|
17177
|
+
];
|
|
17178
|
+
if (currencyConfig.fmt_base) {
|
|
17179
|
+
cmdArgs.push("--fmt-base", currencyConfig.fmt_base);
|
|
17180
|
+
}
|
|
17181
|
+
return cmdArgs;
|
|
17182
|
+
}
|
|
17183
|
+
function buildErrorResult(error45) {
|
|
17184
|
+
return JSON.stringify({ error: error45 });
|
|
17185
|
+
}
|
|
17186
|
+
function buildSuccessResult(results, endDate, backfill) {
|
|
17187
|
+
return JSON.stringify({
|
|
17188
|
+
success: results.every((r) => !("error" in r)),
|
|
17189
|
+
endDate,
|
|
17190
|
+
backfill,
|
|
17191
|
+
results
|
|
17192
|
+
});
|
|
17068
17193
|
}
|
|
17069
17194
|
function parsePriceLine(line) {
|
|
17070
17195
|
const match = line.match(/^P (\d{4}-\d{2}-\d{2})(?: \d{2}:\d{2}:\d{2})? .+$/);
|
|
@@ -17082,30 +17207,8 @@ function filterPriceLinesByDateRange(priceLines, startDate, endDate) {
|
|
|
17082
17207
|
return parsed.date >= startDate && parsed.date <= endDate;
|
|
17083
17208
|
}).sort((a, b) => a.date.localeCompare(b.date)).map((parsed) => parsed.formattedLine);
|
|
17084
17209
|
}
|
|
17085
|
-
function
|
|
17086
|
-
|
|
17087
|
-
if (fs2.existsSync(journalPath)) {
|
|
17088
|
-
existingLines = fs2.readFileSync(journalPath, "utf-8").split(`
|
|
17089
|
-
`).filter((line) => line.trim() !== "");
|
|
17090
|
-
}
|
|
17091
|
-
const priceMap = new Map;
|
|
17092
|
-
for (const line of existingLines) {
|
|
17093
|
-
const date5 = line.split(" ")[1];
|
|
17094
|
-
if (date5)
|
|
17095
|
-
priceMap.set(date5, line);
|
|
17096
|
-
}
|
|
17097
|
-
for (const line of newPriceLines) {
|
|
17098
|
-
const date5 = line.split(" ")[1];
|
|
17099
|
-
if (date5)
|
|
17100
|
-
priceMap.set(date5, line);
|
|
17101
|
-
}
|
|
17102
|
-
const sortedLines = Array.from(priceMap.entries()).sort((a, b) => a[0].localeCompare(b[0])).map(([, line]) => line);
|
|
17103
|
-
fs2.writeFileSync(journalPath, sortedLines.join(`
|
|
17104
|
-
`) + `
|
|
17105
|
-
`);
|
|
17106
|
-
}
|
|
17107
|
-
async function updatePricesCore(directory, agent, backfill, priceFetcher = defaultPriceFetcher, configLoader = loadPricesConfig) {
|
|
17108
|
-
const restrictionError = checkAccountantAgent(agent, "update prices");
|
|
17210
|
+
async function fetchCurrencyPrices(directory, agent, backfill, priceFetcher = defaultPriceFetcher, configLoader = loadPricesConfig) {
|
|
17211
|
+
const restrictionError = checkAccountantAgent(agent, "fetch currency prices");
|
|
17109
17212
|
if (restrictionError) {
|
|
17110
17213
|
return restrictionError;
|
|
17111
17214
|
}
|
|
@@ -17113,9 +17216,8 @@ async function updatePricesCore(directory, agent, backfill, priceFetcher = defau
|
|
|
17113
17216
|
try {
|
|
17114
17217
|
config2 = configLoader(directory);
|
|
17115
17218
|
} catch (err) {
|
|
17116
|
-
|
|
17117
|
-
|
|
17118
|
-
});
|
|
17219
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
17220
|
+
return buildErrorResult(errorMessage);
|
|
17119
17221
|
}
|
|
17120
17222
|
const endDate = getYesterday();
|
|
17121
17223
|
const defaultBackfillDate = getDefaultBackfillDate();
|
|
@@ -17123,20 +17225,7 @@ async function updatePricesCore(directory, agent, backfill, priceFetcher = defau
|
|
|
17123
17225
|
for (const [ticker, currencyConfig] of Object.entries(config2.currencies)) {
|
|
17124
17226
|
try {
|
|
17125
17227
|
const startDate = backfill ? currencyConfig.backfill_date || defaultBackfillDate : endDate;
|
|
17126
|
-
const cmdArgs =
|
|
17127
|
-
"fetch",
|
|
17128
|
-
"-o",
|
|
17129
|
-
"ledger",
|
|
17130
|
-
"-s",
|
|
17131
|
-
startDate,
|
|
17132
|
-
"-e",
|
|
17133
|
-
endDate,
|
|
17134
|
-
currencyConfig.source,
|
|
17135
|
-
currencyConfig.pair
|
|
17136
|
-
];
|
|
17137
|
-
if (currencyConfig.fmt_base) {
|
|
17138
|
-
cmdArgs.push("--fmt-base", currencyConfig.fmt_base);
|
|
17139
|
-
}
|
|
17228
|
+
const cmdArgs = buildPricehistArgs(startDate, endDate, currencyConfig);
|
|
17140
17229
|
const output = await priceFetcher(cmdArgs);
|
|
17141
17230
|
const rawPriceLines = output.split(`
|
|
17142
17231
|
`).filter((line) => line.startsWith("P "));
|
|
@@ -17155,8 +17244,8 @@ async function updatePricesCore(directory, agent, backfill, priceFetcher = defau
|
|
|
17155
17244
|
});
|
|
17156
17245
|
continue;
|
|
17157
17246
|
}
|
|
17158
|
-
const journalPath =
|
|
17159
|
-
|
|
17247
|
+
const journalPath = path3.join(directory, "ledger", "currencies", currencyConfig.file);
|
|
17248
|
+
updatePriceJournal(journalPath, priceLines);
|
|
17160
17249
|
const latestPriceLine = priceLines[priceLines.length - 1];
|
|
17161
17250
|
results.push({
|
|
17162
17251
|
ticker,
|
|
@@ -17170,14 +17259,9 @@ async function updatePricesCore(directory, agent, backfill, priceFetcher = defau
|
|
|
17170
17259
|
});
|
|
17171
17260
|
}
|
|
17172
17261
|
}
|
|
17173
|
-
return
|
|
17174
|
-
success: results.every((r) => !("error" in r)),
|
|
17175
|
-
endDate,
|
|
17176
|
-
backfill: !!backfill,
|
|
17177
|
-
results
|
|
17178
|
-
});
|
|
17262
|
+
return buildSuccessResult(results, endDate, backfill);
|
|
17179
17263
|
}
|
|
17180
|
-
var
|
|
17264
|
+
var fetch_currency_prices_default = tool({
|
|
17181
17265
|
description: "ACCOUNTANT AGENT ONLY: Fetches end-of-day prices for all configured currencies (from config/prices.yaml) and appends them to the corresponding price journals in ledger/currencies/.",
|
|
17182
17266
|
args: {
|
|
17183
17267
|
backfill: tool.schema.boolean().optional().describe("If true, fetch history from each currency's configured backfill_date (or Jan 1 of current year if not specified)")
|
|
@@ -17185,23 +17269,1442 @@ var update_prices_default = tool({
|
|
|
17185
17269
|
async execute(params, context) {
|
|
17186
17270
|
const { directory, agent } = context;
|
|
17187
17271
|
const { backfill } = params;
|
|
17188
|
-
return
|
|
17272
|
+
return fetchCurrencyPrices(directory, agent, backfill || false);
|
|
17189
17273
|
}
|
|
17190
17274
|
});
|
|
17275
|
+
// src/tools/classify-statements.ts
|
|
17276
|
+
import * as fs5 from "fs";
|
|
17277
|
+
import * as path6 from "path";
|
|
17278
|
+
|
|
17279
|
+
// src/utils/importConfig.ts
|
|
17280
|
+
import * as fs3 from "fs";
|
|
17281
|
+
import * as path4 from "path";
|
|
17282
|
+
var CONFIG_FILE2 = "config/import/providers.yaml";
|
|
17283
|
+
var REQUIRED_PATH_FIELDS = [
|
|
17284
|
+
"import",
|
|
17285
|
+
"pending",
|
|
17286
|
+
"done",
|
|
17287
|
+
"unrecognized",
|
|
17288
|
+
"rules"
|
|
17289
|
+
];
|
|
17290
|
+
var REQUIRED_DETECTION_FIELDS = ["header", "currencyField"];
|
|
17291
|
+
function validatePaths(paths) {
|
|
17292
|
+
if (typeof paths !== "object" || paths === null) {
|
|
17293
|
+
throw new Error("Invalid config: 'paths' must be an object");
|
|
17294
|
+
}
|
|
17295
|
+
const pathsObj = paths;
|
|
17296
|
+
for (const field of REQUIRED_PATH_FIELDS) {
|
|
17297
|
+
if (typeof pathsObj[field] !== "string" || pathsObj[field] === "") {
|
|
17298
|
+
throw new Error(`Invalid config: 'paths.${field}' is required`);
|
|
17299
|
+
}
|
|
17300
|
+
}
|
|
17301
|
+
return {
|
|
17302
|
+
import: pathsObj.import,
|
|
17303
|
+
pending: pathsObj.pending,
|
|
17304
|
+
done: pathsObj.done,
|
|
17305
|
+
unrecognized: pathsObj.unrecognized,
|
|
17306
|
+
rules: pathsObj.rules
|
|
17307
|
+
};
|
|
17308
|
+
}
|
|
17309
|
+
function validateDetectionRule(providerName, index, rule) {
|
|
17310
|
+
if (typeof rule !== "object" || rule === null) {
|
|
17311
|
+
throw new Error(`Invalid config: provider '${providerName}' detect[${index}] must be an object`);
|
|
17312
|
+
}
|
|
17313
|
+
const ruleObj = rule;
|
|
17314
|
+
for (const field of REQUIRED_DETECTION_FIELDS) {
|
|
17315
|
+
if (typeof ruleObj[field] !== "string" || ruleObj[field] === "") {
|
|
17316
|
+
throw new Error(`Invalid config: provider '${providerName}' detect[${index}].${field} is required`);
|
|
17317
|
+
}
|
|
17318
|
+
}
|
|
17319
|
+
if (ruleObj.filenamePattern !== undefined) {
|
|
17320
|
+
if (typeof ruleObj.filenamePattern !== "string") {
|
|
17321
|
+
throw new Error(`Invalid config: provider '${providerName}' detect[${index}].filenamePattern must be a string`);
|
|
17322
|
+
}
|
|
17323
|
+
try {
|
|
17324
|
+
new RegExp(ruleObj.filenamePattern);
|
|
17325
|
+
} catch {
|
|
17326
|
+
throw new Error(`Invalid config: provider '${providerName}' detect[${index}].filenamePattern is not a valid regex`);
|
|
17327
|
+
}
|
|
17328
|
+
}
|
|
17329
|
+
if (ruleObj.skipRows !== undefined) {
|
|
17330
|
+
if (typeof ruleObj.skipRows !== "number" || ruleObj.skipRows < 0) {
|
|
17331
|
+
throw new Error(`Invalid config: provider '${providerName}' detect[${index}].skipRows must be a non-negative number`);
|
|
17332
|
+
}
|
|
17333
|
+
}
|
|
17334
|
+
if (ruleObj.delimiter !== undefined) {
|
|
17335
|
+
if (typeof ruleObj.delimiter !== "string" || ruleObj.delimiter.length !== 1) {
|
|
17336
|
+
throw new Error(`Invalid config: provider '${providerName}' detect[${index}].delimiter must be a single character`);
|
|
17337
|
+
}
|
|
17338
|
+
}
|
|
17339
|
+
if (ruleObj.renamePattern !== undefined) {
|
|
17340
|
+
if (typeof ruleObj.renamePattern !== "string") {
|
|
17341
|
+
throw new Error(`Invalid config: provider '${providerName}' detect[${index}].renamePattern must be a string`);
|
|
17342
|
+
}
|
|
17343
|
+
}
|
|
17344
|
+
if (ruleObj.metadata !== undefined) {
|
|
17345
|
+
if (!Array.isArray(ruleObj.metadata)) {
|
|
17346
|
+
throw new Error(`Invalid config: provider '${providerName}' detect[${index}].metadata must be an array`);
|
|
17347
|
+
}
|
|
17348
|
+
for (let i2 = 0;i2 < ruleObj.metadata.length; i2++) {
|
|
17349
|
+
const meta = ruleObj.metadata[i2];
|
|
17350
|
+
if (typeof meta.field !== "string" || meta.field === "") {
|
|
17351
|
+
throw new Error(`Invalid config: provider '${providerName}' detect[${index}].metadata[${i2}].field is required`);
|
|
17352
|
+
}
|
|
17353
|
+
if (typeof meta.row !== "number" || meta.row < 0) {
|
|
17354
|
+
throw new Error(`Invalid config: provider '${providerName}' detect[${index}].metadata[${i2}].row must be a non-negative number`);
|
|
17355
|
+
}
|
|
17356
|
+
if (typeof meta.column !== "number" || meta.column < 0) {
|
|
17357
|
+
throw new Error(`Invalid config: provider '${providerName}' detect[${index}].metadata[${i2}].column must be a non-negative number`);
|
|
17358
|
+
}
|
|
17359
|
+
if (meta.normalize !== undefined && meta.normalize !== "spaces-to-dashes") {
|
|
17360
|
+
throw new Error(`Invalid config: provider '${providerName}' detect[${index}].metadata[${i2}].normalize must be 'spaces-to-dashes'`);
|
|
17361
|
+
}
|
|
17362
|
+
}
|
|
17363
|
+
}
|
|
17364
|
+
return {
|
|
17365
|
+
filenamePattern: ruleObj.filenamePattern,
|
|
17366
|
+
header: ruleObj.header,
|
|
17367
|
+
currencyField: ruleObj.currencyField,
|
|
17368
|
+
skipRows: ruleObj.skipRows,
|
|
17369
|
+
delimiter: ruleObj.delimiter,
|
|
17370
|
+
renamePattern: ruleObj.renamePattern,
|
|
17371
|
+
metadata: ruleObj.metadata
|
|
17372
|
+
};
|
|
17373
|
+
}
|
|
17374
|
+
function validateProviderConfig(name, config2) {
|
|
17375
|
+
if (typeof config2 !== "object" || config2 === null) {
|
|
17376
|
+
throw new Error(`Invalid config for provider '${name}': expected an object`);
|
|
17377
|
+
}
|
|
17378
|
+
const configObj = config2;
|
|
17379
|
+
if (!Array.isArray(configObj.detect) || configObj.detect.length === 0) {
|
|
17380
|
+
throw new Error(`Invalid config for provider '${name}': 'detect' must be a non-empty array`);
|
|
17381
|
+
}
|
|
17382
|
+
const detect = [];
|
|
17383
|
+
for (let i2 = 0;i2 < configObj.detect.length; i2++) {
|
|
17384
|
+
detect.push(validateDetectionRule(name, i2, configObj.detect[i2]));
|
|
17385
|
+
}
|
|
17386
|
+
if (typeof configObj.currencies !== "object" || configObj.currencies === null) {
|
|
17387
|
+
throw new Error(`Invalid config for provider '${name}': 'currencies' must be an object`);
|
|
17388
|
+
}
|
|
17389
|
+
const currenciesObj = configObj.currencies;
|
|
17390
|
+
const currencies = {};
|
|
17391
|
+
for (const [key, value] of Object.entries(currenciesObj)) {
|
|
17392
|
+
if (typeof value !== "string") {
|
|
17393
|
+
throw new Error(`Invalid config for provider '${name}': currencies.${key} must be a string`);
|
|
17394
|
+
}
|
|
17395
|
+
currencies[key] = value;
|
|
17396
|
+
}
|
|
17397
|
+
if (Object.keys(currencies).length === 0) {
|
|
17398
|
+
throw new Error(`Invalid config for provider '${name}': 'currencies' must contain at least one mapping`);
|
|
17399
|
+
}
|
|
17400
|
+
return { detect, currencies };
|
|
17401
|
+
}
|
|
17402
|
+
function loadImportConfig(directory) {
|
|
17403
|
+
const configPath = path4.join(directory, CONFIG_FILE2);
|
|
17404
|
+
if (!fs3.existsSync(configPath)) {
|
|
17405
|
+
throw new Error(`Configuration file not found: ${CONFIG_FILE2}. Please create this file to configure statement imports.`);
|
|
17406
|
+
}
|
|
17407
|
+
let parsed;
|
|
17408
|
+
try {
|
|
17409
|
+
const content = fs3.readFileSync(configPath, "utf-8");
|
|
17410
|
+
parsed = jsYaml.load(content);
|
|
17411
|
+
} catch (err) {
|
|
17412
|
+
if (err instanceof jsYaml.YAMLException) {
|
|
17413
|
+
throw new Error(`Failed to parse ${CONFIG_FILE2}: ${err.message}`);
|
|
17414
|
+
}
|
|
17415
|
+
throw err;
|
|
17416
|
+
}
|
|
17417
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
17418
|
+
throw new Error(`Invalid config: ${CONFIG_FILE2} must contain a YAML object`);
|
|
17419
|
+
}
|
|
17420
|
+
const parsedObj = parsed;
|
|
17421
|
+
if (!parsedObj.paths) {
|
|
17422
|
+
throw new Error("Invalid config: 'paths' section is required");
|
|
17423
|
+
}
|
|
17424
|
+
const paths = validatePaths(parsedObj.paths);
|
|
17425
|
+
if (!parsedObj.providers || typeof parsedObj.providers !== "object") {
|
|
17426
|
+
throw new Error("Invalid config: 'providers' section is required");
|
|
17427
|
+
}
|
|
17428
|
+
const providersObj = parsedObj.providers;
|
|
17429
|
+
if (Object.keys(providersObj).length === 0) {
|
|
17430
|
+
throw new Error("Invalid config: 'providers' section must contain at least one provider");
|
|
17431
|
+
}
|
|
17432
|
+
const providers = {};
|
|
17433
|
+
for (const [name, config2] of Object.entries(providersObj)) {
|
|
17434
|
+
providers[name] = validateProviderConfig(name, config2);
|
|
17435
|
+
}
|
|
17436
|
+
return { paths, providers };
|
|
17437
|
+
}
|
|
17438
|
+
|
|
17191
17439
|
// src/utils/providerDetector.ts
|
|
17192
17440
|
var import_papaparse = __toESM(require_papaparse(), 1);
|
|
17193
|
-
|
|
17194
|
-
|
|
17441
|
+
function extractMetadata(content, skipRows, delimiter, metadataConfig) {
|
|
17442
|
+
if (!metadataConfig || metadataConfig.length === 0 || skipRows === 0) {
|
|
17443
|
+
return {};
|
|
17444
|
+
}
|
|
17445
|
+
const lines = content.split(`
|
|
17446
|
+
`).slice(0, skipRows);
|
|
17447
|
+
const metadata = {};
|
|
17448
|
+
for (const config2 of metadataConfig) {
|
|
17449
|
+
if (config2.row >= lines.length)
|
|
17450
|
+
continue;
|
|
17451
|
+
const columns = lines[config2.row].split(delimiter);
|
|
17452
|
+
if (config2.column >= columns.length)
|
|
17453
|
+
continue;
|
|
17454
|
+
let value = columns[config2.column].trim();
|
|
17455
|
+
if (config2.normalize === "spaces-to-dashes") {
|
|
17456
|
+
value = value.replace(/\s+/g, "-");
|
|
17457
|
+
}
|
|
17458
|
+
metadata[config2.field] = value;
|
|
17459
|
+
}
|
|
17460
|
+
return metadata;
|
|
17461
|
+
}
|
|
17462
|
+
function generateOutputFilename(renamePattern, metadata) {
|
|
17463
|
+
if (!renamePattern) {
|
|
17464
|
+
return;
|
|
17465
|
+
}
|
|
17466
|
+
let filename = renamePattern;
|
|
17467
|
+
for (const [key, value] of Object.entries(metadata)) {
|
|
17468
|
+
filename = filename.replace(`{${key}}`, value);
|
|
17469
|
+
}
|
|
17470
|
+
return filename;
|
|
17471
|
+
}
|
|
17472
|
+
function parseCSVPreview(content, skipRows = 0, delimiter = ",") {
|
|
17473
|
+
let csvContent = content;
|
|
17474
|
+
if (skipRows > 0) {
|
|
17475
|
+
const lines = content.split(`
|
|
17476
|
+
`);
|
|
17477
|
+
csvContent = lines.slice(skipRows).join(`
|
|
17478
|
+
`);
|
|
17479
|
+
}
|
|
17480
|
+
const result = import_papaparse.default.parse(csvContent, {
|
|
17481
|
+
header: true,
|
|
17482
|
+
preview: 1,
|
|
17483
|
+
skipEmptyLines: true,
|
|
17484
|
+
delimiter
|
|
17485
|
+
});
|
|
17486
|
+
return {
|
|
17487
|
+
fields: result.meta.fields,
|
|
17488
|
+
firstRow: result.data[0]
|
|
17489
|
+
};
|
|
17490
|
+
}
|
|
17491
|
+
function normalizeHeader(fields) {
|
|
17492
|
+
return fields.map((f) => f.trim()).join(",");
|
|
17493
|
+
}
|
|
17494
|
+
function detectProvider(filename, content, config2) {
|
|
17495
|
+
for (const [providerName, providerConfig] of Object.entries(config2.providers)) {
|
|
17496
|
+
for (const rule of providerConfig.detect) {
|
|
17497
|
+
if (rule.filenamePattern !== undefined) {
|
|
17498
|
+
const filenameRegex = new RegExp(rule.filenamePattern);
|
|
17499
|
+
if (!filenameRegex.test(filename)) {
|
|
17500
|
+
continue;
|
|
17501
|
+
}
|
|
17502
|
+
}
|
|
17503
|
+
const skipRows = rule.skipRows ?? 0;
|
|
17504
|
+
const delimiter = rule.delimiter ?? ",";
|
|
17505
|
+
const { fields, firstRow } = parseCSVPreview(content, skipRows, delimiter);
|
|
17506
|
+
if (!fields || fields.length === 0) {
|
|
17507
|
+
continue;
|
|
17508
|
+
}
|
|
17509
|
+
const actualHeader = normalizeHeader(fields);
|
|
17510
|
+
if (actualHeader !== rule.header) {
|
|
17511
|
+
continue;
|
|
17512
|
+
}
|
|
17513
|
+
if (!firstRow) {
|
|
17514
|
+
continue;
|
|
17515
|
+
}
|
|
17516
|
+
const rawCurrency = firstRow[rule.currencyField];
|
|
17517
|
+
if (!rawCurrency) {
|
|
17518
|
+
continue;
|
|
17519
|
+
}
|
|
17520
|
+
const metadata = extractMetadata(content, skipRows, delimiter, rule.metadata);
|
|
17521
|
+
const outputFilename = generateOutputFilename(rule.renamePattern, metadata);
|
|
17522
|
+
const normalizedCurrency = providerConfig.currencies[rawCurrency];
|
|
17523
|
+
if (!normalizedCurrency) {
|
|
17524
|
+
return {
|
|
17525
|
+
provider: providerName,
|
|
17526
|
+
currency: rawCurrency.toLowerCase(),
|
|
17527
|
+
rule,
|
|
17528
|
+
outputFilename,
|
|
17529
|
+
metadata: Object.keys(metadata).length > 0 ? metadata : undefined
|
|
17530
|
+
};
|
|
17531
|
+
}
|
|
17532
|
+
return {
|
|
17533
|
+
provider: providerName,
|
|
17534
|
+
currency: normalizedCurrency,
|
|
17535
|
+
rule,
|
|
17536
|
+
outputFilename,
|
|
17537
|
+
metadata: Object.keys(metadata).length > 0 ? metadata : undefined
|
|
17538
|
+
};
|
|
17539
|
+
}
|
|
17540
|
+
}
|
|
17541
|
+
return null;
|
|
17542
|
+
}
|
|
17543
|
+
|
|
17544
|
+
// src/utils/worktreeManager.ts
|
|
17545
|
+
import { spawnSync } from "child_process";
|
|
17546
|
+
function execGit(args, cwd) {
|
|
17547
|
+
const result = spawnSync("git", args, { cwd, encoding: "utf-8" });
|
|
17548
|
+
if (result.status !== 0) {
|
|
17549
|
+
throw new Error(result.stderr || result.stdout || `git ${args[0]} failed`);
|
|
17550
|
+
}
|
|
17551
|
+
return (result.stdout || "").trim();
|
|
17552
|
+
}
|
|
17553
|
+
function isInWorktree(directory) {
|
|
17554
|
+
try {
|
|
17555
|
+
const gitDir = execGit(["rev-parse", "--git-dir"], directory);
|
|
17556
|
+
return gitDir.includes(".git/worktrees/");
|
|
17557
|
+
} catch {
|
|
17558
|
+
return false;
|
|
17559
|
+
}
|
|
17560
|
+
}
|
|
17561
|
+
|
|
17562
|
+
// src/utils/fileUtils.ts
|
|
17563
|
+
import * as fs4 from "fs";
|
|
17564
|
+
import * as path5 from "path";
|
|
17565
|
+
function findCSVFiles(importsDir) {
|
|
17566
|
+
if (!fs4.existsSync(importsDir)) {
|
|
17567
|
+
return [];
|
|
17568
|
+
}
|
|
17569
|
+
return fs4.readdirSync(importsDir).filter((file2) => file2.toLowerCase().endsWith(".csv")).filter((file2) => {
|
|
17570
|
+
const fullPath = path5.join(importsDir, file2);
|
|
17571
|
+
return fs4.statSync(fullPath).isFile();
|
|
17572
|
+
});
|
|
17573
|
+
}
|
|
17574
|
+
function ensureDirectory(dirPath) {
|
|
17575
|
+
if (!fs4.existsSync(dirPath)) {
|
|
17576
|
+
fs4.mkdirSync(dirPath, { recursive: true });
|
|
17577
|
+
}
|
|
17578
|
+
}
|
|
17579
|
+
|
|
17580
|
+
// src/tools/classify-statements.ts
|
|
17581
|
+
function buildSuccessResult2(classified, unrecognized, message) {
|
|
17582
|
+
return JSON.stringify({
|
|
17583
|
+
success: true,
|
|
17584
|
+
classified,
|
|
17585
|
+
unrecognized,
|
|
17586
|
+
message,
|
|
17587
|
+
summary: {
|
|
17588
|
+
total: classified.length + unrecognized.length,
|
|
17589
|
+
classified: classified.length,
|
|
17590
|
+
unrecognized: unrecognized.length
|
|
17591
|
+
}
|
|
17592
|
+
});
|
|
17593
|
+
}
|
|
17594
|
+
function buildErrorResult2(error45, hint) {
|
|
17595
|
+
return JSON.stringify({
|
|
17596
|
+
success: false,
|
|
17597
|
+
error: error45,
|
|
17598
|
+
hint,
|
|
17599
|
+
classified: [],
|
|
17600
|
+
unrecognized: []
|
|
17601
|
+
});
|
|
17602
|
+
}
|
|
17603
|
+
function buildCollisionError(collisions) {
|
|
17604
|
+
const error45 = `Cannot classify: ${collisions.length} file(s) would overwrite existing pending files.`;
|
|
17605
|
+
return JSON.stringify({
|
|
17606
|
+
success: false,
|
|
17607
|
+
error: error45,
|
|
17608
|
+
collisions,
|
|
17609
|
+
classified: [],
|
|
17610
|
+
unrecognized: []
|
|
17611
|
+
});
|
|
17612
|
+
}
|
|
17613
|
+
function planMoves(csvFiles, importsDir, pendingDir, unrecognizedDir, config2) {
|
|
17614
|
+
const plannedMoves = [];
|
|
17615
|
+
const collisions = [];
|
|
17616
|
+
for (const filename of csvFiles) {
|
|
17617
|
+
const sourcePath = path6.join(importsDir, filename);
|
|
17618
|
+
const content = fs5.readFileSync(sourcePath, "utf-8");
|
|
17619
|
+
const detection = detectProvider(filename, content, config2);
|
|
17620
|
+
let targetPath;
|
|
17621
|
+
let targetFilename;
|
|
17622
|
+
if (detection) {
|
|
17623
|
+
targetFilename = detection.outputFilename || filename;
|
|
17624
|
+
const targetDir = path6.join(pendingDir, detection.provider, detection.currency);
|
|
17625
|
+
targetPath = path6.join(targetDir, targetFilename);
|
|
17626
|
+
} else {
|
|
17627
|
+
targetFilename = filename;
|
|
17628
|
+
targetPath = path6.join(unrecognizedDir, filename);
|
|
17629
|
+
}
|
|
17630
|
+
if (fs5.existsSync(targetPath)) {
|
|
17631
|
+
collisions.push({
|
|
17632
|
+
filename,
|
|
17633
|
+
existingPath: targetPath
|
|
17634
|
+
});
|
|
17635
|
+
}
|
|
17636
|
+
plannedMoves.push({
|
|
17637
|
+
filename,
|
|
17638
|
+
sourcePath,
|
|
17639
|
+
targetPath,
|
|
17640
|
+
targetFilename,
|
|
17641
|
+
detection
|
|
17642
|
+
});
|
|
17643
|
+
}
|
|
17644
|
+
return { plannedMoves, collisions };
|
|
17645
|
+
}
|
|
17646
|
+
function executeMoves(plannedMoves, config2, unrecognizedDir) {
|
|
17647
|
+
const classified = [];
|
|
17648
|
+
const unrecognized = [];
|
|
17649
|
+
for (const move of plannedMoves) {
|
|
17650
|
+
if (move.detection) {
|
|
17651
|
+
const targetDir = path6.dirname(move.targetPath);
|
|
17652
|
+
ensureDirectory(targetDir);
|
|
17653
|
+
fs5.renameSync(move.sourcePath, move.targetPath);
|
|
17654
|
+
classified.push({
|
|
17655
|
+
filename: move.targetFilename,
|
|
17656
|
+
originalFilename: move.detection.outputFilename ? move.filename : undefined,
|
|
17657
|
+
provider: move.detection.provider,
|
|
17658
|
+
currency: move.detection.currency,
|
|
17659
|
+
targetPath: path6.join(config2.paths.pending, move.detection.provider, move.detection.currency, move.targetFilename)
|
|
17660
|
+
});
|
|
17661
|
+
} else {
|
|
17662
|
+
ensureDirectory(unrecognizedDir);
|
|
17663
|
+
fs5.renameSync(move.sourcePath, move.targetPath);
|
|
17664
|
+
unrecognized.push({
|
|
17665
|
+
filename: move.filename,
|
|
17666
|
+
targetPath: path6.join(config2.paths.unrecognized, move.filename)
|
|
17667
|
+
});
|
|
17668
|
+
}
|
|
17669
|
+
}
|
|
17670
|
+
return { classified, unrecognized };
|
|
17671
|
+
}
|
|
17672
|
+
async function classifyStatements(directory, agent, configLoader = loadImportConfig, worktreeChecker = isInWorktree) {
|
|
17673
|
+
const restrictionError = checkAccountantAgent(agent, "classify statements", {
|
|
17674
|
+
classified: [],
|
|
17675
|
+
unrecognized: []
|
|
17676
|
+
});
|
|
17677
|
+
if (restrictionError) {
|
|
17678
|
+
return restrictionError;
|
|
17679
|
+
}
|
|
17680
|
+
if (!worktreeChecker(directory)) {
|
|
17681
|
+
return buildErrorResult2("classify-statements must be run inside an import worktree", "Use import-pipeline tool to orchestrate the full workflow");
|
|
17682
|
+
}
|
|
17683
|
+
let config2;
|
|
17684
|
+
try {
|
|
17685
|
+
config2 = configLoader(directory);
|
|
17686
|
+
} catch (err) {
|
|
17687
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
17688
|
+
return buildErrorResult2(errorMessage);
|
|
17689
|
+
}
|
|
17690
|
+
const importsDir = path6.join(directory, config2.paths.import);
|
|
17691
|
+
const pendingDir = path6.join(directory, config2.paths.pending);
|
|
17692
|
+
const unrecognizedDir = path6.join(directory, config2.paths.unrecognized);
|
|
17693
|
+
const csvFiles = findCSVFiles(importsDir);
|
|
17694
|
+
if (csvFiles.length === 0) {
|
|
17695
|
+
return buildSuccessResult2([], [], `No CSV files found in ${config2.paths.import}`);
|
|
17696
|
+
}
|
|
17697
|
+
const { plannedMoves, collisions } = planMoves(csvFiles, importsDir, pendingDir, unrecognizedDir, config2);
|
|
17698
|
+
if (collisions.length > 0) {
|
|
17699
|
+
return buildCollisionError(collisions);
|
|
17700
|
+
}
|
|
17701
|
+
const { classified, unrecognized } = executeMoves(plannedMoves, config2, unrecognizedDir);
|
|
17702
|
+
return buildSuccessResult2(classified, unrecognized);
|
|
17703
|
+
}
|
|
17704
|
+
var classify_statements_default = tool({
|
|
17705
|
+
description: "ACCOUNTANT AGENT ONLY: Classifies bank statement CSV files from the imports directory by detecting their provider and currency, then moves them to the appropriate pending import directories.",
|
|
17706
|
+
args: {},
|
|
17707
|
+
async execute(_params, context) {
|
|
17708
|
+
const { directory, agent } = context;
|
|
17709
|
+
return classifyStatements(directory, agent);
|
|
17710
|
+
}
|
|
17711
|
+
});
|
|
17712
|
+
// src/tools/import-statements.ts
|
|
17713
|
+
import * as fs9 from "fs";
|
|
17714
|
+
import * as path8 from "path";
|
|
17715
|
+
|
|
17716
|
+
// src/utils/rulesMatcher.ts
|
|
17717
|
+
import * as fs6 from "fs";
|
|
17718
|
+
import * as path7 from "path";
|
|
17719
|
+
function parseSourceDirective(content) {
|
|
17720
|
+
const match = content.match(/^source\s+([^\n#]+)/m);
|
|
17721
|
+
if (!match) {
|
|
17722
|
+
return null;
|
|
17723
|
+
}
|
|
17724
|
+
return match[1].trim();
|
|
17725
|
+
}
|
|
17726
|
+
function resolveSourcePath(sourcePath, rulesFilePath) {
|
|
17727
|
+
if (path7.isAbsolute(sourcePath)) {
|
|
17728
|
+
return sourcePath;
|
|
17729
|
+
}
|
|
17730
|
+
const rulesDir = path7.dirname(rulesFilePath);
|
|
17731
|
+
return path7.resolve(rulesDir, sourcePath);
|
|
17732
|
+
}
|
|
17733
|
+
function loadRulesMapping(rulesDir) {
|
|
17734
|
+
const mapping = {};
|
|
17735
|
+
if (!fs6.existsSync(rulesDir)) {
|
|
17736
|
+
return mapping;
|
|
17737
|
+
}
|
|
17738
|
+
const files = fs6.readdirSync(rulesDir);
|
|
17739
|
+
for (const file2 of files) {
|
|
17740
|
+
if (!file2.endsWith(".rules")) {
|
|
17741
|
+
continue;
|
|
17742
|
+
}
|
|
17743
|
+
const rulesFilePath = path7.join(rulesDir, file2);
|
|
17744
|
+
const stat = fs6.statSync(rulesFilePath);
|
|
17745
|
+
if (!stat.isFile()) {
|
|
17746
|
+
continue;
|
|
17747
|
+
}
|
|
17748
|
+
const content = fs6.readFileSync(rulesFilePath, "utf-8");
|
|
17749
|
+
const sourcePath = parseSourceDirective(content);
|
|
17750
|
+
if (!sourcePath) {
|
|
17751
|
+
continue;
|
|
17752
|
+
}
|
|
17753
|
+
const absoluteCsvPath = resolveSourcePath(sourcePath, rulesFilePath);
|
|
17754
|
+
mapping[absoluteCsvPath] = rulesFilePath;
|
|
17755
|
+
}
|
|
17756
|
+
return mapping;
|
|
17757
|
+
}
|
|
17758
|
+
function findRulesForCsv(csvPath, mapping) {
|
|
17759
|
+
if (mapping[csvPath]) {
|
|
17760
|
+
return mapping[csvPath];
|
|
17761
|
+
}
|
|
17762
|
+
const normalizedCsvPath = path7.normalize(csvPath);
|
|
17763
|
+
for (const [mappedCsv, rulesFile] of Object.entries(mapping)) {
|
|
17764
|
+
if (path7.normalize(mappedCsv) === normalizedCsvPath) {
|
|
17765
|
+
return rulesFile;
|
|
17766
|
+
}
|
|
17767
|
+
}
|
|
17768
|
+
return null;
|
|
17769
|
+
}
|
|
17770
|
+
|
|
17771
|
+
// src/utils/hledgerExecutor.ts
|
|
17772
|
+
var {$: $2 } = globalThis.Bun;
|
|
17773
|
+
async function defaultHledgerExecutor(cmdArgs) {
|
|
17774
|
+
try {
|
|
17775
|
+
const result = await $2`hledger ${cmdArgs}`.quiet().nothrow();
|
|
17776
|
+
return {
|
|
17777
|
+
stdout: result.stdout.toString(),
|
|
17778
|
+
stderr: result.stderr.toString(),
|
|
17779
|
+
exitCode: result.exitCode
|
|
17780
|
+
};
|
|
17781
|
+
} catch (error45) {
|
|
17782
|
+
return {
|
|
17783
|
+
stdout: "",
|
|
17784
|
+
stderr: error45 instanceof Error ? error45.message : String(error45),
|
|
17785
|
+
exitCode: 1
|
|
17786
|
+
};
|
|
17787
|
+
}
|
|
17788
|
+
}
|
|
17789
|
+
function parseUnknownPostings(hledgerOutput) {
|
|
17790
|
+
const unknownPostings = [];
|
|
17791
|
+
const lines = hledgerOutput.split(`
|
|
17792
|
+
`);
|
|
17793
|
+
let currentDate = "";
|
|
17794
|
+
let currentDescription = "";
|
|
17795
|
+
for (const line of lines) {
|
|
17796
|
+
const headerMatch = line.match(/^(\d{4}-\d{2}-\d{2})\s+(.+)$/);
|
|
17797
|
+
if (headerMatch) {
|
|
17798
|
+
currentDate = headerMatch[1];
|
|
17799
|
+
currentDescription = headerMatch[2].trim();
|
|
17800
|
+
continue;
|
|
17801
|
+
}
|
|
17802
|
+
const postingMatch = line.match(/^\s+(income:unknown|expenses:unknown)\s+([^\s]+(?:\s+[^\s=]+)?)\s*(?:=\s*(.+))?$/);
|
|
17803
|
+
if (postingMatch && currentDate) {
|
|
17804
|
+
unknownPostings.push({
|
|
17805
|
+
date: currentDate,
|
|
17806
|
+
description: currentDescription,
|
|
17807
|
+
amount: postingMatch[2].trim(),
|
|
17808
|
+
account: postingMatch[1],
|
|
17809
|
+
balance: postingMatch[3]?.trim()
|
|
17810
|
+
});
|
|
17811
|
+
}
|
|
17812
|
+
}
|
|
17813
|
+
return unknownPostings;
|
|
17814
|
+
}
|
|
17815
|
+
function countTransactions(hledgerOutput) {
|
|
17816
|
+
const lines = hledgerOutput.split(`
|
|
17817
|
+
`);
|
|
17818
|
+
let count = 0;
|
|
17819
|
+
for (const line of lines) {
|
|
17820
|
+
if (/^\d{4}-\d{2}-\d{2}\s+/.test(line)) {
|
|
17821
|
+
count++;
|
|
17822
|
+
}
|
|
17823
|
+
}
|
|
17824
|
+
return count;
|
|
17825
|
+
}
|
|
17826
|
+
function extractTransactionYears(hledgerOutput) {
|
|
17827
|
+
const years = new Set;
|
|
17828
|
+
const lines = hledgerOutput.split(`
|
|
17829
|
+
`);
|
|
17830
|
+
for (const line of lines) {
|
|
17831
|
+
const match = line.match(/^(\d{4})-\d{2}-\d{2}\s+/);
|
|
17832
|
+
if (match) {
|
|
17833
|
+
years.add(parseInt(match[1], 10));
|
|
17834
|
+
}
|
|
17835
|
+
}
|
|
17836
|
+
return years;
|
|
17837
|
+
}
|
|
17838
|
+
async function validateLedger(mainJournalPath, executor = defaultHledgerExecutor) {
|
|
17839
|
+
const errors3 = [];
|
|
17840
|
+
const checkResult = await executor(["check", "--strict", "-f", mainJournalPath]);
|
|
17841
|
+
if (checkResult.exitCode !== 0) {
|
|
17842
|
+
const errorMsg = checkResult.stderr.trim() || checkResult.stdout.trim();
|
|
17843
|
+
errors3.push(`hledger check --strict failed: ${errorMsg}`);
|
|
17844
|
+
}
|
|
17845
|
+
const balResult = await executor(["bal", "-f", mainJournalPath]);
|
|
17846
|
+
if (balResult.exitCode !== 0) {
|
|
17847
|
+
const errorMsg = balResult.stderr.trim() || balResult.stdout.trim();
|
|
17848
|
+
errors3.push(`hledger bal failed: ${errorMsg}`);
|
|
17849
|
+
}
|
|
17850
|
+
return { valid: errors3.length === 0, errors: errors3 };
|
|
17851
|
+
}
|
|
17852
|
+
async function getLastTransactionDate(mainJournalPath, account, executor = defaultHledgerExecutor) {
|
|
17853
|
+
const result = await executor(["register", account, "-f", mainJournalPath, "-O", "csv"]);
|
|
17854
|
+
if (result.exitCode !== 0 || !result.stdout.trim()) {
|
|
17855
|
+
return null;
|
|
17856
|
+
}
|
|
17857
|
+
const lines = result.stdout.trim().split(`
|
|
17858
|
+
`);
|
|
17859
|
+
if (lines.length < 2) {
|
|
17860
|
+
return null;
|
|
17861
|
+
}
|
|
17862
|
+
const lastLine = lines[lines.length - 1];
|
|
17863
|
+
const match = lastLine.match(/^"?\d+"?,"?(\d{4}-\d{2}-\d{2})"?/);
|
|
17864
|
+
return match ? match[1] : null;
|
|
17865
|
+
}
|
|
17866
|
+
async function getAccountBalance(mainJournalPath, account, asOfDate, executor = defaultHledgerExecutor) {
|
|
17867
|
+
const nextDay = getNextDay(asOfDate);
|
|
17868
|
+
const result = await executor([
|
|
17869
|
+
"bal",
|
|
17870
|
+
account,
|
|
17871
|
+
"-f",
|
|
17872
|
+
mainJournalPath,
|
|
17873
|
+
"-e",
|
|
17874
|
+
nextDay,
|
|
17875
|
+
"-N",
|
|
17876
|
+
"--flat"
|
|
17877
|
+
]);
|
|
17878
|
+
if (result.exitCode !== 0) {
|
|
17879
|
+
return null;
|
|
17880
|
+
}
|
|
17881
|
+
const output = result.stdout.trim();
|
|
17882
|
+
if (!output) {
|
|
17883
|
+
return "0";
|
|
17884
|
+
}
|
|
17885
|
+
const match = output.match(/^\s*(.+?)\s{2,}/);
|
|
17886
|
+
return match ? match[1].trim() : output.trim();
|
|
17887
|
+
}
|
|
17888
|
+
|
|
17889
|
+
// src/utils/rulesParser.ts
|
|
17890
|
+
import * as fs7 from "fs";
|
|
17891
|
+
function parseSkipRows(rulesContent) {
|
|
17892
|
+
const match = rulesContent.match(/^skip\s+(\d+)/m);
|
|
17893
|
+
return match ? parseInt(match[1], 10) : 0;
|
|
17894
|
+
}
|
|
17895
|
+
function parseSeparator(rulesContent) {
|
|
17896
|
+
const match = rulesContent.match(/^separator\s+(.)/m);
|
|
17897
|
+
return match ? match[1] : ",";
|
|
17898
|
+
}
|
|
17899
|
+
function parseFieldNames(rulesContent) {
|
|
17900
|
+
const match = rulesContent.match(/^fields\s+(.+)$/m);
|
|
17901
|
+
if (!match) {
|
|
17902
|
+
return [];
|
|
17903
|
+
}
|
|
17904
|
+
return match[1].split(",").map((field) => field.trim());
|
|
17905
|
+
}
|
|
17906
|
+
function parseDateFormat(rulesContent) {
|
|
17907
|
+
const match = rulesContent.match(/^date-format\s+(.+)$/m);
|
|
17908
|
+
return match ? match[1].trim() : "%Y-%m-%d";
|
|
17909
|
+
}
|
|
17910
|
+
function parseDateField(rulesContent, fieldNames) {
|
|
17911
|
+
const match = rulesContent.match(/^date\s+%(\w+|\d+)/m);
|
|
17912
|
+
if (!match) {
|
|
17913
|
+
return fieldNames[0] || "date";
|
|
17914
|
+
}
|
|
17915
|
+
const value = match[1];
|
|
17916
|
+
if (/^\d+$/.test(value)) {
|
|
17917
|
+
const index = parseInt(value, 10) - 1;
|
|
17918
|
+
return fieldNames[index] || value;
|
|
17919
|
+
}
|
|
17920
|
+
return value;
|
|
17921
|
+
}
|
|
17922
|
+
function parseAmountFields(rulesContent, fieldNames) {
|
|
17923
|
+
const result = {};
|
|
17924
|
+
const simpleMatch = rulesContent.match(/^amount\s+(-?)%(\w+|\d+)/m);
|
|
17925
|
+
if (simpleMatch) {
|
|
17926
|
+
const fieldRef = simpleMatch[2];
|
|
17927
|
+
if (/^\d+$/.test(fieldRef)) {
|
|
17928
|
+
const index = parseInt(fieldRef, 10) - 1;
|
|
17929
|
+
result.single = fieldNames[index] || fieldRef;
|
|
17930
|
+
} else {
|
|
17931
|
+
result.single = fieldRef;
|
|
17932
|
+
}
|
|
17933
|
+
}
|
|
17934
|
+
const debitMatch = rulesContent.match(/if\s+%(\w+)\s+\.\s*\n\s*amount\s+-?%\1/m);
|
|
17935
|
+
if (debitMatch) {
|
|
17936
|
+
result.debit = debitMatch[1];
|
|
17937
|
+
}
|
|
17938
|
+
const creditMatch = rulesContent.match(/if\s+%(\w+)\s+\.\s*\n\s*amount\s+%\1(?!\w)/m);
|
|
17939
|
+
if (creditMatch && creditMatch[1] !== result.debit) {
|
|
17940
|
+
result.credit = creditMatch[1];
|
|
17941
|
+
}
|
|
17942
|
+
if (result.debit || result.credit) {
|
|
17943
|
+
delete result.single;
|
|
17944
|
+
}
|
|
17945
|
+
if (!result.single && !result.debit && !result.credit) {
|
|
17946
|
+
result.single = "amount";
|
|
17947
|
+
}
|
|
17948
|
+
return result;
|
|
17949
|
+
}
|
|
17950
|
+
function parseAccount1(rulesContent) {
|
|
17951
|
+
const match = rulesContent.match(/^account1\s+(.+)$/m);
|
|
17952
|
+
return match ? match[1].trim() : null;
|
|
17953
|
+
}
|
|
17954
|
+
function getAccountFromRulesFile(rulesFilePath) {
|
|
17955
|
+
try {
|
|
17956
|
+
const content = fs7.readFileSync(rulesFilePath, "utf-8");
|
|
17957
|
+
return parseAccount1(content);
|
|
17958
|
+
} catch {
|
|
17959
|
+
return null;
|
|
17960
|
+
}
|
|
17961
|
+
}
|
|
17962
|
+
function parseRulesFile(rulesContent) {
|
|
17963
|
+
const fieldNames = parseFieldNames(rulesContent);
|
|
17964
|
+
return {
|
|
17965
|
+
skipRows: parseSkipRows(rulesContent),
|
|
17966
|
+
separator: parseSeparator(rulesContent),
|
|
17967
|
+
fieldNames,
|
|
17968
|
+
dateFormat: parseDateFormat(rulesContent),
|
|
17969
|
+
dateField: parseDateField(rulesContent, fieldNames),
|
|
17970
|
+
amountFields: parseAmountFields(rulesContent, fieldNames)
|
|
17971
|
+
};
|
|
17972
|
+
}
|
|
17973
|
+
|
|
17974
|
+
// src/utils/csvParser.ts
|
|
17975
|
+
var import_convert_csv_to_json = __toESM(require_convert_csv_to_json(), 1);
|
|
17976
|
+
import * as fs8 from "fs";
|
|
17977
|
+
|
|
17978
|
+
// src/utils/balanceUtils.ts
|
|
17979
|
+
function parseAmountValue(amountStr) {
|
|
17980
|
+
const cleaned = amountStr.replace(/[A-Z]{3}\s*/g, "").replace(/,/g, "").trim();
|
|
17981
|
+
return parseFloat(cleaned) || 0;
|
|
17982
|
+
}
|
|
17983
|
+
function parseBalance(balance) {
|
|
17984
|
+
const match = balance.match(/([A-Z]{3})\s*([-\d.,]+)|([+-]?[\d.,]+)\s*([A-Z]{3})/);
|
|
17985
|
+
if (!match) {
|
|
17986
|
+
const numMatch = balance.match(/^([+-]?[\d.,]+)$/);
|
|
17987
|
+
if (numMatch) {
|
|
17988
|
+
return { currency: "", amount: parseFloat(numMatch[1].replace(/,/g, "")) };
|
|
17989
|
+
}
|
|
17990
|
+
return null;
|
|
17991
|
+
}
|
|
17992
|
+
const currency = match[1] || match[4];
|
|
17993
|
+
const amountStr = match[2] || match[3];
|
|
17994
|
+
const amount = parseFloat(amountStr.replace(/,/g, ""));
|
|
17995
|
+
return { currency, amount };
|
|
17996
|
+
}
|
|
17997
|
+
function calculateDifference(expected, actual) {
|
|
17998
|
+
const expectedParsed = parseBalance(expected);
|
|
17999
|
+
const actualParsed = parseBalance(actual);
|
|
18000
|
+
if (!expectedParsed || !actualParsed) {
|
|
18001
|
+
throw new Error(`Cannot parse balances: expected="${expected}", actual="${actual}"`);
|
|
18002
|
+
}
|
|
18003
|
+
if (expectedParsed.currency && actualParsed.currency && expectedParsed.currency !== actualParsed.currency) {
|
|
18004
|
+
throw new Error(`Currency mismatch: expected ${expectedParsed.currency}, got ${actualParsed.currency}`);
|
|
18005
|
+
}
|
|
18006
|
+
const diff = actualParsed.amount - expectedParsed.amount;
|
|
18007
|
+
const sign = diff >= 0 ? "+" : "";
|
|
18008
|
+
const currency = expectedParsed.currency || actualParsed.currency;
|
|
18009
|
+
return currency ? `${currency} ${sign}${diff.toFixed(2)}` : `${sign}${diff.toFixed(2)}`;
|
|
18010
|
+
}
|
|
18011
|
+
function balancesMatch(balance1, balance2) {
|
|
18012
|
+
const parsed1 = parseBalance(balance1);
|
|
18013
|
+
const parsed2 = parseBalance(balance2);
|
|
18014
|
+
if (!parsed1 || !parsed2) {
|
|
18015
|
+
return false;
|
|
18016
|
+
}
|
|
18017
|
+
if (parsed1.currency && parsed2.currency && parsed1.currency !== parsed2.currency) {
|
|
18018
|
+
throw new Error(`Currency mismatch: ${parsed1.currency} vs ${parsed2.currency}`);
|
|
18019
|
+
}
|
|
18020
|
+
return parsed1.amount === parsed2.amount;
|
|
18021
|
+
}
|
|
18022
|
+
|
|
18023
|
+
// src/utils/csvParser.ts
|
|
18024
|
+
function parseCsvFile(csvPath, config2) {
|
|
18025
|
+
const csvContent = fs8.readFileSync(csvPath, "utf-8");
|
|
18026
|
+
const lines = csvContent.split(`
|
|
18027
|
+
`);
|
|
18028
|
+
const headerIndex = config2.skipRows;
|
|
18029
|
+
if (headerIndex >= lines.length) {
|
|
18030
|
+
return [];
|
|
18031
|
+
}
|
|
18032
|
+
const headerLine = lines[headerIndex];
|
|
18033
|
+
const dataLines = lines.slice(headerIndex + 1).filter((line) => line.trim() !== "");
|
|
18034
|
+
const csvWithHeader = [headerLine, ...dataLines].join(`
|
|
18035
|
+
`);
|
|
18036
|
+
const rawRows = import_convert_csv_to_json.default.indexHeader(0).fieldDelimiter(config2.separator).supportQuotedField(true).csvStringToJson(csvWithHeader);
|
|
18037
|
+
const fieldNames = config2.fieldNames.length > 0 ? config2.fieldNames : Object.keys(rawRows[0] || {});
|
|
18038
|
+
const mappedRows = [];
|
|
18039
|
+
for (const parsedRow of rawRows) {
|
|
18040
|
+
const row = {};
|
|
18041
|
+
const values = Object.values(parsedRow);
|
|
18042
|
+
for (let i2 = 0;i2 < fieldNames.length && i2 < values.length; i2++) {
|
|
18043
|
+
row[fieldNames[i2]] = values[i2];
|
|
18044
|
+
}
|
|
18045
|
+
mappedRows.push(row);
|
|
18046
|
+
}
|
|
18047
|
+
return mappedRows;
|
|
18048
|
+
}
|
|
18049
|
+
function getRowAmount(row, amountFields) {
|
|
18050
|
+
if (amountFields.single) {
|
|
18051
|
+
return parseAmountValue(row[amountFields.single] || "0");
|
|
18052
|
+
}
|
|
18053
|
+
const debitValue = amountFields.debit ? parseAmountValue(row[amountFields.debit] || "0") : 0;
|
|
18054
|
+
const creditValue = amountFields.credit ? parseAmountValue(row[amountFields.credit] || "0") : 0;
|
|
18055
|
+
if (debitValue !== 0) {
|
|
18056
|
+
return -Math.abs(debitValue);
|
|
18057
|
+
}
|
|
18058
|
+
if (creditValue !== 0) {
|
|
18059
|
+
return Math.abs(creditValue);
|
|
18060
|
+
}
|
|
18061
|
+
return 0;
|
|
18062
|
+
}
|
|
18063
|
+
function parseDateToIso(dateStr, dateFormat) {
|
|
18064
|
+
if (!dateStr)
|
|
18065
|
+
return "";
|
|
18066
|
+
if (dateFormat === "%Y-%m-%d" || dateFormat === "%F") {
|
|
18067
|
+
return dateStr.trim();
|
|
18068
|
+
}
|
|
18069
|
+
if (dateFormat === "%d.%m.%Y") {
|
|
18070
|
+
const parts = dateStr.split(".");
|
|
18071
|
+
if (parts.length === 3) {
|
|
18072
|
+
return `${parts[2]}-${parts[1].padStart(2, "0")}-${parts[0].padStart(2, "0")}`;
|
|
18073
|
+
}
|
|
18074
|
+
}
|
|
18075
|
+
if (dateFormat === "%m/%d/%Y") {
|
|
18076
|
+
const parts = dateStr.split("/");
|
|
18077
|
+
if (parts.length === 3) {
|
|
18078
|
+
return `${parts[2]}-${parts[0].padStart(2, "0")}-${parts[1].padStart(2, "0")}`;
|
|
18079
|
+
}
|
|
18080
|
+
}
|
|
18081
|
+
if (dateFormat === "%d/%m/%Y") {
|
|
18082
|
+
const parts = dateStr.split("/");
|
|
18083
|
+
if (parts.length === 3) {
|
|
18084
|
+
return `${parts[2]}-${parts[1].padStart(2, "0")}-${parts[0].padStart(2, "0")}`;
|
|
18085
|
+
}
|
|
18086
|
+
}
|
|
18087
|
+
return dateStr.trim();
|
|
18088
|
+
}
|
|
18089
|
+
function looksLikeTransactionId(fieldName, value) {
|
|
18090
|
+
if (!value || value.trim() === "")
|
|
18091
|
+
return false;
|
|
18092
|
+
const idFieldPatterns = [
|
|
18093
|
+
/transaction/i,
|
|
18094
|
+
/trans_?no/i,
|
|
18095
|
+
/trans_?id/i,
|
|
18096
|
+
/reference/i,
|
|
18097
|
+
/ref_?no/i,
|
|
18098
|
+
/ref_?id/i,
|
|
18099
|
+
/booking_?id/i,
|
|
18100
|
+
/payment_?id/i,
|
|
18101
|
+
/order_?id/i
|
|
18102
|
+
];
|
|
18103
|
+
const nameMatches = idFieldPatterns.some((pattern) => pattern.test(fieldName));
|
|
18104
|
+
if (!nameMatches)
|
|
18105
|
+
return false;
|
|
18106
|
+
const trimmedValue = value.trim();
|
|
18107
|
+
const looksLikeId = /^[A-Za-z0-9_-]+$/.test(trimmedValue) && trimmedValue.length >= 3;
|
|
18108
|
+
return looksLikeId;
|
|
18109
|
+
}
|
|
18110
|
+
function findTransactionId(row) {
|
|
18111
|
+
for (const [field, value] of Object.entries(row)) {
|
|
18112
|
+
if (looksLikeTransactionId(field, value)) {
|
|
18113
|
+
return { field, value: value.trim() };
|
|
18114
|
+
}
|
|
18115
|
+
}
|
|
18116
|
+
return null;
|
|
18117
|
+
}
|
|
18118
|
+
function findMatchingCsvRow(posting, csvRows, config2) {
|
|
18119
|
+
const postingAmount = parseAmountValue(posting.amount);
|
|
18120
|
+
let candidates = csvRows.filter((row) => {
|
|
18121
|
+
const rowDate = parseDateToIso(row[config2.dateField] || "", config2.dateFormat);
|
|
18122
|
+
const rowAmount = getRowAmount(row, config2.amountFields);
|
|
18123
|
+
if (rowDate !== posting.date)
|
|
18124
|
+
return false;
|
|
18125
|
+
if (Math.abs(rowAmount - postingAmount) > 0.001)
|
|
18126
|
+
return false;
|
|
18127
|
+
return true;
|
|
18128
|
+
});
|
|
18129
|
+
if (candidates.length === 1) {
|
|
18130
|
+
return candidates[0];
|
|
18131
|
+
}
|
|
18132
|
+
if (candidates.length === 0) {
|
|
18133
|
+
throw new Error(`Bug: Could not find CSV row for posting: ${posting.date} ${posting.description} ${posting.amount}. ` + `This indicates a mismatch between hledger output and CSV parsing.`);
|
|
18134
|
+
}
|
|
18135
|
+
for (const candidate of candidates) {
|
|
18136
|
+
const txId = findTransactionId(candidate);
|
|
18137
|
+
if (txId) {
|
|
18138
|
+
const withSameTxId = candidates.filter((row) => row[txId.field] === txId.value);
|
|
18139
|
+
if (withSameTxId.length === 1) {
|
|
18140
|
+
return withSameTxId[0];
|
|
18141
|
+
}
|
|
18142
|
+
}
|
|
18143
|
+
}
|
|
18144
|
+
const descriptionLower = posting.description.toLowerCase();
|
|
18145
|
+
const descMatches = candidates.filter((row) => {
|
|
18146
|
+
return Object.values(row).some((value) => value && value.toLowerCase().includes(descriptionLower));
|
|
18147
|
+
});
|
|
18148
|
+
if (descMatches.length === 1) {
|
|
18149
|
+
return descMatches[0];
|
|
18150
|
+
}
|
|
18151
|
+
if (descMatches.length > 1) {
|
|
18152
|
+
return descMatches[0];
|
|
18153
|
+
}
|
|
18154
|
+
return candidates[0];
|
|
18155
|
+
}
|
|
18156
|
+
|
|
18157
|
+
// src/tools/import-statements.ts
|
|
18158
|
+
function buildErrorResult3(error45, hint) {
|
|
18159
|
+
return JSON.stringify({
|
|
18160
|
+
success: false,
|
|
18161
|
+
error: error45,
|
|
18162
|
+
hint
|
|
18163
|
+
});
|
|
18164
|
+
}
|
|
18165
|
+
function buildErrorResultWithDetails(error45, files, summary, hint) {
|
|
18166
|
+
return JSON.stringify({
|
|
18167
|
+
success: false,
|
|
18168
|
+
error: error45,
|
|
18169
|
+
hint,
|
|
18170
|
+
files,
|
|
18171
|
+
summary
|
|
18172
|
+
});
|
|
18173
|
+
}
|
|
18174
|
+
function buildSuccessResult3(files, summary, message) {
|
|
18175
|
+
return JSON.stringify({
|
|
18176
|
+
success: true,
|
|
18177
|
+
files,
|
|
18178
|
+
summary,
|
|
18179
|
+
message
|
|
18180
|
+
});
|
|
18181
|
+
}
|
|
18182
|
+
async function executeImports(fileResults, directory, pendingDir, doneDir, hledgerExecutor) {
|
|
18183
|
+
const importedFiles = [];
|
|
18184
|
+
for (const fileResult of fileResults) {
|
|
18185
|
+
const csvFile = path8.join(directory, fileResult.csv);
|
|
18186
|
+
const rulesFile = fileResult.rulesFile ? path8.join(directory, fileResult.rulesFile) : null;
|
|
18187
|
+
if (!rulesFile)
|
|
18188
|
+
continue;
|
|
18189
|
+
const year = fileResult.transactionYear;
|
|
18190
|
+
if (!year) {
|
|
18191
|
+
return {
|
|
18192
|
+
success: false,
|
|
18193
|
+
error: `No transactions found in ${fileResult.csv}`
|
|
18194
|
+
};
|
|
18195
|
+
}
|
|
18196
|
+
let yearJournalPath;
|
|
18197
|
+
try {
|
|
18198
|
+
yearJournalPath = ensureYearJournalExists(directory, year);
|
|
18199
|
+
} catch (error45) {
|
|
18200
|
+
const errorMessage = error45 instanceof Error ? error45.message : String(error45);
|
|
18201
|
+
return {
|
|
18202
|
+
success: false,
|
|
18203
|
+
error: errorMessage
|
|
18204
|
+
};
|
|
18205
|
+
}
|
|
18206
|
+
const result = await hledgerExecutor([
|
|
18207
|
+
"import",
|
|
18208
|
+
"-f",
|
|
18209
|
+
yearJournalPath,
|
|
18210
|
+
csvFile,
|
|
18211
|
+
"--rules-file",
|
|
18212
|
+
rulesFile
|
|
18213
|
+
]);
|
|
18214
|
+
if (result.exitCode !== 0) {
|
|
18215
|
+
return {
|
|
18216
|
+
success: false,
|
|
18217
|
+
error: `Import failed for ${fileResult.csv}: ${result.stderr.trim()}`
|
|
18218
|
+
};
|
|
18219
|
+
}
|
|
18220
|
+
importedFiles.push(csvFile);
|
|
18221
|
+
}
|
|
18222
|
+
const mainJournalPath = path8.join(directory, ".hledger.journal");
|
|
18223
|
+
const validationResult = await validateLedger(mainJournalPath, hledgerExecutor);
|
|
18224
|
+
if (!validationResult.valid) {
|
|
18225
|
+
return {
|
|
18226
|
+
success: false,
|
|
18227
|
+
error: `Ledger validation failed after import: ${validationResult.errors.join("; ")}`,
|
|
18228
|
+
hint: "The import created invalid transactions. Check your rules file configuration. CSV files have NOT been moved to done."
|
|
18229
|
+
};
|
|
18230
|
+
}
|
|
18231
|
+
for (const csvFile of importedFiles) {
|
|
18232
|
+
const relativePath = path8.relative(pendingDir, csvFile);
|
|
18233
|
+
const destPath = path8.join(doneDir, relativePath);
|
|
18234
|
+
const destDir = path8.dirname(destPath);
|
|
18235
|
+
if (!fs9.existsSync(destDir)) {
|
|
18236
|
+
fs9.mkdirSync(destDir, { recursive: true });
|
|
18237
|
+
}
|
|
18238
|
+
fs9.renameSync(csvFile, destPath);
|
|
18239
|
+
}
|
|
18240
|
+
return {
|
|
18241
|
+
success: true,
|
|
18242
|
+
importedCount: importedFiles.length
|
|
18243
|
+
};
|
|
18244
|
+
}
|
|
18245
|
+
async function processCsvFile(csvFile, rulesMapping, directory, hledgerExecutor) {
|
|
18246
|
+
const rulesFile = findRulesForCsv(csvFile, rulesMapping);
|
|
18247
|
+
if (!rulesFile) {
|
|
18248
|
+
return {
|
|
18249
|
+
csv: path8.relative(directory, csvFile),
|
|
18250
|
+
rulesFile: null,
|
|
18251
|
+
totalTransactions: 0,
|
|
18252
|
+
matchedTransactions: 0,
|
|
18253
|
+
unknownPostings: [],
|
|
18254
|
+
error: "No matching rules file found"
|
|
18255
|
+
};
|
|
18256
|
+
}
|
|
18257
|
+
const result = await hledgerExecutor(["print", "-f", csvFile, "--rules-file", rulesFile]);
|
|
18258
|
+
if (result.exitCode !== 0) {
|
|
18259
|
+
return {
|
|
18260
|
+
csv: path8.relative(directory, csvFile),
|
|
18261
|
+
rulesFile: path8.relative(directory, rulesFile),
|
|
18262
|
+
totalTransactions: 0,
|
|
18263
|
+
matchedTransactions: 0,
|
|
18264
|
+
unknownPostings: [],
|
|
18265
|
+
error: `hledger error: ${result.stderr.trim() || "Unknown error"}`
|
|
18266
|
+
};
|
|
18267
|
+
}
|
|
18268
|
+
const unknownPostings = parseUnknownPostings(result.stdout);
|
|
18269
|
+
const transactionCount = countTransactions(result.stdout);
|
|
18270
|
+
const matchedCount = transactionCount - unknownPostings.length;
|
|
18271
|
+
const years = extractTransactionYears(result.stdout);
|
|
18272
|
+
if (years.size > 1) {
|
|
18273
|
+
const yearList = Array.from(years).sort().join(", ");
|
|
18274
|
+
return {
|
|
18275
|
+
csv: path8.relative(directory, csvFile),
|
|
18276
|
+
rulesFile: path8.relative(directory, rulesFile),
|
|
18277
|
+
totalTransactions: transactionCount,
|
|
18278
|
+
matchedTransactions: matchedCount,
|
|
18279
|
+
unknownPostings: [],
|
|
18280
|
+
error: `CSV contains transactions from multiple years (${yearList}). Split the CSV by year before importing.`
|
|
18281
|
+
};
|
|
18282
|
+
}
|
|
18283
|
+
const transactionYear = years.size === 1 ? Array.from(years)[0] : undefined;
|
|
18284
|
+
if (unknownPostings.length > 0) {
|
|
18285
|
+
try {
|
|
18286
|
+
const rulesContent = fs9.readFileSync(rulesFile, "utf-8");
|
|
18287
|
+
const rulesConfig = parseRulesFile(rulesContent);
|
|
18288
|
+
const csvRows = parseCsvFile(csvFile, rulesConfig);
|
|
18289
|
+
for (const posting of unknownPostings) {
|
|
18290
|
+
posting.csvRow = findMatchingCsvRow({
|
|
18291
|
+
date: posting.date,
|
|
18292
|
+
description: posting.description,
|
|
18293
|
+
amount: posting.amount
|
|
18294
|
+
}, csvRows, rulesConfig);
|
|
18295
|
+
}
|
|
18296
|
+
} catch {
|
|
18297
|
+
for (const posting of unknownPostings) {
|
|
18298
|
+
posting.csvRow = undefined;
|
|
18299
|
+
}
|
|
18300
|
+
}
|
|
18301
|
+
}
|
|
18302
|
+
return {
|
|
18303
|
+
csv: path8.relative(directory, csvFile),
|
|
18304
|
+
rulesFile: path8.relative(directory, rulesFile),
|
|
18305
|
+
totalTransactions: transactionCount,
|
|
18306
|
+
matchedTransactions: matchedCount,
|
|
18307
|
+
unknownPostings,
|
|
18308
|
+
transactionYear
|
|
18309
|
+
};
|
|
18310
|
+
}
|
|
18311
|
+
async function importStatements(directory, agent, options, configLoader = loadImportConfig, hledgerExecutor = defaultHledgerExecutor, worktreeChecker = isInWorktree) {
|
|
18312
|
+
const restrictionError = checkAccountantAgent(agent, "import statements");
|
|
18313
|
+
if (restrictionError) {
|
|
18314
|
+
return restrictionError;
|
|
18315
|
+
}
|
|
18316
|
+
if (!worktreeChecker(directory)) {
|
|
18317
|
+
return buildErrorResult3("import-statements must be run inside an import worktree", "Use import-pipeline tool to orchestrate the full workflow");
|
|
18318
|
+
}
|
|
18319
|
+
let config2;
|
|
18320
|
+
try {
|
|
18321
|
+
config2 = configLoader(directory);
|
|
18322
|
+
} catch (error45) {
|
|
18323
|
+
const errorMessage = `Failed to load configuration: ${error45 instanceof Error ? error45.message : String(error45)}`;
|
|
18324
|
+
return buildErrorResult3(errorMessage, 'Ensure config/import/providers.yaml exists with required paths including "rules"');
|
|
18325
|
+
}
|
|
18326
|
+
const pendingDir = path8.join(directory, config2.paths.pending);
|
|
18327
|
+
const rulesDir = path8.join(directory, config2.paths.rules);
|
|
18328
|
+
const doneDir = path8.join(directory, config2.paths.done);
|
|
18329
|
+
const rulesMapping = loadRulesMapping(rulesDir);
|
|
18330
|
+
const csvFiles = findCsvFiles(pendingDir, options.provider, options.currency);
|
|
18331
|
+
if (csvFiles.length === 0) {
|
|
18332
|
+
return buildSuccessResult3([], {
|
|
18333
|
+
filesProcessed: 0,
|
|
18334
|
+
filesWithErrors: 0,
|
|
18335
|
+
filesWithoutRules: 0,
|
|
18336
|
+
totalTransactions: 0,
|
|
18337
|
+
matched: 0,
|
|
18338
|
+
unknown: 0
|
|
18339
|
+
}, "No CSV files found to process");
|
|
18340
|
+
}
|
|
18341
|
+
const fileResults = [];
|
|
18342
|
+
let totalTransactions = 0;
|
|
18343
|
+
let totalMatched = 0;
|
|
18344
|
+
let totalUnknown = 0;
|
|
18345
|
+
let filesWithErrors = 0;
|
|
18346
|
+
let filesWithoutRules = 0;
|
|
18347
|
+
for (const csvFile of csvFiles) {
|
|
18348
|
+
const fileResult = await processCsvFile(csvFile, rulesMapping, directory, hledgerExecutor);
|
|
18349
|
+
fileResults.push(fileResult);
|
|
18350
|
+
if (fileResult.error) {
|
|
18351
|
+
if (fileResult.rulesFile === null) {
|
|
18352
|
+
filesWithoutRules++;
|
|
18353
|
+
} else {
|
|
18354
|
+
filesWithErrors++;
|
|
18355
|
+
}
|
|
18356
|
+
}
|
|
18357
|
+
totalTransactions += fileResult.totalTransactions;
|
|
18358
|
+
totalMatched += fileResult.matchedTransactions;
|
|
18359
|
+
totalUnknown += fileResult.unknownPostings.length;
|
|
18360
|
+
}
|
|
18361
|
+
const hasUnknowns = totalUnknown > 0;
|
|
18362
|
+
const hasErrors = filesWithErrors > 0 || filesWithoutRules > 0;
|
|
18363
|
+
if (options.checkOnly !== false) {
|
|
18364
|
+
const result = {
|
|
18365
|
+
success: !hasUnknowns && !hasErrors,
|
|
18366
|
+
files: fileResults,
|
|
18367
|
+
summary: {
|
|
18368
|
+
filesProcessed: csvFiles.length,
|
|
18369
|
+
filesWithErrors,
|
|
18370
|
+
filesWithoutRules,
|
|
18371
|
+
totalTransactions,
|
|
18372
|
+
matched: totalMatched,
|
|
18373
|
+
unknown: totalUnknown
|
|
18374
|
+
}
|
|
18375
|
+
};
|
|
18376
|
+
if (hasUnknowns) {
|
|
18377
|
+
result.message = `Found ${totalUnknown} transaction(s) with unknown accounts. Add rules to categorize them.`;
|
|
18378
|
+
} else if (hasErrors) {
|
|
18379
|
+
result.message = `Some files had errors. Check the file results for details.`;
|
|
18380
|
+
} else {
|
|
18381
|
+
result.message = "All transactions matched. Ready to import with checkOnly: false";
|
|
18382
|
+
}
|
|
18383
|
+
return JSON.stringify(result);
|
|
18384
|
+
}
|
|
18385
|
+
if (hasUnknowns || hasErrors) {
|
|
18386
|
+
return buildErrorResultWithDetails("Cannot import: some transactions have unknown accounts or files have errors", fileResults, {
|
|
18387
|
+
filesProcessed: csvFiles.length,
|
|
18388
|
+
filesWithErrors,
|
|
18389
|
+
filesWithoutRules,
|
|
18390
|
+
totalTransactions,
|
|
18391
|
+
matched: totalMatched,
|
|
18392
|
+
unknown: totalUnknown
|
|
18393
|
+
}, "Run with checkOnly: true to see details, then add missing rules");
|
|
18394
|
+
}
|
|
18395
|
+
const importResult = await executeImports(fileResults, directory, pendingDir, doneDir, hledgerExecutor);
|
|
18396
|
+
if (!importResult.success) {
|
|
18397
|
+
return buildErrorResultWithDetails(importResult.error, fileResults, {
|
|
18398
|
+
filesProcessed: csvFiles.length,
|
|
18399
|
+
filesWithErrors: 1,
|
|
18400
|
+
filesWithoutRules,
|
|
18401
|
+
totalTransactions,
|
|
18402
|
+
matched: totalMatched,
|
|
18403
|
+
unknown: totalUnknown
|
|
18404
|
+
}, importResult.hint);
|
|
18405
|
+
}
|
|
18406
|
+
return buildSuccessResult3(fileResults.map((f) => ({
|
|
18407
|
+
...f,
|
|
18408
|
+
imported: true
|
|
18409
|
+
})), {
|
|
18410
|
+
filesProcessed: csvFiles.length,
|
|
18411
|
+
filesWithErrors: 0,
|
|
18412
|
+
filesWithoutRules: 0,
|
|
18413
|
+
totalTransactions,
|
|
18414
|
+
matched: totalMatched,
|
|
18415
|
+
unknown: 0
|
|
18416
|
+
}, `Successfully imported ${totalTransactions} transaction(s) from ${importResult.importedCount} file(s)`);
|
|
18417
|
+
}
|
|
18418
|
+
var import_statements_default = tool({
|
|
18419
|
+
description: `ACCOUNTANT AGENT ONLY: Import classified bank statement CSVs into hledger using rules files.
|
|
18420
|
+
|
|
18421
|
+
This tool processes CSV files in the pending import directory and uses hledger's CSV import capabilities with matching rules files.
|
|
18422
|
+
|
|
18423
|
+
**Check Mode (checkOnly: true, default):**
|
|
18424
|
+
- Runs hledger print to preview transactions
|
|
18425
|
+
- Identifies transactions with 'income:unknown' or 'expenses:unknown' accounts
|
|
18426
|
+
- These indicate missing rules that need to be added
|
|
18427
|
+
|
|
18428
|
+
**Import Mode (checkOnly: false):**
|
|
18429
|
+
- First validates all transactions have known accounts
|
|
18430
|
+
- If any unknowns exist, aborts and reports them
|
|
18431
|
+
- If all clean, imports transactions and moves CSVs to done directory
|
|
18432
|
+
|
|
18433
|
+
**Workflow:**
|
|
18434
|
+
1. Run with checkOnly: true (or no args)
|
|
18435
|
+
2. If unknowns found, add rules to the appropriate .rules file
|
|
18436
|
+
3. Repeat until no unknowns
|
|
18437
|
+
4. Run with checkOnly: false to import`,
|
|
18438
|
+
args: {
|
|
18439
|
+
provider: tool.schema.string().optional().describe('Filter by provider (e.g., "revolut", "ubs"). If omitted, process all providers.'),
|
|
18440
|
+
currency: tool.schema.string().optional().describe('Filter by currency (e.g., "chf", "eur"). If omitted, process all currencies for the provider.'),
|
|
18441
|
+
checkOnly: tool.schema.boolean().optional().describe("If true (default), only check for unknown accounts without importing. Set to false to perform actual import.")
|
|
18442
|
+
},
|
|
18443
|
+
async execute(params, context) {
|
|
18444
|
+
const { directory, agent } = context;
|
|
18445
|
+
return importStatements(directory, agent, {
|
|
18446
|
+
provider: params.provider,
|
|
18447
|
+
currency: params.currency,
|
|
18448
|
+
checkOnly: params.checkOnly
|
|
18449
|
+
});
|
|
18450
|
+
}
|
|
18451
|
+
});
|
|
18452
|
+
// src/tools/reconcile-statement.ts
|
|
18453
|
+
import * as fs10 from "fs";
|
|
18454
|
+
import * as path9 from "path";
|
|
18455
|
+
function buildErrorResult4(params) {
|
|
18456
|
+
return JSON.stringify({
|
|
18457
|
+
success: false,
|
|
18458
|
+
...params
|
|
18459
|
+
});
|
|
18460
|
+
}
|
|
18461
|
+
function buildSuccessResult4(params) {
|
|
18462
|
+
return JSON.stringify({
|
|
18463
|
+
success: true,
|
|
18464
|
+
...params
|
|
18465
|
+
});
|
|
18466
|
+
}
|
|
18467
|
+
function validateWorktree(directory, worktreeChecker) {
|
|
18468
|
+
if (!worktreeChecker(directory)) {
|
|
18469
|
+
return buildErrorResult4({
|
|
18470
|
+
error: "reconcile-statement must be run inside an import worktree",
|
|
18471
|
+
hint: "Use import-pipeline tool to orchestrate the full workflow"
|
|
18472
|
+
});
|
|
18473
|
+
}
|
|
18474
|
+
return null;
|
|
18475
|
+
}
|
|
18476
|
+
function loadConfiguration(directory, configLoader) {
|
|
18477
|
+
try {
|
|
18478
|
+
const config2 = configLoader(directory);
|
|
18479
|
+
return { config: config2 };
|
|
18480
|
+
} catch (error45) {
|
|
18481
|
+
return {
|
|
18482
|
+
error: buildErrorResult4({
|
|
18483
|
+
error: `Failed to load configuration: ${error45 instanceof Error ? error45.message : String(error45)}`,
|
|
18484
|
+
hint: "Ensure config/import/providers.yaml exists"
|
|
18485
|
+
})
|
|
18486
|
+
};
|
|
18487
|
+
}
|
|
18488
|
+
}
|
|
18489
|
+
function findCsvToReconcile(doneDir, options) {
|
|
18490
|
+
const csvFiles = findCsvFiles(doneDir, options.provider, options.currency);
|
|
18491
|
+
if (csvFiles.length === 0) {
|
|
18492
|
+
const providerFilter = options.provider ? ` --provider=${options.provider}` : "";
|
|
18493
|
+
const currencyFilter = options.currency ? ` --currency=${options.currency}` : "";
|
|
18494
|
+
return {
|
|
18495
|
+
error: buildErrorResult4({
|
|
18496
|
+
error: `No CSV files found in ${doneDir}`,
|
|
18497
|
+
hint: `Run: import-statements${providerFilter}${currencyFilter}`
|
|
18498
|
+
})
|
|
18499
|
+
};
|
|
18500
|
+
}
|
|
18501
|
+
const csvFile = csvFiles[csvFiles.length - 1];
|
|
18502
|
+
const relativePath = path9.relative(path9.dirname(path9.dirname(doneDir)), csvFile);
|
|
18503
|
+
return { csvFile, relativePath };
|
|
18504
|
+
}
|
|
18505
|
+
function determineClosingBalance(csvFile, config2, options, relativeCsvPath) {
|
|
18506
|
+
let metadata;
|
|
18507
|
+
try {
|
|
18508
|
+
const content = fs10.readFileSync(csvFile, "utf-8");
|
|
18509
|
+
const filename = path9.basename(csvFile);
|
|
18510
|
+
const detectionResult = detectProvider(filename, content, config2);
|
|
18511
|
+
metadata = detectionResult?.metadata;
|
|
18512
|
+
} catch {
|
|
18513
|
+
metadata = undefined;
|
|
18514
|
+
}
|
|
18515
|
+
let closingBalance = options.closingBalance;
|
|
18516
|
+
if (!closingBalance && metadata?.closing_balance) {
|
|
18517
|
+
closingBalance = metadata.closing_balance;
|
|
18518
|
+
if (metadata.currency && !closingBalance.includes(metadata.currency)) {
|
|
18519
|
+
closingBalance = `${metadata.currency} ${closingBalance}`;
|
|
18520
|
+
}
|
|
18521
|
+
}
|
|
18522
|
+
if (!closingBalance) {
|
|
18523
|
+
return {
|
|
18524
|
+
error: buildErrorResult4({
|
|
18525
|
+
csvFile: relativeCsvPath,
|
|
18526
|
+
error: "No closing balance found in CSV metadata",
|
|
18527
|
+
hint: "Provide closingBalance parameter manually",
|
|
18528
|
+
metadata
|
|
18529
|
+
})
|
|
18530
|
+
};
|
|
18531
|
+
}
|
|
18532
|
+
return { closingBalance, metadata };
|
|
18533
|
+
}
|
|
18534
|
+
function determineAccount(csvFile, rulesDir, options, relativeCsvPath, metadata) {
|
|
18535
|
+
let account = options.account;
|
|
18536
|
+
if (!account) {
|
|
18537
|
+
const rulesMapping = loadRulesMapping(rulesDir);
|
|
18538
|
+
const rulesFile = findRulesForCsv(csvFile, rulesMapping);
|
|
18539
|
+
if (rulesFile) {
|
|
18540
|
+
account = getAccountFromRulesFile(rulesFile) ?? undefined;
|
|
18541
|
+
}
|
|
18542
|
+
}
|
|
18543
|
+
if (!account) {
|
|
18544
|
+
const rulesMapping = loadRulesMapping(rulesDir);
|
|
18545
|
+
const rulesFile = findRulesForCsv(csvFile, rulesMapping);
|
|
18546
|
+
const rulesHint = rulesFile ? `Add 'account1 assets:bank:...' to ${rulesFile} or use --account parameter` : `Create a rules file in ${rulesDir} with 'account1' directive or use --account parameter`;
|
|
18547
|
+
return {
|
|
18548
|
+
error: buildErrorResult4({
|
|
18549
|
+
csvFile: relativeCsvPath,
|
|
18550
|
+
error: "Could not determine account from rules file",
|
|
18551
|
+
hint: rulesHint,
|
|
18552
|
+
metadata
|
|
18553
|
+
})
|
|
18554
|
+
};
|
|
18555
|
+
}
|
|
18556
|
+
return { account };
|
|
18557
|
+
}
|
|
18558
|
+
async function reconcileStatementCore(directory, agent, options, configLoader = loadImportConfig, hledgerExecutor = defaultHledgerExecutor, worktreeChecker = isInWorktree) {
|
|
18559
|
+
const restrictionError = checkAccountantAgent(agent, "reconcile statement");
|
|
18560
|
+
if (restrictionError) {
|
|
18561
|
+
return restrictionError;
|
|
18562
|
+
}
|
|
18563
|
+
const worktreeError = validateWorktree(directory, worktreeChecker);
|
|
18564
|
+
if (worktreeError) {
|
|
18565
|
+
return worktreeError;
|
|
18566
|
+
}
|
|
18567
|
+
const configResult = loadConfiguration(directory, configLoader);
|
|
18568
|
+
if ("error" in configResult) {
|
|
18569
|
+
return configResult.error;
|
|
18570
|
+
}
|
|
18571
|
+
const { config: config2 } = configResult;
|
|
18572
|
+
const doneDir = path9.join(directory, config2.paths.done);
|
|
18573
|
+
const rulesDir = path9.join(directory, config2.paths.rules);
|
|
18574
|
+
const mainJournalPath = path9.join(directory, ".hledger.journal");
|
|
18575
|
+
const csvResult = findCsvToReconcile(doneDir, options);
|
|
18576
|
+
if ("error" in csvResult) {
|
|
18577
|
+
return csvResult.error;
|
|
18578
|
+
}
|
|
18579
|
+
const { csvFile, relativePath: relativeCsvPath } = csvResult;
|
|
18580
|
+
const balanceResult = determineClosingBalance(csvFile, config2, options, relativeCsvPath);
|
|
18581
|
+
if ("error" in balanceResult) {
|
|
18582
|
+
return balanceResult.error;
|
|
18583
|
+
}
|
|
18584
|
+
const { closingBalance, metadata } = balanceResult;
|
|
18585
|
+
const accountResult = determineAccount(csvFile, rulesDir, options, relativeCsvPath, metadata);
|
|
18586
|
+
if ("error" in accountResult) {
|
|
18587
|
+
return accountResult.error;
|
|
18588
|
+
}
|
|
18589
|
+
const { account } = accountResult;
|
|
18590
|
+
const lastTransactionDate = await getLastTransactionDate(mainJournalPath, account, hledgerExecutor);
|
|
18591
|
+
if (!lastTransactionDate) {
|
|
18592
|
+
return buildErrorResult4({
|
|
18593
|
+
csvFile: relativeCsvPath,
|
|
18594
|
+
account,
|
|
18595
|
+
error: "No transactions found for account",
|
|
18596
|
+
hint: "Ensure import completed successfully",
|
|
18597
|
+
metadata
|
|
18598
|
+
});
|
|
18599
|
+
}
|
|
18600
|
+
const actualBalance = await getAccountBalance(mainJournalPath, account, lastTransactionDate, hledgerExecutor);
|
|
18601
|
+
if (actualBalance === null) {
|
|
18602
|
+
return buildErrorResult4({
|
|
18603
|
+
csvFile: relativeCsvPath,
|
|
18604
|
+
account,
|
|
18605
|
+
lastTransactionDate,
|
|
18606
|
+
error: "Failed to query account balance from hledger",
|
|
18607
|
+
hint: `Check journal syntax: hledger check -f ${mainJournalPath}`,
|
|
18608
|
+
metadata
|
|
18609
|
+
});
|
|
18610
|
+
}
|
|
18611
|
+
let doBalancesMatch;
|
|
18612
|
+
try {
|
|
18613
|
+
doBalancesMatch = balancesMatch(closingBalance, actualBalance);
|
|
18614
|
+
} catch (error45) {
|
|
18615
|
+
return buildErrorResult4({
|
|
18616
|
+
csvFile: relativeCsvPath,
|
|
18617
|
+
account,
|
|
18618
|
+
lastTransactionDate,
|
|
18619
|
+
expectedBalance: closingBalance,
|
|
18620
|
+
actualBalance,
|
|
18621
|
+
error: `Cannot parse balances for comparison: ${error45 instanceof Error ? error45.message : String(error45)}`,
|
|
18622
|
+
metadata
|
|
18623
|
+
});
|
|
18624
|
+
}
|
|
18625
|
+
if (doBalancesMatch) {
|
|
18626
|
+
return buildSuccessResult4({
|
|
18627
|
+
csvFile: relativeCsvPath,
|
|
18628
|
+
account,
|
|
18629
|
+
lastTransactionDate,
|
|
18630
|
+
expectedBalance: closingBalance,
|
|
18631
|
+
actualBalance,
|
|
18632
|
+
metadata
|
|
18633
|
+
});
|
|
18634
|
+
}
|
|
18635
|
+
let difference;
|
|
18636
|
+
try {
|
|
18637
|
+
difference = calculateDifference(closingBalance, actualBalance);
|
|
18638
|
+
} catch (error45) {
|
|
18639
|
+
return buildErrorResult4({
|
|
18640
|
+
csvFile: relativeCsvPath,
|
|
18641
|
+
account,
|
|
18642
|
+
lastTransactionDate,
|
|
18643
|
+
expectedBalance: closingBalance,
|
|
18644
|
+
actualBalance,
|
|
18645
|
+
error: `Failed to calculate difference: ${error45 instanceof Error ? error45.message : String(error45)}`,
|
|
18646
|
+
metadata
|
|
18647
|
+
});
|
|
18648
|
+
}
|
|
18649
|
+
return buildErrorResult4({
|
|
18650
|
+
csvFile: relativeCsvPath,
|
|
18651
|
+
account,
|
|
18652
|
+
lastTransactionDate,
|
|
18653
|
+
expectedBalance: closingBalance,
|
|
18654
|
+
actualBalance,
|
|
18655
|
+
difference,
|
|
18656
|
+
error: `Balance mismatch: expected ${closingBalance}, got ${actualBalance} (difference: ${difference})`,
|
|
18657
|
+
hint: "Check for missing transactions, duplicate imports, or incorrect rules",
|
|
18658
|
+
metadata
|
|
18659
|
+
});
|
|
18660
|
+
}
|
|
18661
|
+
var reconcile_statement_default = tool({
|
|
18662
|
+
description: `ACCOUNTANT AGENT ONLY: Reconcile imported bank statement against closing balance.
|
|
18663
|
+
|
|
18664
|
+
This tool validates that the imported transactions result in the correct closing balance.
|
|
18665
|
+
It must be run inside an import worktree (use import-pipeline for the full workflow).
|
|
18666
|
+
|
|
18667
|
+
**Workflow:**
|
|
18668
|
+
1. Finds the most recently imported CSV in the done directory
|
|
18669
|
+
2. Extracts closing balance from CSV metadata (or uses manual override)
|
|
18670
|
+
3. Determines the account from the matching rules file (or uses manual override)
|
|
18671
|
+
4. Queries hledger for the actual balance as of the last transaction date
|
|
18672
|
+
5. Compares expected vs actual balance
|
|
18673
|
+
|
|
18674
|
+
**Balance Sources:**
|
|
18675
|
+
- Automatic: Extracted from CSV header metadata (e.g., UBS files have "Closing balance:" row)
|
|
18676
|
+
- Manual: Provided via closingBalance parameter (required for providers like Revolut)
|
|
18677
|
+
|
|
18678
|
+
**Account Detection:**
|
|
18679
|
+
- Automatic: Parsed from account1 directive in matching rules file
|
|
18680
|
+
- Manual: Provided via account parameter`,
|
|
18681
|
+
args: {
|
|
18682
|
+
provider: tool.schema.string().optional().describe('Filter by provider (e.g., "ubs", "revolut")'),
|
|
18683
|
+
currency: tool.schema.string().optional().describe('Filter by currency (e.g., "chf", "eur")'),
|
|
18684
|
+
closingBalance: tool.schema.string().optional().describe('Manual closing balance (e.g., "CHF 2324.79"). Required if not in CSV metadata.'),
|
|
18685
|
+
account: tool.schema.string().optional().describe('Manual account (e.g., "assets:bank:ubs:checking"). Auto-detected from rules file if not provided.')
|
|
18686
|
+
},
|
|
18687
|
+
async execute(params, context) {
|
|
18688
|
+
const { directory, agent } = context;
|
|
18689
|
+
return reconcileStatementCore(directory, agent, {
|
|
18690
|
+
provider: params.provider,
|
|
18691
|
+
currency: params.currency,
|
|
18692
|
+
closingBalance: params.closingBalance,
|
|
18693
|
+
account: params.account
|
|
18694
|
+
});
|
|
18695
|
+
}
|
|
18696
|
+
});
|
|
17195
18697
|
// src/index.ts
|
|
17196
|
-
var __dirname2 =
|
|
17197
|
-
var AGENT_FILE =
|
|
18698
|
+
var __dirname2 = dirname5(fileURLToPath(import.meta.url));
|
|
18699
|
+
var AGENT_FILE = join10(__dirname2, "..", "agent", "accountant.md");
|
|
17198
18700
|
var AccountantPlugin = async () => {
|
|
17199
18701
|
const agent = loadAgent(AGENT_FILE);
|
|
17200
18702
|
return {
|
|
17201
18703
|
tool: {
|
|
17202
|
-
"
|
|
17203
|
-
"classify-statements":
|
|
17204
|
-
"import-statements":
|
|
18704
|
+
"fetch-currency-prices": fetch_currency_prices_default,
|
|
18705
|
+
"classify-statements": classify_statements_default,
|
|
18706
|
+
"import-statements": import_statements_default,
|
|
18707
|
+
"reconcile-statements": reconcile_statement_default
|
|
17205
18708
|
},
|
|
17206
18709
|
config: async (config2) => {
|
|
17207
18710
|
if (agent) {
|