@fuzzle/opencode-accountant 0.1.0-next.1 → 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 +799 -434
- package/docs/tools/{update-prices.md → fetch-currency-prices.md} +10 -10
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1342,18 +1342,18 @@ 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
1355
|
return new Promise((resolve2, reject) => {
|
|
1356
|
-
|
|
1356
|
+
fs8.readFile(fileInputName, encoding, (err, data) => {
|
|
1357
1357
|
if (err) {
|
|
1358
1358
|
reject(err);
|
|
1359
1359
|
return;
|
|
@@ -1363,7 +1363,7 @@ var require_fileUtils = __commonJS((exports, module) => {
|
|
|
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,11 +1372,11 @@ 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
1378
|
return new Promise((resolve2, reject) => {
|
|
1379
|
-
|
|
1379
|
+
fs8.writeFile(fileOutputName, json3, (err) => {
|
|
1380
1380
|
if (err)
|
|
1381
1381
|
return reject(err);
|
|
1382
1382
|
resolve2();
|
|
@@ -1941,7 +1941,7 @@ var require_convert_csv_to_json = __commonJS((exports) => {
|
|
|
1941
1941
|
});
|
|
1942
1942
|
|
|
1943
1943
|
// src/index.ts
|
|
1944
|
-
import { dirname 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,16 +17269,16 @@ 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
|
});
|
|
17191
17275
|
// src/tools/classify-statements.ts
|
|
17192
|
-
import * as
|
|
17193
|
-
import * as
|
|
17276
|
+
import * as fs5 from "fs";
|
|
17277
|
+
import * as path6 from "path";
|
|
17194
17278
|
|
|
17195
17279
|
// src/utils/importConfig.ts
|
|
17196
17280
|
import * as fs3 from "fs";
|
|
17197
|
-
import * as
|
|
17281
|
+
import * as path4 from "path";
|
|
17198
17282
|
var CONFIG_FILE2 = "config/import/providers.yaml";
|
|
17199
17283
|
var REQUIRED_PATH_FIELDS = [
|
|
17200
17284
|
"import",
|
|
@@ -17316,7 +17400,7 @@ function validateProviderConfig(name, config2) {
|
|
|
17316
17400
|
return { detect, currencies };
|
|
17317
17401
|
}
|
|
17318
17402
|
function loadImportConfig(directory) {
|
|
17319
|
-
const configPath =
|
|
17403
|
+
const configPath = path4.join(directory, CONFIG_FILE2);
|
|
17320
17404
|
if (!fs3.existsSync(configPath)) {
|
|
17321
17405
|
throw new Error(`Configuration file not found: ${CONFIG_FILE2}. Please create this file to configure statement imports.`);
|
|
17322
17406
|
}
|
|
@@ -17475,13 +17559,15 @@ function isInWorktree(directory) {
|
|
|
17475
17559
|
}
|
|
17476
17560
|
}
|
|
17477
17561
|
|
|
17478
|
-
// src/
|
|
17562
|
+
// src/utils/fileUtils.ts
|
|
17563
|
+
import * as fs4 from "fs";
|
|
17564
|
+
import * as path5 from "path";
|
|
17479
17565
|
function findCSVFiles(importsDir) {
|
|
17480
17566
|
if (!fs4.existsSync(importsDir)) {
|
|
17481
17567
|
return [];
|
|
17482
17568
|
}
|
|
17483
17569
|
return fs4.readdirSync(importsDir).filter((file2) => file2.toLowerCase().endsWith(".csv")).filter((file2) => {
|
|
17484
|
-
const fullPath =
|
|
17570
|
+
const fullPath = path5.join(importsDir, file2);
|
|
17485
17571
|
return fs4.statSync(fullPath).isFile();
|
|
17486
17572
|
});
|
|
17487
17573
|
}
|
|
@@ -17490,63 +17576,58 @@ function ensureDirectory(dirPath) {
|
|
|
17490
17576
|
fs4.mkdirSync(dirPath, { recursive: true });
|
|
17491
17577
|
}
|
|
17492
17578
|
}
|
|
17493
|
-
|
|
17494
|
-
|
|
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,
|
|
17495
17599
|
classified: [],
|
|
17496
17600
|
unrecognized: []
|
|
17497
17601
|
});
|
|
17498
|
-
|
|
17499
|
-
|
|
17500
|
-
}
|
|
17501
|
-
|
|
17502
|
-
|
|
17503
|
-
|
|
17504
|
-
|
|
17505
|
-
|
|
17506
|
-
|
|
17507
|
-
|
|
17508
|
-
|
|
17509
|
-
|
|
17510
|
-
let config2;
|
|
17511
|
-
try {
|
|
17512
|
-
config2 = configLoader(directory);
|
|
17513
|
-
} catch (err) {
|
|
17514
|
-
return JSON.stringify({
|
|
17515
|
-
success: false,
|
|
17516
|
-
error: err instanceof Error ? err.message : String(err),
|
|
17517
|
-
classified: [],
|
|
17518
|
-
unrecognized: []
|
|
17519
|
-
});
|
|
17520
|
-
}
|
|
17521
|
-
const importsDir = path4.join(directory, config2.paths.import);
|
|
17522
|
-
const pendingDir = path4.join(directory, config2.paths.pending);
|
|
17523
|
-
const unrecognizedDir = path4.join(directory, config2.paths.unrecognized);
|
|
17524
|
-
const csvFiles = findCSVFiles(importsDir);
|
|
17525
|
-
if (csvFiles.length === 0) {
|
|
17526
|
-
return JSON.stringify({
|
|
17527
|
-
success: true,
|
|
17528
|
-
classified: [],
|
|
17529
|
-
unrecognized: [],
|
|
17530
|
-
message: `No CSV files found in ${config2.paths.import}`
|
|
17531
|
-
});
|
|
17532
|
-
}
|
|
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) {
|
|
17533
17614
|
const plannedMoves = [];
|
|
17534
17615
|
const collisions = [];
|
|
17535
17616
|
for (const filename of csvFiles) {
|
|
17536
|
-
const sourcePath =
|
|
17537
|
-
const content =
|
|
17617
|
+
const sourcePath = path6.join(importsDir, filename);
|
|
17618
|
+
const content = fs5.readFileSync(sourcePath, "utf-8");
|
|
17538
17619
|
const detection = detectProvider(filename, content, config2);
|
|
17539
17620
|
let targetPath;
|
|
17540
17621
|
let targetFilename;
|
|
17541
17622
|
if (detection) {
|
|
17542
17623
|
targetFilename = detection.outputFilename || filename;
|
|
17543
|
-
const targetDir =
|
|
17544
|
-
targetPath =
|
|
17624
|
+
const targetDir = path6.join(pendingDir, detection.provider, detection.currency);
|
|
17625
|
+
targetPath = path6.join(targetDir, targetFilename);
|
|
17545
17626
|
} else {
|
|
17546
17627
|
targetFilename = filename;
|
|
17547
|
-
targetPath =
|
|
17628
|
+
targetPath = path6.join(unrecognizedDir, filename);
|
|
17548
17629
|
}
|
|
17549
|
-
if (
|
|
17630
|
+
if (fs5.existsSync(targetPath)) {
|
|
17550
17631
|
collisions.push({
|
|
17551
17632
|
filename,
|
|
17552
17633
|
existingPath: targetPath
|
|
@@ -17560,64 +17641,81 @@ async function classifyStatementsCore(directory, agent, configLoader = loadImpor
|
|
|
17560
17641
|
detection
|
|
17561
17642
|
});
|
|
17562
17643
|
}
|
|
17563
|
-
|
|
17564
|
-
|
|
17565
|
-
|
|
17566
|
-
error: `Cannot classify: ${collisions.length} file(s) would overwrite existing pending files.`,
|
|
17567
|
-
collisions,
|
|
17568
|
-
classified: [],
|
|
17569
|
-
unrecognized: []
|
|
17570
|
-
});
|
|
17571
|
-
}
|
|
17644
|
+
return { plannedMoves, collisions };
|
|
17645
|
+
}
|
|
17646
|
+
function executeMoves(plannedMoves, config2, unrecognizedDir) {
|
|
17572
17647
|
const classified = [];
|
|
17573
17648
|
const unrecognized = [];
|
|
17574
17649
|
for (const move of plannedMoves) {
|
|
17575
17650
|
if (move.detection) {
|
|
17576
|
-
const targetDir =
|
|
17651
|
+
const targetDir = path6.dirname(move.targetPath);
|
|
17577
17652
|
ensureDirectory(targetDir);
|
|
17578
|
-
|
|
17653
|
+
fs5.renameSync(move.sourcePath, move.targetPath);
|
|
17579
17654
|
classified.push({
|
|
17580
17655
|
filename: move.targetFilename,
|
|
17581
17656
|
originalFilename: move.detection.outputFilename ? move.filename : undefined,
|
|
17582
17657
|
provider: move.detection.provider,
|
|
17583
17658
|
currency: move.detection.currency,
|
|
17584
|
-
targetPath:
|
|
17659
|
+
targetPath: path6.join(config2.paths.pending, move.detection.provider, move.detection.currency, move.targetFilename)
|
|
17585
17660
|
});
|
|
17586
17661
|
} else {
|
|
17587
17662
|
ensureDirectory(unrecognizedDir);
|
|
17588
|
-
|
|
17663
|
+
fs5.renameSync(move.sourcePath, move.targetPath);
|
|
17589
17664
|
unrecognized.push({
|
|
17590
17665
|
filename: move.filename,
|
|
17591
|
-
targetPath:
|
|
17666
|
+
targetPath: path6.join(config2.paths.unrecognized, move.filename)
|
|
17592
17667
|
});
|
|
17593
17668
|
}
|
|
17594
17669
|
}
|
|
17595
|
-
return
|
|
17596
|
-
|
|
17597
|
-
|
|
17598
|
-
|
|
17599
|
-
|
|
17600
|
-
|
|
17601
|
-
classified: classified.length,
|
|
17602
|
-
unrecognized: unrecognized.length
|
|
17603
|
-
}
|
|
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: []
|
|
17604
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);
|
|
17605
17703
|
}
|
|
17606
17704
|
var classify_statements_default = tool({
|
|
17607
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.",
|
|
17608
17706
|
args: {},
|
|
17609
17707
|
async execute(_params, context) {
|
|
17610
17708
|
const { directory, agent } = context;
|
|
17611
|
-
return
|
|
17709
|
+
return classifyStatements(directory, agent);
|
|
17612
17710
|
}
|
|
17613
17711
|
});
|
|
17614
17712
|
// src/tools/import-statements.ts
|
|
17615
|
-
import * as
|
|
17616
|
-
import * as
|
|
17713
|
+
import * as fs9 from "fs";
|
|
17714
|
+
import * as path8 from "path";
|
|
17617
17715
|
|
|
17618
17716
|
// src/utils/rulesMatcher.ts
|
|
17619
|
-
import * as
|
|
17620
|
-
import * as
|
|
17717
|
+
import * as fs6 from "fs";
|
|
17718
|
+
import * as path7 from "path";
|
|
17621
17719
|
function parseSourceDirective(content) {
|
|
17622
17720
|
const match = content.match(/^source\s+([^\n#]+)/m);
|
|
17623
17721
|
if (!match) {
|
|
@@ -17626,28 +17724,28 @@ function parseSourceDirective(content) {
|
|
|
17626
17724
|
return match[1].trim();
|
|
17627
17725
|
}
|
|
17628
17726
|
function resolveSourcePath(sourcePath, rulesFilePath) {
|
|
17629
|
-
if (
|
|
17727
|
+
if (path7.isAbsolute(sourcePath)) {
|
|
17630
17728
|
return sourcePath;
|
|
17631
17729
|
}
|
|
17632
|
-
const rulesDir =
|
|
17633
|
-
return
|
|
17730
|
+
const rulesDir = path7.dirname(rulesFilePath);
|
|
17731
|
+
return path7.resolve(rulesDir, sourcePath);
|
|
17634
17732
|
}
|
|
17635
17733
|
function loadRulesMapping(rulesDir) {
|
|
17636
17734
|
const mapping = {};
|
|
17637
|
-
if (!
|
|
17735
|
+
if (!fs6.existsSync(rulesDir)) {
|
|
17638
17736
|
return mapping;
|
|
17639
17737
|
}
|
|
17640
|
-
const files =
|
|
17738
|
+
const files = fs6.readdirSync(rulesDir);
|
|
17641
17739
|
for (const file2 of files) {
|
|
17642
17740
|
if (!file2.endsWith(".rules")) {
|
|
17643
17741
|
continue;
|
|
17644
17742
|
}
|
|
17645
|
-
const rulesFilePath =
|
|
17646
|
-
const stat =
|
|
17743
|
+
const rulesFilePath = path7.join(rulesDir, file2);
|
|
17744
|
+
const stat = fs6.statSync(rulesFilePath);
|
|
17647
17745
|
if (!stat.isFile()) {
|
|
17648
17746
|
continue;
|
|
17649
17747
|
}
|
|
17650
|
-
const content =
|
|
17748
|
+
const content = fs6.readFileSync(rulesFilePath, "utf-8");
|
|
17651
17749
|
const sourcePath = parseSourceDirective(content);
|
|
17652
17750
|
if (!sourcePath) {
|
|
17653
17751
|
continue;
|
|
@@ -17661,9 +17759,9 @@ function findRulesForCsv(csvPath, mapping) {
|
|
|
17661
17759
|
if (mapping[csvPath]) {
|
|
17662
17760
|
return mapping[csvPath];
|
|
17663
17761
|
}
|
|
17664
|
-
const normalizedCsvPath =
|
|
17762
|
+
const normalizedCsvPath = path7.normalize(csvPath);
|
|
17665
17763
|
for (const [mappedCsv, rulesFile] of Object.entries(mapping)) {
|
|
17666
|
-
if (
|
|
17764
|
+
if (path7.normalize(mappedCsv) === normalizedCsvPath) {
|
|
17667
17765
|
return rulesFile;
|
|
17668
17766
|
}
|
|
17669
17767
|
}
|
|
@@ -17751,8 +17849,45 @@ async function validateLedger(mainJournalPath, executor = defaultHledgerExecutor
|
|
|
17751
17849
|
}
|
|
17752
17850
|
return { valid: errors3.length === 0, errors: errors3 };
|
|
17753
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
|
+
}
|
|
17754
17888
|
|
|
17755
17889
|
// src/utils/rulesParser.ts
|
|
17890
|
+
import * as fs7 from "fs";
|
|
17756
17891
|
function parseSkipRows(rulesContent) {
|
|
17757
17892
|
const match = rulesContent.match(/^skip\s+(\d+)/m);
|
|
17758
17893
|
return match ? parseInt(match[1], 10) : 0;
|
|
@@ -17812,6 +17947,18 @@ function parseAmountFields(rulesContent, fieldNames) {
|
|
|
17812
17947
|
}
|
|
17813
17948
|
return result;
|
|
17814
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
|
+
}
|
|
17815
17962
|
function parseRulesFile(rulesContent) {
|
|
17816
17963
|
const fieldNames = parseFieldNames(rulesContent);
|
|
17817
17964
|
return {
|
|
@@ -17826,9 +17973,56 @@ function parseRulesFile(rulesContent) {
|
|
|
17826
17973
|
|
|
17827
17974
|
// src/utils/csvParser.ts
|
|
17828
17975
|
var import_convert_csv_to_json = __toESM(require_convert_csv_to_json(), 1);
|
|
17829
|
-
import * as
|
|
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
|
|
17830
18024
|
function parseCsvFile(csvPath, config2) {
|
|
17831
|
-
const csvContent =
|
|
18025
|
+
const csvContent = fs8.readFileSync(csvPath, "utf-8");
|
|
17832
18026
|
const lines = csvContent.split(`
|
|
17833
18027
|
`);
|
|
17834
18028
|
const headerIndex = config2.skipRows;
|
|
@@ -17852,10 +18046,6 @@ function parseCsvFile(csvPath, config2) {
|
|
|
17852
18046
|
}
|
|
17853
18047
|
return mappedRows;
|
|
17854
18048
|
}
|
|
17855
|
-
function parseAmountValue(amountStr) {
|
|
17856
|
-
const cleaned = amountStr.replace(/[A-Z]{3}\s*/g, "").trim();
|
|
17857
|
-
return parseFloat(cleaned) || 0;
|
|
17858
|
-
}
|
|
17859
18049
|
function getRowAmount(row, amountFields) {
|
|
17860
18050
|
if (amountFields.single) {
|
|
17861
18051
|
return parseAmountValue(row[amountFields.single] || "0");
|
|
@@ -17965,106 +18155,188 @@ function findMatchingCsvRow(posting, csvRows, config2) {
|
|
|
17965
18155
|
}
|
|
17966
18156
|
|
|
17967
18157
|
// src/tools/import-statements.ts
|
|
17968
|
-
function
|
|
17969
|
-
|
|
17970
|
-
|
|
17971
|
-
|
|
17972
|
-
|
|
17973
|
-
|
|
17974
|
-
|
|
17975
|
-
|
|
17976
|
-
|
|
17977
|
-
|
|
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);
|
|
17978
18221
|
}
|
|
17979
|
-
|
|
17980
|
-
|
|
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
|
+
};
|
|
17981
18230
|
}
|
|
17982
|
-
const
|
|
17983
|
-
|
|
17984
|
-
|
|
17985
|
-
|
|
17986
|
-
|
|
17987
|
-
|
|
17988
|
-
|
|
17989
|
-
|
|
17990
|
-
if (!includeExists) {
|
|
17991
|
-
const newContent = mainJournalContent.trimEnd() + `
|
|
17992
|
-
` + includeDirective + `
|
|
17993
|
-
`;
|
|
17994
|
-
fs7.writeFileSync(mainJournalPath, newContent);
|
|
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);
|
|
17995
18239
|
}
|
|
17996
|
-
return
|
|
18240
|
+
return {
|
|
18241
|
+
success: true,
|
|
18242
|
+
importedCount: importedFiles.length
|
|
18243
|
+
};
|
|
17997
18244
|
}
|
|
17998
|
-
function
|
|
17999
|
-
const
|
|
18000
|
-
if (!
|
|
18001
|
-
return
|
|
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
|
+
};
|
|
18002
18256
|
}
|
|
18003
|
-
|
|
18004
|
-
if (
|
|
18005
|
-
|
|
18006
|
-
|
|
18007
|
-
|
|
18008
|
-
|
|
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
|
+
};
|
|
18009
18267
|
}
|
|
18010
|
-
|
|
18011
|
-
|
|
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
|
+
};
|
|
18012
18282
|
}
|
|
18013
|
-
|
|
18014
|
-
|
|
18015
|
-
|
|
18016
|
-
const
|
|
18017
|
-
|
|
18018
|
-
|
|
18019
|
-
|
|
18020
|
-
|
|
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;
|
|
18021
18299
|
}
|
|
18022
18300
|
}
|
|
18023
18301
|
}
|
|
18024
|
-
|
|
18025
|
-
|
|
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
|
+
};
|
|
18026
18310
|
}
|
|
18027
|
-
async function
|
|
18311
|
+
async function importStatements(directory, agent, options, configLoader = loadImportConfig, hledgerExecutor = defaultHledgerExecutor, worktreeChecker = isInWorktree) {
|
|
18028
18312
|
const restrictionError = checkAccountantAgent(agent, "import statements");
|
|
18029
18313
|
if (restrictionError) {
|
|
18030
18314
|
return restrictionError;
|
|
18031
18315
|
}
|
|
18032
18316
|
if (!worktreeChecker(directory)) {
|
|
18033
|
-
return
|
|
18034
|
-
success: false,
|
|
18035
|
-
error: "import-statements must be run inside an import worktree",
|
|
18036
|
-
hint: "Use import-pipeline tool to orchestrate the full workflow"
|
|
18037
|
-
});
|
|
18317
|
+
return buildErrorResult3("import-statements must be run inside an import worktree", "Use import-pipeline tool to orchestrate the full workflow");
|
|
18038
18318
|
}
|
|
18039
18319
|
let config2;
|
|
18040
18320
|
try {
|
|
18041
18321
|
config2 = configLoader(directory);
|
|
18042
18322
|
} catch (error45) {
|
|
18043
|
-
|
|
18044
|
-
|
|
18045
|
-
error: `Failed to load configuration: ${error45 instanceof Error ? error45.message : String(error45)}`,
|
|
18046
|
-
hint: 'Ensure config/import/providers.yaml exists with required paths including "rules"'
|
|
18047
|
-
});
|
|
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"');
|
|
18048
18325
|
}
|
|
18049
|
-
const pendingDir =
|
|
18050
|
-
const rulesDir =
|
|
18051
|
-
const doneDir =
|
|
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);
|
|
18052
18329
|
const rulesMapping = loadRulesMapping(rulesDir);
|
|
18053
|
-
const csvFiles =
|
|
18330
|
+
const csvFiles = findCsvFiles(pendingDir, options.provider, options.currency);
|
|
18054
18331
|
if (csvFiles.length === 0) {
|
|
18055
|
-
return
|
|
18056
|
-
|
|
18057
|
-
|
|
18058
|
-
|
|
18059
|
-
|
|
18060
|
-
|
|
18061
|
-
|
|
18062
|
-
|
|
18063
|
-
matched: 0,
|
|
18064
|
-
unknown: 0
|
|
18065
|
-
},
|
|
18066
|
-
message: "No CSV files found to process"
|
|
18067
|
-
});
|
|
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");
|
|
18068
18340
|
}
|
|
18069
18341
|
const fileResults = [];
|
|
18070
18342
|
let totalTransactions = 0;
|
|
@@ -18073,79 +18345,18 @@ async function importStatementsCore(directory, agent, options, configLoader = lo
|
|
|
18073
18345
|
let filesWithErrors = 0;
|
|
18074
18346
|
let filesWithoutRules = 0;
|
|
18075
18347
|
for (const csvFile of csvFiles) {
|
|
18076
|
-
const
|
|
18077
|
-
|
|
18078
|
-
|
|
18079
|
-
|
|
18080
|
-
|
|
18081
|
-
|
|
18082
|
-
|
|
18083
|
-
|
|
18084
|
-
unknownPostings: [],
|
|
18085
|
-
error: "No matching rules file found"
|
|
18086
|
-
});
|
|
18087
|
-
continue;
|
|
18088
|
-
}
|
|
18089
|
-
const result = await hledgerExecutor(["print", "-f", csvFile, "--rules-file", rulesFile]);
|
|
18090
|
-
if (result.exitCode !== 0) {
|
|
18091
|
-
filesWithErrors++;
|
|
18092
|
-
fileResults.push({
|
|
18093
|
-
csv: path6.relative(directory, csvFile),
|
|
18094
|
-
rulesFile: path6.relative(directory, rulesFile),
|
|
18095
|
-
totalTransactions: 0,
|
|
18096
|
-
matchedTransactions: 0,
|
|
18097
|
-
unknownPostings: [],
|
|
18098
|
-
error: `hledger error: ${result.stderr.trim() || "Unknown error"}`
|
|
18099
|
-
});
|
|
18100
|
-
continue;
|
|
18101
|
-
}
|
|
18102
|
-
const unknownPostings = parseUnknownPostings(result.stdout);
|
|
18103
|
-
const transactionCount = countTransactions(result.stdout);
|
|
18104
|
-
const matchedCount = transactionCount - unknownPostings.length;
|
|
18105
|
-
const years = extractTransactionYears(result.stdout);
|
|
18106
|
-
if (years.size > 1) {
|
|
18107
|
-
const yearList = Array.from(years).sort().join(", ");
|
|
18108
|
-
filesWithErrors++;
|
|
18109
|
-
fileResults.push({
|
|
18110
|
-
csv: path6.relative(directory, csvFile),
|
|
18111
|
-
rulesFile: path6.relative(directory, rulesFile),
|
|
18112
|
-
totalTransactions: transactionCount,
|
|
18113
|
-
matchedTransactions: matchedCount,
|
|
18114
|
-
unknownPostings: [],
|
|
18115
|
-
error: `CSV contains transactions from multiple years (${yearList}). Split the CSV by year before importing.`
|
|
18116
|
-
});
|
|
18117
|
-
continue;
|
|
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
|
+
}
|
|
18118
18356
|
}
|
|
18119
|
-
|
|
18120
|
-
|
|
18121
|
-
|
|
18122
|
-
const rulesContent = fs7.readFileSync(rulesFile, "utf-8");
|
|
18123
|
-
const rulesConfig = parseRulesFile(rulesContent);
|
|
18124
|
-
const csvRows = parseCsvFile(csvFile, rulesConfig);
|
|
18125
|
-
for (const posting of unknownPostings) {
|
|
18126
|
-
posting.csvRow = findMatchingCsvRow({
|
|
18127
|
-
date: posting.date,
|
|
18128
|
-
description: posting.description,
|
|
18129
|
-
amount: posting.amount
|
|
18130
|
-
}, csvRows, rulesConfig);
|
|
18131
|
-
}
|
|
18132
|
-
} catch {
|
|
18133
|
-
for (const posting of unknownPostings) {
|
|
18134
|
-
posting.csvRow = undefined;
|
|
18135
|
-
}
|
|
18136
|
-
}
|
|
18137
|
-
}
|
|
18138
|
-
totalTransactions += transactionCount;
|
|
18139
|
-
totalMatched += matchedCount;
|
|
18140
|
-
totalUnknown += unknownPostings.length;
|
|
18141
|
-
fileResults.push({
|
|
18142
|
-
csv: path6.relative(directory, csvFile),
|
|
18143
|
-
rulesFile: path6.relative(directory, rulesFile),
|
|
18144
|
-
totalTransactions: transactionCount,
|
|
18145
|
-
matchedTransactions: matchedCount,
|
|
18146
|
-
unknownPostings,
|
|
18147
|
-
transactionYear
|
|
18148
|
-
});
|
|
18357
|
+
totalTransactions += fileResult.totalTransactions;
|
|
18358
|
+
totalMatched += fileResult.matchedTransactions;
|
|
18359
|
+
totalUnknown += fileResult.unknownPostings.length;
|
|
18149
18360
|
}
|
|
18150
18361
|
const hasUnknowns = totalUnknown > 0;
|
|
18151
18362
|
const hasErrors = filesWithErrors > 0 || filesWithoutRules > 0;
|
|
@@ -18172,129 +18383,37 @@ async function importStatementsCore(directory, agent, options, configLoader = lo
|
|
|
18172
18383
|
return JSON.stringify(result);
|
|
18173
18384
|
}
|
|
18174
18385
|
if (hasUnknowns || hasErrors) {
|
|
18175
|
-
return
|
|
18176
|
-
|
|
18177
|
-
|
|
18178
|
-
|
|
18179
|
-
|
|
18180
|
-
|
|
18181
|
-
|
|
18182
|
-
|
|
18183
|
-
matched: totalMatched,
|
|
18184
|
-
unknown: totalUnknown
|
|
18185
|
-
},
|
|
18186
|
-
error: "Cannot import: some transactions have unknown accounts or files have errors",
|
|
18187
|
-
hint: "Run with checkOnly: true to see details, then add missing rules"
|
|
18188
|
-
});
|
|
18189
|
-
}
|
|
18190
|
-
const importedFiles = [];
|
|
18191
|
-
for (const fileResult of fileResults) {
|
|
18192
|
-
const csvFile = path6.join(directory, fileResult.csv);
|
|
18193
|
-
const rulesFile = fileResult.rulesFile ? path6.join(directory, fileResult.rulesFile) : null;
|
|
18194
|
-
if (!rulesFile)
|
|
18195
|
-
continue;
|
|
18196
|
-
const year = fileResult.transactionYear;
|
|
18197
|
-
if (!year) {
|
|
18198
|
-
return JSON.stringify({
|
|
18199
|
-
success: false,
|
|
18200
|
-
files: fileResults,
|
|
18201
|
-
summary: {
|
|
18202
|
-
filesProcessed: csvFiles.length,
|
|
18203
|
-
filesWithErrors: 1,
|
|
18204
|
-
filesWithoutRules,
|
|
18205
|
-
totalTransactions,
|
|
18206
|
-
matched: totalMatched,
|
|
18207
|
-
unknown: totalUnknown
|
|
18208
|
-
},
|
|
18209
|
-
error: `No transactions found in ${fileResult.csv}`
|
|
18210
|
-
});
|
|
18211
|
-
}
|
|
18212
|
-
let yearJournalPath;
|
|
18213
|
-
try {
|
|
18214
|
-
yearJournalPath = ensureYearJournalExists(directory, year);
|
|
18215
|
-
} catch (error45) {
|
|
18216
|
-
return JSON.stringify({
|
|
18217
|
-
success: false,
|
|
18218
|
-
files: fileResults,
|
|
18219
|
-
summary: {
|
|
18220
|
-
filesProcessed: csvFiles.length,
|
|
18221
|
-
filesWithErrors: 1,
|
|
18222
|
-
filesWithoutRules,
|
|
18223
|
-
totalTransactions,
|
|
18224
|
-
matched: totalMatched,
|
|
18225
|
-
unknown: totalUnknown
|
|
18226
|
-
},
|
|
18227
|
-
error: error45 instanceof Error ? error45.message : String(error45)
|
|
18228
|
-
});
|
|
18229
|
-
}
|
|
18230
|
-
const result = await hledgerExecutor([
|
|
18231
|
-
"import",
|
|
18232
|
-
"-f",
|
|
18233
|
-
yearJournalPath,
|
|
18234
|
-
csvFile,
|
|
18235
|
-
"--rules-file",
|
|
18236
|
-
rulesFile
|
|
18237
|
-
]);
|
|
18238
|
-
if (result.exitCode !== 0) {
|
|
18239
|
-
return JSON.stringify({
|
|
18240
|
-
success: false,
|
|
18241
|
-
files: fileResults,
|
|
18242
|
-
summary: {
|
|
18243
|
-
filesProcessed: csvFiles.length,
|
|
18244
|
-
filesWithErrors: 1,
|
|
18245
|
-
filesWithoutRules,
|
|
18246
|
-
totalTransactions,
|
|
18247
|
-
matched: totalMatched,
|
|
18248
|
-
unknown: totalUnknown
|
|
18249
|
-
},
|
|
18250
|
-
error: `Import failed for ${fileResult.csv}: ${result.stderr.trim()}`
|
|
18251
|
-
});
|
|
18252
|
-
}
|
|
18253
|
-
importedFiles.push(csvFile);
|
|
18254
|
-
}
|
|
18255
|
-
const mainJournalPath = path6.join(directory, ".hledger.journal");
|
|
18256
|
-
const validationResult = await validateLedger(mainJournalPath, hledgerExecutor);
|
|
18257
|
-
if (!validationResult.valid) {
|
|
18258
|
-
return JSON.stringify({
|
|
18259
|
-
success: false,
|
|
18260
|
-
files: fileResults,
|
|
18261
|
-
summary: {
|
|
18262
|
-
filesProcessed: csvFiles.length,
|
|
18263
|
-
filesWithErrors: 1,
|
|
18264
|
-
filesWithoutRules,
|
|
18265
|
-
totalTransactions,
|
|
18266
|
-
matched: totalMatched,
|
|
18267
|
-
unknown: totalUnknown
|
|
18268
|
-
},
|
|
18269
|
-
error: `Ledger validation failed after import: ${validationResult.errors.join("; ")}`,
|
|
18270
|
-
hint: "The import created invalid transactions. Check your rules file configuration. CSV files have NOT been moved to done."
|
|
18271
|
-
});
|
|
18272
|
-
}
|
|
18273
|
-
for (const csvFile of importedFiles) {
|
|
18274
|
-
const relativePath = path6.relative(pendingDir, csvFile);
|
|
18275
|
-
const destPath = path6.join(doneDir, relativePath);
|
|
18276
|
-
const destDir = path6.dirname(destPath);
|
|
18277
|
-
if (!fs7.existsSync(destDir)) {
|
|
18278
|
-
fs7.mkdirSync(destDir, { recursive: true });
|
|
18279
|
-
}
|
|
18280
|
-
fs7.renameSync(csvFile, destPath);
|
|
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");
|
|
18281
18394
|
}
|
|
18282
|
-
|
|
18283
|
-
|
|
18284
|
-
|
|
18285
|
-
...f,
|
|
18286
|
-
imported: true
|
|
18287
|
-
})),
|
|
18288
|
-
summary: {
|
|
18395
|
+
const importResult = await executeImports(fileResults, directory, pendingDir, doneDir, hledgerExecutor);
|
|
18396
|
+
if (!importResult.success) {
|
|
18397
|
+
return buildErrorResultWithDetails(importResult.error, fileResults, {
|
|
18289
18398
|
filesProcessed: csvFiles.length,
|
|
18290
|
-
filesWithErrors:
|
|
18291
|
-
filesWithoutRules
|
|
18399
|
+
filesWithErrors: 1,
|
|
18400
|
+
filesWithoutRules,
|
|
18292
18401
|
totalTransactions,
|
|
18293
18402
|
matched: totalMatched,
|
|
18294
|
-
unknown:
|
|
18295
|
-
},
|
|
18296
|
-
|
|
18297
|
-
|
|
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)`);
|
|
18298
18417
|
}
|
|
18299
18418
|
var import_statements_default = tool({
|
|
18300
18419
|
description: `ACCOUNTANT AGENT ONLY: Import classified bank statement CSVs into hledger using rules files.
|
|
@@ -18323,23 +18442,269 @@ This tool processes CSV files in the pending import directory and uses hledger's
|
|
|
18323
18442
|
},
|
|
18324
18443
|
async execute(params, context) {
|
|
18325
18444
|
const { directory, agent } = context;
|
|
18326
|
-
return
|
|
18445
|
+
return importStatements(directory, agent, {
|
|
18327
18446
|
provider: params.provider,
|
|
18328
18447
|
currency: params.currency,
|
|
18329
18448
|
checkOnly: params.checkOnly
|
|
18330
18449
|
});
|
|
18331
18450
|
}
|
|
18332
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
|
+
});
|
|
18333
18697
|
// src/index.ts
|
|
18334
|
-
var __dirname2 =
|
|
18335
|
-
var AGENT_FILE =
|
|
18698
|
+
var __dirname2 = dirname5(fileURLToPath(import.meta.url));
|
|
18699
|
+
var AGENT_FILE = join10(__dirname2, "..", "agent", "accountant.md");
|
|
18336
18700
|
var AccountantPlugin = async () => {
|
|
18337
18701
|
const agent = loadAgent(AGENT_FILE);
|
|
18338
18702
|
return {
|
|
18339
18703
|
tool: {
|
|
18340
|
-
"
|
|
18704
|
+
"fetch-currency-prices": fetch_currency_prices_default,
|
|
18341
18705
|
"classify-statements": classify_statements_default,
|
|
18342
|
-
"import-statements": import_statements_default
|
|
18706
|
+
"import-statements": import_statements_default,
|
|
18707
|
+
"reconcile-statements": reconcile_statement_default
|
|
18343
18708
|
},
|
|
18344
18709
|
config: async (config2) => {
|
|
18345
18710
|
if (agent) {
|