@fuzzle/opencode-accountant 0.16.6 → 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 +314 -222
  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() {
@@ -26903,6 +26941,16 @@ function generateIbkrBuyEntry(trade, logger) {
26903
26941
  account: `assets:broker:ibkr:${trade.currency.toLowerCase()}`,
26904
26942
  amount: formatAmount(-cashOut, trade.currency)
26905
26943
  });
26944
+ const computedNetCents = Math.round(totalCost * 100) + Math.round(fees * 100);
26945
+ const brokerNetCents = Math.round(cashOut * 100);
26946
+ const roundingCents = brokerNetCents - computedNetCents;
26947
+ if (roundingCents !== 0) {
26948
+ postings.push({
26949
+ account: "equity:rounding",
26950
+ amount: formatAmount(roundingCents / 100, trade.currency)
26951
+ });
26952
+ logger?.debug(`Rounding adjustment: ${roundingCents / 100} ${trade.currency}`);
26953
+ }
26906
26954
  let entry = `${trade.date} ${description}
26907
26955
  `;
26908
26956
  entry += ` ; ibkr:account:${trade.account}
@@ -26952,6 +27000,16 @@ function generateIbkrSellEntry(trade, consumed, logger) {
26952
27000
  amount: formatAmount(-capitalGain, trade.currency)
26953
27001
  });
26954
27002
  }
27003
+ const computedNetCents = Math.round(saleProceeds * 100) - Math.round(fees * 100);
27004
+ const brokerNetCents = Math.round(Math.abs(cashIn) * 100);
27005
+ const roundingCents = computedNetCents - brokerNetCents;
27006
+ if (roundingCents !== 0) {
27007
+ postings.push({
27008
+ account: "equity:rounding",
27009
+ amount: formatAmount(roundingCents / 100, trade.currency)
27010
+ });
27011
+ logger?.debug(`Rounding adjustment: ${roundingCents / 100} ${trade.currency}`);
27012
+ }
26955
27013
  let entry = `${trade.date} ${description}
26956
27014
  `;
26957
27015
  entry += ` ; ibkr:account:${trade.account}
@@ -26981,6 +27039,16 @@ function generateIbkrDividendEntry(dividend, logger) {
26981
27039
  account: `income:dividends:${dividend.symbol}`,
26982
27040
  amount: formatAmount(-dividend.grossAmount, dividend.currency)
26983
27041
  });
27042
+ const computedNetCents = Math.round(dividend.grossAmount * 100) - Math.round(dividend.withholdingTax * 100);
27043
+ const brokerNetCents = Math.round(dividend.netAmount * 100);
27044
+ const roundingCents = brokerNetCents - computedNetCents;
27045
+ if (roundingCents !== 0) {
27046
+ postings.push({
27047
+ account: "equity:rounding",
27048
+ amount: formatAmount(roundingCents / 100, dividend.currency)
27049
+ });
27050
+ logger?.debug(`Rounding adjustment: ${roundingCents / 100} ${dividend.currency}`);
27051
+ }
26984
27052
  let entry = `${dividend.date} ${description}
26985
27053
  `;
