@fuzzle/opencode-accountant 0.10.0 → 0.10.1-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
@@ -29,6 +29,24 @@ var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
29
29
  var __require = import.meta.require;
30
30
 
31
31
  // node_modules/js-yaml/dist/js-yaml.mjs
32
+ var exports_js_yaml = {};
33
+ __export(exports_js_yaml, {
34
+ types: () => types,
35
+ safeLoadAll: () => safeLoadAll,
36
+ safeLoad: () => safeLoad,
37
+ safeDump: () => safeDump,
38
+ loadAll: () => loadAll,
39
+ load: () => load,
40
+ dump: () => dump,
41
+ default: () => jsYaml,
42
+ YAMLException: () => YAMLException,
43
+ Type: () => Type,
44
+ Schema: () => Schema,
45
+ JSON_SCHEMA: () => JSON_SCHEMA,
46
+ FAILSAFE_SCHEMA: () => FAILSAFE_SCHEMA,
47
+ DEFAULT_SCHEMA: () => DEFAULT_SCHEMA,
48
+ CORE_SCHEMA: () => CORE_SCHEMA
49
+ });
32
50
  function isNothing(subject) {
33
51
  return typeof subject === "undefined" || subject === null;
34
52
  }
@@ -17446,7 +17464,9 @@ function detectProvider(filename, content, config2) {
17446
17464
  continue;
17447
17465
  }
17448
17466
  const actualHeader = normalizeHeader(fields);
