@fuzzle/opencode-accountant 0.10.8-next.1 → 0.11.0-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.
package/dist/index.js CHANGED
@@ -4280,7 +4280,7 @@ __export(exports_accountSuggester, {
4280
4280
  extractRulePatternsFromFile: () => extractRulePatternsFromFile,
4281
4281
  clearSuggestionCache: () => clearSuggestionCache
4282
4282
  });
4283
- import * as fs19 from "fs";
4283
+ import * as fs20 from "fs";
4284
4284
  import * as crypto from "crypto";
4285
4285
  function clearSuggestionCache() {
4286
4286
  Object.keys(suggestionCache).forEach((key) => delete suggestionCache[key]);
@@ -4290,10 +4290,10 @@ function hashTransaction(posting) {
4290
4290
  return crypto.createHash("md5").update(data).digest("hex");
4291
4291
  }
4292
4292
  function loadExistingAccounts(yearJournalPath) {
4293
- if (!fs19.existsSync(yearJournalPath)) {
4293
+ if (!fs20.existsSync(yearJournalPath)) {
4294
4294
  return [];
4295
4295
  }
4296
- const content = fs19.readFileSync(yearJournalPath, "utf-8");
4296
+ const content = fs20.readFileSync(yearJournalPath, "utf-8");
4297
4297
  const lines = content.split(`
4298
4298
  `);
4299
4299
  const accounts = [];
@@ -4309,10 +4309,10 @@ function loadExistingAccounts(yearJournalPath) {
4309
4309
  return accounts.sort();
4310
4310
  }
4311
4311
  function extractRulePatternsFromFile(rulesPath) {
4312
- if (!fs19.existsSync(rulesPath)) {
4312
+ if (!fs20.existsSync(rulesPath)) {
4313
4313
  return [];
4314
4314
  }
4315
- const content = fs19.readFileSync(rulesPath, "utf-8");
4315
+ const content = fs20.readFileSync(rulesPath, "utf-8");
4316
4316
  const lines = content.split(`
4317
4317
  `);
4318
4318
  const patterns = [];
@@ -4547,7 +4547,7 @@ var init_accountSuggester = __esm(() => {
4547
4547
 
4548
4548
  // src/index.ts
4549
4549
  init_agentLoader();
4550
- import { dirname as dirname7, join as join15 } from "path";
4550
+ import { dirname as dirname8, join as join16 } from "path";
4551
4551
  import { fileURLToPath as fileURLToPath3 } from "url";
4552
4552
 
4553
4553
  // node_modules/zod/v4/classic/external.js
@@ -24192,8 +24192,8 @@ Note: This tool requires a contextId from a prior classify/import step.`,
24192
24192
  }
24193
24193
  });
24194
24194
  // src/tools/import-pipeline.ts
24195
- import * as fs20 from "fs";
24196
- import * as path14 from "path";
24195
+ import * as fs21 from "fs";
24196
+ import * as path15 from "path";
24197
24197
 
24198
24198
  // src/utils/accountDeclarations.ts
24199
24199
  init_journalMatchers();
@@ -25065,7 +25065,7 @@ function removeLots(inventory, symbol2, logger) {
25065
25065
  return [];
25066
25066
  }
25067
25067
  const removedLots = [...lots];
25068
- delete inventory[symbol2];
25068
+ inventory[symbol2] = [];
25069
25069
  const totalQuantity = removedLots.reduce((sum, lot) => sum + lot.quantity, 0);
25070
25070
  const totalCost = removedLots.reduce((sum, lot) => sum + lot.quantity * lot.costBasis, 0);
25071
25071
  logger?.info(`Removed ${removedLots.length} lots for ${symbol2}: ${totalQuantity} shares, cost basis ${totalCost}`);
@@ -25234,17 +25234,18 @@ function generateWorthlessEntry(action, removedLots, logger) {
25234
25234
  `;
25235
25235
  return entry;
25236
25236
  }
25237
- function generateMultiWayMergerEntry(group, crossCurrencyOutgoingSymbols, logger) {
25237
+ function generateMultiWayMergerEntry(group, crossCurrencyOutgoingSymbols, crossCurrencyOutgoingIsins, logger) {
25238
25238
  const date5 = formatDate(group.date);
25239
25239
  const outSymbols = crossCurrencyOutgoingSymbols ?? group.outgoing.map((a) => a.symbol);
25240
25240
  const inSymbols = group.incoming.map((a) => a.symbol);
25241
- const description = escapeDescription(`Merger: ${outSymbols.join(" + ")} -> ${inSymbols.join(" + ")}`);
25241
+ const descriptionParts = outSymbols.join(" + ");
25242
+ const description = escapeDescription(inSymbols.length > 0 ? `Merger: ${descriptionParts} -> ${inSymbols.join(" + ")}` : `Merger: ${descriptionParts}`);
25242
25243
  logger?.debug(`Generating multi-way merger entry: ${outSymbols.join(", ")} -> ${inSymbols.join(", ")}`);
25243
25244
  let entry = `${date5} ${description}
25244
25245
  `;
25245
25246
  entry += ` ; swissquote:order:${group.orderNum}
25246
25247
  `;
25247
- const oldIsins = group.outgoing.map((a) => a.isin).filter(Boolean);
25248
+ const oldIsins = (crossCurrencyOutgoingIsins ?? group.outgoing.map((a) => a.isin)).filter(Boolean);
25248
25249
  const newIsins = group.incoming.map((a) => a.isin).filter(Boolean);
25249
25250
  if (oldIsins.length > 0 || newIsins.length > 0) {
25250
25251
  entry += ` ; Old ISIN: ${oldIsins.join(", ") || "n/a"}, New ISINs: ${newIsins.join(", ") || "n/a"}
@@ -25546,7 +25547,7 @@ function processMultiWayMerger(group, inventory, lotInventoryPath, projectDir, l
25546
25547
  inventory[inc.symbol].push(lot);
25547
25548
  logger?.info(`Cross-currency merger incoming: ${absQty} ${inc.symbol} @ ${costBasisPerUnit.toFixed(2)} ${pendingState.currency}`);
25548
25549
  }
25549
- const entry2 = generateMultiWayMergerEntry(group, pendingState.outgoingSymbols, logger);
25550
+ const entry2 = generateMultiWayMergerEntry(group, pendingState.outgoingSymbols, pendingState.outgoingIsins, logger);
25550
25551
  entries.push(entry2);
25551
25552
  removePendingMerger(projectDir, lotInventoryPath, group.key, logger);
25552
25553
  } else {
@@ -25577,9 +25578,12 @@ function processMultiWayMerger(group, inventory, lotInventoryPath, projectDir, l
25577
25578
  date: group.date,
25578
25579
  orderNum: group.orderNum,
25579
25580
  outgoingSymbols,
25581
+ outgoingIsins: group.outgoing.map((a) => a.isin),
25580
25582
  totalCostBasis,
25581
25583
  currency: outgoingCurrency || "CAD"
25582
25584
  }, logger);
25585
+ const entry2 = generateMultiWayMergerEntry(group, undefined, undefined, logger);
25586
+ entries.push(entry2);
25583
25587
  logger?.info(`Cross-currency merger outgoing: ${outgoingSymbols.join(", ")} -> pending (cost basis: ${totalCostBasis.toFixed(2)})`);
25584
25588
  return entries;
25585
25589
  }
@@ -25608,7 +25612,7 @@ function processMultiWayMerger(group, inventory, lotInventoryPath, projectDir, l
25608
25612
  inventory[inc.symbol].push(lot);
25609
25613
  logger?.debug(`Merger incoming: added ${absQty} ${inc.symbol} @ ${costBasisPerUnit.toFixed(2)}`);
25610
25614
  }
25611
- const entry = generateMultiWayMergerEntry(group, undefined, logger);
25615
+ const entry = generateMultiWayMergerEntry(group, undefined, undefined, logger);
25612
25616
  entries.push(entry);
25613
25617
  return entries;
25614
25618
  }
@@ -25895,6 +25899,208 @@ async function preprocessSwissquote(csvPath, projectDir, currency, year, lotInve
25895
25899
  };
25896
25900
  }
25897
25901
 
25902
+ // src/utils/currencyExchangeGenerator.ts
25903
+ import * as fs19 from "fs";
25904
+ import * as path14 from "path";
25905
+ function parseExchangeRows(csvPath) {
25906
+ const content = fs19.readFileSync(csvPath, "utf-8");
25907
+ const lines = content.trim().split(`
25908
+ `);
25909
+ if (lines.length < 2)
25910
+ return [];
25911
+ const rows = [];
25912
+ for (let i2 = 1;i2 < lines.length; i2++) {
25913
+ const line = lines[i2];
25914
+ const fields = line.split(",");
25915
+ if (fields.length < 9)
25916
+ continue;
25917
+ const [type2, , startedDate, , description, amountStr, , currency] = fields;
25918
+ if (type2 !== "EXCHANGE")
25919
+ continue;
25920
+ if (!description?.startsWith("Exchanged to"))
25921
+ continue;
25922
+ const rawAmount = parseFloat(amountStr);
25923
+ const date5 = parseRevolutDatetime(startedDate);
25924
+ const dateIso = formatDateIso2(date5);
25925
+ rows.push({
25926
+ date: date5,
25927
+ dateStr: dateIso,
25928
+ description,
25929
+ amount: rawAmount,
25930
+ currency,
25931
+ lineIndex: i2,
25932
+ csvPath
25933
+ });
25934
+ }
25935
+ return rows;
25936
+ }
25937
+ function matchExchangePairs(rowsByCsv) {
25938
+ const allRows = [];
25939
+ for (const rows of rowsByCsv.values()) {
25940
+ allRows.push(...rows);
25941
+ }
25942
+ const sources = allRows.filter((r) => r.amount > 0);
25943
+ const targets = allRows.filter((r) => r.amount < 0);
25944
+ const matches = [];
25945
+ const matchedSourceIndices = new Set;
25946
+ const matchedTargetIndices = new Set;
25947
+ for (let si = 0;si < sources.length; si++) {
25948
+ const source = sources[si];
25949
+ for (let ti = 0;ti < targets.length; ti++) {
25950
+ if (matchedTargetIndices.has(ti))
25951
+ continue;
25952
+ const target = targets[ti];
25953
+ if (source.currency === target.currency)
25954
+ continue;
25955
+ if (!datesMatchToSecond2(source.date, target.date))
25956
+ continue;
25957
+ const targetCurrency = target.currency;
25958
+ if (!source.description.includes(targetCurrency))
25959
+ continue;
25960
+ matches.push({
25961
+ source,
25962
+ target: { ...target, amount: Math.abs(target.amount) }
25963
+ });
25964
+ matchedSourceIndices.add(si);
25965
+ matchedTargetIndices.add(ti);
25966
+ break;
25967
+ }
25968
+ }
25969
+ return matches;
25970
+ }
25971
+ function datesMatchToSecond2(a, b) {
25972
+ return Math.abs(a.getTime() - b.getTime()) < 1000;
25973
+ }
25974
+ function formatExchangeEntry(match2) {
25975
+ const { source, target } = match2;
25976
+ const date5 = source.dateStr;
25977
+ const description = source.description;
25978
+ const sourceCurrency = source.currency;
25979
+ const targetCurrency = target.currency;
25980
+ const sourceAmount = formatAmount3(source.amount);
25981
+ const targetAmount = formatAmount3(target.amount);
25982
+ const rate = target.amount / source.amount;
25983
+ const rateStr = formatRate(rate);
25984
+ return [
25985
+ `${date5} ${description}`,
25986
+ ` assets:bank:revolut:${sourceCurrency.toLowerCase()} -${sourceAmount} ${sourceCurrency} @ ${rateStr} ${targetCurrency}`,
25987
+ ` equity:currency:conversion ${sourceAmount} ${sourceCurrency}`,
25988
+ ` equity:currency:conversion -${targetAmount} ${targetCurrency}`,
25989
+ ` assets:bank:revolut:${targetCurrency.toLowerCase()} ${targetAmount} ${targetCurrency}`
25990
+ ].join(`
25991
+ `);
25992
+ }
25993
+ function formatAmount3(amount) {
25994
+ return amount.toFixed(2);
25995
+ }
25996
+ function formatRate(rate) {
25997
+ return rate.toFixed(4);
25998
+ }
25999
+ function isDuplicate2(match2, journalContent) {
26000
+ const date5 = match2.source.dateStr;
26001
+ const description = match2.source.description;
26002
+ const pattern = `${date5} ${description}`;
26003
+ if (!journalContent.includes(pattern))
26004
+ return false;
26005
+ const sourceAmount = formatAmount3(match2.source.amount);
26006
+ const sourceCurrency = match2.source.currency;
26007
+ const amountPattern = `-${sourceAmount} ${sourceCurrency}`;
26008
+ const idx = journalContent.indexOf(pattern);
26009
+ const chunk = journalContent.slice(idx, idx + 500);
26010
+ return chunk.includes(amountPattern);
26011
+ }
26012
+ function parseRevolutDatetime(dateStr) {
26013
+ const [datePart, timePart] = dateStr.split(" ");
26014
+ if (!datePart || !timePart) {
26015
+ throw new Error(`Invalid datetime: ${dateStr}`);
26016
+ }
26017
+ return new Date(`${datePart}T${timePart}`);
26018
+ }
26019
+ function formatDateIso2(date5) {
26020
+ const y = date5.getFullYear();
26021
+ const m = String(date5.getMonth() + 1).padStart(2, "0");
26022
+ const d = String(date5.getDate()).padStart(2, "0");
26023
+ return `${y}-${m}-${d}`;
26024
+ }
26025
+ function filterExchangeRows(csvPath, rowIndicesToRemove, logger) {
26026
+ const content = fs19.readFileSync(csvPath, "utf-8");
26027
+ const lines = content.trim().split(`
26028
+ `);
26029
+ const filteredLines = [lines[0]];
26030
+ for (let i2 = 1;i2 < lines.length; i2++) {
26031
+ if (rowIndicesToRemove.has(i2))
26032
+ continue;
26033
+ filteredLines.push(lines[i2]);
26034
+ }
26035
+ const dir = path14.dirname(csvPath);
26036
+ const basename6 = path14.basename(csvPath, ".csv");
26037
+ const filteredPath = path14.join(dir, `${basename6}-filtered.csv`);
26038
+ fs19.writeFileSync(filteredPath, filteredLines.join(`
26039
+ `) + `
26040
+ `);
26041
+ logger?.info(`Filtered ${rowIndicesToRemove.size} exchange rows from ${path14.basename(csvPath)} \u2192 ${path14.basename(filteredPath)}`);
26042
+ return filteredPath;
26043
+ }
26044
+ function generateCurrencyExchangeJournal(fiatCsvPaths, yearJournalPath, logger) {
26045
+ logger?.info("Parsing fiat CSVs for EXCHANGE rows...");
26046
+ const rowsByCsv = new Map;
26047
+ for (const csvPath of fiatCsvPaths) {
26048
+ const rows = parseExchangeRows(csvPath);
26049
+ if (rows.length > 0) {
26050
+ rowsByCsv.set(csvPath, rows);
26051
+ logger?.info(`Found ${rows.length} EXCHANGE rows in ${path14.basename(csvPath)}`);
26052
+ }
26053
+ }
26054
+ if (rowsByCsv.size < 2) {
26055
+ logger?.info("Need at least 2 CSVs with EXCHANGE rows to match pairs, skipping");
26056
+ return { matchCount: 0, entriesAdded: 0, skippedDuplicates: 0, matchedRowIndices: new Map };
26057
+ }
26058
+ logger?.info("Matching exchange pairs across CSVs...");
26059
+ const matches = matchExchangePairs(rowsByCsv);
26060
+ logger?.info(`Matched ${matches.length} exchange pairs`);
26061
+ let journalContent = "";
26062
+ if (fs19.existsSync(yearJournalPath)) {
26063
+ journalContent = fs19.readFileSync(yearJournalPath, "utf-8");
26064
+ }
26065
+ const newEntries = [];
26066
+ let skippedDuplicates = 0;
26067
+ const sortedMatches = [...matches].sort((a, b) => a.source.date.getTime() - b.source.date.getTime());
26068
+ for (const match2 of sortedMatches) {
26069
+ if (isDuplicate2(match2, journalContent)) {
26070
+ skippedDuplicates++;
26071
+ logger?.debug(`Skipping duplicate: ${match2.source.dateStr} ${formatAmount3(match2.source.amount)} ${match2.source.currency} \u2192 ${formatAmount3(match2.target.amount)} ${match2.target.currency}`);
26072
+ continue;
26073
+ }
26074
+ newEntries.push(formatExchangeEntry(match2));
26075
+ }
26076
+ if (newEntries.length > 0) {
26077
+ const appendContent = `
26078
+ ` + newEntries.join(`
26079
+
26080
+ `) + `
26081
+ `;
26082
+ fs19.appendFileSync(yearJournalPath, appendContent);
26083
+ logger?.info(`Appended ${newEntries.length} currency exchange entries to ${path14.basename(yearJournalPath)}`);
26084
+ } else {
26085
+ logger?.info("No new currency exchange entries to add");
26086
+ }
26087
+ const matchedRowIndices = new Map;
26088
+ for (const match2 of matches) {
26089
+ for (const row of [match2.source, match2.target]) {
26090
+ if (!matchedRowIndices.has(row.csvPath)) {
26091
+ matchedRowIndices.set(row.csvPath, new Set);
26092
+ }
26093
+ matchedRowIndices.get(row.csvPath).add(row.lineIndex);
26094
+ }
26095
+ }
26096
+ return {
26097
+ matchCount: matches.length,
26098
+ entriesAdded: newEntries.length,
26099
+ skippedDuplicates,
26100
+ matchedRowIndices
26101
+ };
26102
+ }
26103
+
25898
26104
  // src/tools/import-pipeline.ts
25899
26105
  class NoTransactionsError extends Error {
25900
26106
  constructor() {
@@ -25967,7 +26173,7 @@ function executePreprocessFiatStep(context, contextIds, logger) {
25967
26173
  for (const contextId of contextIds) {
25968
26174
  const importCtx = loadContext(context.directory, contextId);
25969
26175
  if (importCtx.provider === "revolut" && importCtx.currency !== "btc") {
25970
- fiatCsvPaths.push(path14.join(context.directory, importCtx.filePath));
26176
+ fiatCsvPaths.push(path15.join(context.directory, importCtx.filePath));
25971
26177
  }
25972
26178
  }
25973
26179
  if (fiatCsvPaths.length === 0) {
@@ -26011,7 +26217,7 @@ function executePreprocessBtcStep(context, contextIds, logger) {
26011
26217
  for (const contextId of contextIds) {
26012
26218
  const importCtx = loadContext(context.directory, contextId);
26013
26219
  if (importCtx.provider === "revolut" && importCtx.currency === "btc") {
26014
- btcCsvPath = path14.join(context.directory, importCtx.filePath);
26220
+ btcCsvPath = path15.join(context.directory, importCtx.filePath);
26015
26221
  break;
26016
26222
  }
26017
26223
  }
@@ -26045,7 +26251,7 @@ async function executeBtcPurchaseStep(context, contextIds, logger) {
26045
26251
  const importCtx = loadContext(context.directory, contextId);
26046
26252
  if (importCtx.provider !== "revolut")
26047
26253
  continue;
26048
- const csvPath = path14.join(context.directory, importCtx.filePath);
26254
+ const csvPath = path15.join(context.directory, importCtx.filePath);
26049
26255
  if (importCtx.currency === "btc") {
26050
26256
  btcCsvPath = csvPath;
26051
26257
  } else {
@@ -26058,7 +26264,7 @@ async function executeBtcPurchaseStep(context, contextIds, logger) {
26058
26264
  }
26059
26265
  logger?.startSection("Step 1b: Generate BTC Purchase Entries");
26060
26266
  logger?.logStep("BTC Purchases", "start");
26061
- const btcFilename = path14.basename(btcCsvPath);
26267
+ const btcFilename = path15.basename(btcCsvPath);
26062
26268
  const yearMatch = btcFilename.match(/(\d{4})-\d{2}-\d{2}/);
26063
26269
  const year = yearMatch ? parseInt(yearMatch[1], 10) : new Date().getFullYear();
26064
26270
  const yearJournalPath = ensureYearJournalExists(context.directory, year);
@@ -26074,12 +26280,56 @@ async function executeBtcPurchaseStep(context, contextIds, logger) {
26074
26280
  unmatchedBtc: result.unmatchedBtc.length
26075
26281
  });
26076
26282
  }
26283
+ async function executeCurrencyExchangeStep(context, contextIds, logger) {
26284
+ const fiatContexts = [];
26285
+ for (const contextId of contextIds) {
26286
+ const importCtx = loadContext(context.directory, contextId);
26287
+ if (importCtx.provider !== "revolut")
26288
+ continue;
26289
+ if (importCtx.currency === "btc")
26290
+ continue;
26291
+ const csvPath = path15.join(context.directory, importCtx.filePath);
26292
+ fiatContexts.push({ contextId, csvPath });
26293
+ }
26294
+ if (fiatContexts.length < 2) {
26295
+ logger?.info("Need at least 2 Revolut fiat CSVs for exchange matching, skipping");
26296
+ return;
26297
+ }
26298
+ logger?.startSection("Step 1e: Generate Currency Exchange Entries");
26299
+ logger?.logStep("Currency Exchanges", "start");
26300
+ const firstFilename = path15.basename(fiatContexts[0].csvPath);
26301
+ const yearMatch = firstFilename.match(/(\d{4})-\d{2}-\d{2}/);
26302
+ const year = yearMatch ? parseInt(yearMatch[1], 10) : new Date().getFullYear();
26303
+ const yearJournalPath = ensureYearJournalExists(context.directory, year);
26304
+ const fiatCsvPaths = fiatContexts.map((c) => c.csvPath);
26305
+ const result = generateCurrencyExchangeJournal(fiatCsvPaths, yearJournalPath, logger);
26306
+ if (result.matchedRowIndices.size > 0) {
26307
+ for (const { contextId, csvPath } of fiatContexts) {
26308
+ const indices = result.matchedRowIndices.get(csvPath);
26309
+ if (!indices || indices.size === 0)
26310
+ continue;
26311
+ const filteredCsvPath = filterExchangeRows(csvPath, indices, logger);
26312
+ updateContext(context.directory, contextId, {
26313
+ filePath: path15.relative(context.directory, filteredCsvPath)
26314
+ });
26315
+ logger?.info(`Updated context ${contextId} to use filtered CSV: ${path15.basename(filteredCsvPath)}`);
26316
+ }
26317
+ }
26318
+ 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)`;
26319
+ logger?.logStep("Currency Exchanges", "success", message);
26320
+ logger?.endSection();
26321
+ context.result.steps.currencyExchanges = buildStepResult(true, message, {
26322
+ matchCount: result.matchCount,
26323
+ entriesAdded: result.entriesAdded,
26324
+ skippedDuplicates: result.skippedDuplicates
26325
+ });
26326
+ }
26077
26327
  async function executeSwissquotePreprocessStep(context, contextIds, logger) {
26078
26328
  const swissquoteContexts = [];
26079
26329
  for (const contextId of contextIds) {
26080
26330
  const importCtx = loadContext(context.directory, contextId);
26081
26331
  if (importCtx.provider === "swissquote") {
26082
- const csvPath = path14.join(context.directory, importCtx.filePath);
26332
+ const csvPath = path15.join(context.directory, importCtx.filePath);
26083
26333
  const toDateMatch = importCtx.filename.match(/to-\d{4}(\d{4})\./);
26084
26334
  let year = new Date().getFullYear();
26085
26335
  if (toDateMatch) {
@@ -26103,11 +26353,11 @@ async function executeSwissquotePreprocessStep(context, contextIds, logger) {
26103
26353
  const lotInventoryPath = swissquoteProvider?.lotInventoryPath ?? DEFAULT_LOT_INVENTORY_PATH;
26104
26354
  const symbolMapPath = swissquoteProvider?.symbolMapPath ?? "config/import/symbolMap.yaml";
26105
26355
  let symbolMap = {};
26106
- const symbolMapFullPath = path14.join(context.directory, symbolMapPath);
26107
- if (fs20.existsSync(symbolMapFullPath)) {
26356
+ const symbolMapFullPath = path15.join(context.directory, symbolMapPath);
26357
+ if (fs21.existsSync(symbolMapFullPath)) {
26108
26358
  try {
26109
26359
  const yaml = await Promise.resolve().then(() => (init_js_yaml(), exports_js_yaml));
26110
- const content = fs20.readFileSync(symbolMapFullPath, "utf-8");
26360
+ const content = fs21.readFileSync(symbolMapFullPath, "utf-8");
26111
26361
  const parsed = yaml.load(content);
26112
26362
  if (parsed && typeof parsed === "object") {
26113
26363
  symbolMap = parsed;
@@ -26129,7 +26379,7 @@ async function executeSwissquotePreprocessStep(context, contextIds, logger) {
26129
26379
  };
26130
26380
  let lastJournalFile = null;
26131
26381
  for (const sqCtx of swissquoteContexts) {
26132
- logger?.logStep("Swissquote Preprocess", "start", `Processing ${path14.basename(sqCtx.csvPath)}`);
26382
+ logger?.logStep("Swissquote Preprocess", "start", `Processing ${path15.basename(sqCtx.csvPath)}`);
26133
26383
  const result = await preprocessSwissquote(sqCtx.csvPath, context.directory, sqCtx.currency, sqCtx.year, lotInventoryPath, symbolMap, logger);
26134
26384
  totalStats.totalRows += result.stats.totalRows;
26135
26385
  totalStats.simpleTransactions += result.stats.simpleTransactions;
@@ -26142,9 +26392,9 @@ async function executeSwissquotePreprocessStep(context, contextIds, logger) {
26142
26392
  }
26143
26393
  if (result.simpleTransactionsCsv) {
26144
26394
  updateContext(context.directory, sqCtx.contextId, {
26145
- filePath: path14.relative(context.directory, result.simpleTransactionsCsv)
26395
+ filePath: path15.relative(context.directory, result.simpleTransactionsCsv)
26146
26396
  });
26147
- logger?.info(`Updated context ${sqCtx.contextId} to use filtered CSV: ${path14.basename(result.simpleTransactionsCsv)}`);
26397
+ logger?.info(`Updated context ${sqCtx.contextId} to use filtered CSV: ${path15.basename(result.simpleTransactionsCsv)}`);
26148
26398
  }
26149
26399
  logger?.logStep("Swissquote Preprocess", "success", `Processed: ${result.stats.trades} trades, ${result.stats.dividends} dividends, ${result.stats.corporateActions} corporate actions`);
26150
26400
  }
@@ -26169,9 +26419,9 @@ async function executeAccountDeclarationsStep(context, contextId, logger) {
26169
26419
  logger?.startSection("Step 2: Check Account Declarations");
26170
26420
  logger?.logStep("Check Accounts", "start");
26171
26421
  const config2 = context.configLoader(context.directory);
26172
- const rulesDir = path14.join(context.directory, config2.paths.rules);
26422
+ const rulesDir = path15.join(context.directory, config2.paths.rules);
26173
26423
  const importCtx = loadContext(context.directory, contextId);
26174
- const csvPath = path14.join(context.directory, importCtx.filePath);
26424
+ const csvPath = path15.join(context.directory, importCtx.filePath);
26175
26425
  const csvFiles = [csvPath];
26176
26426
  const rulesMapping = loadRulesMapping(rulesDir);
26177
26427
  const matchedRulesFiles = new Set;
@@ -26194,7 +26444,7 @@ async function executeAccountDeclarationsStep(context, contextId, logger) {
26194
26444
  context.result.steps.accountDeclarations = buildStepResult(true, "No accounts found in rules files", {
26195
26445
  accountsAdded: [],
26196
26446
  journalUpdated: "",
26197
- rulesScanned: Array.from(matchedRulesFiles).map((f) => path14.relative(context.directory, f))
26447
+ rulesScanned: Array.from(matchedRulesFiles).map((f) => path15.relative(context.directory, f))
26198
26448
  });
26199
26449
  return;
26200
26450
  }
@@ -26218,7 +26468,7 @@ async function executeAccountDeclarationsStep(context, contextId, logger) {
26218
26468
  context.result.steps.accountDeclarations = buildStepResult(true, "No transactions found, skipping account declarations", {
26219
26469
  accountsAdded: [],
26220
26470
  journalUpdated: "",
26221
- rulesScanned: Array.from(matchedRulesFiles).map((f) => path14.relative(context.directory, f))
26471
+ rulesScanned: Array.from(matchedRulesFiles).map((f) => path15.relative(context.directory, f))
26222
26472
  });
26223
26473
  return;
26224
26474
  }
@@ -26229,12 +26479,12 @@ async function executeAccountDeclarationsStep(context, contextId, logger) {
26229
26479
  context.result.steps.accountDeclarations = buildStepResult(false, `Failed to create year journal: ${error45 instanceof Error ? error45.message : String(error45)}`, {
26230
26480
  accountsAdded: [],
26231
26481
  journalUpdated: "",
26232
- rulesScanned: Array.from(matchedRulesFiles).map((f) => path14.relative(context.directory, f))
26482
+ rulesScanned: Array.from(matchedRulesFiles).map((f) => path15.relative(context.directory, f))
26233
26483
  });
26234
26484
  return;
26235
26485
  }
26236
26486
  const result = ensureAccountDeclarations(yearJournalPath, allAccounts);
26237
- const message = result.added.length > 0 ? `Added ${result.added.length} account declaration(s) to ${path14.relative(context.directory, yearJournalPath)}` : "All required accounts already declared";
26487
+ const message = result.added.length > 0 ? `Added ${result.added.length} account declaration(s) to ${path15.relative(context.directory, yearJournalPath)}` : "All required accounts already declared";
26238
26488
  logger?.logStep("Check Accounts", "success", message);
26239
26489
  if (result.added.length > 0) {
26240
26490
  for (const account of result.added) {
@@ -26243,17 +26493,17 @@ async function executeAccountDeclarationsStep(context, contextId, logger) {
26243
26493
  }
26244
26494
  context.result.steps.accountDeclarations = buildStepResult(true, message, {
26245
26495
  accountsAdded: result.added,
26246
- journalUpdated: path14.relative(context.directory, yearJournalPath),
26247
- rulesScanned: Array.from(matchedRulesFiles).map((f) => path14.relative(context.directory, f))
26496
+ journalUpdated: path15.relative(context.directory, yearJournalPath),
26497
+ rulesScanned: Array.from(matchedRulesFiles).map((f) => path15.relative(context.directory, f))
26248
26498
  });
26249
26499
  logger?.endSection();
26250
26500
  }
26251
26501
  async function buildSuggestionContext(context, contextId, logger) {
26252
26502
  const { loadExistingAccounts: loadExistingAccounts2, extractRulePatternsFromFile: extractRulePatternsFromFile2 } = await Promise.resolve().then(() => (init_accountSuggester(), exports_accountSuggester));
26253
26503
  const config2 = context.configLoader(context.directory);
26254
- const rulesDir = path14.join(context.directory, config2.paths.rules);
26504
+ const rulesDir = path15.join(context.directory, config2.paths.rules);
26255
26505
  const importCtx = loadContext(context.directory, contextId);
26256
- const csvPath = path14.join(context.directory, importCtx.filePath);
26506
+ const csvPath = path15.join(context.directory, importCtx.filePath);
26257
26507
  const rulesMapping = loadRulesMapping(rulesDir);
26258
26508
  const rulesFile = findRulesForCsv(csvPath, rulesMapping);
26259
26509
  if (!rulesFile) {
@@ -26460,6 +26710,7 @@ async function importPipeline(directory, agent, options, configLoader = loadImpo
26460
26710
  executePreprocessFiatStep(context, contextIds, logger);
26461
26711
  executePreprocessBtcStep(context, contextIds, logger);
26462
26712
  await executeBtcPurchaseStep(context, contextIds, logger);
26713
+ await executeCurrencyExchangeStep(context, contextIds, logger);
26463
26714
  await executeSwissquotePreprocessStep(context, contextIds, logger);
26464
26715
  const importConfig = loadImportConfig(context.directory);
26465
26716
  const orderedContextIds = [...contextIds].sort((a, b) => {
@@ -26559,16 +26810,16 @@ This tool orchestrates the full import workflow:
26559
26810
  }
26560
26811
  });
26561
26812
  // src/tools/init-directories.ts
26562
- import * as fs21 from "fs";
26563
- import * as path15 from "path";
26813
+ import * as fs22 from "fs";
26814
+ import * as path16 from "path";
26564
26815
  async function initDirectories(directory) {
26565
26816
  try {
26566
26817
  const config2 = loadImportConfig(directory);
26567
26818
  const directoriesCreated = [];
26568
26819
  const gitkeepFiles = [];
26569
- const importBase = path15.join(directory, "import");
26570
- if (!fs21.existsSync(importBase)) {
26571
- fs21.mkdirSync(importBase, { recursive: true });
26820
+ const importBase = path16.join(directory, "import");
26821
+ if (!fs22.existsSync(importBase)) {
26822
+ fs22.mkdirSync(importBase, { recursive: true });
26572
26823
  directoriesCreated.push("import");
26573
26824
  }
26574
26825
  const pathsToCreate = [
@@ -26578,20 +26829,20 @@ async function initDirectories(directory) {
26578
26829
  { key: "unrecognized", path: config2.paths.unrecognized }
26579
26830
  ];
26580
26831
  for (const { path: dirPath } of pathsToCreate) {
26581
- const fullPath = path15.join(directory, dirPath);
26582
- if (!fs21.existsSync(fullPath)) {
26583
- fs21.mkdirSync(fullPath, { recursive: true });
26832
+ const fullPath = path16.join(directory, dirPath);
26833
+ if (!fs22.existsSync(fullPath)) {
26834
+ fs22.mkdirSync(fullPath, { recursive: true });
26584
26835
  directoriesCreated.push(dirPath);
26585
26836
  }
26586
- const gitkeepPath = path15.join(fullPath, ".gitkeep");
26587
- if (!fs21.existsSync(gitkeepPath)) {
26588
- fs21.writeFileSync(gitkeepPath, "");
26589
- gitkeepFiles.push(path15.join(dirPath, ".gitkeep"));
26837
+ const gitkeepPath = path16.join(fullPath, ".gitkeep");
26838
+ if (!fs22.existsSync(gitkeepPath)) {
26839
+ fs22.writeFileSync(gitkeepPath, "");
26840
+ gitkeepFiles.push(path16.join(dirPath, ".gitkeep"));
26590
26841
  }
26591
26842
  }
26592
- const gitignorePath = path15.join(importBase, ".gitignore");
26843
+ const gitignorePath = path16.join(importBase, ".gitignore");
26593
26844
  let gitignoreCreated = false;
26594
- if (!fs21.existsSync(gitignorePath)) {
26845
+ if (!fs22.existsSync(gitignorePath)) {
26595
26846
  const gitignoreContent = `# Ignore CSV/PDF files in temporary directories
26596
26847
  /incoming/*.csv
26597
26848
  /incoming/*.pdf
@@ -26609,7 +26860,7 @@ async function initDirectories(directory) {
26609
26860
  .DS_Store
26610
26861
  Thumbs.db
26611
26862
  `;
26612
- fs21.writeFileSync(gitignorePath, gitignoreContent);
26863
+ fs22.writeFileSync(gitignorePath, gitignoreContent);
26613
26864
  gitignoreCreated = true;
26614
26865
  }
26615
26866
  const parts = [];
@@ -26685,32 +26936,32 @@ You can now drop CSV files into import/incoming/ and run import-pipeline.`);
26685
26936
  }
26686
26937
  });
26687
26938
  // src/tools/generate-btc-purchases.ts
26688
- import * as path16 from "path";
26689
- import * as fs22 from "fs";
26939
+ import * as path17 from "path";
26940
+ import * as fs23 from "fs";
26690
26941
  function findFiatCsvPaths(directory, pendingDir, provider) {
26691
- const providerDir = path16.join(directory, pendingDir, provider);
26692
- if (!fs22.existsSync(providerDir))
26942
+ const providerDir = path17.join(directory, pendingDir, provider);
26943
+ if (!fs23.existsSync(providerDir))
26693
26944
  return [];
26694
26945
  const csvPaths = [];
26695
- const entries = fs22.readdirSync(providerDir, { withFileTypes: true });
26946
+ const entries = fs23.readdirSync(providerDir, { withFileTypes: true });
26696
26947
  for (const entry of entries) {
26697
26948
  if (!entry.isDirectory())
26698
26949
  continue;
26699
26950
  if (entry.name === "btc")
26700
26951
  continue;
26701
- const csvFiles = findCsvFiles(path16.join(providerDir, entry.name), { fullPaths: true });
26952
+ const csvFiles = findCsvFiles(path17.join(providerDir, entry.name), { fullPaths: true });
26702
26953
  csvPaths.push(...csvFiles);
26703
26954
  }
26704
26955
  return csvPaths;
26705
26956
  }
26706
26957
  function findBtcCsvPath(directory, pendingDir, provider) {
26707
- const btcDir = path16.join(directory, pendingDir, provider, "btc");
26958
+ const btcDir = path17.join(directory, pendingDir, provider, "btc");
26708
26959
  const csvFiles = findCsvFiles(btcDir, { fullPaths: true });
26709
26960
  return csvFiles[0];
26710
26961
  }
26711
26962
  function determineYear(csvPaths) {
26712
26963
  for (const csvPath of csvPaths) {
26713
- const match2 = path16.basename(csvPath).match(/(\d{4})-\d{2}-\d{2}/);
26964
+ const match2 = path17.basename(csvPath).match(/(\d{4})-\d{2}-\d{2}/);
26714
26965
  if (match2)
26715
26966
  return parseInt(match2[1], 10);
26716
26967
  }
@@ -26763,7 +27014,7 @@ async function generateBtcPurchases(directory, agent, options = {}, configLoader
26763
27014
  skippedDuplicates: result.skippedDuplicates,
26764
27015
  unmatchedFiat: result.unmatchedFiat.length,
26765
27016
  unmatchedBtc: result.unmatchedBtc.length,
26766
- yearJournal: path16.relative(directory, yearJournalPath)
27017
+ yearJournal: path17.relative(directory, yearJournalPath)
26767
27018
  });
26768
27019
  } catch (err) {
26769
27020
  logger.error("Failed to generate BTC purchases", err);
@@ -26805,8 +27056,8 @@ to produce equity conversion entries for Bitcoin purchases.
26805
27056
  }
26806
27057
  });
26807
27058
  // src/index.ts
26808
- var __dirname2 = dirname7(fileURLToPath3(import.meta.url));
26809
- var AGENT_FILE = join15(__dirname2, "..", "agent", "accountant.md");
27059
+ var __dirname2 = dirname8(fileURLToPath3(import.meta.url));
27060
+ var AGENT_FILE = join16(__dirname2, "..", "agent", "accountant.md");
26810
27061
  var AccountantPlugin = async () => {
26811
27062
  const agent = loadAgent(AGENT_FILE);
26812
27063
  return {
@@ -12,12 +12,13 @@ The pipeline automates these sequential steps:
12
12
  1a. **Preprocess Fiat CSV** _(Revolut only)_ - Add computed `Net_Amount` column (`Amount - Fee`) for hledger rules
13
13
  1b. **Preprocess BTC CSV** _(Revolut only)_ - Add computed columns (fees in BTC, total, clean price) for hledger rules
14
14
  1c. **Generate BTC Purchases** _(Revolut only)_ - Cross-reference fiat + BTC CSVs for equity conversion entries
15
+ 1e. **Generate Currency Exchanges** _(Revolut only)_ - Cross-reference fiat CSVs for unified multi-currency exchange entries
15
16
  1d. **Preprocess Swissquote CSV** _(Swissquote only)_ - Generate FIFO capital gains entries for trades, dividends, and corporate actions
16
17
  2. **Account Declarations** - Ensure all accounts exist in year journals
17
18
  3. **Import** - Validate transactions, add to journals, move files to done
18
19
  4. **Reconcile** - Verify balances match expectations
19
20
 
20
- Steps 1a–1c are Revolut-specific and steps 1d is Swissquote-specific. These preprocessing steps are automatically skipped when no matching CSVs are present.
21
+ Steps 1a–1c and 1e are Revolut-specific and step 1d is Swissquote-specific. These preprocessing steps are automatically skipped when no matching CSVs are present.
21
22
 
22
23
  **Key behavior**: The pipeline processes files via **import contexts**. Each classified CSV gets a unique context ID, and subsequent steps operate on these contexts **sequentially** with **fail-fast** error handling.
23
24
 
@@ -201,6 +202,34 @@ See [classify-statements](classify-statements.md) for details.
201
202
 
202
203
  **Skipped when**: No fiat+BTC CSV pair is found among the classified files.
203
204
 
205
+ ### Step 1e: Generate Currency Exchange Entries
206
+
207
+ **Purpose**: Cross-reference Revolut fiat CSVs to generate unified 4-posting journal entries for currency exchanges (e.g., CHF to EUR)
208
+
209
+ **What happens**:
210
+
211
+ 1. Finds all Revolut fiat contexts (provider=revolut, currency≠btc) from classification
212
+ 2. Parses each CSV for `EXCHANGE` rows with "Exchanged to" descriptions
213
+ 3. Matches source (debited) and target (credited) rows across CSVs by timestamp (within 1 second)
214
+ 4. Generates unified 4-posting journal entries using `equity:currency:conversion` with `@ cost` notation
215
+ 5. Appends entries to year journal, skipping duplicates
216
+ 6. Filters matched EXCHANGE rows from CSVs (writes `-filtered.csv`) so hledger import doesn't double-count them
217
+ 7. Updates import contexts to point to filtered CSVs
218
+
219
+ **Journal entry format**:
220
+
221
+ ```journal
222
+ 2026-01-15 Exchanged to EUR
223
+ assets:bank:revolut:chf -100.00 CHF @ 1.0713 EUR
224
+ equity:currency:conversion 100.00 CHF
225
+ equity:currency:conversion -107.13 EUR
226
+ assets:bank:revolut:eur 107.13 EUR
227
+ ```
228
+
229
+ **Why**: Revolut exports currency exchanges as two separate CSV rows — one per currency. Processing each through its own rules file produces two independent 2-posting transactions with a non-zero `equity:conversion` balance. This step intercepts EXCHANGE rows before hledger import, matches them across currencies, and generates a single unified transaction with explicit cost notation that keeps `equity:currency:conversion` balanced.
230
+
231
+ **Skipped when**: Fewer than 2 Revolut fiat CSVs with EXCHANGE rows are present among the classified files.
232
+
204
233
  ### Step 1d: Preprocess Swissquote CSV
205
234
 
206
235
  **Purpose**: Generate FIFO capital gains journal entries for Swissquote investment transactions including trades, dividends, and corporate actions
@@ -356,6 +385,14 @@ See [reconcile-statement](reconcile-statement.md) for details.
356
385
 
357
386
 
358
387
  ┌──────────────────────────────────────────────────────────────┐
388
+ │ STEP 1e: Generate Currency Exchange Entries │
389
+ │ • Cross-reference fiat CSVs for EXCHANGE rows │
390
+ │ • Generate unified 4-posting entries with @ cost │
391
+ │ • Filter matched rows from CSVs │
392
+ └──────────────────────────────────────────────────────────────┘
393
+
394
+
395
+ ┌──────────────────────────────────────────────────────────────┐
359
396
  │ STEP 1d: Preprocess Swissquote CSV (if swissquote context) │
360
397
  │ • FIFO lot tracking for trades │
361
398
  │ • Capital gains calculation │
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fuzzle/opencode-accountant",
3
- "version": "0.10.8-next.1",
3
+ "version": "0.11.0-next.1",
4
4
  "description": "An OpenCode accounting agent, specialized in double-entry-bookkepping with hledger",
5
5
  "author": {
6
6
  "name": "ali bengali",