@fuzzle/opencode-accountant 0.10.0-next.1 → 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
  }
@@ -24092,6 +24110,7 @@ Note: This tool requires a contextId from a prior classify/import step.`,
24092
24110
  }
24093
24111
  });
24094
24112
  // src/tools/import-pipeline.ts
24113
+ import * as fs20 from "fs";
24095
24114
  import * as path14 from "path";
24096
24115
 
24097
24116
  // src/utils/accountDeclarations.ts
@@ -24951,24 +24970,6 @@ function adjustLotsForSplit(inventory, symbol2, ratio, logger) {
24951
24970
  }
24952
24971
  logger?.info(`Adjusted ${lots.length} lots for ${symbol2} split (ratio: ${ratio})`);
24953
24972
  }
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
24973
  function removeLots(inventory, symbol2, logger) {
24973
24974
  const lots = inventory[symbol2];
24974
24975
  if (!lots || lots.length === 0) {
@@ -25088,27 +25089,6 @@ function generateDividendEntry(dividend, logger) {
25088
25089
  `;
25089
25090
  return entry;
25090
25091
  }
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
25092
  function generateSplitEntry(action, oldQuantity, newQuantity, logger) {
25113
25093
  const date5 = formatDate(action.date);
25114
25094
  const ratio = action.ratio || newQuantity / oldQuantity;
@@ -25154,6 +25134,53 @@ function generateWorthlessEntry(action, removedLots, logger) {
25154
25134
  `;
25155
25135
  return entry;
25156
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
+ }
25157
25184
  function formatJournalFile(entries, year, currency) {
25158
25185
  const header = `; Swissquote ${currency.toUpperCase()} investment transactions for ${year}
25159
25186
  ; Generated by opencode-accountant
@@ -25172,8 +25199,15 @@ var SIMPLE_TRANSACTION_TYPES = new Set([
25172
25199
  "Forex debit",
25173
25200
  "Interest on debits",
25174
25201
  "Interest on credits",
25202
+ "Interest on deposits",
25175
25203
  "Exchange fees",
25176
- "Exchange fees rectif."
25204
+ "Exchange fees rectif.",
25205
+ "Payment",
25206
+ "Debit",
25207
+ "Fees Tax Statement",
25208
+ "Dividend (Reversed)",
25209
+ "Reversal (Dividend)",
25210
+ "Redemption"
25177
25211
  ]);
25178
25212
  var TRADE_TYPES = new Set(["Buy", "Sell"]);
25179
25213
  var DIVIDEND_TYPES = new Set(["Dividend"]);
@@ -25181,7 +25215,10 @@ var CORPORATE_ACTION_TYPES = new Set([
25181
25215
  "Merger",
25182
25216
  "Reverse Split",
25183
25217
  "Worthless Liquidation",
25184
- "Internal exchange of securities"
25218
+ "Internal exchange of securities",
25219
+ "Exchange of securities",
25220
+ "Corporate Action",
25221
+ "Rights Distribution"
25185
25222
  ]);
25186
25223
  var SKIP_TYPES = new Set([
25187
25224
  "Internal exchange",
@@ -25290,45 +25327,41 @@ function toCorporateActionEntry(txn) {
25290
25327
  symbol: txn.symbol,
25291
25328
  name: txn.name,
25292
25329
  isin: txn.isin,
25293
- quantity: Math.abs(parseNumber(txn.quantity))
25330
+ quantity: parseNumber(txn.quantity)
25294
25331
  };
25295
25332
  }
25296
- 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) {
25297
25340
  const entries = [];
25298
- const pendingMergers = new Map;
25299
25341
  actions.sort((a, b) => a.date.localeCompare(b.date));
25342
+ const mergerActions = [];
25343
+ const otherActions = [];
25300
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) {
25301
25357
  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
25358
  case "Reverse Split": {
25359
+ const qty = action.quantity;
25360
+ if (qty < 0) {
25361
+ continue;
25362
+ }
25330
25363
  const oldQty = getHeldQuantity(inventory, action.symbol);
25331
- const newQty = action.quantity;
25364
+ const newQty = Math.abs(qty);
25332
25365
  if (oldQty > 0) {
25333
25366
  const ratio = newQty / oldQty;
25334
25367
  adjustLotsForSplit(inventory, action.symbol, ratio, logger);
@@ -25349,14 +25382,170 @@ function processCorporateActions(actions, inventory, logger) {
25349
25382
  }
25350
25383
  break;
25351
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);
25352
25414
  }
25353
25415
  }
25354
- for (const [key, pending] of pendingMergers.entries()) {
25355
- 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)}`);
25356
25507
  }
25508
+ const entry = generateMultiWayMergerEntry(group, undefined, logger);
25509
+ entries.push(entry);
25357
25510
  return entries;
25358
25511
  }
25359
- 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) {
25360
25549
  logger?.startSection(`Swissquote Preprocessing: ${path13.basename(csvPath)}`);
25361
25550
  const stats = {
25362
25551
  totalRows: 0,
@@ -25392,14 +25581,22 @@ async function preprocessSwissquote(csvPath, projectDir, currency, year, lotInve
25392
25581
  };
25393
25582
  }
25394
25583
  const transactions = [];
25584
+ const hasSymbolMap = Object.keys(symbolMap).length > 0;
25395
25585
  for (let i2 = 1;i2 < lines.length; i2++) {
25396
25586
  const txn = parseTransaction(lines[i2]);
25397
25587
  if (txn) {
25588
+ if (hasSymbolMap && txn.symbol) {
25589
+ txn.symbol = symbolMap[txn.symbol] ?? txn.symbol;
25590
+ }
25398
25591
  transactions.push(txn);
25399
25592
  }
25400
25593
  }
25401
25594
  stats.totalRows = transactions.length;
25402
- 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
+ }
25403
25600
  logger?.logStep("load-inventory", "start", "Loading lot inventory");
25404
25601
  const inventory = loadLotInventory(projectDir, lotInventoryPath, logger);
25405
25602
  logger?.logStep("load-inventory", "success", "Lot inventory loaded");
@@ -25424,8 +25621,17 @@ async function preprocessSwissquote(csvPath, projectDir, currency, year, lotInve
25424
25621
  stats.dividends++;
25425
25622
  break;
25426
25623
  case "corporate":
25427
- corporateActions.push(toCorporateActionEntry(txn));
25428
- 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
+ }
25429
25635
  break;
25430
25636
  case "skip":
25431
25637
  stats.skipped++;
@@ -25481,7 +25687,7 @@ async function preprocessSwissquote(csvPath, projectDir, currency, year, lotInve
25481
25687
  }
25482
25688
  if (corporateActions.length > 0) {
25483
25689
  logger?.startSection("Corporate Actions Processing", 2);
25484
- const corpEntries = processCorporateActions(corporateActions, inventory, logger);
25690
+ const corpEntries = processCorporateActions(corporateActions, inventory, lotInventoryPath, projectDir, logger);
25485
25691
  journalEntries.push(...corpEntries);
25486
25692
  logger?.logStep("corporate", "success", `Processed ${corporateActions.length} corporate actions`);
25487
25693
  logger?.endSection();
@@ -25737,14 +25943,10 @@ async function executeSwissquotePreprocessStep(context, contextIds, logger) {
25737
25943
  const importCtx = loadContext(context.directory, contextId);
25738
25944
  if (importCtx.provider === "swissquote") {
25739
25945
  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}/);
25946
+ const toDateMatch = importCtx.filename.match(/to-\d{4}(\d{4})\./);
25741
25947
  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
- }
25948
+ if (toDateMatch) {
25949
+ year = parseInt(toDateMatch[1], 10);
25748
25950
  }
25749
25951
  swissquoteContexts.push({
25750
25952
  contextId,
@@ -25762,6 +25964,23 @@ async function executeSwissquotePreprocessStep(context, contextIds, logger) {
25762
25964
  const config2 = context.configLoader(context.directory);
25763
25965
  const swissquoteProvider = config2.providers?.["swissquote"];
25764
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
+ }
25765
25984
  try {
25766
25985
  let totalStats = {
25767
25986
  totalRows: 0,
@@ -25774,7 +25993,7 @@ async function executeSwissquotePreprocessStep(context, contextIds, logger) {
25774
25993
  let lastJournalFile = null;
25775
25994
  for (const sqCtx of swissquoteContexts) {
25776
25995
  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);
25996
+ const result = await preprocessSwissquote(sqCtx.csvPath, context.directory, sqCtx.currency, sqCtx.year, lotInventoryPath, symbolMap, logger);
25778
25997
  totalStats.totalRows += result.stats.totalRows;
25779
25998
  totalStats.simpleTransactions += result.stats.simpleTransactions;
25780
25999
  totalStats.trades += result.stats.trades;
@@ -25792,7 +26011,7 @@ async function executeSwissquotePreprocessStep(context, contextIds, logger) {
25792
26011
  }
25793
26012
  logger?.logStep("Swissquote Preprocess", "success", `Processed: ${result.stats.trades} trades, ${result.stats.dividends} dividends, ${result.stats.corporateActions} corporate actions`);
25794
26013
  }
25795
- 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`;
25796
26015
  context.result.steps.swissquotePreprocess = buildStepResult(true, message, {
25797
26016
  ...totalStats,
25798
26017
  journalFile: lastJournalFile
@@ -26203,7 +26422,7 @@ This tool orchestrates the full import workflow:
26203
26422
  }
26204
26423
  });