17449
- if (actualHeader !== rule.header) {
17467
+ const ruleHeaderSep = delimiter !== "," && rule.header.includes(delimiter) ? delimiter : ",";
17468
+ const expectedHeader = normalizeHeader(rule.header.split(ruleHeaderSep));
17469
+ if (actualHeader !== expectedHeader) {
17450
17470
  continue;
17451
17471
  }
17452
17472
  if (!firstRow) {
@@ -24090,6 +24110,7 @@ Note: This tool requires a contextId from a prior classify/import step.`,
24090
24110
  }
24091
24111
  });
24092
24112
  // src/tools/import-pipeline.ts
24113
+ import * as fs20 from "fs";
24093
24114
  import * as path14 from "path";
24094
24115
 
24095
24116
  // src/utils/accountDeclarations.ts
@@ -24949,24 +24970,6 @@ function adjustLotsForSplit(inventory, symbol2, ratio, logger) {
24949
24970
  }
24950
24971
  logger?.info(`Adjusted ${lots.length} lots for ${symbol2} split (ratio: ${ratio})`);
24951
24972
  }
24952
- function adjustLotsForMerger(inventory, oldSymbol, newSymbol, ratio, logger) {
24953
- const oldLots = inventory[oldSymbol];
24954
- if (!oldLots || oldLots.length === 0) {
24955
- logger?.warn(`No lots found for ${oldSymbol} during merger adjustment`);
24956
- return;
24957
- }
24958
- const newLots = oldLots.map((lot) => ({
24959
- ...lot,
24960
- quantity: lot.quantity * ratio,
24961
- costBasis: lot.costBasis / ratio
24962
- }));
24963
- if (!inventory[newSymbol]) {
24964
- inventory[newSymbol] = [];
24965
- }
24966
- inventory[newSymbol].push(...newLots);
24967
- delete inventory[oldSymbol];
24968
- logger?.info(`Merger: ${oldLots.length} lots moved from ${oldSymbol} to ${newSymbol} (ratio: ${ratio})`);
24969
- }
24970
24973
  function removeLots(inventory, symbol2, logger) {
24971
24974
  const lots = inventory[symbol2];
24972
24975
  if (!lots || lots.length === 0) {
@@ -25086,27 +25089,6 @@ function generateDividendEntry(dividend, logger) {
25086
25089
  `;
25087
25090
  return entry;
25088
25091
  }
25089
- function generateMergerEntry(action, oldQuantity, newQuantity, logger) {
25090
- const date5 = formatDate(action.date);
25091
- const newSymbol = action.newSymbol || "UNKNOWN";
25092
- const description = escapeDescription(`Merger: ${action.symbol} -> ${newSymbol}`);
25093
- logger?.debug(`Generating Merger entry: ${oldQuantity} ${action.symbol} -> ${newQuantity} ${newSymbol}`);
25094
- let entry = `${date5} ${description}
25095
- `;
25096
- entry += ` ; swissquote:order:${action.orderNum}
25097
- `;
25098
- entry += ` ; Old ISIN: ${action.isin}, New ISIN: ${action.newIsin || "unknown"}
25099
- `;
25100
- entry += ` assets:investments:stocks:${action.symbol} -${formatQuantity(oldQuantity)} ${action.symbol}
25101
- `;
25102
- entry += ` equity:conversion ${formatQuantity(oldQuantity)} ${action.symbol}
25103
- `;
25104
- entry += ` equity:conversion -${formatQuantity(newQuantity)} ${newSymbol}
25105
- `;
25106
- entry += ` assets:investments:stocks:${newSymbol} ${formatQuantity(newQuantity)} ${newSymbol}
25107
- `;
25108
- return entry;
25109
- }
25110
25092
  function generateSplitEntry(action, oldQuantity, newQuantity, logger) {
25111
25093
  const date5 = formatDate(action.date);
25112
25094
  const ratio = action.ratio || newQuantity / oldQuantity;
@@ -25152,6 +25134,53 @@ function generateWorthlessEntry(action, removedLots, logger) {
25152
25134
  `;
25153
25135
  return entry;
25154
25136
  }
25137
+ function generateMultiWayMergerEntry(group, crossCurrencyOutgoingSymbols, logger) {
25138
+ const date5 = formatDate(group.date);
25139
+ const outSymbols = crossCurrencyOutgoingSymbols ?? group.outgoing.map((a) => a.symbol);
25140
+ const inSymbols = group.incoming.map((a) => a.symbol);
25141
+ const description = escapeDescription(`Merger: ${outSymbols.join(" + ")} -> ${inSymbols.join(" + ")}`);
25142
+ logger?.debug(`Generating multi-way merger entry: ${outSymbols.join(", ")} -> ${inSymbols.join(", ")}`);
25143
+ let entry = `${date5} ${description}
25144
+ `;
25145
+ entry += ` ; swissquote:order:${group.orderNum}
25146
+ `;
25147
+ const oldIsins = group.outgoing.map((a) => a.isin).filter(Boolean);
25148
+ const newIsins = group.incoming.map((a) => a.isin).filter(Boolean);
25149
+ if (oldIsins.length > 0 || newIsins.length > 0) {
25150
+ entry += ` ; Old ISIN: ${oldIsins.join(", ") || "n/a"}, New ISINs: ${newIsins.join(", ") || "n/a"}
25151
+ `;
25152
+ }
25153
+ for (const out of group.outgoing) {
25154
+ const qty = formatQuantity(Math.abs(out.quantity));
25155
+ entry += ` assets:investments:stocks:${out.symbol} -${qty} ${out.symbol}
25156
+ `;
25157
+ entry += ` equity:conversion ${qty} ${out.symbol}
25158
+ `;
25159
+ }
25160
+ for (const inc of group.incoming) {
25161
+ const qty = formatQuantity(Math.abs(inc.quantity));
25162
+ entry += ` equity:conversion -${qty} ${inc.symbol}
25163
+ `;
25164
+ entry += ` assets:investments:stocks:${inc.symbol} ${qty} ${inc.symbol}
25165
+ `;
25166
+ }
25167
+ return entry;
25168
+ }
25169
+ function generateRightsDistributionEntry(action, logger) {
25170
+ const date5 = formatDate(action.date);
25171
+ const qty = formatQuantity(Math.abs(action.quantity));
25172
+ const description = escapeDescription(`Rights Distribution: ${action.symbol} - ${action.name}`);
25173
+ logger?.debug(`Generating Rights Distribution entry: ${qty} ${action.symbol}`);
25174
+ let entry = `${date5} ${description}
25175
+ `;
25176
+ entry += ` ; swissquote:order:${action.orderNum} isin:${action.isin}
25177
+ `;
25178
+ entry += ` assets:investments:stocks:${action.symbol} ${qty} ${action.symbol} @ 0.00 CAD
25179
+ `;
25180
+ entry += ` income:capital-gains:rights-distribution 0.00 CAD
25181
+ `;
25182
+ return entry;
25183
+ }
25155
25184
  function formatJournalFile(entries, year, currency) {
25156
25185
  const header = `; Swissquote ${currency.toUpperCase()} investment transactions for ${year}
25157
25186
  ; Generated by opencode-accountant
@@ -25170,8 +25199,15 @@ var SIMPLE_TRANSACTION_TYPES = new Set([
25170
25199
  "Forex debit",
25171
25200
  "Interest on debits",
25172
25201
  "Interest on credits",
25202
+ "Interest on deposits",
25173
25203
  "Exchange fees",
25174
- "Exchange fees rectif."
25204
+ "Exchange fees rectif.",
25205
+ "Payment",
25206
+ "Debit",
25207
+ "Fees Tax Statement",
25208
+ "Dividend (Reversed)",
25209
+ "Reversal (Dividend)",
25210
+ "Redemption"
25175
25211
  ]);
25176
25212
  var TRADE_TYPES = new Set(["Buy", "Sell"]);
25177
25213
  var DIVIDEND_TYPES = new Set(["Dividend"]);
@@ -25179,7 +25215,10 @@ var CORPORATE_ACTION_TYPES = new Set([
25179
25215
  "Merger",
25180
25216
  "Reverse Split",
25181
25217
  "Worthless Liquidation",
25182
- "Internal exchange of securities"
25218
+ "Internal exchange of securities",
25219
+ "Exchange of securities",
25220
+ "Corporate Action",
25221
+ "Rights Distribution"
25183
25222
  ]);
25184
25223
  var SKIP_TYPES = new Set([
25185
25224
  "Internal exchange",
@@ -25288,45 +25327,41 @@ function toCorporateActionEntry(txn) {
25288
25327
  symbol: txn.symbol,
25289
25328
  name: txn.name,
25290
25329
  isin: txn.isin,
25291
- quantity: Math.abs(parseNumber(txn.quantity))
25330
+ quantity: parseNumber(txn.quantity)
25292
25331
  };
25293
25332
  }
25294
- function processCorporateActions(actions, inventory, logger) {
25333
+ var MERGER_LIKE_TYPES = new Set([
25334
+ "Merger",
25335
+ "Internal exchange of securities",
25336
+ "Exchange of securities",
25337
+ "Corporate Action"
25338
+ ]);
25339
+ function processCorporateActions(actions, inventory, lotInventoryPath, projectDir, logger) {
25295
25340
  const entries = [];
25296
- const pendingMergers = new Map;
25297
25341
  actions.sort((a, b) => a.date.localeCompare(b.date));
25342
+ const mergerActions = [];
25343
+ const otherActions = [];
25298
25344
  for (const action of actions) {
25345
+ if (MERGER_LIKE_TYPES.has(action.type)) {
25346
+ mergerActions.push(action);
25347
+ } else {
25348
+ otherActions.push(action);
25349
+ }
25350
+ }
25351
+ const mergerGroups = groupMergerActions(mergerActions);
25352
+ for (const group of mergerGroups) {
25353
+ const groupEntries = processMultiWayMerger(group, inventory, lotInventoryPath, projectDir, logger);
25354
+ entries.push(...groupEntries);
25355
+ }
25356
+ for (const action of otherActions) {
25299
25357
  switch (action.type) {
25300
- case "Merger":
25301
- case "Internal exchange of securities": {
25302
- const key = `${action.date}-${action.orderNum}`;
25303
- if (action.quantity < 0 || pendingMergers.has(key)) {
25304
- const pending = pendingMergers.get(key);
25305
- if (pending && pending.outgoing) {
25306
- pending.incoming = action;
25307
- const oldQty = Math.abs(parseNumber(String(pending.outgoing.quantity)));
25308
- const newQty = Math.abs(parseNumber(String(action.quantity)));
25309
- const ratio = newQty / oldQty;
25310
- adjustLotsForMerger(inventory, pending.outgoing.symbol, action.symbol, ratio, logger);
25311
- const entry = generateMergerEntry({
25312
- ...pending.outgoing,
25313
- newSymbol: action.symbol,
25314
- newIsin: action.isin,
25315
- ratio
25316
- }, oldQty, newQty, logger);
25317
- entries.push(entry);
25318
- pendingMergers.delete(key);
25319
- } else {
25320
- pendingMergers.set(key, { outgoing: action });
25321
- }
25322
- } else {
25323
- pendingMergers.set(key, { outgoing: action });
25324
- }
25325
- break;
25326
- }
25327
25358
  case "Reverse Split": {
25359
+ const qty = action.quantity;
25360
+ if (qty < 0) {
25361
+ continue;
25362
+ }
25328
25363
  const oldQty = getHeldQuantity(inventory, action.symbol);
25329
- const newQty = action.quantity;
25364
+ const newQty = Math.abs(qty);
25330
25365
  if (oldQty > 0) {
25331
25366
  const ratio = newQty / oldQty;
25332
25367
  adjustLotsForSplit(inventory, action.symbol, ratio, logger);
@@ -25347,14 +25382,170 @@ function processCorporateActions(actions, inventory, logger) {
25347
25382
  }
25348
25383
  break;
25349
25384
  }
25385
+ case "Rights Distribution": {
25386
+ if (action.quantity > 0) {
25387
+ const entry = generateRightsDistributionEntry(action, logger);
25388
+ entries.push(entry);
25389
+ }
25390
+ break;
25391
+ }
25392
+ }
25393
+ }
25394
+ return entries;
25395
+ }
25396
+ function groupMergerActions(actions) {
25397
+ const groupMap = new Map;
25398
+ for (const action of actions) {
25399
+ const key = `${action.date}-${action.orderNum}`;
25400
+ if (!groupMap.has(key)) {
25401
+ groupMap.set(key, {
25402
+ key,
25403
+ date: action.date,
25404
+ orderNum: action.orderNum,
25405
+ outgoing: [],
25406
+ incoming: []
25407
+ });
25408
+ }
25409
+ const group = groupMap.get(key);
25410
+ if (action.quantity < 0) {
25411
+ group.outgoing.push(action);
25412
+ } else if (action.quantity > 0) {
25413
+ group.incoming.push(action);
25350
25414
  }
25351
25415
  }
25352
- for (const [key, pending] of pendingMergers.entries()) {
25353
- logger?.warn(`Unmatched merger entry: ${key} - ${pending.outgoing.symbol}`);
25416
+ return Array.from(groupMap.values());
25417
+ }
25418
+ function processMultiWayMerger(group, inventory, lotInventoryPath, projectDir, logger) {
25419
+ const entries = [];
25420
+ if (group.outgoing.length === 0 && group.incoming.length === 0) {
25421
+ return entries;
25422
+ }
25423
+ if (group.outgoing.length === 0 && group.incoming.length > 0) {
25424
+ const pendingState = loadPendingMerger(projectDir, lotInventoryPath, group.key, logger);
25425
+ if (pendingState) {
25426
+ const totalIncomingQty2 = group.incoming.reduce((sum, a) => sum + Math.abs(a.quantity), 0);
25427
+ for (const inc of group.incoming) {
25428
+ const absQty = Math.abs(inc.quantity);
25429
+ const proportion = absQty / totalIncomingQty2;
25430
+ const allocatedCost = pendingState.totalCostBasis * proportion;
25431
+ const costBasisPerUnit = absQty > 0 ? allocatedCost / absQty : 0;
25432
+ const lot = {
25433
+ date: formatDate(inc.date),
25434
+ quantity: absQty,
25435
+ costBasis: costBasisPerUnit,
25436
+ currency: pendingState.currency,
25437
+ isin: inc.isin,
25438
+ orderNum: inc.orderNum
25439
+ };
25440
+ if (!inventory[inc.symbol]) {
25441
+ inventory[inc.symbol] = [];
25442
+ }
25443
+ inventory[inc.symbol].push(lot);
25444
+ logger?.info(`Cross-currency merger incoming: ${absQty} ${inc.symbol} @ ${costBasisPerUnit.toFixed(2)} ${pendingState.currency}`);
25445
+ }
25446
+ const entry2 = generateMultiWayMergerEntry(group, pendingState.outgoingSymbols, logger);
25447
+ entries.push(entry2);
25448
+ removePendingMerger(projectDir, lotInventoryPath, group.key, logger);
25449
+ } else {
25450
+ logger?.warn(`Incoming-only merger group ${group.key} with no pending state \u2014 skipping`);
25451
+ }
25452
+ return entries;
25453
+ }
25454
+ let totalCostBasis = 0;
25455
+ let outgoingCurrency = "";
25456
+ const outgoingSymbols = [];
25457
+ for (const out of group.outgoing) {
25458
+ outgoingSymbols.push(out.symbol);
25459
+ const lots = inventory[out.symbol];
25460
+ if (lots) {
25461
+ for (const lot of lots) {
25462
+ totalCostBasis += lot.quantity * lot.costBasis;
25463
+ if (!outgoingCurrency && lot.currency) {
25464
+ outgoingCurrency = lot.currency;
25465
+ }
25466
+ }
25467
+ }
25468
+ }
25469
+ if (group.outgoing.length > 0 && group.incoming.length === 0) {
25470
+ for (const out of group.outgoing) {
25471
+ removeLots(inventory, out.symbol, logger);
25472
+ }
25473
+ savePendingMerger(projectDir, lotInventoryPath, group.key, {
25474
+ date: group.date,
25475
+ orderNum: group.orderNum,
25476
+ outgoingSymbols,
25477
+ totalCostBasis,
25478
+ currency: outgoingCurrency || "CAD"
25479
+ }, logger);
25480
+ logger?.info(`Cross-currency merger outgoing: ${outgoingSymbols.join(", ")} -> pending (cost basis: ${totalCostBasis.toFixed(2)})`);
25481
+ return entries;
25482
+ }
25483
+ for (const out of group.outgoing) {
25484
+ const absQty = Math.abs(out.quantity);
25485
+ removeLots(inventory, out.symbol, logger);
25486
+ logger?.debug(`Merger outgoing: removed ${absQty} ${out.symbol}`);
25487
+ }
25488
+ const totalIncomingQty = group.incoming.reduce((sum, a) => sum + Math.abs(a.quantity), 0);
25489
+ for (const inc of group.incoming) {
25490
+ const absQty = Math.abs(inc.quantity);
25491
+ const proportion = totalIncomingQty > 0 ? absQty / totalIncomingQty : 0;
25492
+ const allocatedCost = totalCostBasis * proportion;
25493
+ const costBasisPerUnit = absQty > 0 ? allocatedCost / absQty : 0;
25494
+ const lot = {
25495
+ date: formatDate(inc.date),
25496
+ quantity: absQty,
25497
+ costBasis: costBasisPerUnit,
25498
+ currency: outgoingCurrency || "CAD",
25499
+ isin: inc.isin,
25500
+ orderNum: inc.orderNum
25501
+ };
25502
+ if (!inventory[inc.symbol]) {
25503
+ inventory[inc.symbol] = [];
25504
+ }
25505
+ inventory[inc.symbol].push(lot);
25506
+ logger?.debug(`Merger incoming: added ${absQty} ${inc.symbol} @ ${costBasisPerUnit.toFixed(2)}`);
25354
25507
  }
25508
+ const entry = generateMultiWayMergerEntry(group, undefined, logger);
25509
+ entries.push(entry);
25355
25510
  return entries;
25356
25511
  }
25357
- async function preprocessSwissquote(csvPath, projectDir, currency, year, lotInventoryPath = DEFAULT_LOT_INVENTORY_PATH, logger) {
25512
+ function getPendingMergerDir(projectDir, lotInventoryPath) {
25513
+ const lotDir = lotInventoryPath.replace(/{symbol}[^/]*$/, "");
25514
+ return path13.join(projectDir, lotDir, "pending-mergers");
25515
+ }
25516
+ function savePendingMerger(projectDir, lotInventoryPath, key, state, logger) {
25517
+ const dir = getPendingMergerDir(projectDir, lotInventoryPath);
25518
+ if (!fs18.existsSync(dir)) {
25519
+ fs18.mkdirSync(dir, { recursive: true });
25520
+ }
25521
+ const filePath = path13.join(dir, `${key.replace(/[^a-zA-Z0-9-]/g, "_")}.json`);
25522
+ fs18.writeFileSync(filePath, JSON.stringify(state, null, 2));
25523
+ logger?.debug(`Saved pending merger state: ${key}`);
25524
+ }
25525
+ function loadPendingMerger(projectDir, lotInventoryPath, key, logger) {
25526
+ const dir = getPendingMergerDir(projectDir, lotInventoryPath);
25527
+ const filePath = path13.join(dir, `${key.replace(/[^a-zA-Z0-9-]/g, "_")}.json`);
25528
+ if (!fs18.existsSync(filePath)) {
25529
+ return null;
25530
+ }
25531
+ try {
25532
+ const content = fs18.readFileSync(filePath, "utf-8");
25533
+ return JSON.parse(content);
25534
+ } catch (error45) {
25535
+ const message = error45 instanceof Error ? error45.message : String(error45);
25536
+ logger?.warn(`Failed to load pending merger state for ${key}: ${message}`);
25537
+ return null;
25538
+ }
25539
+ }
25540
+ function removePendingMerger(projectDir, lotInventoryPath, key, logger) {
25541
+ const dir = getPendingMergerDir(projectDir, lotInventoryPath);
25542
+ const filePath = path13.join(dir, `${key.replace(/[^a-zA-Z0-9-]/g, "_")}.json`);
25543
+ if (fs18.existsSync(filePath)) {
25544
+ fs18.unlinkSync(filePath);
25545
+ logger?.debug(`Removed pending merger state: ${key}`);
25546
+ }
25547
+ }
25548
+ async function preprocessSwissquote(csvPath, projectDir, currency, year, lotInventoryPath = DEFAULT_LOT_INVENTORY_PATH, symbolMap = {}, logger) {
25358
25549
  logger?.startSection(`Swissquote Preprocessing: ${path13.basename(csvPath)}`);
25359
25550
  const stats = {
25360
25551
  totalRows: 0,
@@ -25390,14 +25581,22 @@ async function preprocessSwissquote(csvPath, projectDir, currency, year, lotInve
25390
25581
  };
25391
25582
  }
25392
25583
  const transactions = [];
25584
+ const hasSymbolMap = Object.keys(symbolMap).length > 0;
25393
25585
  for (let i2 = 1;i2 < lines.length; i2++) {
25394
25586
  const txn = parseTransaction(lines[i2]);
25395
25587
  if (txn) {
25588
+ if (hasSymbolMap && txn.symbol) {
25589
+ txn.symbol = symbolMap[txn.symbol] ?? txn.symbol;
25590
+ }
25396
25591
  transactions.push(txn);
25397
25592
  }
25398
25593
  }
25399
25594
  stats.totalRows = transactions.length;
25400
- logger?.logStep("parse-csv", "success", `Parsed ${stats.totalRows} transactions`);
25595
+ if (hasSymbolMap) {
25596
+ logger?.logStep("parse-csv", "success", `Parsed ${stats.totalRows} transactions (symbol map applied)`);
25597
+ } else {
25598
+ logger?.logStep("parse-csv", "success", `Parsed ${stats.totalRows} transactions`);
25599
+ }
25401
25600
  logger?.logStep("load-inventory", "start", "Loading lot inventory");
25402
25601
  const inventory = loadLotInventory(projectDir, lotInventoryPath, logger);
25403
25602
  logger?.logStep("load-inventory", "success", "Lot inventory loaded");
@@ -25422,8 +25621,17 @@ async function preprocessSwissquote(csvPath, projectDir, currency, year, lotInve
25422
25621
  stats.dividends++;
25423
25622
  break;
25424
25623
  case "corporate":
25425
- corporateActions.push(toCorporateActionEntry(txn));
25426
- stats.corporateActions++;
25624
+ if (!txn.symbol || txn.symbol.trim() === "") {
25625
+ if (txn.netAmount && txn.netAmount !== "-") {
25626
+ simpleTransactions.push(txn);
25627
+ stats.simpleTransactions++;
25628
+ } else {
25629
+ stats.skipped++;
25630
+ }
25631
+ } else {
25632
+ corporateActions.push(toCorporateActionEntry(txn));
25633
+ stats.corporateActions++;
25634
+ }
25427
25635
  break;
25428
25636
  case "skip":
25429
25637
  stats.skipped++;
@@ -25479,7 +25687,7 @@ async function preprocessSwissquote(csvPath, projectDir, currency, year, lotInve
25479
25687
  }
25480
25688
  if (corporateActions.length > 0) {
25481
25689
  logger?.startSection("Corporate Actions Processing", 2);
25482
- const corpEntries = processCorporateActions(corporateActions, inventory, logger);
25690
+ const corpEntries = processCorporateActions(corporateActions, inventory, lotInventoryPath, projectDir, logger);
25483
25691
  journalEntries.push(...corpEntries);
25484
25692
  logger?.logStep("corporate", "success", `Processed ${corporateActions.length} corporate actions`);
25485
25693
  logger?.endSection();
@@ -25735,14 +25943,10 @@ async function executeSwissquotePreprocessStep(context, contextIds, logger) {
25735
25943
  const importCtx = loadContext(context.directory, contextId);
25736
25944
  if (importCtx.provider === "swissquote") {
25737
25945
  const csvPath = path14.join(context.directory, importCtx.filePath);
25738
- const filenameMatch = importCtx.filename.match(/from-(\d{2})(\d{2})(\d{4})|(\d{4})-\d{2}-\d{2}/);
25946
+ const toDateMatch = importCtx.filename.match(/to-\d{4}(\d{4})\./);
25739
25947
  let year = new Date().getFullYear();
25740
- if (filenameMatch) {
25741
- if (filenameMatch[3]) {
25742
- year = parseInt(filenameMatch[3], 10);
25743
- } else if (filenameMatch[4]) {
25744
- year = parseInt(filenameMatch[4], 10);
25745
- }
25948
+ if (toDateMatch) {
25949
+ year = parseInt(toDateMatch[1], 10);
25746
25950
  }
25747
25951
  swissquoteContexts.push({
25748
25952
  contextId,
@@ -25760,6 +25964,23 @@ async function executeSwissquotePreprocessStep(context, contextIds, logger) {
25760
25964
  const config2 = context.configLoader(context.directory);
25761
25965
  const swissquoteProvider = config2.providers?.["swissquote"];
25762
25966
  const lotInventoryPath = swissquoteProvider?.lotInventoryPath ?? DEFAULT_LOT_INVENTORY_PATH;
25967
+ const symbolMapPath = swissquoteProvider?.symbolMapPath ?? "config/import/symbolMap.yaml";
25968
+ let symbolMap = {};
25969
+ const symbolMapFullPath = path14.join(context.directory, symbolMapPath);
25970
+ if (fs20.existsSync(symbolMapFullPath)) {
25971
+ try {
25972
+ const yaml = await Promise.resolve().then(() => (init_js_yaml(), exports_js_yaml));
25973
+ const content = fs20.readFileSync(symbolMapFullPath, "utf-8");
25974
+ const parsed = yaml.load(content);
25975
+ if (parsed && typeof parsed === "object") {
25976
+ symbolMap = parsed;
25977
+ logger?.info(`Loaded symbol map with ${Object.keys(symbolMap).length} entries`);
25978
+ }
25979
+ } catch (error45) {
25980
+ const message = error45 instanceof Error ? error45.message : String(error45);
25981
+ logger?.warn(`Failed to load symbol map from ${symbolMapPath}: ${message}`);
25982
+ }
25983
+ }
25763
25984
  try {
25764
25985
  let totalStats = {
25765
25986
  totalRows: 0,
@@ -25772,7 +25993,7 @@ async function executeSwissquotePreprocessStep(context, contextIds, logger) {
25772
25993
  let lastJournalFile = null;
25773
25994
  for (const sqCtx of swissquoteContexts) {
25774
25995
  logger?.logStep("Swissquote Preprocess", "start", `Processing ${path14.basename(sqCtx.csvPath)}`);
25775
- const result = await preprocessSwissquote(sqCtx.csvPath, context.directory, sqCtx.currency, sqCtx.year, lotInventoryPath, logger);
25996
+ const result = await preprocessSwissquote(sqCtx.csvPath, context.directory, sqCtx.currency, sqCtx.year, lotInventoryPath, symbolMap, logger);
25776
25997
  totalStats.totalRows += result.stats.totalRows;
25777
25998
  totalStats.simpleTransactions += result.stats.simpleTransactions;
25778
25999
  totalStats.trades += result.stats.trades;
@@ -25790,7 +26011,7 @@ async function executeSwissquotePreprocessStep(context, contextIds, logger) {
25790
26011
  }
25791
26012
  logger?.logStep("Swissquote Preprocess", "success", `Processed: ${result.stats.trades} trades, ${result.stats.dividends} dividends, ${result.stats.corporateActions} corporate actions`);
25792
26013
  }
25793
- const message = `Preprocessed ${totalStats.totalRows} rows: ` + `${totalStats.trades} trades, ${totalStats.dividends} dividends, ` + `${totalStats.corporateActions} corporate actions, ${totalStats.simpleTransactions} simple`;
26014
+ const message = `Preprocessed ${totalStats.totalRows} rows: ${totalStats.trades} trades, ${totalStats.dividends} dividends, ${totalStats.corporateActions} corporate actions, ${totalStats.simpleTransactions} simple`;
25794
26015
  context.result.steps.swissquotePreprocess = buildStepResult(true, message, {
25795
26016
  ...totalStats,
25796
26017
  journalFile: lastJournalFile
@@ -26201,7 +26422,7 @@ This tool orchestrates the full import workflow:
26201
26422
  }
26202
26423
  });
26203
26424
  // src/tools/init-directories.ts
26204
- import * as fs20 from "fs";
26425
+ import * as fs21 from "fs";
26205
26426
  import * as path15 from "path";
26206
26427
  async function initDirectories(directory) {
26207
26428
  try {
@@ -26209,8 +26430,8 @@ async function initDirectories(directory) {
26209
26430
  const directoriesCreated = [];
26210
26431
  const gitkeepFiles = [];
26211
26432
  const importBase = path15.join(directory, "import");
26212
- if (!fs20.existsSync(importBase)) {
26213
- fs20.mkdirSync(importBase, { recursive: true });
26433
+ if (!fs21.existsSync(importBase)) {
26434
+ fs21.mkdirSync(importBase, { recursive: true });
26214
26435
  directoriesCreated.push("import");
26215
26436
  }
26216
26437
  const pathsToCreate = [
@@ -26221,19 +26442,19 @@ async function initDirectories(directory) {
26221
26442
  ];
26222
26443
  for (const { path: dirPath } of pathsToCreate) {
26223
26444
  const fullPath = path15.join(directory, dirPath);
26224
- if (!fs20.existsSync(fullPath)) {
26225
- fs20.mkdirSync(fullPath, { recursive: true });
26445
+ if (!fs21.existsSync(fullPath)) {
26446
+ fs21.mkdirSync(fullPath, { recursive: true });
26226
26447
  directoriesCreated.push(dirPath);
26227
26448
  }
26228
26449
  const gitkeepPath = path15.join(fullPath, ".gitkeep");
26229
- if (!fs20.existsSync(gitkeepPath)) {
26230
- fs20.writeFileSync(gitkeepPath, "");
26450
+ if (!fs21.existsSync(gitkeepPath)) {
26451
+ fs21.writeFileSync(gitkeepPath, "");
26231
26452
  gitkeepFiles.push(path15.join(dirPath, ".gitkeep"));
26232
26453
  }
26233
26454
  }
26234
26455
  const gitignorePath = path15.join(importBase, ".gitignore");
26235
26456
  let gitignoreCreated = false;
26236
- if (!fs20.existsSync(gitignorePath)) {
26457
+ if (!fs21.existsSync(gitignorePath)) {
26237
26458
  const gitignoreContent = `# Ignore CSV/PDF files in temporary directories
26238
26459
  /incoming/*.csv
26239
26460
  /incoming/*.pdf
@@ -26251,7 +26472,7 @@ async function initDirectories(directory) {
26251
26472
  .DS_Store
26252
26473
  Thumbs.db
26253
26474
  `;
26254
- fs20.writeFileSync(gitignorePath, gitignoreContent);
26475
+ fs21.writeFileSync(gitignorePath, gitignoreContent);
26255
26476
  gitignoreCreated = true;
26256
26477
  }
26257
26478
  const parts = [];
@@ -26328,13 +26549,13 @@ You can now drop CSV files into import/incoming/ and run import-pipeline.`);
26328
26549
  });
26329
26550
  // src/tools/generate-btc-purchases.ts
26330
26551
  import * as path16 from "path";
26331
- import * as fs21 from "fs";
26552
+ import * as fs22 from "fs";
26332
26553
  function findFiatCsvPaths(directory, pendingDir, provider) {
26333
26554
  const providerDir = path16.join(directory, pendingDir, provider);
26334
- if (!fs21.existsSync(providerDir))
26555
+ if (!fs22.existsSync(providerDir))
26335
26556
  return [];
26336
26557
  const csvPaths = [];
26337
- const entries = fs21.readdirSync(providerDir, { withFileTypes: true });
26558
+ const entries = fs22.readdirSync(providerDir, { withFileTypes: true });
26338
26559
  for (const entry of entries) {
26339
26560
  if (!entry.isDirectory())
26340
26561
  continue;
@@ -0,0 +1,70 @@
1
+ # Symbol Map Configuration
2
+
3
+ The symbol map allows you to map Swissquote's internal symbol names to canonical ticker symbols, matching your preferred naming convention (e.g., wealthfolio-style with exchange suffixes).
4
+
5
+ ## Purpose
6
+
7
+ Swissquote CSV exports use various symbol formats:
8
+ - Long descriptive names for corporate actions: `GOLD ROYALTY RG`, `VIZSLA ROYAL RG`
9
+ - Short tickers without exchange suffixes: `FNV`, `FVI`, `VCU`
10
+ - Warrant/CVR names: `VIZSLA WT 12.25`, `KINROSS GLD CVR`
11
+
12
+ The symbol map normalizes these to canonical symbols used consistently across journal entries, lot inventory filenames, and account names.
13
+
14
+ ## File Location
15
+
16
+ Default: `config/import/symbolMap.yaml` (relative to project root)
17
+
18
+ Configurable via `symbolMapPath` in `providers.yaml`:
19
+
20
+ ```yaml
21
+ providers:
22
+ swissquote:
23
+ symbolMapPath: config/import/symbolMap.yaml
24
+ ```
25
+
26
+ ## Format
27
+
28
+ Simple YAML key-value pairs where keys are Swissquote symbols and values are canonical symbols:
29
+
30
+ ```yaml
31
+ # Swissquote symbol -> canonical symbol
32
+ FNV: FNV.TO
33
+ FVI: FVI.TO
34
+ GOLD ROYALTY RG: GROY
35
+ VIZSLA ROYAL RG: VROY.V
36
+ KINROSS GLD CVR: KGC-CVR
37
+ VIZSLA WT 12.25: VROY-WT
38
+ VIZSLA WT. 12.2: VROY-WT
39
+ COLOSSUS MIN: CSI.TO
40
+ ```
41
+
42
+ Both keys and values are plain strings. Keys must exactly match the `Symbol` column in the Swissquote CSV.
43
+
44
+ ## When Needed
45
+
46
+ A symbol map is required when:
47
+ - Swissquote uses long descriptive names (common in merger/corporate action entries) that contain spaces, which break hledger commodity parsing
48
+ - You want exchange suffixes (`.TO`, `.V`) for TSX/TSXV-listed stocks
49
+ - Multiple Swissquote names refer to the same security (e.g., `VIZSLA WT 12.25` and `VIZSLA WT. 12.2`)
50
+ - You want consistency with another portfolio tool (e.g., wealthfolio)
51
+
52
+ ## Effect
53
+
54
+ The symbol map is applied **early** in preprocessing, immediately after CSV parsing and before any trade, lot, or journal processing. This means:
55
+
56
+ - **Journal entries** use mapped symbols in descriptions and account names
57
+ - **Lot inventory files** use mapped symbols in filenames (e.g., `GROY-lot.json` instead of `GOLD ROYALTY RG-lot.json`)
58
+ - **Dividend income accounts** use mapped symbols (e.g., `income:dividends:GROY`)
59
+ - **Capital gains tracking** uses mapped symbols throughout
60
+
61
+ ## Example
62
+
63
+ Given a Swissquote CSV with:
64
+ ```
65
+ 15-03-2022;12345678;Merger;GOLD ROYALTY RG;GOLD ROYALTY CORP;CA38071H1064;-1000;...
66
+ ```
67
+
68
+ Without symbol map: creates `GOLD ROYALTY RG-lot.json` and journal entries with `GOLD ROYALTY RG` (breaks hledger).
69
+
70
+ With symbol map (`GOLD ROYALTY RG: GROY`): creates `GROY-lot.json` and journal entries with `GROY`.
@@ -208,13 +208,33 @@ See [classify-statements](classify-statements.md) for details.
208
208
  **What happens**:
209
209
 
210
210
  1. Finds Swissquote contexts from classification
211
- 2. Parses CSV to identify transaction types (Buy, Sell, Dividend, Merger, Reverse Split, Worthless Liquidation, etc.)
212
- 3. For Buy transactions: adds lots to per-symbol inventory files (`ledger/investments/lot-inventory/{symbol}-lot.json`)
213
- 4. For Sell transactions: consumes lots using FIFO, calculates capital gains/losses
214
- 5. For Dividends: generates dividend income entries with withholding tax
215
- 6. For Corporate Actions: adjusts lot quantities (splits), transfers lots (mergers), or removes lots (worthless liquidations)
216
- 7. Generates investment journal entries (`ledger/investments/{year}-investments.journal`)
217
- 8. Outputs filtered CSV (simple transactions only) for hledger rules import
211
+ 2. Loads **symbol map** from `config/import/symbolMap.yaml` (if present) and applies it to all transaction symbols before processing
212
+ 3. Parses CSV to identify transaction types and routes them accordingly
213
+ 4. For Buy transactions: adds lots to per-symbol inventory files (`ledger/investments/lot-inventory/{symbol}-lot.json`)
214
+ 5. For Sell transactions: consumes lots using FIFO, calculates capital gains/losses
215
+ 6. For Dividends: generates dividend income entries with withholding tax
216
+ 7. For Corporate Actions:
217
+ - **Mergers** (including multi-way): groups by date+orderNum, partitions into outgoing/incoming, transfers cost basis proportionally
218
+ - **Reverse Splits**: adjusts lot quantities and cost basis
219
+ - **Worthless Liquidations**: removes lots and records capital loss
220
+ - **Rights Distributions**: adds shares at zero cost basis
221
+ - **Cross-currency mergers**: saves outgoing side to pending state, loads when incoming side is processed
222
+ 8. Generates per-year investment journal entries (`ledger/investments/{year}-{currency}.journal`) based on the CSV filename's date range
223
+ 9. Outputs filtered CSV (simple transactions only) for hledger rules import
224
+
225
+ **Symbol Map**: Maps Swissquote's internal symbol names (e.g., `GOLD ROYALTY RG`, `VIZSLA WT 12.25`) to canonical ticker symbols (e.g., `GROY`, `VROY-WT`). Applied before any processing, so journal entries, lot inventory filenames, and account names all use the mapped symbols. See [Symbol Map Configuration](../configuration/symbol-map.md).
226
+
227
+ **Per-Year Journals**: Each CSV writes to a journal file named `{year}-{currency}.journal` where the year is extracted from the `to-DDMMYYYY` portion of the filename (e.g., `swissquote-cad-transactions-01012013-to-31122013.csv` → `2013-cad.journal`).
228
+
229
+ **Multi-Way Mergers**: Mergers can involve multiple incoming/outgoing securities (1→N, N→1, N→M). The preprocessor groups all entries sharing the same date+orderNum, collects total cost basis from outgoing lots, and distributes it proportionally across incoming symbols.
230
+
231
+ **Cross-Currency Mergers**: When the outgoing and incoming sides of a merger appear in different currency CSV files, the outgoing side saves its state to `pending-mergers/` under the lot inventory directory. When the incoming side is processed later, it loads the pending state to create lots with the correct cost basis.
232
+
233
+ **Supported Transaction Types**:
234
+ - **Simple** (passed to rules import): Custody Fees, Forex credit/debit, Interest on debits/credits/deposits, Exchange fees, Payment, Debit, Fees Tax Statement, Dividend (Reversed), Reversal (Dividend), Redemption
235
+ - **Trades**: Buy, Sell
236
+ - **Dividends**: Dividend
237
+ - **Corporate Actions**: Merger, Internal exchange of securities, Exchange of securities, Corporate Action, Reverse Split, Worthless Liquidation, Rights Distribution
218
238
 
219
239
  **Why**: hledger CSV rules can't perform FIFO lot matching, capital gains calculations, or handle complex corporate actions. The preprocessor handles these computations and generates proper journal entries, while passing through simple transactions (fees, forex, interest) for standard rules-based import.
220
240
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fuzzle/opencode-accountant",
3
- "version": "0.10.0",
3
+ "version": "0.10.1-next.1",
4
4
  "description": "An OpenCode accounting agent, specialized in double-entry-bookkepping with hledger",
5
5
  "author": {
6
6
  "name": "ali bengali",