@fuzzle/opencode-accountant 0.15.1 → 0.16.0

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 +639 -124
  2. package/package.json +1 -1
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 fs22 from "fs";
4283
+ import * as fs23 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 (!fs22.existsSync(yearJournalPath)) {
4293
+ if (!fs23.existsSync(yearJournalPath)) {
4294
4294
  return [];
4295
4295
  }
4296
- const content = fs22.readFileSync(yearJournalPath, "utf-8");
4296
+ const content = fs23.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 (!fs22.existsSync(rulesPath)) {
4312
+ if (!fs23.existsSync(rulesPath)) {
4313
4313
  return [];
4314
4314
  }
4315
- const content = fs22.readFileSync(rulesPath, "utf-8");
4315
+ const content = fs23.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 dirname9, join as join17 } from "path";
4550
+ import { dirname as dirname10, join as join18 } from "path";
4551
4551
  import { fileURLToPath as fileURLToPath3 } from "url";
4552
4552
 
4553
4553
  // node_modules/zod/v4/classic/external.js
@@ -17370,7 +17370,7 @@ var REQUIRED_PATH_FIELDS = [
17370
17370
  "unrecognized",
17371
17371
  "rules"
17372
17372
  ];
17373
- var REQUIRED_DETECTION_FIELDS = ["header", "currencyField"];
17373
+ var REQUIRED_DETECTION_FIELDS = ["header"];
17374
17374
  function validatePaths(paths) {
17375
17375
  if (typeof paths !== "object" || paths === null) {
17376
17376
  throw new Error("Invalid config: 'paths' must be an object");
@@ -17445,6 +17445,11 @@ function validateDetectionRule(providerName, index, rule) {
17445
17445
  }
17446
17446
  }
17447
17447
  }
17448
+ const hasCurrencyField = typeof ruleObj.currencyField === "string" && ruleObj.currencyField !== "";
17449
+ const hasCurrencyMetadata = Array.isArray(ruleObj.metadata) && ruleObj.metadata.some((m) => m.field === "currency");
17450
+ if (!hasCurrencyField && !hasCurrencyMetadata) {
17451
+ throw new Error(`Invalid config: provider '${providerName}' detect[${index}] must have either currencyField or a metadata entry with field: "currency"`);
17452
+ }
17448
17453
  if (ruleObj.closingBalanceField !== undefined) {
17449
17454
  if (typeof ruleObj.closingBalanceField !== "string" || ruleObj.closingBalanceField === "") {
17450
17455
  throw new Error(`Invalid config: provider '${providerName}' detect[${index}].closingBalanceField must be a non-empty string`);
@@ -17646,12 +17651,18 @@ function detectProvider(filename, content, config2) {
17646
17651
  if (!firstRow) {
17647
17652
  continue;
17648
17653
  }
17649
- const rawCurrency = firstRow[rule.currencyField];
17654
+ const headerMetadata = extractMetadata(content, skipRows, delimiter, rule.metadata);
17655
+ const metadata = { ...filenameMetadata, ...headerMetadata };
17656
+ let rawCurrency;
17657
+ if (rule.currencyField) {
17658
+ rawCurrency = firstRow[rule.currencyField];
17659
+ }
17660
+ if (!rawCurrency && metadata["currency"]) {
17661
+ rawCurrency = metadata["currency"];
17662
+ }
17650
17663
  if (!rawCurrency) {
17651
17664
  continue;
17652
17665
  }
17653
- const headerMetadata = extractMetadata(content, skipRows, delimiter, rule.metadata);
17654
- const metadata = { ...filenameMetadata, ...headerMetadata };
17655
17666
  if (rule.closingBalanceField) {
17656
17667
  const closingBalance = extractRowField(content, skipRows, delimiter, rule.closingBalanceField, rule.closingBalanceRow ?? "last");
17657
17668
  if (closingBalance) {
@@ -24266,8 +24277,8 @@ Note: This tool requires a contextId from a prior classify/import step.`,
24266
24277
  }
24267
24278
  });
24268
24279
  // src/tools/import-pipeline.ts
24269
- import * as fs23 from "fs";
24270
- import * as path17 from "path";
24280
+ import * as fs24 from "fs";
24281
+ import * as path18 from "path";
24271
24282
 
24272
24283
  // src/utils/accountDeclarations.ts
24273
24284
  init_journalMatchers();
@@ -24552,6 +24563,32 @@ function formatPostings(postings) {
24552
24563
  }).join(`
24553
24564
  `);
24554
24565
  }
24566
+ function formatAmount(amount, currency) {
24567
+ const formatted = Math.abs(amount).toFixed(2);
24568
+ const sign = amount < 0 ? "-" : "";
24569
+ return `${sign}${formatted} ${currency}`;
24570
+ }
24571
+ function escapeDescription(desc) {
24572
+ return desc.replace(/[;|]/g, "-").trim();
24573
+ }
24574
+ function formatCommodity(symbol2) {
24575
+ return `"${symbol2.toUpperCase()}"`;
24576
+ }
24577
+ function formatQuantity(qty) {
24578
+ return qty.toFixed(6).replace(/\.?0+$/, "");
24579
+ }
24580
+ function formatPrice(price) {
24581
+ const full = price.toFixed(6).replace(/0+$/, "");
24582
+ const [integer2, decimal = ""] = full.split(".");
24583
+ return `${integer2}.${decimal.padEnd(2, "0")}`;
24584
+ }
24585
+ function formatJournalFile(entries, header) {
24586
+ const headerBlock = header ? `${header}
24587
+
24588
+ ` : "";
24589
+ return headerBlock + entries.join(`
24590
+ `);
24591
+ }
24555
24592
 
24556
24593
  // src/utils/btcPurchaseGenerator.ts
24557
24594
  function parseRevolutFiatDatetime(dateStr) {
@@ -24772,9 +24809,9 @@ function formatJournalEntry(match2) {
24772
24809
  const hasFees = btcRow.fees.amount > 0;
24773
24810
  const netFiatAmount = hasFees ? fiatRow.amount - btcRow.fees.amount : fiatRow.amount;
24774
24811
  const effectivePrice = hasFees ? netFiatAmount / btcRow.quantity : btcRow.price.amount;
24775
- const fiatAmount = formatAmount(fiatRow.amount);
24776
- const netFiat = formatAmount(netFiatAmount);
24777
- const btcPrice = formatAmount(effectivePrice);
24812
+ const fiatAmount = formatAmount2(fiatRow.amount);
24813
+ const netFiat = formatAmount2(netFiatAmount);
24814
+ const btcPrice = formatAmount2(effectivePrice);
24778
24815
  const pair = `${fiatCurrency.toLowerCase()}-btc`;
24779
24816
  const equityFiat = `equity:conversion:${pair}:${fiatCurrency.toLowerCase()}`;
24780
24817
  const equityBtc = `equity:conversion:${pair}:btc`;
@@ -24786,7 +24823,7 @@ function formatJournalEntry(match2) {
24786
24823
  }
24787
24824
  ];
24788
24825
  if (hasFees) {
24789
- const feeAmount = formatAmount(btcRow.fees.amount);
24826
+ const feeAmount = formatAmount2(btcRow.fees.amount);
24790
24827
  const feeCurrency = btcRow.fees.currency;
24791
24828
  postings.push({
24792
24829
  account: `expenses:fees:revolut:${feeCurrency.toLowerCase()}`,
@@ -24797,7 +24834,7 @@ function formatJournalEntry(match2) {
24797
24834
  return `${date5} Bitcoin purchase
24798
24835
  ${formatPostings(postings)}`;
24799
24836
  }
24800
- function formatAmount(amount) {
24837
+ function formatAmount2(amount) {
24801
24838
  return amount.toFixed(2);
24802
24839
  }
24803
24840
  function formatBtcQuantity(quantity) {
@@ -24812,7 +24849,7 @@ function formatDateIso(date5) {
24812
24849
  }
24813
24850
  function isDuplicate(match2, journalContent) {
24814
24851
  const date5 = match2.fiatRow.dateStr;
24815
- const amount = formatAmount(match2.fiatRow.amount);
24852
+ const amount = formatAmount2(match2.fiatRow.amount);
24816
24853
  const currency = match2.fiatRow.currency;
24817
24854
  const pattern = `${date5} Bitcoin purchase`;
24818
24855
  if (!journalContent.includes(pattern))
@@ -24862,7 +24899,7 @@ function generateBtcPurchaseJournal(fiatCsvPaths, btcCsvPath, yearJournalPath, l
24862
24899
  }
24863
24900
  if (isDuplicate(match2, journalContent)) {
24864
24901
  skippedDuplicates++;
24865
- logger?.debug(`Skipping duplicate: ${match2.fiatRow.dateStr} ${formatAmount(match2.fiatRow.amount)} ${match2.fiatRow.currency}`);
24902
+ logger?.debug(`Skipping duplicate: ${match2.fiatRow.dateStr} ${formatAmount2(match2.fiatRow.amount)} ${match2.fiatRow.currency}`);
24866
24903
  continue;
24867
24904
  }
24868
24905
  newEntries.push(formatJournalEntry(match2));
@@ -25019,7 +25056,7 @@ function normalizeSymbol(symbol2) {
25019
25056
  return symbol2.toLowerCase();
25020
25057
  }
25021
25058
 
25022
- // src/utils/swissquoteLotTracker.ts
25059
+ // src/utils/lotTracker.ts
25023
25060
  import * as fs17 from "fs";
25024
25061
  import * as path12 from "path";
25025
25062
  var DEFAULT_LOT_INVENTORY_PATH = "ledger/investments/lot-inventory/{symbol}-lot.json";
@@ -25196,11 +25233,6 @@ function getHeldQuantity(inventory, symbol2) {
25196
25233
  }
25197
25234
 
25198
25235
  // src/utils/swissquoteJournalGenerator.ts
25199
- function formatAmount2(amount, currency) {
25200
- const formatted = Math.abs(amount).toFixed(2);
25201
- const sign = amount < 0 ? "-" : "";
25202
- return `${sign}${formatted} ${currency}`;
25203
- }
25204
25236
  function formatDate(dateStr) {
25205
25237
  const match2 = dateStr.match(/^(\d{2})-(\d{2})-(\d{4})/);
25206
25238
  if (!match2) {
@@ -25209,20 +25241,6 @@ function formatDate(dateStr) {
25209
25241
  const [, day, month, year] = match2;
25210
25242
  return `${year}-${month}-${day}`;
25211
25243
  }
25212
- function escapeDescription(desc) {
25213
- return desc.replace(/[;|]/g, "-").trim();
25214
- }
25215
- function formatCommodity(symbol2) {
25216
- return `"${symbol2.toUpperCase()}"`;
25217
- }
25218
- function formatQuantity(qty) {
25219
- return qty.toFixed(6).replace(/\.?0+$/, "");
25220
- }
25221
- function formatPrice(price) {
25222
- const full = price.toFixed(6).replace(/0+$/, "");
25223
- const [integer2, decimal = ""] = full.split(".");
25224
- return `${integer2}.${decimal.padEnd(2, "0")}`;
25225
- }
25226
25244
  function generateBuyEntry(trade, logger) {
25227
25245
  const date5 = formatDate(trade.date);
25228
25246
  const description = escapeDescription(`Buy ${trade.symbol} - ${trade.name}`);
@@ -25241,12 +25259,12 @@ function generateBuyEntry(trade, logger) {
25241
25259
  if (fees > 0) {
25242
25260
  postings.push({
25243
25261
  account: "expenses:fees:trading:swissquote",
25244
- amount: formatAmount2(fees, trade.currency)
25262
+ amount: formatAmount(fees, trade.currency)
25245
25263
  });
25246
25264
  }
25247
25265
  postings.push({
25248
25266
  account: `assets:broker:swissquote:${trade.currency.toLowerCase()}`,
25249
- amount: formatAmount2(-cashOut, trade.currency)
25267
+ amount: formatAmount(-cashOut, trade.currency)
25250
25268
  });
25251
25269
  const computedNet = Math.round((trade.quantity * trade.unitPrice + fees) * 100);
25252
25270
  const brokerNet = Math.round(cashOut * 100);
@@ -25254,7 +25272,7 @@ function generateBuyEntry(trade, logger) {
25254
25272
  if (roundingCents !== 0) {
25255
25273
  postings.push({
25256
25274
  account: "equity:rounding",
25257
- amount: formatAmount2(roundingCents / 100, trade.currency)
25275
+ amount: formatAmount(roundingCents / 100, trade.currency)
25258
25276
  });
25259
25277
  logger?.debug(`Rounding adjustment: ${roundingCents / 100} ${trade.currency}`);
25260
25278
  }
@@ -25288,23 +25306,23 @@ function generateSellEntry(trade, consumed, logger) {
25288
25306
  }
25289
25307
  postings.push({
25290
25308
  account: `assets:broker:swissquote:${trade.currency.toLowerCase()}`,
25291
- amount: formatAmount2(cashIn, trade.currency)
25309
+ amount: formatAmount(cashIn, trade.currency)
25292
25310
  });
25293
25311
  if (fees > 0) {
25294
25312
  postings.push({
25295
25313
  account: "expenses:fees:trading:swissquote",
25296
- amount: formatAmount2(fees, trade.currency)
25314
+ amount: formatAmount(fees, trade.currency)
25297
25315
  });
25298
25316
  }
25299
25317
  if (gain >= 0) {
25300
25318
  postings.push({
25301
25319
  account: "income:capital-gains:realized",
25302
- amount: formatAmount2(-gain, trade.currency)
25320
+ amount: formatAmount(-gain, trade.currency)
25303
25321
  });
25304
25322
  } else {
25305
25323
  postings.push({
25306
25324
  account: "expenses:losses:capital",
25307
- amount: formatAmount2(-gain, trade.currency)
25325
+ amount: formatAmount(-gain, trade.currency)
25308
25326
  });
25309
25327
  }
25310
25328
  const computedNet = Math.round((trade.quantity * trade.unitPrice - fees) * 100);
@@ -25313,7 +25331,7 @@ function generateSellEntry(trade, consumed, logger) {
25313
25331
  if (roundingCents !== 0) {
25314
25332
  postings.push({
25315
25333
  account: "equity:rounding",
25316
- amount: formatAmount2(roundingCents / 100, trade.currency)
25334
+ amount: formatAmount(roundingCents / 100, trade.currency)
25317
25335
  });
25318
25336
  logger?.debug(`Rounding adjustment: ${roundingCents / 100} ${trade.currency}`);
25319
25337
  }
@@ -25335,18 +25353,18 @@ function generateDividendEntry(dividend, logger) {
25335
25353
  const postings = [
25336
25354
  {
25337
25355
  account: `assets:broker:swissquote:${dividend.currency.toLowerCase()}`,
25338
- amount: formatAmount2(dividend.netAmount, dividend.currency)
25356
+ amount: formatAmount(dividend.netAmount, dividend.currency)
25339
25357
  }
25340
25358
  ];
25341
25359
  if (dividend.withholdingTax > 0) {
25342
25360
  postings.push({
25343
25361
  account: "expenses:taxes:withholding",
25344
- amount: formatAmount2(dividend.withholdingTax, dividend.currency)
25362
+ amount: formatAmount(dividend.withholdingTax, dividend.currency)
25345
25363
  });
25346
25364
  }
25347
25365
  postings.push({
25348
25366
  account: `income:dividends:${dividend.symbol}`,
25349
- amount: formatAmount2(-dividend.grossAmount, dividend.currency)
25367
+ amount: formatAmount(-dividend.grossAmount, dividend.currency)
25350
25368
  });
25351
25369
  let entry = `${date5} ${description}
25352
25370
  `;
@@ -25363,7 +25381,7 @@ function generateSplitEntry(action, oldQuantity, newQuantity, totalCostBasis, cu
25363
25381
  const description = escapeDescription(`${splitType}: ${action.symbol} (${action.name})`);
25364
25382
  logger?.debug(`Generating ${splitType} entry: ${oldQuantity} -> ${newQuantity} ${action.symbol}`);
25365
25383
  const commodity = formatCommodity(action.symbol);
25366
- const totalCost = formatAmount2(totalCostBasis, currency);
25384
+ const totalCost = formatAmount(totalCostBasis, currency);
25367
25385
  const conversionAccount = `equity:conversion:${currency.toLowerCase()}`;
25368
25386
  const outgoingPostings = [
25369
25387
  {
@@ -25378,7 +25396,7 @@ function generateSplitEntry(action, oldQuantity, newQuantity, totalCostBasis, cu
25378
25396
  const incomingPostings = [
25379
25397
  {
25380
25398
  account: conversionAccount,
25381
- amount: formatAmount2(-totalCostBasis, currency)
25399
+ amount: formatAmount(-totalCostBasis, currency)
25382
25400
  },
25383
25401
  {
25384
25402
  account: `assets:investments:stocks:${action.symbol}`,
@@ -25419,7 +25437,7 @@ function generateWorthlessEntry(action, removedLots, logger) {
25419
25437
  amount: `-${qty} ${commodity} @ ${price} ${currency}`
25420
25438
  });
25421
25439
  }
25422
- postings.push({ account: "expenses:losses:capital", amount: formatAmount2(totalCost, currency) });
25440
+ postings.push({ account: "expenses:losses:capital", amount: formatAmount(totalCost, currency) });
25423
25441
  let entry = `${date5} ${description}
25424
25442
  `;
25425
25443
  entry += ` ; swissquote:order:${action.orderNum} isin:${action.isin}
@@ -25451,7 +25469,7 @@ function generateMultiWayMergerEntry(group, crossCurrencyOutgoingSymbols, crossC
25451
25469
  const qty = formatQuantity(Math.abs(out.quantity));
25452
25470
  const commodity = formatCommodity(out.symbol);
25453
25471
  if (costInfo && costInfo.outgoingTotalCosts[i2] != null) {
25454
- const costAmount = formatAmount2(costInfo.outgoingTotalCosts[i2], costInfo.currency);
25472
+ const costAmount = formatAmount(costInfo.outgoingTotalCosts[i2], costInfo.currency);
25455
25473
  const postings = [
25456
25474
  {
25457
25475
  account: `assets:investments:stocks:${out.symbol}`,
@@ -25488,11 +25506,11 @@ function generateMultiWayMergerEntry(group, crossCurrencyOutgoingSymbols, crossC
25488
25506
  const qty = formatQuantity(Math.abs(inc.quantity));
25489
25507
  const commodity = formatCommodity(inc.symbol);
25490
25508
  if (costInfo && costInfo.incomingTotalCosts[i2] != null) {
25491
- const costAmount = formatAmount2(costInfo.incomingTotalCosts[i2], costInfo.currency);
25509
+ const costAmount = formatAmount(costInfo.incomingTotalCosts[i2], costInfo.currency);
25492
25510
  const postings = [
25493
25511
  {
25494
25512
  account: `equity:conversion:${costInfo.currency.toLowerCase()}`,
25495
- amount: formatAmount2(-costInfo.incomingTotalCosts[i2], costInfo.currency)
25513
+ amount: formatAmount(-costInfo.incomingTotalCosts[i2], costInfo.currency)
25496
25514
  },
25497
25515
  {
25498
25516
  account: `assets:investments:stocks:${inc.symbol}`,
@@ -25522,7 +25540,7 @@ function generateMultiWayMergerEntry(group, crossCurrencyOutgoingSymbols, crossC
25522
25540
  }
25523
25541
  if (group.cashSettlement) {
25524
25542
  const cashCurrency = group.cashSettlement.currency.toLowerCase();
25525
- const cashAmount = formatAmount2(group.cashSettlement.amount, group.cashSettlement.currency);
25543
+ const cashAmount = formatAmount(group.cashSettlement.amount, group.cashSettlement.currency);
25526
25544
  let sub = `${date5} ${description}
25527
25545
  `;
25528
25546
  sub += metadata;
@@ -25571,12 +25589,12 @@ function generateDirectWorthlessEntry(date5, orderNum, outgoingSymbols, outgoing
25571
25589
  const commodity = formatCommodity(symbol2);
25572
25590
  postings.push({
25573
25591
  account: `assets:investments:stocks:${symbol2}`,
25574
- amount: `-${formatQuantity(quantity)} ${commodity} @@ ${formatAmount2(cost, currency)}`
25592
+ amount: `-${formatQuantity(quantity)} ${commodity} @@ ${formatAmount(cost, currency)}`
25575
25593
  });
25576
25594
  }
25577
25595
  postings.push({
25578
25596
  account: "expenses:losses:capital",
25579
- amount: formatAmount2(totalCostBasis, currency)
25597
+ amount: formatAmount(totalCostBasis, currency)
25580
25598
  });
25581
25599
  let entry = `${formattedDate} ${description}
25582
25600
  `;
@@ -25592,14 +25610,11 @@ function generateDirectWorthlessEntry(date5, orderNum, outgoingSymbols, outgoing
25592
25610
  `;
25593
25611
  return entry;
25594
25612
  }
25595
- function formatJournalFile(entries, year, currency) {
25613
+ function formatJournalFile2(entries, year, currency) {
25596
25614
  const header = `; Swissquote ${currency.toUpperCase()} investment transactions for ${year}
25597
25615
  ; Generated by opencode-accountant
25598
- ; This file is auto-generated - do not edit manually
25599
-
25600
- `;
25601
- return header + entries.join(`
25602
- `);
25616
+ ; This file is auto-generated - do not edit manually`;
25617
+ return formatJournalFile(entries, header);
25603
25618
  }
25604
25619
 
25605
25620
  // src/utils/mergerFmvConfig.ts
@@ -26090,7 +26105,7 @@ function resolveRemainingPendingMergers(projectDir, lotInventoryPath, logger) {
26090
26105
  const parts = basename5.split("-");
26091
26106
  const yearStr = parts[0];
26092
26107
  const currency = parts.slice(1).join("-");
26093
- const header = formatJournalFile(journalEntries, parseInt(yearStr, 10), currency);
26108
+ const header = formatJournalFile2(journalEntries, parseInt(yearStr, 10), currency);
26094
26109
  fs19.writeFileSync(journalFile, header);
26095
26110
  }
26096
26111
  journalFilesUpdated.push(journalFile);
@@ -26364,7 +26379,7 @@ async function preprocessSwissquote(csvPath, projectDir, currency, year, lotInve
26364
26379
  fs19.mkdirSync(investmentsDir, { recursive: true });
26365
26380
  }
26366
26381
  journalFile = path14.join(investmentsDir, `${year}-${currency}.journal`);
26367
- const journalContent = formatJournalFile(journalEntries, year, currency);
26382
+ const journalContent = formatJournalFile2(journalEntries, year, currency);
26368
26383
  if (fs19.existsSync(journalFile)) {
26369
26384
  fs19.appendFileSync(journalFile, `
26370
26385
  ` + journalEntries.join(`
@@ -26803,6 +26818,426 @@ function generateSwissquoteForexJournal(allForexRows, yearJournalPath, logger) {
26803
26818
  };
26804
26819
  }
26805
26820
 
26821
+ // src/utils/ibkrCsvPreprocessor.ts
26822
+ var import_papaparse3 = __toESM(require_papaparse(), 1);
26823
+ import * as fs22 from "fs";
26824
+ import * as path17 from "path";
26825
+
26826
+ // src/utils/ibkrJournalGenerator.ts
26827
+ function generateIbkrBuyEntry(trade, logger) {
26828
+ const description = escapeDescription(`Buy ${trade.symbol} - ${trade.description}`);
26829
+ const qty = formatQuantity(trade.quantity);
26830
+ const totalCost = Math.abs(trade.grossAmount);
26831
+ const fees = Math.abs(trade.commission);
26832
+ const cashOut = Math.abs(trade.netAmount);
26833
+ logger?.debug(`Generating IBKR Buy entry: ${qty} ${trade.symbol} @@ ${totalCost} ${trade.currency}`);
26834
+ const commodity = formatCommodity(trade.symbol);
26835
+ const postings = [
26836
+ {
26837
+ account: `assets:investments:stocks:${trade.symbol}`,
26838
+ amount: `${qty} ${commodity} @@ ${formatAmount(totalCost, trade.currency)}`
26839
+ }
26840
+ ];
26841
+ if (fees > 0) {
26842
+ postings.push({
26843
+ account: "expenses:fees:trading:ibkr",
26844
+ amount: formatAmount(fees, trade.currency)
26845
+ });
26846
+ }
26847
+ postings.push({
26848
+ account: `assets:broker:ibkr:${trade.currency.toLowerCase()}`,
26849
+ amount: formatAmount(-cashOut, trade.currency)
26850
+ });
26851
+ let entry = `${trade.date} ${description}
26852
+ `;
26853
+ entry += ` ; ibkr:account:${trade.account}
26854
+ `;
26855
+ entry += formatPostings(postings) + `
26856
+ `;
26857
+ return entry;
26858
+ }
26859
+ function generateIbkrSellEntry(trade, consumed, logger) {
26860
+ const description = escapeDescription(`Sell ${trade.symbol} - ${trade.description}`);
26861
+ const qty = formatQuantity(trade.quantity);
26862
+ const fees = Math.abs(trade.commission);
26863
+ const cashIn = trade.netAmount;
26864
+ const totalCostBasis = consumed.reduce((sum, c) => sum + c.totalCost, 0);
26865
+ const saleProceeds = Math.abs(trade.grossAmount);
26866
+ const capitalGain = saleProceeds - totalCostBasis;
26867
+ logger?.debug(`Generating IBKR Sell entry: ${qty} ${trade.symbol}, gain: ${capitalGain.toFixed(2)}`);
26868
+ const commodity = formatCommodity(trade.symbol);
26869
+ const lotDetails = consumed.map((c) => `${c.lot.date}: ${formatQuantity(c.quantity)}@${formatPrice(c.lot.costBasis)}`).join(", ");
26870
+ const postings = [];
26871
+ for (const c of consumed) {
26872
+ const lotQty = formatQuantity(c.quantity);
26873
+ const lotPrice = formatPrice(c.lot.costBasis);
26874
+ postings.push({
26875
+ account: `assets:investments:stocks:${trade.symbol}`,
26876
+ amount: `-${lotQty} ${commodity} @ ${lotPrice} ${trade.currency}`
26877
+ });
26878
+ }
26879
+ postings.push({
26880
+ account: `assets:broker:ibkr:${trade.currency.toLowerCase()}`,
26881
+ amount: formatAmount(cashIn, trade.currency)
26882
+ });
26883
+ if (fees > 0) {
26884
+ postings.push({
26885
+ account: "expenses:fees:trading:ibkr",
26886
+ amount: formatAmount(fees, trade.currency)
26887
+ });
26888
+ }
26889
+ if (capitalGain >= 0) {
26890
+ postings.push({
26891
+ account: "income:capital-gains:realized",
26892
+ amount: formatAmount(-capitalGain, trade.currency)
26893
+ });
26894
+ } else {
26895
+ postings.push({
26896
+ account: "expenses:losses:capital",
26897
+ amount: formatAmount(-capitalGain, trade.currency)
26898
+ });
26899
+ }
26900
+ let entry = `${trade.date} ${description}
26901
+ `;
26902
+ entry += ` ; ibkr:account:${trade.account}
26903
+ `;
26904
+ entry += ` ; FIFO lots: ${lotDetails}
26905
+ `;
26906
+ entry += formatPostings(postings) + `
26907
+ `;
26908
+ return entry;
26909
+ }
26910
+ function generateIbkrDividendEntry(dividend, logger) {
26911
+ const description = escapeDescription(`Dividend ${dividend.symbol}`);
26912
+ logger?.debug(`Generating IBKR Dividend entry: ${dividend.symbol}, net: ${dividend.netAmount} ${dividend.currency}`);
26913
+ const postings = [
26914
+ {
26915
+ account: `assets:broker:ibkr:${dividend.currency.toLowerCase()}`,
26916
+ amount: formatAmount(dividend.netAmount, dividend.currency)
26917
+ }
26918
+ ];
26919
+ if (dividend.withholdingTax > 0) {
26920
+ postings.push({
26921
+ account: "expenses:taxes:withholding",
26922
+ amount: formatAmount(dividend.withholdingTax, dividend.currency)
26923
+ });
26924
+ }
26925
+ postings.push({
26926
+ account: `income:dividends:${dividend.symbol}`,
26927
+ amount: formatAmount(-dividend.grossAmount, dividend.currency)
26928
+ });
26929
+ let entry = `${dividend.date} ${description}
26930
+ `;
26931
+ entry += ` ; ibkr:account:${dividend.account}
26932
+ `;
26933
+ entry += formatPostings(postings) + `
26934
+ `;
26935
+ return entry;
26936
+ }
26937
+ function generateIbkrAdjustmentEntry(adjustment, logger) {
26938
+ const description = escapeDescription(adjustment.description);
26939
+ logger?.debug(`Generating IBKR Adjustment entry: ${adjustment.amount} ${adjustment.currency}`);
26940
+ const postings = [
26941
+ {
26942
+ account: `assets:broker:ibkr:${adjustment.currency.toLowerCase()}`,
26943
+ amount: formatAmount(adjustment.amount, adjustment.currency)
26944
+ }
26945
+ ];
26946
+ if (adjustment.amount < 0) {
26947
+ postings.push({
26948
+ account: "expenses:fx-losses:ibkr",
26949
+ amount: formatAmount(Math.abs(adjustment.amount), adjustment.currency)
26950
+ });
26951
+ } else {
26952
+ postings.push({
26953
+ account: "income:fx-gains:ibkr",
26954
+ amount: formatAmount(-adjustment.amount, adjustment.currency)
26955
+ });
26956
+ }
26957
+ let entry = `${adjustment.date} ${description}
26958
+ `;
26959
+ entry += ` ; ibkr:account:${adjustment.account}
26960
+ `;
26961
+ entry += formatPostings(postings) + `
26962
+ `;
26963
+ return entry;
26964
+ }
26965
+
26966
+ // src/utils/ibkrCsvPreprocessor.ts
26967
+ var TRADE_TYPES2 = new Set(["Buy", "Sell"]);
26968
+ var DIVIDEND_TYPE = "Dividend";
26969
+ var TAX_TYPE = "Foreign Tax Withholding";
26970
+ var ADJUSTMENT_TYPE = "Adjustment";
26971
+ var FOREX_TYPE = "Forex Trade Component";
26972
+ var DEPOSIT_TYPE = "Deposit";
26973
+ function parseIbkrCsv(content) {
26974
+ const transactions = [];
26975
+ const result = import_papaparse3.default.parse(content, {
26976
+ header: false,
26977
+ skipEmptyLines: true
26978
+ });
26979
+ for (const row of result.data) {
26980
+ const values = Object.values(row);
26981
+ if (values[0] !== "Transaction History" || values[1] !== "Data") {
26982
+ continue;
26983
+ }
26984
+ const date5 = values[2]?.trim() || "";
26985
+ const account = values[3]?.trim() || "";
26986
+ const description = values[4]?.trim() || "";
26987
+ const transactionType = values[5]?.trim() || "";
26988
+ const symbol2 = values[6]?.trim() || "";
26989
+ const quantity = parseFloat(values[7] || "0") || 0;
26990
+ const price = parseFloat(values[8] || "0") || 0;
26991
+ const priceCurrency = values[9]?.trim() || "";
26992
+ const grossAmount = parseFloat(values[10] || "0") || 0;
26993
+ const commission = parseFloat(values[11] || "0") || 0;
26994
+ const netAmount = parseFloat(values[12] || "0") || 0;
26995
+ if (!date5 || !transactionType)
26996
+ continue;
26997
+ transactions.push({
26998
+ date: date5,
26999
+ account,
27000
+ description,
27001
+ transactionType,
27002
+ symbol: symbol2,
27003
+ quantity,
27004
+ price,
27005
+ priceCurrency,
27006
+ grossAmount,
27007
+ commission,
27008
+ netAmount
27009
+ });
27010
+ }
27011
+ return transactions;
27012
+ }
27013
+ function groupDividends(transactions) {
27014
+ const dividendMap = new Map;
27015
+ for (const txn of transactions) {
27016
+ if (txn.transactionType !== DIVIDEND_TYPE && txn.transactionType !== TAX_TYPE)
27017
+ continue;
27018
+ const key = `${txn.date}:${txn.symbol}`;
27019
+ if (!dividendMap.has(key)) {
27020
+ dividendMap.set(key, {});
27021
+ }
27022
+ const group = dividendMap.get(key);
27023
+ if (txn.transactionType === DIVIDEND_TYPE) {
27024
+ group.dividend = txn;
27025
+ } else {
27026
+ group.tax = txn;
27027
+ }
27028
+ }
27029
+ const groups = [];
27030
+ for (const [, group] of dividendMap) {
27031
+ if (!group.dividend)
27032
+ continue;
27033
+ const grossAmount = Math.abs(group.dividend.netAmount);
27034
+ const withholdingTax = group.tax ? Math.abs(group.tax.netAmount) : 0;
27035
+ groups.push({
27036
+ date: group.dividend.date,
27037
+ symbol: group.dividend.symbol,
27038
+ account: group.dividend.account,
27039
+ grossAmount,
27040
+ withholdingTax
27041
+ });
27042
+ }
27043
+ groups.sort((a, b) => a.date.localeCompare(b.date));
27044
+ return groups;
27045
+ }
27046
+ async function preprocessIbkr(csvPath, directory, currency, year, lotInventoryPath, logger) {
27047
+ const csvDir = path17.dirname(csvPath);
27048
+ const csvBasename = path17.basename(csvPath, ".csv");
27049
+ const journalPath = path17.join(directory, "ledger", "investments", `${year}-ibkr-${currency}.journal`);
27050
+ if (fs22.existsSync(journalPath)) {
27051
+ logger?.info(`IBKR journal already exists: ${journalPath}, skipping preprocessing`);
27052
+ return {
27053
+ simpleTransactionsCsv: null,
27054
+ journalFile: journalPath,
27055
+ stats: {
27056
+ totalRows: 0,
27057
+ deposits: 0,
27058
+ trades: 0,
27059
+ dividends: 0,
27060
+ adjustments: 0,
27061
+ skippedForex: 0
27062
+ },
27063
+ alreadyPreprocessed: true
27064
+ };
27065
+ }
27066
+ const content = fs22.readFileSync(csvPath, "utf-8");
27067
+ const transactions = parseIbkrCsv(content);
27068
+ logger?.info(`Parsed ${transactions.length} IBKR transactions from ${path17.basename(csvPath)}`);
27069
+ const deposits = [];
27070
+ const trades = [];
27071
+ const dividendTxns = [];
27072
+ const adjustments = [];
27073
+ let skippedForex = 0;
27074
+ for (const txn of transactions) {
27075
+ if (TRADE_TYPES2.has(txn.transactionType)) {
27076
+ trades.push(txn);
27077
+ } else if (txn.transactionType === DIVIDEND_TYPE || txn.transactionType === TAX_TYPE) {
27078
+ dividendTxns.push(txn);
27079
+ } else if (txn.transactionType === ADJUSTMENT_TYPE) {
27080
+ adjustments.push(txn);
27081
+ } else if (txn.transactionType === DEPOSIT_TYPE) {
27082
+ deposits.push(txn);
27083
+ } else if (txn.transactionType === FOREX_TYPE) {
27084
+ skippedForex++;
27085
+ } else {
27086
+ logger?.warn(`Unknown IBKR transaction type: ${txn.transactionType}`);
27087
+ }
27088
+ }
27089
+ trades.sort((a, b) => a.date.localeCompare(b.date));
27090
+ const inventory = loadLotInventory(directory, lotInventoryPath, logger);
27091
+ const tradedSymbols = new Set;
27092
+ const usedAccounts = new Set;
27093
+ const journalEntries = [];
27094
+ for (const trade of trades) {
27095
+ tradedSymbols.add(trade.symbol);
27096
+ const totalCost = Math.abs(trade.grossAmount);
27097
+ const unitPrice = trade.quantity !== 0 ? totalCost / Math.abs(trade.quantity) : 0;
27098
+ const tradeEntry = {
27099
+ date: trade.date,
27100
+ account: trade.account,
27101
+ description: trade.description,
27102
+ type: trade.transactionType,
27103
+ symbol: trade.symbol,
27104
+ quantity: Math.abs(trade.quantity),
27105
+ grossAmount: trade.grossAmount,
27106
+ commission: trade.commission,
27107
+ netAmount: trade.netAmount,
27108
+ currency: currency.toUpperCase()
27109
+ };
27110
+ if (trade.transactionType === "Buy") {
27111
+ const tradeInfo = {
27112
+ date: trade.date,
27113
+ orderNum: "",
27114
+ symbol: trade.symbol,
27115
+ isin: "",
27116
+ quantity: Math.abs(trade.quantity),
27117
+ unitPrice,
27118
+ currency: currency.toUpperCase()
27119
+ };
27120
+ addLot(inventory, tradeInfo, logger);
27121
+ journalEntries.push(generateIbkrBuyEntry(tradeEntry, logger));
27122
+ usedAccounts.add(`assets:investments:stocks:${trade.symbol}`);
27123
+ usedAccounts.add(`assets:broker:ibkr:${currency}`);
27124
+ usedAccounts.add("expenses:fees:trading:ibkr");
27125
+ } else {
27126
+ const consumed = consumeLotsFIFO(inventory, trade.symbol, Math.abs(trade.quantity), logger);
27127
+ journalEntries.push(generateIbkrSellEntry(tradeEntry, consumed, logger));
27128
+ usedAccounts.add(`assets:investments:stocks:${trade.symbol}`);
27129
+ usedAccounts.add(`assets:broker:ibkr:${currency}`);
27130
+ usedAccounts.add("expenses:fees:trading:ibkr");
27131
+ usedAccounts.add("income:capital-gains:realized");
27132
+ }
27133
+ }
27134
+ const dividendGroups = groupDividends(dividendTxns);
27135
+ for (const group of dividendGroups) {
27136
+ const netAmount = group.grossAmount - group.withholdingTax;
27137
+ const dividendEntry = {
27138
+ date: group.date,
27139
+ account: group.account,
27140
+ symbol: group.symbol,
27141
+ grossAmount: group.grossAmount,
27142
+ withholdingTax: group.withholdingTax,
27143
+ netAmount,
27144
+ currency: currency.toUpperCase()
27145
+ };
27146
+ journalEntries.push(generateIbkrDividendEntry(dividendEntry, logger));
27147
+ usedAccounts.add(`assets:broker:ibkr:${currency}`);
27148
+ usedAccounts.add(`income:dividends:${group.symbol}`);
27149
+ if (group.withholdingTax > 0) {
27150
+ usedAccounts.add("expenses:taxes:withholding");
27151
+ }
27152
+ }
27153
+ adjustments.sort((a, b) => a.date.localeCompare(b.date));
27154
+ for (const adj of adjustments) {
27155
+ const adjustmentEntry = {
27156
+ date: adj.date,
27157
+ account: adj.account,
27158
+ description: adj.description || "FX Translations P&L",
27159
+ amount: adj.netAmount,
27160
+ currency: currency.toUpperCase()
27161
+ };
27162
+ journalEntries.push(generateIbkrAdjustmentEntry(adjustmentEntry, logger));
27163
+ usedAccounts.add(`assets:broker:ibkr:${currency}`);
27164
+ if (adj.netAmount < 0) {
27165
+ usedAccounts.add("expenses:fx-losses:ibkr");
27166
+ } else {
27167
+ usedAccounts.add("income:fx-gains:ibkr");
27168
+ }
27169
+ }
27170
+ saveLotInventory(directory, lotInventoryPath, inventory, logger);
27171
+ let journalFilePath = null;
27172
+ if (journalEntries.length > 0) {
27173
+ const yearJournalPath = ensureYearJournalExists(directory, year);
27174
+ const header = `; IBKR ${currency.toUpperCase()} investment transactions for ${year}
27175
+ ; Generated by opencode-accountant
27176
+ ; This file is auto-generated - do not edit manually`;
27177
+ const journalContent = formatJournalFile(journalEntries, header);
27178
+ const investmentsDir = path17.join(directory, "ledger", "investments");
27179
+ if (!fs22.existsSync(investmentsDir)) {
27180
+ fs22.mkdirSync(investmentsDir, { recursive: true });
27181
+ }
27182
+ fs22.writeFileSync(journalPath, journalContent);
27183
+ journalFilePath = journalPath;
27184
+ const yearJournalContent = fs22.readFileSync(yearJournalPath, "utf-8");
27185
+ const includeDirective = `include investments/${path17.basename(journalPath)}`;
27186
+ if (!yearJournalContent.includes(includeDirective)) {
27187
+ fs22.writeFileSync(yearJournalPath, yearJournalContent.trimEnd() + `
27188
+ ` + includeDirective + `
27189
+ `);
27190
+ }
27191
+ if (tradedSymbols.size > 0) {
27192
+ const commodityJournalPath = path17.join(directory, "ledger", "investments", "commodities.journal");
27193
+ ensureCommodityDeclarations(commodityJournalPath, tradedSymbols, logger);
27194
+ }
27195
+ if (usedAccounts.size > 0) {
27196
+ const accountJournalPath = path17.join(directory, "ledger", "investments", "accounts.journal");
27197
+ ensureInvestmentAccountDeclarations(accountJournalPath, usedAccounts, logger);
27198
+ }
27199
+ logger?.info(`Generated IBKR journal: ${journalPath} with ${journalEntries.length} entries`);
27200
+ }
27201
+ let simpleTransactionsCsvPath = null;
27202
+ if (deposits.length > 0) {
27203
+ const filteredCsvPath = path17.join(csvDir, `${csvBasename}-filtered.csv`);
27204
+ const csvHeader = "Date,Account,Description,Transaction Type,Symbol,Quantity,Price,Price Currency,Gross Amount,Commission,Net Amount";
27205
+ const csvRows = deposits.map((d) => [
27206
+ d.date,
27207
+ d.account,
27208
+ `"${d.description.replace(/"/g, '""')}"`,
27209
+ d.transactionType,
27210
+ d.symbol,
27211
+ d.quantity || "",
27212
+ d.price || "",
27213
+ d.priceCurrency,
27214
+ d.grossAmount || "",
27215
+ d.commission || "",
27216
+ d.netAmount
27217
+ ].join(","));
27218
+ fs22.writeFileSync(filteredCsvPath, csvHeader + `
27219
+ ` + csvRows.join(`
27220
+ `) + `
27221
+ `);
27222
+ simpleTransactionsCsvPath = filteredCsvPath;
27223
+ logger?.info(`Generated filtered CSV for deposits: ${filteredCsvPath} (${deposits.length} rows)`);
27224
+ }
27225
+ const dividendCount = dividendGroups.length;
27226
+ return {
27227
+ simpleTransactionsCsv: simpleTransactionsCsvPath,
27228
+ journalFile: journalFilePath,
27229
+ stats: {
27230
+ totalRows: transactions.length,
27231
+ deposits: deposits.length,
27232
+ trades: trades.length,
27233
+ dividends: dividendCount,
27234
+ adjustments: adjustments.length,
27235
+ skippedForex
27236
+ },
27237
+ alreadyPreprocessed: false
27238
+ };
27239
+ }
27240
+
26806
27241
  // src/tools/import-pipeline.ts
26807
27242
  class NoTransactionsError extends Error {
26808
27243
  constructor() {
@@ -26875,7 +27310,7 @@ function executePreprocessFiatStep(context, contextIds, logger) {
26875
27310
  for (const contextId of contextIds) {
26876
27311
  const importCtx = loadContext(context.directory, contextId);
26877
27312
  if (importCtx.provider === "revolut" && importCtx.currency !== "btc") {
26878
- fiatCsvPaths.push(path17.join(context.directory, importCtx.filePath));
27313
+ fiatCsvPaths.push(path18.join(context.directory, importCtx.filePath));
26879
27314
  }
26880
27315
  }
26881
27316
  if (fiatCsvPaths.length === 0) {
@@ -26919,7 +27354,7 @@ function executePreprocessBtcStep(context, contextIds, logger) {
26919
27354
  for (const contextId of contextIds) {
26920
27355
  const importCtx = loadContext(context.directory, contextId);
26921
27356
  if (importCtx.provider === "revolut" && importCtx.currency === "btc") {
26922
- btcCsvPath = path17.join(context.directory, importCtx.filePath);
27357
+ btcCsvPath = path18.join(context.directory, importCtx.filePath);
26923
27358
  break;
26924
27359
  }
26925
27360
  }
@@ -26953,7 +27388,7 @@ async function executeBtcPurchaseStep(context, contextIds, logger) {
26953
27388
  const importCtx = loadContext(context.directory, contextId);
26954
27389
  if (importCtx.provider !== "revolut")
26955
27390
  continue;
26956
- const csvPath = path17.join(context.directory, importCtx.filePath);
27391
+ const csvPath = path18.join(context.directory, importCtx.filePath);
26957
27392
  if (importCtx.currency === "btc") {
26958
27393
  btcCsvPath = csvPath;
26959
27394
  } else {
@@ -26966,7 +27401,7 @@ async function executeBtcPurchaseStep(context, contextIds, logger) {
26966
27401
  }
26967
27402
  logger?.startSection("Step 1b: Generate BTC Purchase Entries");
26968
27403
  logger?.logStep("BTC Purchases", "start");
26969
- const btcFilename = path17.basename(btcCsvPath);
27404
+ const btcFilename = path18.basename(btcCsvPath);
26970
27405
  const yearMatch = btcFilename.match(/(\d{4})-\d{2}-\d{2}/);
26971
27406
  const year = yearMatch ? parseInt(yearMatch[1], 10) : new Date().getFullYear();
26972
27407
  const yearJournalPath = ensureYearJournalExists(context.directory, year);
@@ -26993,7 +27428,7 @@ async function executeCurrencyExchangeStep(context, contextIds, logger) {
26993
27428
  continue;
26994
27429
  if (importCtx.currency === "btc")
26995
27430
  continue;
26996
- const csvPath = path17.join(context.directory, importCtx.filePath);
27431
+ const csvPath = path18.join(context.directory, importCtx.filePath);
26997
27432
  fiatContexts.push({ contextId, csvPath });
26998
27433
  }
26999
27434
  if (fiatContexts.length < 2) {
@@ -27002,7 +27437,7 @@ async function executeCurrencyExchangeStep(context, contextIds, logger) {
27002
27437
  }
27003
27438
  logger?.startSection("Step 1e: Generate Currency Exchange Entries");
27004
27439
  logger?.logStep("Currency Exchanges", "start");
27005
- const firstFilename = path17.basename(fiatContexts[0].csvPath);
27440
+ const firstFilename = path18.basename(fiatContexts[0].csvPath);
27006
27441
  const yearMatch = firstFilename.match(/(\d{4})-\d{2}-\d{2}/);
27007
27442
  const year = yearMatch ? parseInt(yearMatch[1], 10) : new Date().getFullYear();
27008
27443
  const yearJournalPath = ensureYearJournalExists(context.directory, year);
@@ -27018,9 +27453,9 @@ async function executeCurrencyExchangeStep(context, contextIds, logger) {
27018
27453
  continue;
27019
27454
  const filteredCsvPath = filterExchangeRows(csvPath, indices, logger);
27020
27455
  updateContext(context.directory, contextId, {
27021
- filePath: path17.relative(context.directory, filteredCsvPath)
27456
+ filePath: path18.relative(context.directory, filteredCsvPath)
27022
27457
  });
27023
- logger?.info(`Updated context ${contextId} to use filtered CSV: ${path17.basename(filteredCsvPath)}`);
27458
+ logger?.info(`Updated context ${contextId} to use filtered CSV: ${path18.basename(filteredCsvPath)}`);
27024
27459
  }
27025
27460
  }
27026
27461
  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)`;
@@ -27037,7 +27472,7 @@ async function executeSwissquotePreprocessStep(context, contextIds, logger) {
27037
27472
  for (const contextId of contextIds) {
27038
27473
  const importCtx = loadContext(context.directory, contextId);
27039
27474
  if (importCtx.provider === "swissquote") {
27040
- const csvPath = path17.join(context.directory, importCtx.filePath);
27475
+ const csvPath = path18.join(context.directory, importCtx.filePath);
27041
27476
  const toDateMatch = importCtx.filename.match(/to-\d{4}(\d{4})\./);
27042
27477
  let year = new Date().getFullYear();
27043
27478
  if (toDateMatch) {
@@ -27061,11 +27496,11 @@ async function executeSwissquotePreprocessStep(context, contextIds, logger) {
27061
27496
  const lotInventoryPath = swissquoteProvider?.lotInventoryPath ?? DEFAULT_LOT_INVENTORY_PATH;
27062
27497
  const symbolMapPath = swissquoteProvider?.symbolMapPath ?? "config/import/symbolMap.yaml";
27063
27498
  let symbolMap = {};
27064
- const symbolMapFullPath = path17.join(context.directory, symbolMapPath);
27065
- if (fs23.existsSync(symbolMapFullPath)) {
27499
+ const symbolMapFullPath = path18.join(context.directory, symbolMapPath);
27500
+ if (fs24.existsSync(symbolMapFullPath)) {
27066
27501
  try {
27067
27502
  const yaml = await Promise.resolve().then(() => (init_js_yaml(), exports_js_yaml));
27068
- const content = fs23.readFileSync(symbolMapFullPath, "utf-8");
27503
+ const content = fs24.readFileSync(symbolMapFullPath, "utf-8");
27069
27504
  const parsed = yaml.load(content);
27070
27505
  if (parsed && typeof parsed === "object") {
27071
27506
  symbolMap = parsed;
@@ -27089,7 +27524,7 @@ async function executeSwissquotePreprocessStep(context, contextIds, logger) {
27089
27524
  let lastJournalFile = null;
27090
27525
  const allForexRows = [];
27091
27526
  for (const sqCtx of swissquoteContexts) {
27092
- logger?.logStep("Swissquote Preprocess", "start", `Processing ${path17.basename(sqCtx.csvPath)}`);
27527
+ logger?.logStep("Swissquote Preprocess", "start", `Processing ${path18.basename(sqCtx.csvPath)}`);
27093
27528
  const result = await preprocessSwissquote(sqCtx.csvPath, context.directory, sqCtx.currency, sqCtx.year, lotInventoryPath, symbolMap, logger);
27094
27529
  totalStats.totalRows += result.stats.totalRows;
27095
27530
  totalStats.simpleTransactions += result.stats.simpleTransactions;
@@ -27104,9 +27539,9 @@ async function executeSwissquotePreprocessStep(context, contextIds, logger) {
27104
27539
  }
27105
27540
  if (result.simpleTransactionsCsv) {
27106
27541
  updateContext(context.directory, sqCtx.contextId, {
27107
- filePath: path17.relative(context.directory, result.simpleTransactionsCsv)
27542
+ filePath: path18.relative(context.directory, result.simpleTransactionsCsv)
27108
27543
  });
27109
- logger?.info(`Updated context ${sqCtx.contextId} to use filtered CSV: ${path17.basename(result.simpleTransactionsCsv)}`);
27544
+ logger?.info(`Updated context ${sqCtx.contextId} to use filtered CSV: ${path18.basename(result.simpleTransactionsCsv)}`);
27110
27545
  }
27111
27546
  logger?.logStep("Swissquote Preprocess", "success", `Processed: ${result.stats.trades} trades, ${result.stats.dividends} dividends, ${result.stats.corporateActions} corporate actions, ${result.stats.forexTransactions} forex`);
27112
27547
  }
@@ -27116,11 +27551,11 @@ async function executeSwissquotePreprocessStep(context, contextIds, logger) {
27116
27551
  }
27117
27552
  if (allForexRows.length > 0) {
27118
27553
  const firstCtx = swissquoteContexts[0];
27119
- const yearJournalPath = path17.join(context.directory, "ledger", `${firstCtx.year}.journal`);
27554
+ const yearJournalPath = path18.join(context.directory, "ledger", `${firstCtx.year}.journal`);
27120
27555
  logger?.info(`Generating forex journal entries for ${allForexRows.length} forex rows...`);
27121
27556
  const forexResult = generateSwissquoteForexJournal(allForexRows, yearJournalPath, logger);
27122
27557
  if (forexResult.accountsUsed.size > 0) {
27123
- const accountJournalPath = path17.join(context.directory, "ledger", "investments", "accounts.journal");
27558
+ const accountJournalPath = path18.join(context.directory, "ledger", "investments", "accounts.journal");
27124
27559
  ensureInvestmentAccountDeclarations(accountJournalPath, forexResult.accountsUsed, logger);
27125
27560
  }
27126
27561
  logger?.logStep("Swissquote Forex", "success", `Matched ${forexResult.matchCount} forex pairs, added ${forexResult.entriesAdded} entries` + (forexResult.skippedDuplicates > 0 ? `, skipped ${forexResult.skippedDuplicates} duplicates` : "") + (forexResult.unmatchedRows > 0 ? `, ${forexResult.unmatchedRows} unmatched rows` : ""));
@@ -27142,13 +27577,92 @@ async function executeSwissquotePreprocessStep(context, contextIds, logger) {
27142
27577
  logger?.endSection();
27143
27578
  }
27144
27579
  }
27580
+ async function executeIbkrPreprocessStep(context, contextIds, logger) {
27581
+ const ibkrContexts = [];
27582
+ for (const contextId of contextIds) {
27583
+ const importCtx = loadContext(context.directory, contextId);
27584
+ if (importCtx.provider === "ibkr") {
27585
+ const csvPath = path18.join(context.directory, importCtx.filePath);
27586
+ const dateMatch = importCtx.filename.match(/(\d{4})\d{4}\./);
27587
+ let year = new Date().getFullYear();
27588
+ if (dateMatch) {
27589
+ year = parseInt(dateMatch[1], 10);
27590
+ }
27591
+ ibkrContexts.push({
27592
+ contextId,
27593
+ csvPath,
27594
+ currency: importCtx.currency,
27595
+ year
27596
+ });
27597
+ }
27598
+ }
27599
+ if (ibkrContexts.length === 0) {
27600
+ logger?.info("No IBKR CSV found, skipping preprocessing");
27601
+ return;
27602
+ }
27603
+ logger?.startSection("Step 1f: Preprocess IBKR CSV");
27604
+ const config2 = context.configLoader(context.directory);
27605
+ const ibkrProvider = config2.providers?.["ibkr"];
27606
+ const lotInventoryPath = ibkrProvider?.lotInventoryPath ?? DEFAULT_LOT_INVENTORY_PATH;
27607
+ try {
27608
+ let totalStats = {
27609
+ totalRows: 0,
27610
+ deposits: 0,
27611
+ trades: 0,
27612
+ dividends: 0,
27613
+ adjustments: 0,
27614
+ skippedForex: 0
27615
+ };
27616
+ let lastJournalFile = null;
27617
+ ibkrContexts.sort((a, b) => {
27618
+ const dateA = path18.basename(a.csvPath).match(/(\d{8})\./)?.[1] || "";
27619
+ const dateB = path18.basename(b.csvPath).match(/(\d{8})\./)?.[1] || "";
27620
+ return dateA.localeCompare(dateB);
27621
+ });
27622
+ for (const ibkrCtx of ibkrContexts) {
27623
+ logger?.logStep("IBKR Preprocess", "start", `Processing ${path18.basename(ibkrCtx.csvPath)}`);
27624
+ const result = await preprocessIbkr(ibkrCtx.csvPath, context.directory, ibkrCtx.currency, ibkrCtx.year, lotInventoryPath, logger);
27625
+ totalStats.totalRows += result.stats.totalRows;
27626
+ totalStats.deposits += result.stats.deposits;
27627
+ totalStats.trades += result.stats.trades;
27628
+ totalStats.dividends += result.stats.dividends;
27629
+ totalStats.adjustments += result.stats.adjustments;
27630
+ totalStats.skippedForex += result.stats.skippedForex;
27631
+ if (result.journalFile) {
27632
+ lastJournalFile = result.journalFile;
27633
+ }
27634
+ if (result.simpleTransactionsCsv) {
27635
+ updateContext(context.directory, ibkrCtx.contextId, {
27636
+ filePath: path18.relative(context.directory, result.simpleTransactionsCsv)
27637
+ });
27638
+ logger?.info(`Updated context ${ibkrCtx.contextId} to use filtered CSV: ${path18.basename(result.simpleTransactionsCsv)}`);
27639
+ }
27640
+ logger?.logStep("IBKR Preprocess", "success", `Processed: ${result.stats.trades} trades, ${result.stats.dividends} dividends, ${result.stats.adjustments} adjustments, ${result.stats.skippedForex} forex skipped`);
27641
+ }
27642
+ const message = `Preprocessed ${totalStats.totalRows} rows: ${totalStats.trades} trades, ${totalStats.dividends} dividends, ${totalStats.adjustments} adjustments, ${totalStats.deposits} deposits, ${totalStats.skippedForex} forex skipped`;
27643
+ context.result.steps.ibkrPreprocess = buildStepResult(true, message, {
27644
+ ...totalStats,
27645
+ journalFile: lastJournalFile
27646
+ });
27647
+ } catch (error45) {
27648
+ const errorMessage = `IBKR preprocessing failed: ${error45 instanceof Error ? error45.message : String(error45)}`;
27649
+ logger?.error(errorMessage);
27650
+ logger?.logStep("IBKR Preprocess", "error", errorMessage);
27651
+ context.result.steps.ibkrPreprocess = buildStepResult(false, errorMessage);
27652
+ context.result.error = errorMessage;
27653
+ context.result.hint = "Check the IBKR CSV format and lot inventory state";
27654
+ throw new Error(errorMessage);
27655
+ } finally {
27656
+ logger?.endSection();
27657
+ }
27658
+ }
27145
27659
  async function executeAccountDeclarationsStep(context, contextId, logger) {
27146
27660
  logger?.startSection("Step 2: Check Account Declarations");
27147
27661
  logger?.logStep("Check Accounts", "start");
27148
27662
  const config2 = context.configLoader(context.directory);
27149
- const rulesDir = path17.join(context.directory, config2.paths.rules);
27663
+ const rulesDir = path18.join(context.directory, config2.paths.rules);
27150
27664
  const importCtx = loadContext(context.directory, contextId);
27151
- const csvPath = path17.join(context.directory, importCtx.filePath);
27665
+ const csvPath = path18.join(context.directory, importCtx.filePath);
27152
27666
  const csvFiles = [csvPath];
27153
27667
  const rulesMapping = loadRulesMapping(rulesDir);
27154
27668
  const matchedRulesFiles = new Set;
@@ -27171,7 +27685,7 @@ async function executeAccountDeclarationsStep(context, contextId, logger) {
27171
27685
  context.result.steps.accountDeclarations = buildStepResult(true, "No accounts found in rules files", {
27172
27686
  accountsAdded: [],
27173
27687
  journalUpdated: "",
27174
- rulesScanned: Array.from(matchedRulesFiles).map((f) => path17.relative(context.directory, f))
27688
+ rulesScanned: Array.from(matchedRulesFiles).map((f) => path18.relative(context.directory, f))
27175
27689
  });
27176
27690
  return;
27177
27691
  }
@@ -27195,7 +27709,7 @@ async function executeAccountDeclarationsStep(context, contextId, logger) {
27195
27709
  context.result.steps.accountDeclarations = buildStepResult(true, "No transactions found, skipping account declarations", {
27196
27710
  accountsAdded: [],
27197
27711
  journalUpdated: "",
27198
- rulesScanned: Array.from(matchedRulesFiles).map((f) => path17.relative(context.directory, f))
27712
+ rulesScanned: Array.from(matchedRulesFiles).map((f) => path18.relative(context.directory, f))
27199
27713
  });
27200
27714
  return;
27201
27715
  }
@@ -27206,12 +27720,12 @@ async function executeAccountDeclarationsStep(context, contextId, logger) {
27206
27720
  context.result.steps.accountDeclarations = buildStepResult(false, `Failed to create year journal: ${error45 instanceof Error ? error45.message : String(error45)}`, {
27207
27721
  accountsAdded: [],
27208
27722
  journalUpdated: "",
27209
- rulesScanned: Array.from(matchedRulesFiles).map((f) => path17.relative(context.directory, f))
27723
+ rulesScanned: Array.from(matchedRulesFiles).map((f) => path18.relative(context.directory, f))
27210
27724
  });
27211
27725
  return;
27212
27726
  }
27213
27727
  const result = ensureAccountDeclarations(yearJournalPath, allAccounts);
27214
- const message = result.added.length > 0 ? `Added ${result.added.length} account declaration(s) to ${path17.relative(context.directory, yearJournalPath)}` : "All required accounts already declared";
27728
+ const message = result.added.length > 0 ? `Added ${result.added.length} account declaration(s) to ${path18.relative(context.directory, yearJournalPath)}` : "All required accounts already declared";
27215
27729
  logger?.logStep("Check Accounts", "success", message);
27216
27730
  if (result.added.length > 0) {
27217
27731
  for (const account of result.added) {
@@ -27220,17 +27734,17 @@ async function executeAccountDeclarationsStep(context, contextId, logger) {
27220
27734
  }
27221
27735
  context.result.steps.accountDeclarations = buildStepResult(true, message, {
27222
27736
  accountsAdded: result.added,
27223
- journalUpdated: path17.relative(context.directory, yearJournalPath),
27224
- rulesScanned: Array.from(matchedRulesFiles).map((f) => path17.relative(context.directory, f))
27737
+ journalUpdated: path18.relative(context.directory, yearJournalPath),
27738
+ rulesScanned: Array.from(matchedRulesFiles).map((f) => path18.relative(context.directory, f))
27225
27739
  });
27226
27740
  logger?.endSection();
27227
27741
  }
27228
27742
  async function buildSuggestionContext(context, contextId, logger) {
27229
27743
  const { loadExistingAccounts: loadExistingAccounts2, extractRulePatternsFromFile: extractRulePatternsFromFile2 } = await Promise.resolve().then(() => (init_accountSuggester(), exports_accountSuggester));
27230
27744
  const config2 = context.configLoader(context.directory);
27231
- const rulesDir = path17.join(context.directory, config2.paths.rules);
27745
+ const rulesDir = path18.join(context.directory, config2.paths.rules);
27232
27746
  const importCtx = loadContext(context.directory, contextId);
27233
- const csvPath = path17.join(context.directory, importCtx.filePath);
27747
+ const csvPath = path18.join(context.directory, importCtx.filePath);
27234
27748
  const rulesMapping = loadRulesMapping(rulesDir);
27235
27749
  const rulesFile = findRulesForCsv(csvPath, rulesMapping);
27236
27750
  if (!rulesFile) {
@@ -27446,6 +27960,7 @@ async function importPipeline(directory, agent, options, configLoader = loadImpo
27446
27960
  await executeBtcPurchaseStep(context, contextIds, logger);
27447
27961
  await executeCurrencyExchangeStep(context, contextIds, logger);
27448
27962
  await executeSwissquotePreprocessStep(context, contextIds, logger);
27963
+ await executeIbkrPreprocessStep(context, contextIds, logger);
27449
27964
  if (context.generatedAccounts.size > 0) {
27450
27965
  const firstCtx = contextIds.map((id) => loadContext(context.directory, id)).find((c) => c.provider === "revolut");
27451
27966
  if (firstCtx) {
@@ -27459,8 +27974,8 @@ async function importPipeline(directory, agent, options, configLoader = loadImpo
27459
27974
  }
27460
27975
  }
27461
27976
  const importConfig = loadImportConfig(context.directory);
27462
- const pendingDir = path17.join(context.directory, importConfig.paths.pending);
27463
- const doneDir = path17.join(context.directory, importConfig.paths.done);
27977
+ const pendingDir = path18.join(context.directory, importConfig.paths.pending);
27978
+ const doneDir = path18.join(context.directory, importConfig.paths.done);
27464
27979
  const orderedContextIds = [...contextIds].sort((a, b) => {
27465
27980
  const ctxA = loadContext(context.directory, a);
27466
27981
  const ctxB = loadContext(context.directory, b);
@@ -27483,8 +27998,8 @@ async function importPipeline(directory, agent, options, configLoader = loadImpo
27483
27998
  totalTransactions += context.result.steps.import?.details?.summary?.totalTransactions || 0;
27484
27999
  } catch (error45) {
27485
28000
  if (error45 instanceof NoTransactionsError) {
27486
- const csvPath = path17.join(context.directory, importContext.filePath);
27487
- if (fs23.existsSync(csvPath)) {
28001
+ const csvPath = path18.join(context.directory, importContext.filePath);
28002
+ if (fs24.existsSync(csvPath)) {
27488
28003
  moveCsvToDone(csvPath, pendingDir, doneDir);
27489
28004
  logger.info(`No transactions to import for ${importContext.filename}, moved to done`);
27490
28005
  } else {
@@ -27565,16 +28080,16 @@ This tool orchestrates the full import workflow:
27565
28080
  }
27566
28081
  });
27567
28082
  // src/tools/init-directories.ts
27568
- import * as fs24 from "fs";
27569
- import * as path18 from "path";
28083
+ import * as fs25 from "fs";
28084
+ import * as path19 from "path";
27570
28085
  async function initDirectories(directory) {
27571
28086
  try {
27572
28087
  const config2 = loadImportConfig(directory);
27573
28088
  const directoriesCreated = [];
27574
28089
  const gitkeepFiles = [];
27575
- const importBase = path18.join(directory, "import");
27576
- if (!fs24.existsSync(importBase)) {
27577
- fs24.mkdirSync(importBase, { recursive: true });
28090
+ const importBase = path19.join(directory, "import");
28091
+ if (!fs25.existsSync(importBase)) {
28092
+ fs25.mkdirSync(importBase, { recursive: true });
27578
28093
  directoriesCreated.push("import");
27579
28094
  }
27580
28095
  const pathsToCreate = [
@@ -27584,20 +28099,20 @@ async function initDirectories(directory) {
27584
28099
  { key: "unrecognized", path: config2.paths.unrecognized }
27585
28100
  ];
27586
28101
  for (const { path: dirPath } of pathsToCreate) {
27587
- const fullPath = path18.join(directory, dirPath);
27588
- if (!fs24.existsSync(fullPath)) {
27589
- fs24.mkdirSync(fullPath, { recursive: true });
28102
+ const fullPath = path19.join(directory, dirPath);
28103
+ if (!fs25.existsSync(fullPath)) {
28104
+ fs25.mkdirSync(fullPath, { recursive: true });
27590
28105
  directoriesCreated.push(dirPath);
27591
28106
  }
27592
- const gitkeepPath = path18.join(fullPath, ".gitkeep");
27593
- if (!fs24.existsSync(gitkeepPath)) {
27594
- fs24.writeFileSync(gitkeepPath, "");
27595
- gitkeepFiles.push(path18.join(dirPath, ".gitkeep"));
28107
+ const gitkeepPath = path19.join(fullPath, ".gitkeep");
28108
+ if (!fs25.existsSync(gitkeepPath)) {
28109
+ fs25.writeFileSync(gitkeepPath, "");
28110
+ gitkeepFiles.push(path19.join(dirPath, ".gitkeep"));
27596
28111
  }
27597
28112
  }
27598
- const gitignorePath = path18.join(importBase, ".gitignore");
28113
+ const gitignorePath = path19.join(importBase, ".gitignore");
27599
28114
  let gitignoreCreated = false;
27600
- if (!fs24.existsSync(gitignorePath)) {
28115
+ if (!fs25.existsSync(gitignorePath)) {
27601
28116
  const gitignoreContent = `# Ignore CSV/PDF files in temporary directories
27602
28117
  /incoming/*.csv
27603
28118
  /incoming/*.pdf
@@ -27615,7 +28130,7 @@ async function initDirectories(directory) {
27615
28130
  .DS_Store
27616
28131
  Thumbs.db
27617
28132
  `;
27618
- fs24.writeFileSync(gitignorePath, gitignoreContent);
28133
+ fs25.writeFileSync(gitignorePath, gitignoreContent);
27619
28134
  gitignoreCreated = true;
27620
28135
  }
27621
28136
  const parts = [];
@@ -27691,32 +28206,32 @@ You can now drop CSV files into import/incoming/ and run import-pipeline.`);
27691
28206
  }
27692
28207
  });
27693
28208
  // src/tools/generate-btc-purchases.ts
27694
- import * as path19 from "path";
27695
- import * as fs25 from "fs";
28209
+ import * as path20 from "path";
28210
+ import * as fs26 from "fs";
27696
28211
  function findFiatCsvPaths(directory, pendingDir, provider) {
27697
- const providerDir = path19.join(directory, pendingDir, provider);
27698
- if (!fs25.existsSync(providerDir))
28212
+ const providerDir = path20.join(directory, pendingDir, provider);
28213
+ if (!fs26.existsSync(providerDir))
27699
28214
  return [];
27700
28215
  const csvPaths = [];
27701
- const entries = fs25.readdirSync(providerDir, { withFileTypes: true });
28216
+ const entries = fs26.readdirSync(providerDir, { withFileTypes: true });
27702
28217
  for (const entry of entries) {
27703
28218
  if (!entry.isDirectory())
27704
28219
  continue;
27705
28220
  if (entry.name === "btc")
27706
28221
  continue;
27707
- const csvFiles = findCsvFiles(path19.join(providerDir, entry.name), { fullPaths: true });
28222
+ const csvFiles = findCsvFiles(path20.join(providerDir, entry.name), { fullPaths: true });
27708
28223
  csvPaths.push(...csvFiles);
27709
28224
  }
27710
28225
  return csvPaths;
27711
28226
  }
27712
28227
  function findBtcCsvPath(directory, pendingDir, provider) {
27713
- const btcDir = path19.join(directory, pendingDir, provider, "btc");
28228
+ const btcDir = path20.join(directory, pendingDir, provider, "btc");
27714
28229
  const csvFiles = findCsvFiles(btcDir, { fullPaths: true });
27715
28230
  return csvFiles[0];
27716
28231
  }
27717
28232
  function determineYear(csvPaths) {
27718
28233
  for (const csvPath of csvPaths) {
27719
- const match2 = path19.basename(csvPath).match(/(\d{4})-\d{2}-\d{2}/);
28234
+ const match2 = path20.basename(csvPath).match(/(\d{4})-\d{2}-\d{2}/);
27720
28235
  if (match2)
27721
28236
  return parseInt(match2[1], 10);
27722
28237
  }
@@ -27769,7 +28284,7 @@ async function generateBtcPurchases(directory, agent, options = {}, configLoader
27769
28284
  skippedDuplicates: result.skippedDuplicates,
27770
28285
  unmatchedFiat: result.unmatchedFiat.length,
27771
28286
  unmatchedBtc: result.unmatchedBtc.length,
27772
- yearJournal: path19.relative(directory, yearJournalPath)
28287
+ yearJournal: path20.relative(directory, yearJournalPath)
27773
28288
  });
27774
28289
  } catch (err) {
27775
28290
  logger.error("Failed to generate BTC purchases", err);
@@ -27811,8 +28326,8 @@ to produce equity conversion entries for Bitcoin purchases.
27811
28326
  }
27812
28327
  });
27813
28328
  // src/index.ts
27814
- var __dirname2 = dirname9(fileURLToPath3(import.meta.url));
27815
- var AGENT_FILE = join17(__dirname2, "..", "agent", "accountant.md");
28329
+ var __dirname2 = dirname10(fileURLToPath3(import.meta.url));
28330
+ var AGENT_FILE = join18(__dirname2, "..", "agent", "accountant.md");
27816
28331
  var AccountantPlugin = async () => {
27817
28332
  const agent = loadAgent(AGENT_FILE);
27818
28333
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fuzzle/opencode-accountant",
3
- "version": "0.15.1",
3
+ "version": "0.16.0",
4
4
  "description": "An OpenCode accounting agent, specialized in double-entry-bookkepping with hledger",
5
5
  "author": {
6
6
  "name": "ali bengali",