26205
26424
  // src/tools/init-directories.ts
26206
- import * as fs20 from "fs";
26425
+ import * as fs21 from "fs";
26207
26426
  import * as path15 from "path";
26208
26427
  async function initDirectories(directory) {
26209
26428
  try {
@@ -26211,8 +26430,8 @@ async function initDirectories(directory) {
26211
26430
  const directoriesCreated = [];
26212
26431
  const gitkeepFiles = [];
26213
26432
  const importBase = path15.join(directory, "import");
26214
- if (!fs20.existsSync(importBase)) {
26215
- fs20.mkdirSync(importBase, { recursive: true });
26433
+ if (!fs21.existsSync(importBase)) {
26434
+ fs21.mkdirSync(importBase, { recursive: true });
26216
26435
  directoriesCreated.push("import");
26217
26436
  }
26218
26437
  const pathsToCreate = [
@@ -26223,19 +26442,19 @@ async function initDirectories(directory) {
26223
26442
  ];
26224
26443
  for (const { path: dirPath } of pathsToCreate) {
26225
26444
  const fullPath = path15.join(directory, dirPath);
26226
- if (!fs20.existsSync(fullPath)) {
26227
- fs20.mkdirSync(fullPath, { recursive: true });
26445
+ if (!fs21.existsSync(fullPath)) {
26446
+ fs21.mkdirSync(fullPath, { recursive: true });
26228
26447
  directoriesCreated.push(dirPath);
26229
26448
  }
26230
26449
  const gitkeepPath = path15.join(fullPath, ".gitkeep");
26231
- if (!fs20.existsSync(gitkeepPath)) {
26232
- fs20.writeFileSync(gitkeepPath, "");
26450
+ if (!fs21.existsSync(gitkeepPath)) {
26451
+ fs21.writeFileSync(gitkeepPath, "");
26233
26452
  gitkeepFiles.push(path15.join(dirPath, ".gitkeep"));
26234
26453
  }
26235
26454
  }
26236
26455
  const gitignorePath = path15.join(importBase, ".gitignore");
26237
26456
  let gitignoreCreated = false;
26238
- if (!fs20.existsSync(gitignorePath)) {
26457
+ if (!fs21.existsSync(gitignorePath)) {
26239
26458
  const gitignoreContent = `# Ignore CSV/PDF files in temporary directories
26240
26459
  /incoming/*.csv
26241
26460
  /incoming/*.pdf
@@ -26253,7 +26472,7 @@ async function initDirectories(directory) {
26253
26472
  .DS_Store
26254
26473
  Thumbs.db
26255
26474
  `;
26256
- fs20.writeFileSync(gitignorePath, gitignoreContent);
26475
+ fs21.writeFileSync(gitignorePath, gitignoreContent);
26257
26476
  gitignoreCreated = true;
26258
26477
  }
26259
26478
  const parts = [];
@@ -26330,13 +26549,13 @@ You can now drop CSV files into import/incoming/ and run import-pipeline.`);
26330
26549
  });
26331
26550
  // src/tools/generate-btc-purchases.ts
26332
26551
  import * as path16 from "path";
26333
- import * as fs21 from "fs";
26552
+ import * as fs22 from "fs";
26334
26553
  function findFiatCsvPaths(directory, pendingDir, provider) {
26335
26554
  const providerDir = path16.join(directory, pendingDir, provider);
26336
- if (!fs21.existsSync(providerDir))
26555
+ if (!fs22.existsSync(providerDir))
26337
26556
  return [];
26338
26557
  const csvPaths = [];
26339
- const entries = fs21.readdirSync(providerDir, { withFileTypes: true });
26558
+ const entries = fs22.readdirSync(providerDir, { withFileTypes: true });
26340
26559
  for (const entry of entries) {
26341
26560
  if (!entry.isDirectory())
26342
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-next.1",
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",