@fuzzle/opencode-accountant 0.16.6-next.1 → 0.16.7-next.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.
Files changed (2) hide show
  1. package/dist/index.js +283 -218
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -17225,6 +17225,35 @@ stocks:
17225
17225
  }
17226
17226
 
17227
17227
  // src/utils/dateUtils.ts
17228
+ var PROVIDER_YEAR_PATTERNS = {
17229
+ swissquote: { pattern: /to-\d{4}(\d{4})\./, group: 1 },
17230
+ ibkr: { pattern: /(\d{4})\d{4}\./, group: 1 }
17231
+ };
17232
+ var DEFAULT_YEAR_PATTERN = /(\d{4})-\d{2}-\d{2}/;
17233
+ function getTransactionYear(ctx) {
17234
+ if (ctx.untilDate) {
17235
+ const year = parseInt(ctx.untilDate.slice(0, 4), 10);
17236
+ if (!isNaN(year))
17237
+ return year;
17238
+ }
17239
+ if (ctx.fromDate) {
17240
+ const year = parseInt(ctx.fromDate.slice(0, 4), 10);
17241
+ if (!isNaN(year))
17242
+ return year;
17243
+ }
17244
+ const providerPattern = PROVIDER_YEAR_PATTERNS[ctx.provider];
17245
+ if (providerPattern) {
17246
+ const match = ctx.filename.match(providerPattern.pattern);
17247
+ if (match) {
17248
+ return parseInt(match[providerPattern.group], 10);
17249
+ }
17250
+ }
17251
+ const defaultMatch = ctx.filePath.match(DEFAULT_YEAR_PATTERN);
17252
+ if (defaultMatch) {
17253
+ return parseInt(defaultMatch[1], 10);
17254
+ }
17255
+ return new Date().getFullYear();
17256
+ }
17228
17257
  function formatDateISO(date5) {
17229
17258
  return date5.toISOString().split("T")[0];
17230
17259
  }