26986
27054
  entry += ` ; ibkr:account:${dividend.account}
@@ -27131,8 +27199,6 @@ async function preprocessIbkr(csvPath, directory, currency, year, lotInventoryPa
27131
27199
  const usedAccounts = new Set;
27132
27200
  const journalEntries = [];
27133
27201
  for (const trade of trades) {
27134
- rawSum += trade.netAmount;
27135
- roundedSum += round2(trade.netAmount);
27136
27202
  const totalCost = Math.abs(trade.grossAmount);
27137
27203
  const unitPrice = trade.quantity !== 0 ? totalCost / Math.abs(trade.quantity) : 0;
27138
27204
  const tradeEntry = {
@@ -27185,8 +27251,6 @@ async function preprocessIbkr(csvPath, directory, currency, year, lotInventoryPa
27185
27251
  const dividendGroups = groupDividends(dividendTxns);
27186
27252
  for (const group of dividendGroups) {
27187
27253
  const netAmount = group.grossAmount - group.withholdingTax;
27188
- rawSum += netAmount;
27189
- roundedSum += round2(netAmount);
27190
27254
  const dividendEntry = {
27191
27255
  date: group.date,
27192
27256
  account: group.account,
@@ -27231,6 +27295,7 @@ async function preprocessIbkr(csvPath, directory, currency, year, lotInventoryPa
27231
27295
  const commodityJournalPath = path17.join(directory, "ledger", "investments", "commodities.journal");
27232
27296
  ensureCommodityDeclarations(commodityJournalPath, tradedSymbols, logger);
27233
27297
  }
27298
+ usedAccounts.add("equity:rounding");
27234
27299
  if (usedAccounts.size > 0) {
27235
27300
  const accountJournalPath = path17.join(directory, "ledger", "investments", "accounts.journal");
27236
27301
  ensureInvestmentAccountDeclarations(accountJournalPath, usedAccounts, logger);
@@ -27309,6 +27374,19 @@ class NoTransactionsError extends Error {
27309
27374
  this.name = "NoTransactionsError";
27310
27375
  }
27311
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
+ }
27312
27390
  function buildStepResult(success2, message, details) {
27313
27391
  const result = { success: success2, message };
27314
27392
  if (details !== undefined) {
@@ -27372,7 +27450,7 @@ async function executeClassifyStep(context, logger) {
27372
27450
  function executePreprocessFiatStep(context, contextIds, logger) {
27373
27451
  const fiatCsvPaths = [];
27374
27452
  for (const contextId of contextIds) {
27375
- const importCtx = loadContext(context.directory, contextId);
27453
+ const importCtx = cachedLoadContext(context, contextId);
27376
27454
  if (importCtx.provider === "revolut" && importCtx.currency !== "btc") {
27377
27455
  fiatCsvPaths.push(path18.join(context.directory, importCtx.filePath));
27378
27456
  }
@@ -27416,7 +27494,7 @@ function executePreprocessFiatStep(context, contextIds, logger) {
27416
27494
  function executePreprocessBtcStep(context, contextIds, logger) {
27417
27495
  let btcCsvPath;
27418
27496
  for (const contextId of contextIds) {
27419
- const importCtx = loadContext(context.directory, contextId);
27497
+ const importCtx = cachedLoadContext(context, contextId);
27420
27498
  if (importCtx.provider === "revolut" && importCtx.currency === "btc") {
27421
27499
  btcCsvPath = path18.join(context.directory, importCtx.filePath);
27422
27500
  break;
@@ -27449,7 +27527,7 @@ async function executeBtcPurchaseStep(context, contextIds, logger) {
27449
27527
  const fiatCsvPaths = [];
27450
27528
  let btcCsvPath;
27451
27529
  for (const contextId of contextIds) {
27452
- const importCtx = loadContext(context.directory, contextId);
27530
+ const importCtx = cachedLoadContext(context, contextId);
27453
27531
  if (importCtx.provider !== "revolut")
27454
27532
  continue;
27455
27533
  const csvPath = path18.join(context.directory, importCtx.filePath);
@@ -27465,29 +27543,39 @@ async function executeBtcPurchaseStep(context, contextIds, logger) {
27465
27543
  }
27466
27544
  logger?.startSection("Step 1b: Generate BTC Purchase Entries");
27467
27545
  logger?.logStep("BTC Purchases", "start");
27468
- const btcFilename = path18.basename(btcCsvPath);
27469
- const yearMatch = btcFilename.match(/(\d{4})-\d{2}-\d{2}/);
27470
- const year = yearMatch ? parseInt(yearMatch[1], 10) : new Date().getFullYear();
27471
- const yearJournalPath = ensureYearJournalExists(context.directory, year);
27472
- const result = generateBtcPurchaseJournal(fiatCsvPaths, btcCsvPath, yearJournalPath, logger);
27473
- for (const account of result.accountsUsed) {
27474
- context.generatedAccounts.add(account);
27475
- }
27476
- 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)`;
27477
- logger?.logStep("BTC Purchases", "success", message);
27478
- logger?.endSection();
27479
- context.result.steps.btcPurchases = buildStepResult(true, message, {
27480
- matchCount: result.matchCount,
27481
- entriesAdded: result.entriesAdded,
27482
- skippedDuplicates: result.skippedDuplicates,
27483
- unmatchedFiat: result.unmatchedFiat.length,
27484
- unmatchedBtc: result.unmatchedBtc.length
27485
- });
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
+ }
27486
27574
  }
