@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/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 fs6 = __require("fs");
1345
+ var fs8 = __require("fs");
1346
1346
 
1347
1347
  class FileUtils {
1348
1348
  readFile(fileInputName, encoding) {
1349
- return fs6.readFileSync(fileInputName, encoding).toString();
1349
+ return fs8.readFileSync(fileInputName, encoding).toString();
1350
1350
  }
1351
1351
  readFileAsync(fileInputName, encoding = "utf8") {
1352
- if (fs6.promises && typeof fs6.promises.readFile === "function") {
1353
- return fs6.promises.readFile(fileInputName, encoding).then((buf) => buf.toString());
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
- fs6.readFile(fileInputName, encoding, (err, data) => {
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
- fs6.writeFile(fileOutputName, json3, function(err) {
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 (fs6.promises && typeof fs6.promises.writeFile === "function") {
1376
- return fs6.promises.writeFile(fileOutputName, json3);
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
- fs6.writeFile(fileOutputName, json3, (err) => {
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 dirname4, join as join7 } from "path";
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/update-prices.ts
16979
+ // src/tools/fetch-currency-prices.ts
16980
16980
  var {$ } = globalThis.Bun;
16981
- import * as path2 from "path";
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/tools/update-prices.ts
17060
- async function defaultPriceFetcher(cmdArgs) {
17061
- const result = await $`pricehist ${cmdArgs}`.quiet();
17062
- return result.stdout.toString().trim();
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.toISOString().split("T")[0];
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 updateJournalWithPrices(journalPath, newPriceLines) {
17086
- let existingLines = [];
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
- return JSON.stringify({
17117
- error: err instanceof Error ? err.message : String(err)
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 = path2.join(directory, "ledger", "currencies", currencyConfig.file);
17159
- updateJournalWithPrices(journalPath, priceLines);
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 JSON.stringify({
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 update_prices_default = tool({
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 updatePricesCore(directory, agent, backfill || false);
17272
+ return fetchCurrencyPrices(directory, agent, backfill || false);
17189
17273
  }
17190
17274
  });
17191
17275
  // src/tools/classify-statements.ts
17192
- import * as path4 from "path";
17193
- import * as fs4 from "fs";
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 path3 from "path";
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 = path3.join(directory, CONFIG_FILE2);
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/tools/classify-statements.ts
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 = path4.join(importsDir, file2);
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
- async function classifyStatementsCore(directory, agent, configLoader = loadImportConfig, worktreeChecker = isInWorktree) {
17494
- const restrictionError = checkAccountantAgent(agent, "classify statements", {
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
- if (restrictionError) {
17499
- return restrictionError;
17500
- }
17501
- if (!worktreeChecker(directory)) {
17502
- return JSON.stringify({
17503
- success: false,
17504
- error: "classify-statements must be run inside an import worktree",
17505
- hint: "Use import-pipeline tool to orchestrate the full workflow",
17506
- classified: [],
17507
- unrecognized: []
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 = path4.join(importsDir, filename);
17537
- const content = fs4.readFileSync(sourcePath, "utf-8");
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 = path4.join(pendingDir, detection.provider, detection.currency);
17544
- targetPath = path4.join(targetDir, targetFilename);
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 = path4.join(unrecognizedDir, filename);
17628
+ targetPath = path6.join(unrecognizedDir, filename);
17548
17629
  }
17549
- if (fs4.existsSync(targetPath)) {
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
- if (collisions.length > 0) {
17564
- return JSON.stringify({
17565
- success: false,
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 = path4.dirname(move.targetPath);
17651
+ const targetDir = path6.dirname(move.targetPath);
17577
17652
  ensureDirectory(targetDir);
17578
- fs4.renameSync(move.sourcePath, move.targetPath);
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: path4.join(config2.paths.pending, move.detection.provider, move.detection.currency, move.targetFilename)
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
- fs4.renameSync(move.sourcePath, move.targetPath);
17663
+ fs5.renameSync(move.sourcePath, move.targetPath);
17589
17664
  unrecognized.push({
17590
17665
  filename: move.filename,
17591
- targetPath: path4.join(config2.paths.unrecognized, move.filename)
17666
+ targetPath: path6.join(config2.paths.unrecognized, move.filename)
17592
17667
  });
17593
17668
  }
17594
17669
  }
17595
- return JSON.stringify({
17596
- success: true,
17597
- classified,
17598
- unrecognized,
17599
- summary: {
17600
- total: csvFiles.length,
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 classifyStatementsCore(directory, agent);
17709
+ return classifyStatements(directory, agent);
17612
17710
  }
17613
17711
  });
17614
17712
  // src/tools/import-statements.ts
17615
- import * as fs7 from "fs";
17616
- import * as path6 from "path";
17713
+ import * as fs9 from "fs";
17714
+ import * as path8 from "path";
17617
17715
 
17618
17716
  // src/utils/rulesMatcher.ts
17619
- import * as fs5 from "fs";
17620
- import * as path5 from "path";
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 (path5.isAbsolute(sourcePath)) {
17727
+ if (path7.isAbsolute(sourcePath)) {
17630
17728
  return sourcePath;
17631
17729
  }
17632
- const rulesDir = path5.dirname(rulesFilePath);
17633
- return path5.resolve(rulesDir, sourcePath);
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 (!fs5.existsSync(rulesDir)) {
17735
+ if (!fs6.existsSync(rulesDir)) {
17638
17736
  return mapping;
17639
17737
  }
17640
- const files = fs5.readdirSync(rulesDir);
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 = path5.join(rulesDir, file2);
17646
- const stat = fs5.statSync(rulesFilePath);
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 = fs5.readFileSync(rulesFilePath, "utf-8");
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 = path5.normalize(csvPath);
17762
+ const normalizedCsvPath = path7.normalize(csvPath);
17665
17763
  for (const [mappedCsv, rulesFile] of Object.entries(mapping)) {
17666
- if (path5.normalize(mappedCsv) === normalizedCsvPath) {
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 fs6 from "fs";
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 = fs6.readFileSync(csvPath, "utf-8");
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 ensureYearJournalExists(directory, year) {
17969
- const ledgerDir = path6.join(directory, "ledger");
17970
- const yearJournalPath = path6.join(ledgerDir, `${year}.journal`);
17971
- const mainJournalPath = path6.join(directory, ".hledger.journal");
17972
- if (!fs7.existsSync(ledgerDir)) {
17973
- fs7.mkdirSync(ledgerDir, { recursive: true });
17974
- }
17975
- if (!fs7.existsSync(yearJournalPath)) {
17976
- fs7.writeFileSync(yearJournalPath, `; ${year} transactions
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
- if (!fs7.existsSync(mainJournalPath)) {
17980
- throw new Error(`.hledger.journal not found at ${mainJournalPath}. Create it first with appropriate includes.`);
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 mainJournalContent = fs7.readFileSync(mainJournalPath, "utf-8");
17983
- const includeDirective = `include ledger/${year}.journal`;
17984
- const lines = mainJournalContent.split(`
17985
- `);
17986
- const includeExists = lines.some((line) => {
17987
- const trimmed = line.trim();
17988
- return trimmed === includeDirective || trimmed.startsWith(includeDirective + " ");
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 yearJournalPath;
18240
+ return {
18241
+ success: true,
18242
+ importedCount: importedFiles.length
18243
+ };
17997
18244
  }
17998
- function findPendingCsvFiles(pendingDir, provider, currency) {
17999
- const csvFiles = [];
18000
- if (!fs7.existsSync(pendingDir)) {
18001
- return csvFiles;
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
- let searchPath = pendingDir;
18004
- if (provider) {
18005
- searchPath = path6.join(searchPath, provider);
18006
- if (currency) {
18007
- searchPath = path6.join(searchPath, currency);
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
- if (!fs7.existsSync(searchPath)) {
18011
- return csvFiles;
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
- function scanDirectory(directory) {
18014
- const entries = fs7.readdirSync(directory, { withFileTypes: true });
18015
- for (const entry of entries) {
18016
- const fullPath = path6.join(directory, entry.name);
18017
- if (entry.isDirectory()) {
18018
- scanDirectory(fullPath);
18019
- } else if (entry.isFile() && entry.name.endsWith(".csv")) {
18020
- csvFiles.push(fullPath);
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
- scanDirectory(searchPath);
18025
- return csvFiles.sort();
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 importStatementsCore(directory, agent, options, configLoader = loadImportConfig, hledgerExecutor = defaultHledgerExecutor, worktreeChecker = isInWorktree) {
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 JSON.stringify({
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
- return JSON.stringify({
18044
- success: false,
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 = path6.join(directory, config2.paths.pending);
18050
- const rulesDir = path6.join(directory, config2.paths.rules);
18051
- const doneDir = path6.join(directory, config2.paths.done);
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 = findPendingCsvFiles(pendingDir, options.provider, options.currency);
18330
+ const csvFiles = findCsvFiles(pendingDir, options.provider, options.currency);
18054
18331
  if (csvFiles.length === 0) {
18055
- return JSON.stringify({
18056
- success: true,
18057
- files: [],
18058
- summary: {
18059
- filesProcessed: 0,
18060
- filesWithErrors: 0,
18061
- filesWithoutRules: 0,
18062
- totalTransactions: 0,
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 rulesFile = findRulesForCsv(csvFile, rulesMapping);
18077
- if (!rulesFile) {
18078
- filesWithoutRules++;
18079
- fileResults.push({
18080
- csv: path6.relative(directory, csvFile),
18081
- rulesFile: null,
18082
- totalTransactions: 0,
18083
- matchedTransactions: 0,
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
- const transactionYear = years.size === 1 ? Array.from(years)[0] : undefined;
18120
- if (unknownPostings.length > 0) {
18121
- try {
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 JSON.stringify({
18176
- success: false,
18177
- files: fileResults,
18178
- summary: {
18179
- filesProcessed: csvFiles.length,
18180
- filesWithErrors,
18181
- filesWithoutRules,
18182
- totalTransactions,
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
- return JSON.stringify({
18283
- success: true,
18284
- files: fileResults.map((f) => ({
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: 0,
18291
- filesWithoutRules: 0,
18399
+ filesWithErrors: 1,
18400
+ filesWithoutRules,
18292
18401
  totalTransactions,
18293
18402
  matched: totalMatched,
18294
- unknown: 0
18295
- },
18296
- message: `Successfully imported ${totalTransactions} transaction(s) from ${importedFiles.length} file(s)`
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 importStatementsCore(directory, agent, {
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 = dirname4(fileURLToPath(import.meta.url));
18335
- var AGENT_FILE = join7(__dirname2, "..", "agent", "accountant.md");
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
- "update-prices": update_prices_default,
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) {