@fuzzle/opencode-accountant 0.10.1 → 0.10.2-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
  }
@@ -23060,19 +23078,12 @@ function findRulesForCsv(csvPath, mapping) {
23060
23078
 
23061
23079
  // src/utils/hledgerExecutor.ts
23062
23080
  var {$: $2 } = globalThis.Bun;
23063
- var STDERR_TRUNCATE_LENGTH = 500;
23064
23081
  var TX_HEADER_PATTERN = /^(\d{4})-(\d{2}-\d{2})(\s+(.+))?$/;
23065
23082
  async function defaultHledgerExecutor(cmdArgs) {
23066
23083
  try {
23067
23084
  const result = await $2`hledger ${cmdArgs}`.quiet().nothrow();
23068
23085
  const stdout = result.stdout.toString();
23069
23086
  const stderr = result.stderr.toString();
23070
- if (result.exitCode !== 0 && stderr) {
23071
- process.stderr.write(`[hledger] command failed (exit ${result.exitCode}): hledger ${cmdArgs.join(" ")}
23072
- `);
23073
- process.stderr.write(`[hledger] stderr: ${stderr.slice(0, STDERR_TRUNCATE_LENGTH)}
23074
- `);
23075
- }
23076
23087
  return {
23077
23088
  stdout,
23078
23089
  stderr,
@@ -23080,8 +23091,6 @@ async function defaultHledgerExecutor(cmdArgs) {
23080
23091
  };
23081
23092
  } catch (error45) {
23082
23093
  const errorMessage = error45 instanceof Error ? error45.message : String(error45);
23083
- process.stderr.write(`[hledger] exception: ${errorMessage}
23084
- `);
23085
23094
  return {
23086
23095
  stdout: "",
23087
23096
  stderr: errorMessage,
@@ -24092,6 +24101,7 @@ Note: This tool requires a contextId from a prior classify/import step.`,
24092
24101
  }
24093
24102
  });
24094
24103
  // src/tools/import-pipeline.ts
24104
+ import * as fs20 from "fs";
24095
24105
  import * as path14 from "path";
24096
24106
 
24097
24107
  // src/utils/accountDeclarations.ts
@@ -24951,24 +24961,6 @@ function adjustLotsForSplit(inventory, symbol2, ratio, logger) {
24951
24961
  }
24952
24962
  logger?.info(`Adjusted ${lots.length} lots for ${symbol2} split (ratio: ${ratio})`);
24953
24963
  }
24954
- function adjustLotsForMerger(inventory, oldSymbol, newSymbol, ratio, logger) {
24955
- const oldLots = inventory[oldSymbol];
24956
- if (!oldLots || oldLots.length === 0) {
24957
- logger?.warn(`No lots found for ${oldSymbol} during merger adjustment`);
24958
- return;
24959
- }
24960
- const newLots = oldLots.map((lot) => ({
24961
- ...lot,
24962
- quantity: lot.quantity * ratio,
24963
- costBasis: lot.costBasis / ratio
24964
- }));
24965
- if (!inventory[newSymbol]) {
24966
- inventory[newSymbol] = [];
24967
- }
24968
- inventory[newSymbol].push(...newLots);
24969
- delete inventory[oldSymbol];
24970
- logger?.info(`Merger: ${oldLots.length} lots moved from ${oldSymbol} to ${newSymbol} (ratio: ${ratio})`);
24971
- }
24972
24964
  function removeLots(inventory, symbol2, logger) {
24973
24965
  const lots = inventory[symbol2];
24974
24966
  if (!lots || lots.length === 0) {
@@ -25088,27 +25080,6 @@ function generateDividendEntry(dividend, logger) {
25088
25080
  `;
25089
25081
  return entry;
25090
25082
  }
25091
- function generateMergerEntry(action, oldQuantity, newQuantity, logger) {
25092
- const date5 = formatDate(action.date);
25093
- const newSymbol = action.newSymbol || "UNKNOWN";
25094
- const description = escapeDescription(`Merger: ${action.symbol} -> ${newSymbol}`);
25095
- logger?.debug(`Generating Merger entry: ${oldQuantity} ${action.symbol} -> ${newQuantity} ${newSymbol}`);
25096
- let entry = `${date5} ${description}
25097
- `;
25098
- entry += ` ; swissquote:order:${action.orderNum}
25099
- `;
25100
- entry += ` ; Old ISIN: ${action.isin}, New ISIN: ${action.newIsin || "unknown"}
25101
- `;
25102
- entry += ` assets:investments:stocks:${action.symbol} -${formatQuantity(oldQuantity)} ${action.symbol}
25103
- `;
25104
- entry += ` equity:conversion ${formatQuantity(oldQuantity)} ${action.symbol}
25105
- `;
25106
- entry += ` equity:conversion -${formatQuantity(newQuantity)} ${newSymbol}
25107
- `;
25108
- entry += ` assets:investments:stocks:${newSymbol} ${formatQuantity(newQuantity)} ${newSymbol}
25109
- `;
25110
- return entry;
25111
- }
25112
25083
  function generateSplitEntry(action, oldQuantity, newQuantity, logger) {
25113
25084
  const date5 = formatDate(action.date);
25114
25085
  const ratio = action.ratio || newQuantity / oldQuantity;
@@ -25154,6 +25125,53 @@ function generateWorthlessEntry(action, removedLots, logger) {
25154
25125
  `;
25155
25126
  return entry;
25156
25127
  }
25128
+ function generateMultiWayMergerEntry(group, crossCurrencyOutgoingSymbols, logger) {
25129
+ const date5 = formatDate(group.date);
25130
+ const outSymbols = crossCurrencyOutgoingSymbols ?? group.outgoing.map((a) => a.symbol);
25131
+ const inSymbols = group.incoming.map((a) => a.symbol);
25132
+ const description = escapeDescription(`Merger: ${outSymbols.join(" + ")} -> ${inSymbols.join(" + ")}`);
25133
+ logger?.debug(`Generating multi-way merger entry: ${outSymbols.join(", ")} -> ${inSymbols.join(", ")}`);
25134
+ let entry = `${date5} ${description}
25135
+ `;
25136
+ entry += ` ; swissquote:order:${group.orderNum}
25137
+ `;
25138
+ const oldIsins = group.outgoing.map((a) => a.isin).filter(Boolean);
25139
+ const newIsins = group.incoming.map((a) => a.isin).filter(Boolean);
25140
+ if (oldIsins.length > 0 || newIsins.length > 0) {
25141
+ entry += ` ; Old ISIN: ${oldIsins.join(", ") || "n/a"}, New ISINs: ${newIsins.join(", ") || "n/a"}
25142
+ `;
25143
+ }
25144
+ for (const out of group.outgoing) {
25145
+ const qty = formatQuantity(Math.abs(out.quantity));
25146
+ entry += ` assets:investments:stocks:${out.symbol} -${qty} ${out.symbol}
25147
+ `;
25148
+ entry += ` equity:conversion ${qty} ${out.symbol}
25149
+ `;
25150
+ }
25151
+ for (const inc of group.incoming) {
25152
+ const qty = formatQuantity(Math.abs(inc.quantity));
25153
+ entry += ` equity:conversion -${qty} ${inc.symbol}
25154
+ `;
25155
+ entry += ` assets:investments:stocks:${inc.symbol} ${qty} ${inc.symbol}
25156
+ `;
25157
+ }
25158
+ return entry;
25159
+ }
25160
+ function generateRightsDistributionEntry(action, logger) {
25161
+ const date5 = formatDate(action.date);
25162
+ const qty = formatQuantity(Math.abs(action.quantity));
25163
+ const description = escapeDescription(`Rights Distribution: ${action.symbol} - ${action.name}`);
25164
+ logger?.debug(`Generating Rights Distribution entry: ${qty} ${action.symbol}`);
25165
+ let entry = `${date5} ${description}
25166
+ `;
25167
+ entry += ` ; swissquote:order:${action.orderNum} isin:${action.isin}
25168
+ `;
25169
+ entry += ` assets:investments:stocks:${action.symbol} ${qty} ${action.symbol} @ 0.00 CAD
25170
+ `;
25171
+ entry += ` income:capital-gains:rights-distribution 0.00 CAD
25172
+ `;
25173
+ return entry;
25174
+ }
25157
25175
  function formatJournalFile(entries, year, currency) {
25158
25176
  const header = `; Swissquote ${currency.toUpperCase()} investment transactions for ${year}
25159
25177
  ; Generated by opencode-accountant
@@ -25172,8 +25190,15 @@ var SIMPLE_TRANSACTION_TYPES = new Set([
25172
25190
  "Forex debit",
25173
25191
  "Interest on debits",
25174
25192
  "Interest on credits",
25193
+ "Interest on deposits",
25175
25194
  "Exchange fees",
25176
- "Exchange fees rectif."
25195
+ "Exchange fees rectif.",
25196
+ "Payment",
25197
+ "Debit",
25198
+ "Fees Tax Statement",
25199
+ "Dividend (Reversed)",
25200
+ "Reversal (Dividend)",
25201
+ "Redemption"
25177
25202
  ]);
25178
25203
  var TRADE_TYPES = new Set(["Buy", "Sell"]);
25179
25204
  var DIVIDEND_TYPES = new Set(["Dividend"]);
@@ -25181,7 +25206,10 @@ var CORPORATE_ACTION_TYPES = new Set([
25181
25206
  "Merger",
25182
25207
  "Reverse Split",
25183
25208
  "Worthless Liquidation",
25184
- "Internal exchange of securities"
25209
+ "Internal exchange of securities",
25210
+ "Exchange of securities",
25211
+ "Corporate Action",
25212
+ "Rights Distribution"
25185
25213
  ]);
25186
25214
  var SKIP_TYPES = new Set([
25187
25215
  "Internal exchange",
@@ -25290,45 +25318,41 @@ function toCorporateActionEntry(txn) {
25290
25318
  symbol: txn.symbol,
25291
25319
  name: txn.name,
25292
25320
  isin: txn.isin,
25293
- quantity: Math.abs(parseNumber(txn.quantity))
25321
+ quantity: parseNumber(txn.quantity)
25294
25322
  };
25295
25323
  }
25296
- function processCorporateActions(actions, inventory, logger) {
25324
+ var MERGER_LIKE_TYPES = new Set([
25325
+ "Merger",
25326
+ "Internal exchange of securities",
25327
+ "Exchange of securities",
25328
+ "Corporate Action"
25329
+ ]);
25330
+ function processCorporateActions(actions, inventory, lotInventoryPath, projectDir, logger) {
25297
25331
  const entries = [];
25298
- const pendingMergers = new Map;
25299
25332
  actions.sort((a, b) => a.date.localeCompare(b.date));
25333
+ const mergerActions = [];
25334
+ const otherActions = [];
25300
25335
  for (const action of actions) {
25336
+ if (MERGER_LIKE_TYPES.has(action.type)) {
25337
+ mergerActions.push(action);
25338
+ } else {
25339
+ otherActions.push(action);
25340
+ }
25341
+ }
25342
+ const mergerGroups = groupMergerActions(mergerActions);
25343
+ for (const group of mergerGroups) {
25344
+ const groupEntries = processMultiWayMerger(group, inventory, lotInventoryPath, projectDir, logger);
25345
+ entries.push(...groupEntries);
25346
+ }
25347
+ for (const action of otherActions) {
25301
25348
  switch (action.type) {
25302
- case "Merger":
25303
- case "Internal exchange of securities": {
25304
- const key = `${action.date}-${action.orderNum}`;
25305
- if (action.quantity < 0 || pendingMergers.has(key)) {
25306
- const pending = pendingMergers.get(key);
25307
- if (pending && pending.outgoing) {
25308
- pending.incoming = action;
25309
- const oldQty = Math.abs(parseNumber(String(pending.outgoing.quantity)));
25310
- const newQty = Math.abs(parseNumber(String(action.quantity)));
25311
- const ratio = newQty / oldQty;
25312
- adjustLotsForMerger(inventory, pending.outgoing.symbol, action.symbol, ratio, logger);
25313
- const entry = generateMergerEntry({
25314
- ...pending.outgoing,
25315
- newSymbol: action.symbol,
25316
- newIsin: action.isin,
25317
- ratio
25318
- }, oldQty, newQty, logger);
25319
- entries.push(entry);
25320
- pendingMergers.delete(key);
25321
- } else {
25322
- pendingMergers.set(key, { outgoing: action });
25323
- }
25324
- } else {
25325
- pendingMergers.set(key, { outgoing: action });
25326
- }
25327
- break;
25328
- }
25329
25349
  case "Reverse Split": {
25350
+ const qty = action.quantity;
25351
+ if (qty < 0) {
25352
+ continue;
25353
+ }
25330
25354
  const oldQty = getHeldQuantity(inventory, action.symbol);
25331
- const newQty = action.quantity;
25355
+ const newQty = Math.abs(qty);
25332
25356
  if (oldQty > 0) {
25333
25357
  const ratio = newQty / oldQty;
25334
25358
  adjustLotsForSplit(inventory, action.symbol, ratio, logger);
@@ -25349,14 +25373,170 @@ function processCorporateActions(actions, inventory, logger) {
25349
25373
  }
25350
25374
  break;
25351
25375
  }
25376
+ case "Rights Distribution": {
25377
+ if (action.quantity > 0) {
25378
+ const entry = generateRightsDistributionEntry(action, logger);
25379
+ entries.push(entry);
25380
+ }
25381
+ break;
25382
+ }
25352
25383
  }
25353
25384
  }
25354
- for (const [key, pending] of pendingMergers.entries()) {
25355
- logger?.warn(`Unmatched merger entry: ${key} - ${pending.outgoing.symbol}`);
25385
+ return entries;
25386
+ }
25387
+ function groupMergerActions(actions) {
25388
+ const groupMap = new Map;
25389
+ for (const action of actions) {
25390
+ const key = `${action.date}-${action.orderNum}`;
25391
+ if (!groupMap.has(key)) {
25392
+ groupMap.set(key, {
25393
+ key,
25394
+ date: action.date,
25395
+ orderNum: action.orderNum,
25396
+ outgoing: [],
25397
+ incoming: []
25398
+ });
25399
+ }
25400
+ const group = groupMap.get(key);
25401
+ if (action.quantity < 0) {
25402
+ group.outgoing.push(action);
25403
+ } else if (action.quantity > 0) {
25404
+ group.incoming.push(action);
25405
+ }
25356
25406
  }
25407
+ return Array.from(groupMap.values());
25408
+ }
25409
+ function processMultiWayMerger(group, inventory, lotInventoryPath, projectDir, logger) {
25410
+ const entries = [];
25411
+ if (group.outgoing.length === 0 && group.incoming.length === 0) {
25412
+ return entries;
25413
+ }
25414
+ if (group.outgoing.length === 0 && group.incoming.length > 0) {
25415
+ const pendingState = loadPendingMerger(projectDir, lotInventoryPath, group.key, logger);
25416
+ if (pendingState) {
25417
+ const totalIncomingQty2 = group.incoming.reduce((sum, a) => sum + Math.abs(a.quantity), 0);
25418
+ for (const inc of group.incoming) {
25419
+ const absQty = Math.abs(inc.quantity);
25420
+ const proportion = absQty / totalIncomingQty2;
25421
+ const allocatedCost = pendingState.totalCostBasis * proportion;
25422
+ const costBasisPerUnit = absQty > 0 ? allocatedCost / absQty : 0;
25423
+ const lot = {
25424
+ date: formatDate(inc.date),
25425
+ quantity: absQty,
25426
+ costBasis: costBasisPerUnit,
25427
+ currency: pendingState.currency,
25428
+ isin: inc.isin,
25429
+ orderNum: inc.orderNum
25430
+ };
25431
+ if (!inventory[inc.symbol]) {
25432
+ inventory[inc.symbol] = [];
25433
+ }
25434
+ inventory[inc.symbol].push(lot);
25435
+ logger?.info(`Cross-currency merger incoming: ${absQty} ${inc.symbol} @ ${costBasisPerUnit.toFixed(2)} ${pendingState.currency}`);
25436
+ }
25437
+ const entry2 = generateMultiWayMergerEntry(group, pendingState.outgoingSymbols, logger);
25438
+ entries.push(entry2);
25439
+ removePendingMerger(projectDir, lotInventoryPath, group.key, logger);
25440
+ } else {
25441
+ logger?.warn(`Incoming-only merger group ${group.key} with no pending state \u2014 skipping`);
25442
+ }
25443
+ return entries;
25444
+ }
25445
+ let totalCostBasis = 0;
25446
+ let outgoingCurrency = "";
25447
+ const outgoingSymbols = [];
25448
+ for (const out of group.outgoing) {
25449
+ outgoingSymbols.push(out.symbol);
25450
+ const lots = inventory[out.symbol];
25451
+ if (lots) {
25452
+ for (const lot of lots) {
25453
+ totalCostBasis += lot.quantity * lot.costBasis;
25454
+ if (!outgoingCurrency && lot.currency) {
25455
+ outgoingCurrency = lot.currency;
25456
+ }
25457
+ }
25458
+ }
25459
+ }
25460
+ if (group.outgoing.length > 0 && group.incoming.length === 0) {
25461
+ for (const out of group.outgoing) {
25462
+ removeLots(inventory, out.symbol, logger);
25463
+ }
25464
+ savePendingMerger(projectDir, lotInventoryPath, group.key, {
25465
+ date: group.date,
25466
+ orderNum: group.orderNum,
25467
+ outgoingSymbols,
25468
+ totalCostBasis,
25469
+ currency: outgoingCurrency || "CAD"
25470
+ }, logger);
25471
+ logger?.info(`Cross-currency merger outgoing: ${outgoingSymbols.join(", ")} -> pending (cost basis: ${totalCostBasis.toFixed(2)})`);
25472
+ return entries;
25473
+ }
25474
+ for (const out of group.outgoing) {
25475
+ const absQty = Math.abs(out.quantity);
25476
+ removeLots(inventory, out.symbol, logger);
25477
+ logger?.debug(`Merger outgoing: removed ${absQty} ${out.symbol}`);
25478
+ }
25479
+ const totalIncomingQty = group.incoming.reduce((sum, a) => sum + Math.abs(a.quantity), 0);
25480
+ for (const inc of group.incoming) {
25481
+ const absQty = Math.abs(inc.quantity);
25482
+ const proportion = totalIncomingQty > 0 ? absQty / totalIncomingQty : 0;
25483
+ const allocatedCost = totalCostBasis * proportion;
25484
+ const costBasisPerUnit = absQty > 0 ? allocatedCost / absQty : 0;
25485
+ const lot = {
25486
+ date: formatDate(inc.date),
25487
+ quantity: absQty,
25488
+ costBasis: costBasisPerUnit,
25489
+ currency: outgoingCurrency || "CAD",
25490
+ isin: inc.isin,
25491
+ orderNum: inc.orderNum
25492
+ };
25493
+ if (!inventory[inc.symbol]) {
25494
+ inventory[inc.symbol] = [];
25495
+ }
25496
+ inventory[inc.symbol].push(lot);
25497
+ logger?.debug(`Merger incoming: added ${absQty} ${inc.symbol} @ ${costBasisPerUnit.toFixed(2)}`);
25498
+ }
25499
+ const entry = generateMultiWayMergerEntry(group, undefined, logger);
25500
+ entries.push(entry);
25357
25501
  return entries;
25358
25502
  }
25359
- async function preprocessSwissquote(csvPath, projectDir, currency, year, lotInventoryPath = DEFAULT_LOT_INVENTORY_PATH, logger) {
25503
+ function getPendingMergerDir(projectDir, lotInventoryPath) {
25504
+ const lotDir = lotInventoryPath.replace(/{symbol}[^/]*$/, "");
25505
+ return path13.join(projectDir, lotDir, "pending-mergers");
25506
+ }
25507
+ function savePendingMerger(projectDir, lotInventoryPath, key, state, logger) {
25508
+ const dir = getPendingMergerDir(projectDir, lotInventoryPath);
25509
+ if (!fs18.existsSync(dir)) {
25510
+ fs18.mkdirSync(dir, { recursive: true });
25511
+ }
25512
+ const filePath = path13.join(dir, `${key.replace(/[^a-zA-Z0-9-]/g, "_")}.json`);
25513
+ fs18.writeFileSync(filePath, JSON.stringify(state, null, 2));
25514
+ logger?.debug(`Saved pending merger state: ${key}`);
25515
+ }
25516
+ function loadPendingMerger(projectDir, lotInventoryPath, key, logger) {
25517
+ const dir = getPendingMergerDir(projectDir, lotInventoryPath);
25518
+ const filePath = path13.join(dir, `${key.replace(/[^a-zA-Z0-9-]/g, "_")}.json`);
25519
+ if (!fs18.existsSync(filePath)) {
25520
+ return null;
25521
+ }
25522
+ try {
25523
+ const content = fs18.readFileSync(filePath, "utf-8");
25524
+ return JSON.parse(content);
25525
+ } catch (error45) {
25526
+ const message = error45 instanceof Error ? error45.message : String(error45);
25527
+ logger?.warn(`Failed to load pending merger state for ${key}: ${message}`);
25528
+ return null;
25529
+ }
25530
+ }
25531
+ function removePendingMerger(projectDir, lotInventoryPath, key, logger) {
25532
+ const dir = getPendingMergerDir(projectDir, lotInventoryPath);
25533
+ const filePath = path13.join(dir, `${key.replace(/[^a-zA-Z0-9-]/g, "_")}.json`);
25534
+ if (fs18.existsSync(filePath)) {
25535
+ fs18.unlinkSync(filePath);
25536
+ logger?.debug(`Removed pending merger state: ${key}`);
25537
+ }
25538
+ }
25539
+ async function preprocessSwissquote(csvPath, projectDir, currency, year, lotInventoryPath = DEFAULT_LOT_INVENTORY_PATH, symbolMap = {}, logger) {
25360
25540
  logger?.startSection(`Swissquote Preprocessing: ${path13.basename(csvPath)}`);
25361
25541
  const stats = {
25362
25542
  totalRows: 0,
@@ -25392,14 +25572,22 @@ async function preprocessSwissquote(csvPath, projectDir, currency, year, lotInve
25392
25572
  };
25393
25573
  }
25394
25574
  const transactions = [];
25575
+ const hasSymbolMap = Object.keys(symbolMap).length > 0;
25395
25576
  for (let i2 = 1;i2 < lines.length; i2++) {
25396
25577
  const txn = parseTransaction(lines[i2]);
25397
25578
  if (txn) {
25579
+ if (hasSymbolMap && txn.symbol) {
25580
+ txn.symbol = symbolMap[txn.symbol] ?? txn.symbol;
25581
+ }
25398
25582
  transactions.push(txn);
25399
25583
  }
25400
25584
  }
25401
25585
  stats.totalRows = transactions.length;
25402
- logger?.logStep("parse-csv", "success", `Parsed ${stats.totalRows} transactions`);
25586
+ if (hasSymbolMap) {
25587
+ logger?.logStep("parse-csv", "success", `Parsed ${stats.totalRows} transactions (symbol map applied)`);
25588
+ } else {
25589
+ logger?.logStep("parse-csv", "success", `Parsed ${stats.totalRows} transactions`);
25590
+ }
25403
25591
  logger?.logStep("load-inventory", "start", "Loading lot inventory");
25404
25592
  const inventory = loadLotInventory(projectDir, lotInventoryPath, logger);
25405
25593
  logger?.logStep("load-inventory", "success", "Lot inventory loaded");
@@ -25424,8 +25612,17 @@ async function preprocessSwissquote(csvPath, projectDir, currency, year, lotInve
25424
25612
  stats.dividends++;
25425
25613
  break;
25426
25614
  case "corporate":
25427
- corporateActions.push(toCorporateActionEntry(txn));
25428
- stats.corporateActions++;
25615
+ if (!txn.symbol || txn.symbol.trim() === "") {
25616
+ if (txn.netAmount && txn.netAmount !== "-") {
25617
+ simpleTransactions.push(txn);
25618
+ stats.simpleTransactions++;
25619
+ } else {
25620
+ stats.skipped++;
25621
+ }
25622
+ } else {
25623
+ corporateActions.push(toCorporateActionEntry(txn));
25624
+ stats.corporateActions++;
25625
+ }
25429
25626
  break;
25430
25627
  case "skip":
25431
25628
  stats.skipped++;
@@ -25481,7 +25678,7 @@ async function preprocessSwissquote(csvPath, projectDir, currency, year, lotInve
25481
25678
  }
25482
25679
  if (corporateActions.length > 0) {
25483
25680
  logger?.startSection("Corporate Actions Processing", 2);
25484
- const corpEntries = processCorporateActions(corporateActions, inventory, logger);
25681
+ const corpEntries = processCorporateActions(corporateActions, inventory, lotInventoryPath, projectDir, logger);
25485
25682
  journalEntries.push(...corpEntries);
25486
25683
  logger?.logStep("corporate", "success", `Processed ${corporateActions.length} corporate actions`);
25487
25684
  logger?.endSection();
@@ -25737,14 +25934,10 @@ async function executeSwissquotePreprocessStep(context, contextIds, logger) {
25737
25934
  const importCtx = loadContext(context.directory, contextId);
25738
25935
  if (importCtx.provider === "swissquote") {
25739
25936
  const csvPath = path14.join(context.directory, importCtx.filePath);
25740
- const filenameMatch = importCtx.filename.match(/from-(\d{2})(\d{2})(\d{4})|(\d{4})-\d{2}-\d{2}/);
25937
+ const toDateMatch = importCtx.filename.match(/to-\d{4}(\d{4})\./);
25741
25938
  let year = new Date().getFullYear();
25742
- if (filenameMatch) {
25743
- if (filenameMatch[3]) {
25744
- year = parseInt(filenameMatch[3], 10);
25745
- } else if (filenameMatch[4]) {
25746
- year = parseInt(filenameMatch[4], 10);
25747
- }
25939
+ if (toDateMatch) {
25940
+ year = parseInt(toDateMatch[1], 10);
25748
25941
  }
25749
25942
  swissquoteContexts.push({
25750
25943
  contextId,
@@ -25762,6 +25955,23 @@ async function executeSwissquotePreprocessStep(context, contextIds, logger) {
25762
25955
  const config2 = context.configLoader(context.directory);
25763
25956
  const swissquoteProvider = config2.providers?.["swissquote"];
25764
25957
  const lotInventoryPath = swissquoteProvider?.lotInventoryPath ?? DEFAULT_LOT_INVENTORY_PATH;
25958
+ const symbolMapPath = swissquoteProvider?.symbolMapPath ?? "config/import/symbolMap.yaml";
25959
+ let symbolMap = {};
25960
+ const symbolMapFullPath = path14.join(context.directory, symbolMapPath);
25961
+ if (fs20.existsSync(symbolMapFullPath)) {
25962
+ try {
25963
+ const yaml = await Promise.resolve().then(() => (init_js_yaml(), exports_js_yaml));
25964
+ const content = fs20.readFileSync(symbolMapFullPath, "utf-8");
25965
+ const parsed = yaml.load(content);
25966
+ if (parsed && typeof parsed === "object") {
25967
+ symbolMap = parsed;
25968
+ logger?.info(`Loaded symbol map with ${Object.keys(symbolMap).length} entries`);
25969
+ }
25970
+ } catch (error45) {
25971
+ const message = error45 instanceof Error ? error45.message : String(error45);
25972
+ logger?.warn(`Failed to load symbol map from ${symbolMapPath}: ${message}`);
25973
+ }
25974
+ }
25765
25975
  try {
25766
25976
  let totalStats = {
25767
25977
  totalRows: 0,
@@ -25774,7 +25984,7 @@ async function executeSwissquotePreprocessStep(context, contextIds, logger) {
25774
25984
  let lastJournalFile = null;
25775
25985
  for (const sqCtx of swissquoteContexts) {
25776
25986
  logger?.logStep("Swissquote Preprocess", "start", `Processing ${path14.basename(sqCtx.csvPath)}`);
25777
- const result = await preprocessSwissquote(sqCtx.csvPath, context.directory, sqCtx.currency, sqCtx.year, lotInventoryPath, logger);
25987
+ const result = await preprocessSwissquote(sqCtx.csvPath, context.directory, sqCtx.currency, sqCtx.year, lotInventoryPath, symbolMap, logger);
25778
25988
  totalStats.totalRows += result.stats.totalRows;
25779
25989
  totalStats.simpleTransactions += result.stats.simpleTransactions;
25780
25990
  totalStats.trades += result.stats.trades;
@@ -25792,7 +26002,7 @@ async function executeSwissquotePreprocessStep(context, contextIds, logger) {
25792
26002
  }
25793
26003
  logger?.logStep("Swissquote Preprocess", "success", `Processed: ${result.stats.trades} trades, ${result.stats.dividends} dividends, ${result.stats.corporateActions} corporate actions`);
25794
26004
  }
25795
- const message = `Preprocessed ${totalStats.totalRows} rows: ` + `${totalStats.trades} trades, ${totalStats.dividends} dividends, ` + `${totalStats.corporateActions} corporate actions, ${totalStats.simpleTransactions} simple`;
26005
+ const message = `Preprocessed ${totalStats.totalRows} rows: ${totalStats.trades} trades, ${totalStats.dividends} dividends, ${totalStats.corporateActions} corporate actions, ${totalStats.simpleTransactions} simple`;
25796
26006
  context.result.steps.swissquotePreprocess = buildStepResult(true, message, {
25797
26007
  ...totalStats,
25798
26008
  journalFile: lastJournalFile
@@ -26203,7 +26413,7 @@ This tool orchestrates the full import workflow:
26203
26413
  }
26204
26414
  });
26205
26415
  // src/tools/init-directories.ts
26206
- import * as fs20 from "fs";
26416
+ import * as fs21 from "fs";
26207
26417
  import * as path15 from "path";
26208
26418
  async function initDirectories(directory) {
26209
26419
  try {
@@ -26211,8 +26421,8 @@ async function initDirectories(directory) {
26211
26421
  const directoriesCreated = [];
26212
26422
  const gitkeepFiles = [];
26213
26423
  const importBase = path15.join(directory, "import");
26214
- if (!fs20.existsSync(importBase)) {
26215
- fs20.mkdirSync(importBase, { recursive: true });
26424
+ if (!fs21.existsSync(importBase)) {
26425
+ fs21.mkdirSync(importBase, { recursive: true });
26216
26426
  directoriesCreated.push("import");
26217
26427
  }
26218
26428
  const pathsToCreate = [
@@ -26223,19 +26433,19 @@ async function initDirectories(directory) {
26223
26433
  ];
26224
26434
  for (const { path: dirPath } of pathsToCreate) {
26225
26435
  const fullPath = path15.join(directory, dirPath);
26226
- if (!fs20.existsSync(fullPath)) {
26227
- fs20.mkdirSync(fullPath, { recursive: true });
26436
+ if (!fs21.existsSync(fullPath)) {
26437
+ fs21.mkdirSync(fullPath, { recursive: true });
26228
26438
  directoriesCreated.push(dirPath);
26229
26439
  }
26230
26440
  const gitkeepPath = path15.join(fullPath, ".gitkeep");
26231
- if (!fs20.existsSync(gitkeepPath)) {
26232
- fs20.writeFileSync(gitkeepPath, "");
26441
+ if (!fs21.existsSync(gitkeepPath)) {
26442
+ fs21.writeFileSync(gitkeepPath, "");
26233
26443
  gitkeepFiles.push(path15.join(dirPath, ".gitkeep"));
26234
26444
  }
26235
26445
  }
26236
26446
  const gitignorePath = path15.join(importBase, ".gitignore");
26237
26447
  let gitignoreCreated = false;
26238
- if (!fs20.existsSync(gitignorePath)) {
26448
+ if (!fs21.existsSync(gitignorePath)) {
26239
26449
  const gitignoreContent = `# Ignore CSV/PDF files in temporary directories
26240
26450
  /incoming/*.csv
26241
26451
  /incoming/*.pdf
@@ -26253,7 +26463,7 @@ async function initDirectories(directory) {
26253
26463
  .DS_Store
26254
26464
  Thumbs.db
26255
26465
  `;
26256
- fs20.writeFileSync(gitignorePath, gitignoreContent);
26466
+ fs21.writeFileSync(gitignorePath, gitignoreContent);
26257
26467
  gitignoreCreated = true;
26258
26468
  }
26259
26469
  const parts = [];
@@ -26330,13 +26540,13 @@ You can now drop CSV files into import/incoming/ and run import-pipeline.`);
26330
26540
  });
26331
26541
  // src/tools/generate-btc-purchases.ts
26332
26542
  import * as path16 from "path";
26333
- import * as fs21 from "fs";
26543
+ import * as fs22 from "fs";
26334
26544
  function findFiatCsvPaths(directory, pendingDir, provider) {
26335
26545
  const providerDir = path16.join(directory, pendingDir, provider);
26336
- if (!fs21.existsSync(providerDir))
26546
+ if (!fs22.existsSync(providerDir))
26337
26547
  return [];
26338
26548
  const csvPaths = [];
26339
- const entries = fs21.readdirSync(providerDir, { withFileTypes: true });
26549
+ const entries = fs22.readdirSync(providerDir, { withFileTypes: true });
26340
26550
  for (const entry of entries) {
26341
26551
  if (!entry.isDirectory())
26342
26552
  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.1",
3
+ "version": "0.10.2-next.1",
4
4
  "description": "An OpenCode accounting agent, specialized in double-entry-bookkepping with hledger",
5
5
  "author": {
6
6
  "name": "ali bengali",