@@ -17741,10 +17770,10 @@ function getContextPath(directory, contextId) {
17741
17770
  function createContext(directory, params) {
17742
17771
  const now = new Date().toISOString();
17743
17772
  const context = {
17773
+ ...params,
17744
17774
  id: randomUUID(),
17745
17775
  createdAt: now,
17746
- updatedAt: now,
17747
- ...params
17776
+ updatedAt: now
17748
17777
  };
17749
17778
  ensureDirectory(path5.join(directory, ".memory"));
17750
17779
  const contextPath = getContextPath(directory, context.id);
@@ -17760,7 +17789,7 @@ function validateContext(context, contextId) {
17760
17789
  "currency"
17761
17790
  ];
17762
17791
  for (const field of requiredFields) {
17763
- if (!context[field]) {
17792
+ if (typeof context[field] !== "string" || context[field] === "") {
17764
17793
  throw new Error(`Invalid context ${contextId}: missing required field '${field}'`);
17765
17794
  }
17766
17795
  }
@@ -24453,6 +24482,7 @@ class MarkdownLogger {
24453
24482
  autoFlush;
24454
24483
  sectionDepth = 0;
24455
24484
  pendingFlush = null;
24485
+ flushedOnce = false;
24456
24486
  constructor(config2) {
24457
24487
  this.autoFlush = config2.autoFlush ?? true;
24458
24488
  this.context = config2.context || {};
@@ -24548,8 +24578,16 @@ class MarkdownLogger {
24548
24578
  return;
24549
24579
  try {
24550
24580
  await fs13.mkdir(path11.dirname(this.logPath), { recursive: true });
24551
- await fs13.writeFile(this.logPath, this.buffer.join(`
24581
+ if (this.flushedOnce) {
24582
+ await fs13.appendFile(this.logPath, `
24583
+ ` + this.buffer.join(`
24552
24584
  `), "utf-8");
24585
+ } else {
24586
+ await fs13.writeFile(this.logPath, this.buffer.join(`
24587
+ `), "utf-8");
24588
+ this.flushedOnce = true;
24589
+ }
24590
+ this.buffer = [];
24553
24591
  } catch {}
24554
24592
  }
24555
24593
  getLogPath() {
@@ -27336,6 +27374,19 @@ class NoTransactionsError extends Error {
27336
27374
  this.name = "NoTransactionsError";
27337
27375
  }
27338
27376
  }
27377
+ function cachedLoadContext(pipeCtx, contextId) {
27378
+ const cached2 = pipeCtx.contextCache.get(contextId);
27379
+ if (cached2)
27380
+ return cached2;
27381
+ const ctx = loadContext(pipeCtx.directory, contextId);
27382
+ pipeCtx.contextCache.set(contextId, ctx);
27383
+ return ctx;
27384
+ }
27385
+ function cachedUpdateContext(pipeCtx, contextId, updates) {
27386
+ const updated = updateContext(pipeCtx.directory, contextId, updates);
27387
+ pipeCtx.contextCache.set(contextId, updated);
27388
+ return updated;
27389
+ }
27339
27390
  function buildStepResult(success2, message, details) {
27340
27391
  const result = { success: success2, message };
27341
27392
  if (details !== undefined) {
@@ -27399,7 +27450,7 @@ async function executeClassifyStep(context, logger) {
27399
27450
  function executePreprocessFiatStep(context, contextIds, logger) {
27400
27451
  const fiatCsvPaths = [];
27401
27452
  for (const contextId of contextIds) {
27402
- const importCtx = loadContext(context.directory, contextId);
27453
+ const importCtx = cachedLoadContext(context, contextId);
27403
27454
  if (importCtx.provider === "revolut" && importCtx.currency !== "btc") {
27404
27455
  fiatCsvPaths.push(path18.join(context.directory, importCtx.filePath));
27405
27456
  }
@@ -27443,7 +27494,7 @@ function executePreprocessFiatStep(context, contextIds, logger) {
27443
27494
  function executePreprocessBtcStep(context, contextIds, logger) {
27444
27495
  let btcCsvPath;
27445
27496
  for (const contextId of contextIds) {
27446
- const importCtx = loadContext(context.directory, contextId);
27497
+ const importCtx = cachedLoadContext(context, contextId);
27447
27498
  if (importCtx.provider === "revolut" && importCtx.currency === "btc") {
27448
27499
  btcCsvPath = path18.join(context.directory, importCtx.filePath);
27449
27500
  break;
@@ -27476,7 +27527,7 @@ async function executeBtcPurchaseStep(context, contextIds, logger) {
27476
27527
  const fiatCsvPaths = [];
27477
27528
  let btcCsvPath;
27478
27529
  for (const contextId of contextIds) {
27479
- const importCtx = loadContext(context.directory, contextId);
27530
+ const importCtx = cachedLoadContext(context, contextId);
27480
27531
  if (importCtx.provider !== "revolut")
27481
27532
  continue;
27482
27533
  const csvPath = path18.join(context.directory, importCtx.filePath);
@@ -27492,29 +27543,39 @@ async function executeBtcPurchaseStep(context, contextIds, logger) {
27492
27543
  }
27493
27544
  logger?.startSection("Step 1b: Generate BTC Purchase Entries");
27494
27545
  logger?.logStep("BTC Purchases", "start");
27495
- const btcFilename = path18.basename(btcCsvPath);
27496
- const yearMatch = btcFilename.match(/(\d{4})-\d{2}-\d{2}/);
27497
- const year = yearMatch ? parseInt(yearMatch[1], 10) : new Date().getFullYear();
27498
- const yearJournalPath = ensureYearJournalExists(context.directory, year);
27499
- const result = generateBtcPurchaseJournal(fiatCsvPaths, btcCsvPath, yearJournalPath, logger);
27500
- for (const account of result.accountsUsed) {
27501
- context.generatedAccounts.add(account);
27502
- }
27503
- const message = result.entriesAdded > 0 ? `Generated ${result.entriesAdded} BTC purchase entries (${result.matchCount} matched, ${result.skippedDuplicates} skipped)` : `No new BTC purchase entries (${result.matchCount} matched, ${result.skippedDuplicates} duplicates)`;
27504
- logger?.logStep("BTC Purchases", "success", message);
27505
- logger?.endSection();
27506
- context.result.steps.btcPurchases = buildStepResult(true, message, {
27507
- matchCount: result.matchCount,
27508
- entriesAdded: result.entriesAdded,
27509
- skippedDuplicates: result.skippedDuplicates,
27510
- unmatchedFiat: result.unmatchedFiat.length,
27511
- unmatchedBtc: result.unmatchedBtc.length
27512
- });
27546
+ try {
27547
+ const btcCtx = contextIds.map((id) => cachedLoadContext(context, id)).find((c) => c.provider === "revolut" && c.currency === "btc");
27548
+ const year = getTransactionYear(btcCtx);
27549
+ const yearJournalPath = ensureYearJournalExists(context.directory, year);
27550
+ const result = generateBtcPurchaseJournal(fiatCsvPaths, btcCsvPath, yearJournalPath, logger);
27551
+ for (const account of result.accountsUsed) {
27552
+ context.generatedAccounts.add(account);
27553
+ }
27554
+ const message = result.entriesAdded > 0 ? `Generated ${result.entriesAdded} BTC purchase entries (${result.matchCount} matched, ${result.skippedDuplicates} skipped)` : `No new BTC purchase entries (${result.matchCount} matched, ${result.skippedDuplicates} duplicates)`;
27555
+ logger?.logStep("BTC Purchases", "success", message);
27556
+ context.result.steps.btcPurchases = buildStepResult(true, message, {
27557
+ matchCount: result.matchCount,
27558
+ entriesAdded: result.entriesAdded,
27559
+ skippedDuplicates: result.skippedDuplicates,
27560
+ unmatchedFiat: result.unmatchedFiat.length,
27561
+ unmatchedBtc: result.unmatchedBtc.length
27562
+ });
27563
+ } catch (error45) {
27564
+ const errorMessage = `BTC purchase generation failed: ${error45 instanceof Error ? error45.message : String(error45)}`;
27565
+ logger?.error(errorMessage);
27566
+ logger?.logStep("BTC Purchases", "error", errorMessage);
27567
+ context.result.steps.btcPurchases = buildStepResult(false, errorMessage);
27568
+ context.result.error = errorMessage;
27569
+ context.result.hint = "Check the Revolut fiat and BTC CSV formats";
27570
+ throw new Error(errorMessage);
27571
+ } finally {
27572
+ logger?.endSection();
27573
+ }
27513
27574
  }
27514
27575
  async function executeCurrencyExchangeStep(context, contextIds, logger) {
27515
27576
  const fiatContexts = [];
27516
27577
  for (const contextId of contextIds) {
27517
- const importCtx = loadContext(context.directory, contextId);
27578
+ const importCtx = cachedLoadContext(context, contextId);
27518
27579
  if (importCtx.provider !== "revolut")
27519
27580
  continue;
27520
27581
  if (importCtx.currency === "btc")
@@ -27526,49 +27587,55 @@ async function executeCurrencyExchangeStep(context, contextIds, logger) {
27526
27587
  logger?.info("Need at least 2 Revolut fiat CSVs for exchange matching, skipping");
27527
27588
  return;
27528
27589
  }
27529
- logger?.startSection("Step 1e: Generate Currency Exchange Entries");
27590
+ logger?.startSection("Step 1d: Generate Currency Exchange Entries");
27530
27591
  logger?.logStep("Currency Exchanges", "start");
27531
- const firstFilename = path18.basename(fiatContexts[0].csvPath);
27532
- const yearMatch = firstFilename.match(/(\d{4})-\d{2}-\d{2}/);
27533
- const year = yearMatch ? parseInt(yearMatch[1], 10) : new Date().getFullYear();
27534
- const yearJournalPath = ensureYearJournalExists(context.directory, year);
27535
- const fiatCsvPaths = fiatContexts.map((c) => c.csvPath);
27536
- const result = generateCurrencyExchangeJournal(fiatCsvPaths, yearJournalPath, logger);
27537
- for (const account of result.accountsUsed) {
27538
- context.generatedAccounts.add(account);
27539
- }
27540
- if (result.matchedRowIndices.size > 0) {
27541
- for (const { contextId, csvPath } of fiatContexts) {
27542
- const indices = result.matchedRowIndices.get(csvPath);
27543
- if (!indices || indices.size === 0)
27544
- continue;
27545
- const filteredCsvPath = filterExchangeRows(csvPath, indices, logger);
27546
- updateContext(context.directory, contextId, {
27547
- filePath: path18.relative(context.directory, filteredCsvPath)
27548
- });
27549
- logger?.info(`Updated context ${contextId} to use filtered CSV: ${path18.basename(filteredCsvPath)}`);
27592
+ try {
27593
+ const firstFiatCtx = cachedLoadContext(context, fiatContexts[0].contextId);
27594
+ const year = getTransactionYear(firstFiatCtx);
27595
+ const yearJournalPath = ensureYearJournalExists(context.directory, year);
27596
+ const fiatCsvPaths = fiatContexts.map((c) => c.csvPath);
27597
+ const result = generateCurrencyExchangeJournal(fiatCsvPaths, yearJournalPath, logger);
27598
+ for (const account of result.accountsUsed) {
27599
+ context.generatedAccounts.add(account);
27600
+ }
27601
+ if (result.matchedRowIndices.size > 0) {
27602
+ for (const { contextId, csvPath } of fiatContexts) {
27603
+ const indices = result.matchedRowIndices.get(csvPath);
27604
+ if (!indices || indices.size === 0)
27605
+ continue;
27606
+ const filteredCsvPath = filterExchangeRows(csvPath, indices, logger);
27607
+ cachedUpdateContext(context, contextId, {
27608
+ filePath: path18.relative(context.directory, filteredCsvPath)
27609
+ });
27610
+ logger?.info(`Updated context ${contextId} to use filtered CSV: ${path18.basename(filteredCsvPath)}`);
27611
+ }
27550
27612
  }
27613
+ const message = result.entriesAdded > 0 ? `Generated ${result.entriesAdded} currency exchange entries (${result.matchCount} matched, ${result.skippedDuplicates} skipped)` : `No new currency exchange entries (${result.matchCount} matched, ${result.skippedDuplicates} duplicates)`;
27614
+ logger?.logStep("Currency Exchanges", "success", message);
27615
+ context.result.steps.currencyExchanges = buildStepResult(true, message, {
27616
+ matchCount: result.matchCount,
27617
+ entriesAdded: result.entriesAdded,
27618
+ skippedDuplicates: result.skippedDuplicates
27619
+ });
27620
+ } catch (error45) {
27621
+ const errorMessage = `Currency exchange generation failed: ${error45 instanceof Error ? error45.message : String(error45)}`;
27622
+ logger?.error(errorMessage);
27623
+ logger?.logStep("Currency Exchanges", "error", errorMessage);
27624
+ context.result.steps.currencyExchanges = buildStepResult(false, errorMessage);
27625
+ context.result.error = errorMessage;
27626
+ context.result.hint = "Check the Revolut fiat CSV formats";
27627
+ throw new Error(errorMessage);
27628
+ } finally {
27629
+ logger?.endSection();
27551
27630
  }
27552
- const message = result.entriesAdded > 0 ? `Generated ${result.entriesAdded} currency exchange entries (${result.matchCount} matched, ${result.skippedDuplicates} skipped)` : `No new currency exchange entries (${result.matchCount} matched, ${result.skippedDuplicates} duplicates)`;
27553
- logger?.logStep("Currency Exchanges", "success", message);
27554
- logger?.endSection();
27555
- context.result.steps.currencyExchanges = buildStepResult(true, message, {
27556
- matchCount: result.matchCount,
27557
- entriesAdded: result.entriesAdded,
27558
- skippedDuplicates: result.skippedDuplicates
27559
- });
27560
27631
  }
27561
27632
  async function executeSwissquotePreprocessStep(context, contextIds, logger) {
27562
27633
  const swissquoteContexts = [];
27563
27634
  for (const contextId of contextIds) {
27564
- const importCtx = loadContext(context.directory, contextId);
27635
+ const importCtx = cachedLoadContext(context, contextId);
27565
27636
  if (importCtx.provider === "swissquote") {
27566
27637
  const csvPath = path18.join(context.directory, importCtx.filePath);
27567
- const toDateMatch = importCtx.filename.match(/to-\d{4}(\d{4})\./);
27568
- let year = new Date().getFullYear();
27569
- if (toDateMatch) {
27570
- year = parseInt(toDateMatch[1], 10);
27571
- }
27638
+ const year = getTransactionYear(importCtx);
27572
27639
  swissquoteContexts.push({
27573
27640
  contextId,
27574
27641
  csvPath,
@@ -27581,7 +27648,7 @@ async function executeSwissquotePreprocessStep(context, contextIds, logger) {
27581
27648
  logger?.info("No Swissquote CSV found, skipping preprocessing");
27582
27649
  return;
27583
27650
  }
27584
- logger?.startSection("Step 1d: Preprocess Swissquote CSV");
27651
+ logger?.startSection("Step 1e: Preprocess Swissquote CSV");
27585
27652
  const config2 = context.configLoader(context.directory);
27586
27653
  const swissquoteProvider = config2.providers?.["swissquote"];
27587
27654
  const lotInventoryPath = swissquoteProvider?.lotInventoryPath ?? DEFAULT_LOT_INVENTORY_PATH;
@@ -27629,7 +27696,7 @@ async function executeSwissquotePreprocessStep(context, contextIds, logger) {
27629
27696
  lastJournalFile = result.journalFile;
27630
27697
  }
27631
27698
  if (result.simpleTransactionsCsv) {
27632
- updateContext(context.directory, sqCtx.contextId, {
27699
+ cachedUpdateContext(context, sqCtx.contextId, {
27633
27700
  filePath: path18.relative(context.directory, result.simpleTransactionsCsv)
27634
27701
  });
27635
27702
  logger?.info(`Updated context ${sqCtx.contextId} to use filtered CSV: ${path18.basename(result.simpleTransactionsCsv)}`);
@@ -27671,14 +27738,10 @@ async function executeSwissquotePreprocessStep(context, contextIds, logger) {
27671
27738
  async function executeIbkrPreprocessStep(context, contextIds, logger) {
27672
27739
  const ibkrContexts = [];
27673
27740
  for (const contextId of contextIds) {
27674
- const importCtx = loadContext(context.directory, contextId);
27741
+ const importCtx = cachedLoadContext(context, contextId);
27675
27742
  if (importCtx.provider === "ibkr") {
27676
27743
  const csvPath = path18.join(context.directory, importCtx.filePath);
27677
- const dateMatch = importCtx.filename.match(/(\d{4})\d{4}\./);
27678
- let year = new Date().getFullYear();
27679
- if (dateMatch) {
27680
- year = parseInt(dateMatch[1], 10);
27681
- }
27744
+ const year = getTransactionYear(importCtx);
27682
27745
  ibkrContexts.push({
27683
27746
  contextId,
27684
27747
  csvPath,
@@ -27723,7 +27786,7 @@ async function executeIbkrPreprocessStep(context, contextIds, logger) {
27723
27786
  lastJournalFile = result.journalFile;
27724
27787
  }
27725
27788
  if (result.simpleTransactionsCsv) {
27726
- updateContext(context.directory, ibkrCtx.contextId, {
27789
+ cachedUpdateContext(context, ibkrCtx.contextId, {
27727
27790
  filePath: path18.relative(context.directory, result.simpleTransactionsCsv)
27728
27791
  });
27729
27792
  logger?.info(`Updated context ${ibkrCtx.contextId} to use filtered CSV: ${path18.basename(result.simpleTransactionsCsv)}`);
@@ -27752,7 +27815,7 @@ async function executeAccountDeclarationsStep(context, contextId, logger) {
27752
27815
  logger?.logStep("Check Accounts", "start");
27753
27816
  const config2 = context.configLoader(context.directory);
27754
27817
  const rulesDir = path18.join(context.directory, config2.paths.rules);
27755
- const importCtx = loadContext(context.directory, contextId);
27818
+ const importCtx = cachedLoadContext(context, contextId);
27756
27819
  const csvPath = path18.join(context.directory, importCtx.filePath);
27757
27820
  const csvFiles = [csvPath];
27758
27821
  const rulesMapping = loadRulesMapping(rulesDir);
@@ -27834,7 +27897,7 @@ async function buildSuggestionContext(context, contextId, logger) {
27834
27897
  const { loadExistingAccounts: loadExistingAccounts2, extractRulePatternsFromFile: extractRulePatternsFromFile2 } = await Promise.resolve().then(() => (init_accountSuggester(), exports_accountSuggester));
27835
27898
  const config2 = context.configLoader(context.directory);
27836
27899
  const rulesDir = path18.join(context.directory, config2.paths.rules);
27837
- const importCtx = loadContext(context.directory, contextId);
27900
+ const importCtx = cachedLoadContext(context, contextId);
27838
27901
  const csvPath = path18.join(context.directory, importCtx.filePath);
27839
27902
  const rulesMapping = loadRulesMapping(rulesDir);
27840
27903
  const rulesFile = findRulesForCsv(csvPath, rulesMapping);
@@ -27896,7 +27959,7 @@ function formatUnknownPostingsLog(postings) {
27896
27959
  return log;
27897
27960
  }
27898
27961
  async function executeImportStep(context, contextId, logger) {
27899
- const importContext = loadContext(context.directory, contextId);
27962
+ const importContext = cachedLoadContext(context, contextId);
27900
27963
  logger?.startSection(`Step 3: Import Transactions (${importContext.accountNumber || contextId})`);
27901
27964
  logger?.logStep("Import", "start");
27902
27965
  const importResult = await importStatements(context.directory, context.agent, {
@@ -27934,7 +27997,7 @@ async function executeImportStep(context, contextId, logger) {
27934
27997
  error: importParsed.error
27935
27998
  });
27936
27999
  if (importParsed.success) {
27937
- updateContext(context.directory, contextId, {
28000
+ cachedUpdateContext(context, contextId, {
27938
28001
  rulesFile: importParsed.files?.[0]?.rulesFile,
27939
28002
  yearJournal: importParsed.files?.[0]?.yearJournal,
27940
28003
  transactionCount: importParsed.summary?.totalTransactions
@@ -27962,7 +28025,7 @@ async function executeImportStep(context, contextId, logger) {
27962
28025
  logger?.endSection();
27963
28026
  }
27964
28027
  async function executeReconcileStep(context, contextId, logger) {
27965
- const importContext = loadContext(context.directory, contextId);
28028
+ const importContext = cachedLoadContext(context, contextId);
27966
28029
  logger?.startSection(`Step 4: Reconcile Balance (${importContext.accountNumber || contextId})`);
27967
28030
  logger?.logStep("Reconcile", "start");
27968
28031
  const reconcileResult = await reconcileStatement(context.directory, context.agent, {
@@ -27991,7 +28054,7 @@ async function executeReconcileStep(context, contextId, logger) {
27991
28054
  error: reconcileParsed.error
27992
28055
  });
27993
28056
  if (reconcileParsed.success) {
27994
- updateContext(context.directory, contextId, {
28057
+ cachedUpdateContext(context, contextId, {
27995
28058
  reconciledAccount: reconcileParsed.account,
27996
28059
  actualBalance: reconcileParsed.actualBalance,
27997
28060
  lastTransactionDate: reconcileParsed.lastTransactionDate,
@@ -28038,7 +28101,8 @@ async function importPipeline(directory, agent, options, configLoader = loadImpo
28038
28101
  configLoader,
28039
28102
  hledgerExecutor,
28040
28103
  result,
28041
- generatedAccounts: new Set
28104
+ generatedAccounts: new Set,
28105
+ contextCache: new Map
28042
28106
  };
28043
28107
  try {
28044
28108
  const contextIds = await executeClassifyStep(context, logger);
@@ -28053,10 +28117,9 @@ async function importPipeline(directory, agent, options, configLoader = loadImpo
28053
28117
  await executeSwissquotePreprocessStep(context, contextIds, logger);
28054
28118
  await executeIbkrPreprocessStep(context, contextIds, logger);
28055
28119
  if (context.generatedAccounts.size > 0) {
28056
- const firstCtx = contextIds.map((id) => loadContext(context.directory, id)).find((c) => c.provider === "revolut");
28120
+ const firstCtx = contextIds.map((id) => cachedLoadContext(context, id)).find((c) => c.provider === "revolut");
28057
28121
  if (firstCtx) {
28058
- const yearMatch = firstCtx.filePath.match(/(\d{4})-\d{2}-\d{2}/);
28059
- const year = yearMatch ? parseInt(yearMatch[1], 10) : new Date().getFullYear();
28122
+ const year = getTransactionYear(firstCtx);
28060
28123
  const yearJournalPath = ensureYearJournalExists(context.directory, year);
28061
28124
  const declResult = ensureAccountDeclarations(yearJournalPath, context.generatedAccounts);
28062
28125
  if (declResult.added.length > 0) {
@@ -28064,12 +28127,12 @@ async function importPipeline(directory, agent, options, configLoader = loadImpo
28064
28127
  }
28065
28128
  }
28066
28129
  }
28067
- const importConfig = loadImportConfig(context.directory);
28130
+ const importConfig = context.configLoader(context.directory);
28068
28131
  const pendingDir = path18.join(context.directory, importConfig.paths.pending);
28069
28132
  const doneDir = path18.join(context.directory, importConfig.paths.done);
28070
28133
  const orderedContextIds = [...contextIds].sort((a, b) => {
28071
- const ctxA = loadContext(context.directory, a);
28072
- const ctxB = loadContext(context.directory, b);
28134
+ const ctxA = cachedLoadContext(context, a);
28135
+ const ctxB = cachedLoadContext(context, b);
28073
28136
  const orderA = importConfig.providers[ctxA.provider]?.importOrder ?? 0;
28074
28137
  const orderB = importConfig.providers[ctxB.provider]?.importOrder ?? 0;
28075
28138
  if (orderA !== orderB)
@@ -28080,7 +28143,7 @@ async function importPipeline(directory, agent, options, configLoader = loadImpo
28080
28143
  });
28081
28144
  let totalTransactions = 0;
28082
28145
  for (const contextId of orderedContextIds) {
28083
- const importContext = loadContext(context.directory, contextId);
28146
+ const importContext = cachedLoadContext(context, contextId);
28084
28147
  logger.info(`Processing: ${importContext.filename} (${importContext.accountNumber || "unknown account"})`);
28085
28148
  try {
28086
28149
  await executeAccountDeclarationsStep(context, contextId, logger);
@@ -28170,17 +28233,137 @@ This tool orchestrates the full import workflow:
28170
28233
  });
28171
28234
  }
28172
28235
  });
28173
- // src/tools/init-directories.ts
28174
- import * as fs25 from "fs";
28236
+ // src/tools/generate-btc-purchases.ts
28175
28237
  import * as path19 from "path";
28238
+ import * as fs25 from "fs";
28239
+ function findFiatCsvPaths(directory, pendingDir, provider) {
28240
+ const providerDir = path19.join(directory, pendingDir, provider);
28241
+ if (!fs25.existsSync(providerDir))
28242
+ return [];
28243
+ const csvPaths = [];
28244
+ const entries = fs25.readdirSync(providerDir, { withFileTypes: true });
28245
+ for (const entry of entries) {
28246
+ if (!entry.isDirectory())
28247
+ continue;
28248
+ if (entry.name === "btc")
28249
+ continue;
28250
+ const csvFiles = findCsvFiles(path19.join(providerDir, entry.name), { fullPaths: true });
28251
+ csvPaths.push(...csvFiles);
28252
+ }
28253
+ return csvPaths;
28254
+ }
28255
+ function findBtcCsvPath(directory, pendingDir, provider) {
28256
+ const btcDir = path19.join(directory, pendingDir, provider, "btc");
28257
+ const csvFiles = findCsvFiles(btcDir, { fullPaths: true });
28258
+ return csvFiles[0];
28259
+ }
28260
+ function determineYear(csvPaths) {
28261
+ for (const csvPath of csvPaths) {
28262
+ const match2 = path19.basename(csvPath).match(/(\d{4})-\d{2}-\d{2}/);
28263
+ if (match2)
28264
+ return parseInt(match2[1], 10);
28265
+ }
28266
+ return new Date().getFullYear();
28267
+ }
28268
+ async function generateBtcPurchases(directory, agent, options = {}, configLoader = loadImportConfig) {
28269
+ const restrictionError = checkAccountantAgent(agent, "generate BTC purchases");
28270
+ if (restrictionError)
28271
+ return restrictionError;
28272
+ const logger = createImportLogger(directory, undefined, "btc-purchases");
28273
+ try {
28274
+ logger.startSection("Generate BTC Purchase Entries", 1);
28275
+ let config2;
28276
+ try {
28277
+ config2 = configLoader(directory);
28278
+ } catch (err) {
28279
+ return buildToolErrorResult(err instanceof Error ? err.message : String(err));
28280
+ }
28281
+ const provider = options.provider || "revolut";
28282
+ const fiatCsvPaths = findFiatCsvPaths(directory, config2.paths.pending, provider);
28283
+ if (fiatCsvPaths.length === 0) {
28284
+ logger.info("No fiat CSV files found in pending directories");
28285
+ return buildToolSuccessResult({
28286
+ message: "No fiat CSV files found in pending directories",
28287
+ matchCount: 0,
28288
+ entriesAdded: 0
28289
+ });
28290
+ }
28291
+ logger.info(`Found ${fiatCsvPaths.length} fiat CSV file(s)`);
28292
+ const btcCsvPath = findBtcCsvPath(directory, config2.paths.pending, provider);
28293
+ if (!btcCsvPath) {
28294
+ logger.info("No BTC CSV file found in pending btc directory");
28295
+ return buildToolSuccessResult({
28296
+ message: "No BTC CSV file found in pending btc directory",
28297
+ matchCount: 0,
28298
+ entriesAdded: 0
28299
+ });
28300
+ }
28301
+ logger.info(`Found BTC CSV: ${btcCsvPath}`);
28302
+ const allCsvPaths = [...fiatCsvPaths, btcCsvPath];
28303
+ const year = determineYear(allCsvPaths);
28304
+ const yearJournalPath = ensureYearJournalExists(directory, year);
28305
+ logger.info(`Year journal: ${yearJournalPath}`);
28306
+ const result = generateBtcPurchaseJournal(fiatCsvPaths, btcCsvPath, yearJournalPath, logger);
28307
+ logger.logStep("Generate BTC Purchases", "success", `${result.entriesAdded} entries added`);
28308
+ logger.endSection();
28309
+ return buildToolSuccessResult({
28310
+ matchCount: result.matchCount,
28311
+ entriesAdded: result.entriesAdded,
28312
+ skippedDuplicates: result.skippedDuplicates,
28313
+ unmatchedFiat: result.unmatchedFiat.length,
28314
+ unmatchedBtc: result.unmatchedBtc.length,
28315
+ yearJournal: path19.relative(directory, yearJournalPath)
28316
+ });
28317
+ } catch (err) {
28318
+ logger.error("Failed to generate BTC purchases", err);
28319
+ return buildToolErrorResult(err instanceof Error ? err.message : String(err));
28320
+ } finally {
28321
+ logger.endSection();
28322
+ await logger.flush();
28323
+ }
28324
+ }
28325
+ var generate_btc_purchases_default = tool({
28326
+ description: `ACCOUNTANT AGENT ONLY: Generate BTC purchase journal entries from Revolut CSVs.
28327
+
28328
+ Cross-references Revolut fiat account CSVs (CHF/EUR/USD) with BTC crypto CSV
28329
+ to produce equity conversion entries for Bitcoin purchases.
28330
+
28331
+ **What it does:**
28332
+ - Scans pending directories for fiat and BTC CSV files
28333
+ - Matches fiat "Transfer to Digital Assets" rows with BTC "Buy" rows by timestamp
28334
+ - Handles REVX_TRANSFER \u2192 Other \u2192 Buy multi-step purchase flows
28335
+ - Generates hledger journal entries with equity conversion postings
28336
+ - Deduplicates to prevent double entries on re-run
28337
+ - Appends entries to the year journal
28338
+
28339
+ **Prerequisites:**
28340
+ - CSV files must be classified (in import/pending/revolut/{currency}/)
28341
+ - hledger rules files should have skip directives for BTC-related rows
28342
+
28343
+ **Usage:**
28344
+ - generate-btc-purchases (scans revolut pending dirs)
28345
+ - generate-btc-purchases --provider revolut`,
28346
+ args: {
28347
+ provider: tool.schema.string().optional().describe('Provider name (default: "revolut")')
28348
+ },
28349
+ async execute(params, context) {
28350
+ const { directory, agent } = context;
28351
+ return generateBtcPurchases(directory, agent, {
28352
+ provider: params.provider
28353
+ });
28354
+ }
28355
+ });
28356
+ // src/tools/init-directories.ts
28357
+ import * as fs26 from "fs";
28358
+ import * as path20 from "path";
28176
28359
  async function initDirectories(directory) {
28177
28360
  try {
28178
28361
  const config2 = loadImportConfig(directory);
28179
28362
  const directoriesCreated = [];
28180
28363
  const gitkeepFiles = [];
28181
- const importBase = path19.join(directory, "import");
28182
- if (!fs25.existsSync(importBase)) {
28183
- fs25.mkdirSync(importBase, { recursive: true });
28364
+ const importBase = path20.join(directory, "import");
28365
+ if (!fs26.existsSync(importBase)) {
28366
+ fs26.mkdirSync(importBase, { recursive: true });
28184
28367
  directoriesCreated.push("import");
28185
28368
  }
28186
28369
  const pathsToCreate = [
@@ -28190,20 +28373,20 @@ async function initDirectories(directory) {
28190
28373
  { key: "unrecognized", path: config2.paths.unrecognized }
28191
28374
  ];
28192
28375
  for (const { path: dirPath } of pathsToCreate) {
28193
- const fullPath = path19.join(directory, dirPath);
28194
- if (!fs25.existsSync(fullPath)) {
28195
- fs25.mkdirSync(fullPath, { recursive: true });
28376
+ const fullPath = path20.join(directory, dirPath);
28377
+ if (!fs26.existsSync(fullPath)) {
28378
+ fs26.mkdirSync(fullPath, { recursive: true });
28196
28379
  directoriesCreated.push(dirPath);
28197
28380
  }
28198
- const gitkeepPath = path19.join(fullPath, ".gitkeep");
28199
- if (!fs25.existsSync(gitkeepPath)) {
28200
- fs25.writeFileSync(gitkeepPath, "");
28201
- gitkeepFiles.push(path19.join(dirPath, ".gitkeep"));
28381
+ const gitkeepPath = path20.join(fullPath, ".gitkeep");
28382
+ if (!fs26.existsSync(gitkeepPath)) {
28383
+ fs26.writeFileSync(gitkeepPath, "");
28384
+ gitkeepFiles.push(path20.join(dirPath, ".gitkeep"));
28202
28385
  }
28203
28386
  }
28204
- const gitignorePath = path19.join(importBase, ".gitignore");
28387
+ const gitignorePath = path20.join(importBase, ".gitignore");
28205
28388
  let gitignoreCreated = false;
28206
- if (!fs25.existsSync(gitignorePath)) {
28389
+ if (!fs26.existsSync(gitignorePath)) {
28207
28390
  const gitignoreContent = `# Ignore CSV/PDF files in temporary directories
28208
28391
  /incoming/*.csv
28209
28392
  /incoming/*.pdf
@@ -28221,7 +28404,7 @@ async function initDirectories(directory) {
28221
28404
  .DS_Store
28222
28405
  Thumbs.db
28223
28406
  `;
28224
- fs25.writeFileSync(gitignorePath, gitignoreContent);
28407
+ fs26.writeFileSync(gitignorePath, gitignoreContent);
28225
28408
  gitignoreCreated = true;
28226
28409
  }
28227
28410
  const parts = [];
@@ -28296,126 +28479,7 @@ You can now drop CSV files into import/incoming/ and run import-pipeline.`);
28296
28479
  `);
28297
28480
  }
28298
28481
  });
28299
- // src/tools/generate-btc-purchases.ts
28300
- import * as path20 from "path";
28301
- import * as fs26 from "fs";
28302
- function findFiatCsvPaths(directory, pendingDir, provider) {
28303
- const providerDir = path20.join(directory, pendingDir, provider);
28304
- if (!fs26.existsSync(providerDir))
28305
- return [];
28306
- const csvPaths = [];
28307
- const entries = fs26.readdirSync(providerDir, { withFileTypes: true });
28308
- for (const entry of entries) {
28309
- if (!entry.isDirectory())
28310
- continue;
28311
- if (entry.name === "btc")
28312
- continue;
28313
- const csvFiles = findCsvFiles(path20.join(providerDir, entry.name), { fullPaths: true });
28314
- csvPaths.push(...csvFiles);
28315
- }
28316
- return csvPaths;
28317
- }
28318
- function findBtcCsvPath(directory, pendingDir, provider) {
28319
- const btcDir = path20.join(directory, pendingDir, provider, "btc");
28320
- const csvFiles = findCsvFiles(btcDir, { fullPaths: true });
28321
- return csvFiles[0];
28322
- }
28323
- function determineYear(csvPaths) {
28324
- for (const csvPath of csvPaths) {
28325
- const match2 = path20.basename(csvPath).match(/(\d{4})-\d{2}-\d{2}/);
28326
- if (match2)
28327
- return parseInt(match2[1], 10);
28328
- }
28329
- return new Date().getFullYear();
28330
- }
28331
- async function generateBtcPurchases(directory, agent, options = {}, configLoader = loadImportConfig) {
28332
- const restrictionError = checkAccountantAgent(agent, "generate BTC purchases");
28333
- if (restrictionError)
28334
- return restrictionError;
28335
- const logger = createImportLogger(directory, undefined, "btc-purchases");
28336
- try {
28337
- logger.startSection("Generate BTC Purchase Entries", 1);
28338
- let config2;
28339
- try {
28340
- config2 = configLoader(directory);
28341
- } catch (err) {
28342
- return buildToolErrorResult(err instanceof Error ? err.message : String(err));
28343
- }
28344
- const provider = options.provider || "revolut";
28345
- const fiatCsvPaths = findFiatCsvPaths(directory, config2.paths.pending, provider);
28346
- if (fiatCsvPaths.length === 0) {
28347
- logger.info("No fiat CSV files found in pending directories");
28348
- return buildToolSuccessResult({
28349
- message: "No fiat CSV files found in pending directories",
28350
- matchCount: 0,
28351
- entriesAdded: 0
28352
- });
28353
- }
28354
- logger.info(`Found ${fiatCsvPaths.length} fiat CSV file(s)`);
28355
- const btcCsvPath = findBtcCsvPath(directory, config2.paths.pending, provider);
28356
- if (!btcCsvPath) {
28357
- logger.info("No BTC CSV file found in pending btc directory");
28358
- return buildToolSuccessResult({
28359
- message: "No BTC CSV file found in pending btc directory",
28360
- matchCount: 0,
28361
- entriesAdded: 0
28362
- });
28363
- }
28364
- logger.info(`Found BTC CSV: ${btcCsvPath}`);
28365
- const allCsvPaths = [...fiatCsvPaths, btcCsvPath];
28366
- const year = determineYear(allCsvPaths);
28367
- const yearJournalPath = ensureYearJournalExists(directory, year);
28368
- logger.info(`Year journal: ${yearJournalPath}`);
28369
- const result = generateBtcPurchaseJournal(fiatCsvPaths, btcCsvPath, yearJournalPath, logger);
28370
- logger.logStep("Generate BTC Purchases", "success", `${result.entriesAdded} entries added`);
28371
- logger.endSection();
28372
- return buildToolSuccessResult({
28373
- matchCount: result.matchCount,
28374
- entriesAdded: result.entriesAdded,
28375
- skippedDuplicates: result.skippedDuplicates,
28376
- unmatchedFiat: result.unmatchedFiat.length,
28377
- unmatchedBtc: result.unmatchedBtc.length,
28378
- yearJournal: path20.relative(directory, yearJournalPath)
28379
- });
28380
- } catch (err) {
28381
- logger.error("Failed to generate BTC purchases", err);
28382
- return buildToolErrorResult(err instanceof Error ? err.message : String(err));
28383
- } finally {
28384
- logger.endSection();
28385
- await logger.flush();
28386
- }
28387
- }
28388
- var generate_btc_purchases_default = tool({
28389
- description: `ACCOUNTANT AGENT ONLY: Generate BTC purchase journal entries from Revolut CSVs.
28390
-
28391
- Cross-references Revolut fiat account CSVs (CHF/EUR/USD) with BTC crypto CSV
28392
- to produce equity conversion entries for Bitcoin purchases.
28393
-
28394
- **What it does:**
28395
- - Scans pending directories for fiat and BTC CSV files
28396
- - Matches fiat "Transfer to Digital Assets" rows with BTC "Buy" rows by timestamp
28397
- - Handles REVX_TRANSFER \u2192 Other \u2192 Buy multi-step purchase flows
28398
- - Generates hledger journal entries with equity conversion postings
28399
- - Deduplicates to prevent double entries on re-run
28400
- - Appends entries to the year journal
28401
28482
 
28402
- **Prerequisites:**
28403
- - CSV files must be classified (in import/pending/revolut/{currency}/)
28404
- - hledger rules files should have skip directives for BTC-related rows
28405
-
28406
- **Usage:**
28407
- - generate-btc-purchases (scans revolut pending dirs)
28408
- - generate-btc-purchases --provider revolut`,
28409
- args: {
28410
- provider: tool.schema.string().optional().describe('Provider name (default: "revolut")')
28411
- },
28412
- async execute(params, context) {
28413
- const { directory, agent } = context;
28414
- return generateBtcPurchases(directory, agent, {
28415
- provider: params.provider
28416
- });
28417
- }
28418
- });
28419
28483
  // src/index.ts
28420
28484
  var __dirname2 = dirname10(fileURLToPath3(import.meta.url));
28421
28485
  var AGENT_FILE = join18(__dirname2, "..", "agent", "accountant.md");
@@ -28428,7 +28492,8 @@ var AccountantPlugin = async () => {
28428
28492
  "import-statements": import_statements_default,
28429
28493
  "reconcile-statements": reconcile_statement_default,
28430
28494
  "import-pipeline": import_pipeline_default,
28431
- "generate-btc-purchases": generate_btc_purchases_default
28495
+ "generate-btc-purchases": generate_btc_purchases_default,
28496
+ "init-directories": init_directories_default
28432
28497
  },
28433
28498
  config: async (config2) => {
28434
28499
  if (agent) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fuzzle/opencode-accountant",
3
- "version": "0.16.6-next.1",
3
+ "version": "0.16.7-next.1",
4
4
  "description": "An OpenCode accounting agent, specialized in double-entry-bookkepping with hledger",
5
5
  "author": {
6
6
  "name": "ali bengali",