27487
27575
  async function executeCurrencyExchangeStep(context, contextIds, logger) {
27488
27576
  const fiatContexts = [];
27489
27577
  for (const contextId of contextIds) {
27490
- const importCtx = loadContext(context.directory, contextId);
27578
+ const importCtx = cachedLoadContext(context, contextId);
27491
27579
  if (importCtx.provider !== "revolut")
27492
27580
  continue;
27493
27581
  if (importCtx.currency === "btc")
@@ -27499,49 +27587,55 @@ async function executeCurrencyExchangeStep(context, contextIds, logger) {
27499
27587
  logger?.info("Need at least 2 Revolut fiat CSVs for exchange matching, skipping");
27500
27588
  return;
27501
27589
  }
27502
- logger?.startSection("Step 1e: Generate Currency Exchange Entries");
27590
+ logger?.startSection("Step 1d: Generate Currency Exchange Entries");
27503
27591
  logger?.logStep("Currency Exchanges", "start");
27504
- const firstFilename = path18.basename(fiatContexts[0].csvPath);
27505
- const yearMatch = firstFilename.match(/(\d{4})-\d{2}-\d{2}/);
27506
- const year = yearMatch ? parseInt(yearMatch[1], 10) : new Date().getFullYear();
27507
- const yearJournalPath = ensureYearJournalExists(context.directory, year);
27508
- const fiatCsvPaths = fiatContexts.map((c) => c.csvPath);
27509
- const result = generateCurrencyExchangeJournal(fiatCsvPaths, yearJournalPath, logger);
27510
- for (const account of result.accountsUsed) {
27511
- context.generatedAccounts.add(account);
27512
- }
27513
- if (result.matchedRowIndices.size > 0) {
27514
- for (const { contextId, csvPath } of fiatContexts) {
27515
- const indices = result.matchedRowIndices.get(csvPath);
27516
- if (!indices || indices.size === 0)
27517
- continue;
27518
- const filteredCsvPath = filterExchangeRows(csvPath, indices, logger);
27519
- updateContext(context.directory, contextId, {
27520
- filePath: path18.relative(context.directory, filteredCsvPath)
27521
- });
27522
- 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
+ }
27523
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();
27524
27630
  }
27525
- 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)`;
27526
- logger?.logStep("Currency Exchanges", "success", message);
27527
- logger?.endSection();
27528
- context.result.steps.currencyExchanges = buildStepResult(true, message, {
27529
- matchCount: result.matchCount,
27530
- entriesAdded: result.entriesAdded,
27531
- skippedDuplicates: result.skippedDuplicates
27532
- });
27533
27631
  }
27534
27632
  async function executeSwissquotePreprocessStep(context, contextIds, logger) {
27535
27633
  const swissquoteContexts = [];
27536
27634
  for (const contextId of contextIds) {
27537
- const importCtx = loadContext(context.directory, contextId);
27635
+ const importCtx = cachedLoadContext(context, contextId);
27538
27636
  if (importCtx.provider === "swissquote") {
27539
27637
  const csvPath = path18.join(context.directory, importCtx.filePath);
27540
- const toDateMatch = importCtx.filename.match(/to-\d{4}(\d{4})\./);
27541
- let year = new Date().getFullYear();
27542
- if (toDateMatch) {
27543
- year = parseInt(toDateMatch[1], 10);
27544
- }
27638
+ const year = getTransactionYear(importCtx);
27545
27639
  swissquoteContexts.push({
27546
27640
  contextId,
27547
27641
  csvPath,
@@ -27554,7 +27648,7 @@ async function executeSwissquotePreprocessStep(context, contextIds, logger) {
27554
27648
  logger?.info("No Swissquote CSV found, skipping preprocessing");
27555
27649
  return;
27556
27650
  }
27557
- logger?.startSection("Step 1d: Preprocess Swissquote CSV");
27651
+ logger?.startSection("Step 1e: Preprocess Swissquote CSV");
27558
27652
  const config2 = context.configLoader(context.directory);
27559
27653
  const swissquoteProvider = config2.providers?.["swissquote"];
27560
27654
  const lotInventoryPath = swissquoteProvider?.lotInventoryPath ?? DEFAULT_LOT_INVENTORY_PATH;
@@ -27602,7 +27696,7 @@ async function executeSwissquotePreprocessStep(context, contextIds, logger) {
27602
27696
  lastJournalFile = result.journalFile;
27603
27697
  }
27604
27698
  if (result.simpleTransactionsCsv) {
27605
- updateContext(context.directory, sqCtx.contextId, {
27699
+ cachedUpdateContext(context, sqCtx.contextId, {
27606
27700
  filePath: path18.relative(context.directory, result.simpleTransactionsCsv)
27607
27701
  });
27608
27702
  logger?.info(`Updated context ${sqCtx.contextId} to use filtered CSV: ${path18.basename(result.simpleTransactionsCsv)}`);
@@ -27644,14 +27738,10 @@ async function executeSwissquotePreprocessStep(context, contextIds, logger) {
27644
27738
  async function executeIbkrPreprocessStep(context, contextIds, logger) {
27645
27739
  const ibkrContexts = [];
27646
27740
  for (const contextId of contextIds) {
27647
- const importCtx = loadContext(context.directory, contextId);
27741
+ const importCtx = cachedLoadContext(context, contextId);
27648
27742
  if (importCtx.provider === "ibkr") {
27649
27743
  const csvPath = path18.join(context.directory, importCtx.filePath);
27650
- const dateMatch = importCtx.filename.match(/(\d{4})\d{4}\./);
27651
- let year = new Date().getFullYear();
27652
- if (dateMatch) {
27653
- year = parseInt(dateMatch[1], 10);
27654
- }
27744
+ const year = getTransactionYear(importCtx);
27655
27745
  ibkrContexts.push({
27656
27746
  contextId,
27657
27747
  csvPath,
@@ -27696,7 +27786,7 @@ async function executeIbkrPreprocessStep(context, contextIds, logger) {
27696
27786
  lastJournalFile = result.journalFile;
27697
27787
  }
27698
27788
  if (result.simpleTransactionsCsv) {
27699
- updateContext(context.directory, ibkrCtx.contextId, {
27789
+ cachedUpdateContext(context, ibkrCtx.contextId, {
27700
27790
  filePath: path18.relative(context.directory, result.simpleTransactionsCsv)
27701
27791
  });
27702
27792
  logger?.info(`Updated context ${ibkrCtx.contextId} to use filtered CSV: ${path18.basename(result.simpleTransactionsCsv)}`);
@@ -27725,7 +27815,7 @@ async function executeAccountDeclarationsStep(context, contextId, logger) {
27725
27815
  logger?.logStep("Check Accounts", "start");
27726
27816
  const config2 = context.configLoader(context.directory);
27727
27817
  const rulesDir = path18.join(context.directory, config2.paths.rules);
27728
- const importCtx = loadContext(context.directory, contextId);
27818
+ const importCtx = cachedLoadContext(context, contextId);
27729
27819
  const csvPath = path18.join(context.directory, importCtx.filePath);
27730
27820
  const csvFiles = [csvPath];
27731
27821
  const rulesMapping = loadRulesMapping(rulesDir);
@@ -27807,7 +27897,7 @@ async function buildSuggestionContext(context, contextId, logger) {
27807
27897
  const { loadExistingAccounts: loadExistingAccounts2, extractRulePatternsFromFile: extractRulePatternsFromFile2 } = await Promise.resolve().then(() => (init_accountSuggester(), exports_accountSuggester));
27808
27898
  const config2 = context.configLoader(context.directory);
27809
27899
  const rulesDir = path18.join(context.directory, config2.paths.rules);
27810
- const importCtx = loadContext(context.directory, contextId);
27900
+ const importCtx = cachedLoadContext(context, contextId);
27811
27901
  const csvPath = path18.join(context.directory, importCtx.filePath);
27812
27902
  const rulesMapping = loadRulesMapping(rulesDir);
27813
27903
  const rulesFile = findRulesForCsv(csvPath, rulesMapping);
@@ -27869,7 +27959,7 @@ function formatUnknownPostingsLog(postings) {
27869
27959
  return log;
27870
27960
  }
27871
27961
  async function executeImportStep(context, contextId, logger) {
27872
- const importContext = loadContext(context.directory, contextId);
27962
+ const importContext = cachedLoadContext(context, contextId);
27873
27963
  logger?.startSection(`Step 3: Import Transactions (${importContext.accountNumber || contextId})`);
27874
27964
  logger?.logStep("Import", "start");
27875
27965
  const importResult = await importStatements(context.directory, context.agent, {
@@ -27907,7 +27997,7 @@ async function executeImportStep(context, contextId, logger) {
27907
27997
  error: importParsed.error
27908
27998
  });
27909
27999
  if (importParsed.success) {
27910
- updateContext(context.directory, contextId, {
28000
+ cachedUpdateContext(context, contextId, {
27911
28001
  rulesFile: importParsed.files?.[0]?.rulesFile,
27912
28002
  yearJournal: importParsed.files?.[0]?.yearJournal,
27913
28003
  transactionCount: importParsed.summary?.totalTransactions
@@ -27935,7 +28025,7 @@ async function executeImportStep(context, contextId, logger) {
27935
28025
  logger?.endSection();
27936
28026
  }
27937
28027
  async function executeReconcileStep(context, contextId, logger) {
27938
- const importContext = loadContext(context.directory, contextId);
28028
+ const importContext = cachedLoadContext(context, contextId);
27939
28029
  logger?.startSection(`Step 4: Reconcile Balance (${importContext.accountNumber || contextId})`);
27940
28030
  logger?.logStep("Reconcile", "start");
27941
28031
  const reconcileResult = await reconcileStatement(context.directory, context.agent, {
@@ -27964,7 +28054,7 @@ async function executeReconcileStep(context, contextId, logger) {
27964
28054
  error: reconcileParsed.error
27965
28055
  });
27966
28056
  if (reconcileParsed.success) {
27967
- updateContext(context.directory, contextId, {
28057
+ cachedUpdateContext(context, contextId, {
27968
28058
  reconciledAccount: reconcileParsed.account,
27969
28059
  actualBalance: reconcileParsed.actualBalance,
27970
28060
  lastTransactionDate: reconcileParsed.lastTransactionDate,
@@ -28011,7 +28101,8 @@ async function importPipeline(directory, agent, options, configLoader = loadImpo
28011
28101
  configLoader,
28012
28102
  hledgerExecutor,
28013
28103
  result,
28014
- generatedAccounts: new Set
28104
+ generatedAccounts: new Set,
28105
+ contextCache: new Map
28015
28106
  };
28016
28107
  try {
28017
28108
  const contextIds = await executeClassifyStep(context, logger);
@@ -28026,10 +28117,9 @@ async function importPipeline(directory, agent, options, configLoader = loadImpo
28026
28117
  await executeSwissquotePreprocessStep(context, contextIds, logger);
28027
28118
  await executeIbkrPreprocessStep(context, contextIds, logger);
28028
28119
  if (context.generatedAccounts.size > 0) {
28029
- 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");
28030
28121
  if (firstCtx) {
28031
- const yearMatch = firstCtx.filePath.match(/(\d{4})-\d{2}-\d{2}/);
28032
- const year = yearMatch ? parseInt(yearMatch[1], 10) : new Date().getFullYear();
28122
+ const year = getTransactionYear(firstCtx);
28033
28123
  const yearJournalPath = ensureYearJournalExists(context.directory, year);
28034
28124
  const declResult = ensureAccountDeclarations(yearJournalPath, context.generatedAccounts);
28035
28125
  if (declResult.added.length > 0) {
@@ -28037,12 +28127,12 @@ async function importPipeline(directory, agent, options, configLoader = loadImpo
28037
28127
  }
28038
28128
  }
28039
28129
  }
28040
- const importConfig = loadImportConfig(context.directory);
28130
+ const importConfig = context.configLoader(context.directory);
28041
28131
  const pendingDir = path18.join(context.directory, importConfig.paths.pending);
28042
28132
  const doneDir = path18.join(context.directory, importConfig.paths.done);
28043
28133
  const orderedContextIds = [...contextIds].sort((a, b) => {
28044
- const ctxA = loadContext(context.directory, a);
28045
- const ctxB = loadContext(context.directory, b);
28134
+ const ctxA = cachedLoadContext(context, a);
28135
+ const ctxB = cachedLoadContext(context, b);
28046
28136
  const orderA = importConfig.providers[ctxA.provider]?.importOrder ?? 0;
28047
28137
  const orderB = importConfig.providers[ctxB.provider]?.importOrder ?? 0;
28048
28138
  if (orderA !== orderB)
@@ -28053,7 +28143,7 @@ async function importPipeline(directory, agent, options, configLoader = loadImpo
28053
28143
  });
28054
28144
  let totalTransactions = 0;
28055
28145
  for (const contextId of orderedContextIds) {
28056
- const importContext = loadContext(context.directory, contextId);
28146
+ const importContext = cachedLoadContext(context, contextId);
28057
28147
  logger.info(`Processing: ${importContext.filename} (${importContext.accountNumber || "unknown account"})`);
28058
28148
  try {
28059
28149
  await executeAccountDeclarationsStep(context, contextId, logger);
@@ -28143,17 +28233,137 @@ This tool orchestrates the full import workflow:
28143
28233
  });
28144
28234
  }
28145
28235
  });
28146
- // src/tools/init-directories.ts
28147
- import * as fs25 from "fs";
28236
+ // src/tools/generate-btc-purchases.ts
28148
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";
28149
28359
  async function initDirectories(directory) {
28150
28360
  try {
28151
28361
  const config2 = loadImportConfig(directory);
28152
28362
  const directoriesCreated = [];
28153
28363
  const gitkeepFiles = [];
28154
- const importBase = path19.join(directory, "import");
28155
- if (!fs25.existsSync(importBase)) {
28156
- fs25.mkdirSync(importBase, { recursive: true });
28364
+ const importBase = path20.join(directory, "import");
28365
+ if (!fs26.existsSync(importBase)) {
28366
+ fs26.mkdirSync(importBase, { recursive: true });
28157
28367
  directoriesCreated.push("import");
28158
28368
  }
28159
28369
  const pathsToCreate = [
@@ -28163,20 +28373,20 @@ async function initDirectories(directory) {
28163
28373
  { key: "unrecognized", path: config2.paths.unrecognized }
28164
28374
  ];
28165
28375
  for (const { path: dirPath } of pathsToCreate) {
28166
- const fullPath = path19.join(directory, dirPath);
28167
- if (!fs25.existsSync(fullPath)) {
28168
- fs25.mkdirSync(fullPath, { recursive: true });
28376
+ const fullPath = path20.join(directory, dirPath);
28377
+ if (!fs26.existsSync(fullPath)) {
28378
+ fs26.mkdirSync(fullPath, { recursive: true });
28169
28379
  directoriesCreated.push(dirPath);
28170
28380
  }
28171
- const gitkeepPath = path19.join(fullPath, ".gitkeep");
28172
- if (!fs25.existsSync(gitkeepPath)) {
28173
- fs25.writeFileSync(gitkeepPath, "");
28174
- 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"));
28175
28385
  }
28176
28386
  }
28177
- const gitignorePath = path19.join(importBase, ".gitignore");
28387
+ const gitignorePath = path20.join(importBase, ".gitignore");
28178
28388
  let gitignoreCreated = false;
28179
- if (!fs25.existsSync(gitignorePath)) {
28389
+ if (!fs26.existsSync(gitignorePath)) {
28180
28390
  const gitignoreContent = `# Ignore CSV/PDF files in temporary directories
28181
28391
  /incoming/*.csv
28182
28392
  /incoming/*.pdf
@@ -28194,7 +28404,7 @@ async function initDirectories(directory) {
28194
28404
  .DS_Store
28195
28405
  Thumbs.db
28196
28406
  `;
28197
- fs25.writeFileSync(gitignorePath, gitignoreContent);
28407
+ fs26.writeFileSync(gitignorePath, gitignoreContent);
28198
28408
  gitignoreCreated = true;
28199
28409
  }
28200
28410
  const parts = [];
@@ -28269,126 +28479,7 @@ You can now drop CSV files into import/incoming/ and run import-pipeline.`);
28269
28479
  `);
28270
28480
  }
28271
28481
  });
28272
- // src/tools/generate-btc-purchases.ts
28273
- import * as path20 from "path";
28274
- import * as fs26 from "fs";
28275
- function findFiatCsvPaths(directory, pendingDir, provider) {
28276
- const providerDir = path20.join(directory, pendingDir, provider);
28277
- if (!fs26.existsSync(providerDir))
28278
- return [];
28279
- const csvPaths = [];
28280
- const entries = fs26.readdirSync(providerDir, { withFileTypes: true });
28281
- for (const entry of entries) {
28282
- if (!entry.isDirectory())
28283
- continue;
28284
- if (entry.name === "btc")
28285
- continue;
28286
- const csvFiles = findCsvFiles(path20.join(providerDir, entry.name), { fullPaths: true });
28287
- csvPaths.push(...csvFiles);
28288
- }
28289
- return csvPaths;
28290
- }
28291
- function findBtcCsvPath(directory, pendingDir, provider) {
28292
- const btcDir = path20.join(directory, pendingDir, provider, "btc");
28293
- const csvFiles = findCsvFiles(btcDir, { fullPaths: true });
28294
- return csvFiles[0];
28295
- }
28296
- function determineYear(csvPaths) {
28297
- for (const csvPath of csvPaths) {
28298
- const match2 = path20.basename(csvPath).match(/(\d{4})-\d{2}-\d{2}/);
28299
- if (match2)
28300
- return parseInt(match2[1], 10);
28301
- }
28302
- return new Date().getFullYear();
28303
- }
28304
- async function generateBtcPurchases(directory, agent, options = {}, configLoader = loadImportConfig) {
28305
- const restrictionError = checkAccountantAgent(agent, "generate BTC purchases");
28306
- if (restrictionError)
28307
- return restrictionError;
28308
- const logger = createImportLogger(directory, undefined, "btc-purchases");
28309
- try {
28310
- logger.startSection("Generate BTC Purchase Entries", 1);
28311
- let config2;
28312
- try {
28313
- config2 = configLoader(directory);
28314
- } catch (err) {
28315
- return buildToolErrorResult(err instanceof Error ? err.message : String(err));
28316
- }
28317
- const provider = options.provider || "revolut";
28318
- const fiatCsvPaths = findFiatCsvPaths(directory, config2.paths.pending, provider);
28319
- if (fiatCsvPaths.length === 0) {
28320
- logger.info("No fiat CSV files found in pending directories");
28321
- return buildToolSuccessResult({
28322
- message: "No fiat CSV files found in pending directories",
28323
- matchCount: 0,
28324
- entriesAdded: 0
28325
- });
28326
- }
28327
- logger.info(`Found ${fiatCsvPaths.length} fiat CSV file(s)`);
28328
- const btcCsvPath = findBtcCsvPath(directory, config2.paths.pending, provider);
28329
- if (!btcCsvPath) {
28330
- logger.info("No BTC CSV file found in pending btc directory");
28331
- return buildToolSuccessResult({
28332
- message: "No BTC CSV file found in pending btc directory",
28333
- matchCount: 0,
28334
- entriesAdded: 0
28335
- });
28336
- }
28337
- logger.info(`Found BTC CSV: ${btcCsvPath}`);
28338
- const allCsvPaths = [...fiatCsvPaths, btcCsvPath];
28339
- const year = determineYear(allCsvPaths);
28340
- const yearJournalPath = ensureYearJournalExists(directory, year);
28341
- logger.info(`Year journal: ${yearJournalPath}`);
28342
- const result = generateBtcPurchaseJournal(fiatCsvPaths, btcCsvPath, yearJournalPath, logger);
28343
- logger.logStep("Generate BTC Purchases", "success", `${result.entriesAdded} entries added`);
28344
- logger.endSection();
28345
- return buildToolSuccessResult({
28346
- matchCount: result.matchCount,
28347
- entriesAdded: result.entriesAdded,
28348
- skippedDuplicates: result.skippedDuplicates,
28349
- unmatchedFiat: result.unmatchedFiat.length,
28350
- unmatchedBtc: result.unmatchedBtc.length,
28351
- yearJournal: path20.relative(directory, yearJournalPath)
28352
- });
28353
- } catch (err) {
28354
- logger.error("Failed to generate BTC purchases", err);
28355
- return buildToolErrorResult(err instanceof Error ? err.message : String(err));
28356
- } finally {
28357
- logger.endSection();
28358
- await logger.flush();
28359
- }
28360
- }
28361
- var generate_btc_purchases_default = tool({
28362
- description: `ACCOUNTANT AGENT ONLY: Generate BTC purchase journal entries from Revolut CSVs.
28363
28482
 
28364
- Cross-references Revolut fiat account CSVs (CHF/EUR/USD) with BTC crypto CSV
28365
- to produce equity conversion entries for Bitcoin purchases.
28366
-
28367
- **What it does:**
28368
- - Scans pending directories for fiat and BTC CSV files
28369
- - Matches fiat "Transfer to Digital Assets" rows with BTC "Buy" rows by timestamp
28370
- - Handles REVX_TRANSFER \u2192 Other \u2192 Buy multi-step purchase flows
28371
- - Generates hledger journal entries with equity conversion postings
28372
- - Deduplicates to prevent double entries on re-run
28373
- - Appends entries to the year journal
28374
-
28375
- **Prerequisites:**
28376
- - CSV files must be classified (in import/pending/revolut/{currency}/)
28377
- - hledger rules files should have skip directives for BTC-related rows
28378
-
28379
- **Usage:**
28380
- - generate-btc-purchases (scans revolut pending dirs)
28381
- - generate-btc-purchases --provider revolut`,
28382
- args: {
28383
- provider: tool.schema.string().optional().describe('Provider name (default: "revolut")')
28384
- },
28385
- async execute(params, context) {
28386
- const { directory, agent } = context;
28387
- return generateBtcPurchases(directory, agent, {
28388
- provider: params.provider
28389
- });
28390
- }
28391
- });
28392
28483
  // src/index.ts
28393
28484
  var __dirname2 = dirname10(fileURLToPath3(import.meta.url));
28394
28485
  var AGENT_FILE = join18(__dirname2, "..", "agent", "accountant.md");
@@ -28401,7 +28492,8 @@ var AccountantPlugin = async () => {
28401
28492
  "import-statements": import_statements_default,
28402
28493
  "reconcile-statements": reconcile_statement_default,
28403
28494
  "import-pipeline": import_pipeline_default,
28404
- "generate-btc-purchases": generate_btc_purchases_default
28495
+ "generate-btc-purchases": generate_btc_purchases_default,
28496
+ "init-directories": init_directories_default
28405
28497
  },
28406
28498
  config: async (config2) => {
28407
28499
  if (agent) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fuzzle/opencode-accountant",
3
- "version": "0.16.6",
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",