@fuzzle/opencode-accountant 0.9.1 → 0.10.0-next.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -4249,7 +4249,7 @@ __export(exports_accountSuggester, {
4249
4249
  extractRulePatternsFromFile: () => extractRulePatternsFromFile,
4250
4250
  clearSuggestionCache: () => clearSuggestionCache
4251
4251
  });
4252
- import * as fs17 from "fs";
4252
+ import * as fs19 from "fs";
4253
4253
  import * as crypto from "crypto";
4254
4254
  function clearSuggestionCache() {
4255
4255
  Object.keys(suggestionCache).forEach((key) => delete suggestionCache[key]);
@@ -4259,10 +4259,10 @@ function hashTransaction(posting) {
4259
4259
  return crypto.createHash("md5").update(data).digest("hex");
4260
4260
  }
4261
4261
  function loadExistingAccounts(yearJournalPath) {
4262
- if (!fs17.existsSync(yearJournalPath)) {
4262
+ if (!fs19.existsSync(yearJournalPath)) {
4263
4263
  return [];
4264
4264
  }
4265
- const content = fs17.readFileSync(yearJournalPath, "utf-8");
4265
+ const content = fs19.readFileSync(yearJournalPath, "utf-8");
4266
4266
  const lines = content.split(`
4267
4267
  `);
4268
4268
  const accounts = [];
@@ -4278,10 +4278,10 @@ function loadExistingAccounts(yearJournalPath) {
4278
4278
  return accounts.sort();
4279
4279
  }
4280
4280
  function extractRulePatternsFromFile(rulesPath) {
4281
- if (!fs17.existsSync(rulesPath)) {
4281
+ if (!fs19.existsSync(rulesPath)) {
4282
4282
  return [];
4283
4283
  }
4284
- const content = fs17.readFileSync(rulesPath, "utf-8");
4284
+ const content = fs19.readFileSync(rulesPath, "utf-8");
4285
4285
  const lines = content.split(`
4286
4286
  `);
4287
4287
  const patterns = [];
@@ -4515,7 +4515,7 @@ var init_accountSuggester = __esm(() => {
4515
4515
 
4516
4516
  // src/index.ts
4517
4517
  init_agentLoader();
4518
- import { dirname as dirname4, join as join13 } from "path";
4518
+ import { dirname as dirname6, join as join15 } from "path";
4519
4519
  import { fileURLToPath as fileURLToPath3 } from "url";
4520
4520
 
4521
4521
  // node_modules/zod/v4/classic/external.js
@@ -17446,7 +17446,9 @@ function detectProvider(filename, content, config2) {
17446
17446
  continue;
17447
17447
  }
17448
17448
  const actualHeader = normalizeHeader(fields);
17449
- if (actualHeader !== rule.header) {
17449
+ const ruleHeaderSep = delimiter !== "," && rule.header.includes(delimiter) ? delimiter : ",";
17450
+ const expectedHeader = normalizeHeader(rule.header.split(ruleHeaderSep));
17451
+ if (actualHeader !== expectedHeader) {
17450
17452
  continue;
17451
17453
  }
17452
17454
  if (!firstRow) {
@@ -24090,7 +24092,7 @@ Note: This tool requires a contextId from a prior classify/import step.`,
24090
24092
  }
24091
24093
  });
24092
24094
  // src/tools/import-pipeline.ts
24093
- import * as path12 from "path";
24095
+ import * as path14 from "path";
24094
24096
 
24095
24097
  // src/utils/accountDeclarations.ts
24096
24098
  import * as fs12 from "fs";
@@ -24789,6 +24791,767 @@ Got:
24789
24791
  return { rowsProcessed, sendRowsEnriched: enrichedRows, alreadyPreprocessed: false };
24790
24792
  }
24791
24793
 
24794
+ // src/utils/swissquoteCsvPreprocessor.ts
24795
+ import * as fs18 from "fs";
24796
+ import * as path13 from "path";
24797
+
24798
+ // src/utils/swissquoteLotTracker.ts
24799
+ import * as fs17 from "fs";
24800
+ import * as path12 from "path";
24801
+ var DEFAULT_LOT_INVENTORY_PATH = "ledger/investments/lot-inventory/{symbol}-lot.json";
24802
+ function getLotInventoryDir(directory, lotInventoryPath) {
24803
+ const pathWithoutPlaceholder = lotInventoryPath.replace(/{symbol}[^/]*$/, "");
24804
+ return path12.join(directory, pathWithoutPlaceholder);
24805
+ }
24806
+ function getSymbolLotPath(directory, lotInventoryPath, symbol2) {
24807
+ const resolvedPath = lotInventoryPath.replace("{symbol}", symbol2);
24808
+ return path12.join(directory, resolvedPath);
24809
+ }
24810
+ function listSymbolFiles(directory, lotInventoryPath) {
24811
+ const lotDir = getLotInventoryDir(directory, lotInventoryPath);
24812
+ if (!fs17.existsSync(lotDir)) {
24813
+ return [];
24814
+ }
24815
+ const filenamePattern = path12.basename(lotInventoryPath);
24816
+ const patternParts = filenamePattern.split("{symbol}");
24817
+ if (patternParts.length !== 2) {
24818
+ return [];
24819
+ }
24820
+ const [prefix, suffix] = patternParts;
24821
+ return fs17.readdirSync(lotDir).filter((file2) => file2.startsWith(prefix) && file2.endsWith(suffix)).map((file2) => {
24822
+ const symbolPart = file2.slice(prefix.length, -suffix.length);
24823
+ return symbolPart;
24824
+ });
24825
+ }
24826
+ function loadSymbolLots(directory, lotInventoryPath, symbol2, logger) {
24827
+ const filePath = getSymbolLotPath(directory, lotInventoryPath, symbol2);
24828
+ if (!fs17.existsSync(filePath)) {
24829
+ return [];
24830
+ }
24831
+ try {
24832
+ const content = fs17.readFileSync(filePath, "utf-8");
24833
+ const lots = JSON.parse(content);
24834
+ logger?.debug(`Loaded ${lots.length} lots for ${symbol2}`);
24835
+ return lots;
24836
+ } catch (error45) {
24837
+ const message = error45 instanceof Error ? error45.message : String(error45);
24838
+ logger?.warn(`Failed to load lots for ${symbol2}: ${message}`);
24839
+ return [];
24840
+ }
24841
+ }
24842
+ function saveSymbolLots(directory, lotInventoryPath, symbol2, lots, logger) {
24843
+ const filePath = getSymbolLotPath(directory, lotInventoryPath, symbol2);
24844
+ const lotDir = path12.dirname(filePath);
24845
+ const nonEmptyLots = lots.filter((lot) => lot.quantity > 0);
24846
+ if (nonEmptyLots.length === 0) {
24847
+ if (fs17.existsSync(filePath)) {
24848
+ fs17.unlinkSync(filePath);
24849
+ logger?.debug(`Removed lot file for ${symbol2} (no remaining lots)`);
24850
+ }
24851
+ return;
24852
+ }
24853
+ if (!fs17.existsSync(lotDir)) {
24854
+ fs17.mkdirSync(lotDir, { recursive: true });
24855
+ }
24856
+ const content = JSON.stringify(nonEmptyLots, null, 2);
24857
+ fs17.writeFileSync(filePath, content);
24858
+ logger?.debug(`Saved ${nonEmptyLots.length} lots for ${symbol2}`);
24859
+ }
24860
+ function loadLotInventory(directory, lotInventoryPath = DEFAULT_LOT_INVENTORY_PATH, logger) {
24861
+ const symbols = listSymbolFiles(directory, lotInventoryPath);
24862
+ if (symbols.length === 0) {
24863
+ logger?.info("No lot inventory files found, starting fresh");
24864
+ return {};
24865
+ }
24866
+ const inventory = {};
24867
+ let totalLots = 0;
24868
+ for (const symbol2 of symbols) {
24869
+ const lots = loadSymbolLots(directory, lotInventoryPath, symbol2, logger);
24870
+ if (lots.length > 0) {
24871
+ inventory[symbol2] = lots;
24872
+ totalLots += lots.length;
24873
+ }
24874
+ }
24875
+ logger?.info(`Loaded lot inventory: ${symbols.length} symbols, ${totalLots} lots`);
24876
+ return inventory;
24877
+ }
24878
+ function saveLotInventory(directory, lotInventoryPath = DEFAULT_LOT_INVENTORY_PATH, inventory, logger) {
24879
+ let symbolCount = 0;
24880
+ let totalLots = 0;
24881
+ for (const [symbol2, lots] of Object.entries(inventory)) {
24882
+ saveSymbolLots(directory, lotInventoryPath, symbol2, lots, logger);
24883
+ const nonEmptyLots = lots.filter((lot) => lot.quantity > 0);
24884
+ if (nonEmptyLots.length > 0) {
24885
+ symbolCount++;
24886
+ totalLots += nonEmptyLots.length;
24887
+ }
24888
+ }
24889
+ logger?.info(`Saved lot inventory: ${symbolCount} symbols, ${totalLots} lots`);
24890
+ }
24891
+ function addLot(inventory, trade, logger) {
24892
+ const lot = {
24893
+ date: trade.date,
24894
+ quantity: trade.quantity,
24895
+ costBasis: trade.unitPrice,
24896
+ currency: trade.currency,
24897
+ isin: trade.isin,
24898
+ orderNum: trade.orderNum
24899
+ };
24900
+ if (!inventory[trade.symbol]) {
24901
+ inventory[trade.symbol] = [];
24902
+ }
24903
+ inventory[trade.symbol].push(lot);
24904
+ logger?.debug(`Added lot: ${trade.quantity} ${trade.symbol} @ ${trade.unitPrice} ${trade.currency} (${trade.date})`);
24905
+ }
24906
+ function consumeLotsFIFO(inventory, symbol2, quantity, logger) {
24907
+ const lots = inventory[symbol2];
24908
+ if (!lots || lots.length === 0) {
24909
+ throw new Error(`No lots found for symbol ${symbol2}`);
24910
+ }
24911
+ lots.sort((a, b) => a.date.localeCompare(b.date));
24912
+ let remaining = quantity;
24913
+ const consumed = [];
24914
+ for (const lot of lots) {
24915
+ if (remaining <= 0)
24916
+ break;
24917
+ const take = Math.min(remaining, lot.quantity);
24918
+ const totalCost = take * lot.costBasis;
24919
+ consumed.push({
24920
+ lot: { ...lot },
24921
+ quantity: take,
24922
+ totalCost
24923
+ });
24924
+ lot.quantity -= take;
24925
+ remaining -= take;
24926
+ logger?.debug(`FIFO consumed: ${take} ${symbol2} from lot ${lot.date} @ ${lot.costBasis} ${lot.currency}`);
24927
+ }
24928
+ if (remaining > 0) {
24929
+ const availableQty = lots.reduce((sum, lot) => sum + lot.quantity, 0) + quantity - remaining;
24930
+ throw new Error(`Insufficient lots for ${symbol2}: need ${quantity}, have ${availableQty}. ` + `Remaining ${remaining} shares could not be matched.`);
24931
+ }
24932
+ return consumed;
24933
+ }
24934
+ function calculateCapitalGain(consumed, salePrice, quantity) {
24935
+ const totalCostBasis = consumed.reduce((sum, c) => sum + c.totalCost, 0);
24936
+ const saleProceeds = salePrice * quantity;
24937
+ return saleProceeds - totalCostBasis;
24938
+ }
24939
+ function adjustLotsForSplit(inventory, symbol2, ratio, logger) {
24940
+ const lots = inventory[symbol2];
24941
+ if (!lots || lots.length === 0) {
24942
+ logger?.warn(`No lots found for ${symbol2} during split adjustment`);
24943
+ return;
24944
+ }
24945
+ for (const lot of lots) {
24946
+ const oldQuantity = lot.quantity;
24947
+ const oldCostBasis = lot.costBasis;
24948
+ lot.quantity = oldQuantity * ratio;
24949
+ lot.costBasis = oldCostBasis / ratio;
24950
+ logger?.debug(`Split adjusted ${symbol2}: ${oldQuantity} @ ${oldCostBasis} -> ${lot.quantity} @ ${lot.costBasis}`);
24951
+ }
24952
+ logger?.info(`Adjusted ${lots.length} lots for ${symbol2} split (ratio: ${ratio})`);
24953
+ }
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
+ function removeLots(inventory, symbol2, logger) {
24973
+ const lots = inventory[symbol2];
24974
+ if (!lots || lots.length === 0) {
24975
+ logger?.warn(`No lots found for ${symbol2} during removal`);
24976
+ return [];
24977
+ }
24978
+ const removedLots = [...lots];
24979
+ delete inventory[symbol2];
24980
+ const totalQuantity = removedLots.reduce((sum, lot) => sum + lot.quantity, 0);
24981
+ const totalCost = removedLots.reduce((sum, lot) => sum + lot.quantity * lot.costBasis, 0);
24982
+ logger?.info(`Removed ${removedLots.length} lots for ${symbol2}: ${totalQuantity} shares, cost basis ${totalCost}`);
24983
+ return removedLots;
24984
+ }
24985
+ function getHeldQuantity(inventory, symbol2) {
24986
+ const lots = inventory[symbol2];
24987
+ if (!lots)
24988
+ return 0;
24989
+ return lots.reduce((sum, lot) => sum + lot.quantity, 0);
24990
+ }
24991
+
24992
+ // src/utils/swissquoteJournalGenerator.ts
24993
+ function formatAmount2(amount, currency) {
24994
+ const formatted = Math.abs(amount).toFixed(2);
24995
+ const sign = amount < 0 ? "-" : "";
24996
+ return `${sign}${formatted} ${currency}`;
24997
+ }
24998
+ function formatDate(dateStr) {
24999
+ const match2 = dateStr.match(/^(\d{2})-(\d{2})-(\d{4})/);
25000
+ if (!match2) {
25001
+ throw new Error(`Invalid date format: ${dateStr}`);
25002
+ }
25003
+ const [, day, month, year] = match2;
25004
+ return `${year}-${month}-${day}`;
25005
+ }
25006
+ function escapeDescription(desc) {
25007
+ return desc.replace(/[;|]/g, "-").trim();
25008
+ }
25009
+ function formatQuantity(qty) {
25010
+ return qty.toFixed(6).replace(/\.?0+$/, "");
25011
+ }
25012
+ function generateBuyEntry(trade, logger) {
25013
+ const date5 = formatDate(trade.date);
25014
+ const description = escapeDescription(`Buy ${trade.symbol} - ${trade.name}`);
25015
+ const qty = formatQuantity(trade.quantity);
25016
+ const price = trade.unitPrice.toFixed(2);
25017
+ const totalCost = trade.quantity * trade.unitPrice;
25018
+ const fees = trade.costs;
25019
+ const cashOut = totalCost + fees;
25020
+ logger?.debug(`Generating Buy entry: ${qty} ${trade.symbol} @ ${price} ${trade.currency}`);
25021
+ let entry = `${date5} ${description}
25022
+ `;
25023
+ entry += ` ; swissquote:order:${trade.orderNum} isin:${trade.isin}
25024
+ `;
25025
+ entry += ` assets:investments:stocks:${trade.symbol} ${qty} ${trade.symbol} @ ${price} ${trade.currency}
25026
+ `;
25027
+ if (fees > 0) {
25028
+ entry += ` expenses:fees:trading:swissquote ${formatAmount2(fees, trade.currency)}
25029
+ `;
25030
+ }
25031
+ entry += ` assets:broker:swissquote:${trade.currency.toLowerCase()} ${formatAmount2(-cashOut, trade.currency)}
25032
+ `;
25033
+ return entry;
25034
+ }
25035
+ function generateSellEntry(trade, consumed, logger) {
25036
+ const date5 = formatDate(trade.date);
25037
+ const description = escapeDescription(`Sell ${trade.symbol} - ${trade.name}`);
25038
+ const qty = formatQuantity(trade.quantity);
25039
+ const salePrice = trade.unitPrice;
25040
+ const fees = trade.costs;
25041
+ const saleProceeds = trade.quantity * salePrice;
25042
+ const cashIn = saleProceeds - fees;
25043
+ const gain = calculateCapitalGain(consumed, salePrice, trade.quantity);
25044
+ logger?.debug(`Generating Sell entry: ${qty} ${trade.symbol} @ ${salePrice} ${trade.currency}, gain: ${gain.toFixed(2)}`);
25045
+ let entry = `${date5} ${description}
25046
+ `;
25047
+ entry += ` ; swissquote:order:${trade.orderNum} isin:${trade.isin}
25048
+ `;
25049
+ const lotDetails = consumed.map((c) => `${c.lot.date}: ${formatQuantity(c.quantity)}@${c.lot.costBasis.toFixed(2)}`).join(", ");
25050
+ entry += ` ; FIFO lots: ${lotDetails}
25051
+ `;
25052
+ for (const c of consumed) {
25053
+ const lotQty = formatQuantity(c.quantity);
25054
+ const lotPrice = c.lot.costBasis.toFixed(2);
25055
+ entry += ` assets:investments:stocks:${trade.symbol} -${lotQty} ${trade.symbol} @ ${lotPrice} ${trade.currency}
25056
+ `;
25057
+ }
25058
+ entry += ` assets:broker:swissquote:${trade.currency.toLowerCase()} ${formatAmount2(cashIn, trade.currency)}
25059
+ `;
25060
+ if (fees > 0) {
25061
+ entry += ` expenses:fees:trading:swissquote ${formatAmount2(fees, trade.currency)}
25062
+ `;
25063
+ }
25064
+ if (gain >= 0) {
25065
+ entry += ` income:capital-gains:realized ${formatAmount2(-gain, trade.currency)}
25066
+ `;
25067
+ } else {
25068
+ entry += ` expenses:losses:capital ${formatAmount2(-gain, trade.currency)}
25069
+ `;
25070
+ }
25071
+ return entry;
25072
+ }
25073
+ function generateDividendEntry(dividend, logger) {
25074
+ const date5 = formatDate(dividend.date);
25075
+ const description = escapeDescription(`Dividend ${dividend.symbol} - ${dividend.name}`);
25076
+ logger?.debug(`Generating Dividend entry: ${dividend.symbol}, net: ${dividend.netAmount} ${dividend.currency}`);
25077
+ let entry = `${date5} ${description}
25078
+ `;
25079
+ entry += ` ; swissquote:order:${dividend.orderNum} isin:${dividend.isin}
25080
+ `;
25081
+ entry += ` assets:broker:swissquote:${dividend.currency.toLowerCase()} ${formatAmount2(dividend.netAmount, dividend.currency)}
25082
+ `;
25083
+ if (dividend.withholdingTax > 0) {
25084
+ entry += ` expenses:taxes:withholding ${formatAmount2(dividend.withholdingTax, dividend.currency)}
25085
+ `;
25086
+ }
25087
+ entry += ` income:dividends:${dividend.symbol} ${formatAmount2(-dividend.grossAmount, dividend.currency)}
25088
+ `;
25089
+ return entry;
25090
+ }
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
+ function generateSplitEntry(action, oldQuantity, newQuantity, logger) {
25113
+ const date5 = formatDate(action.date);
25114
+ const ratio = action.ratio || newQuantity / oldQuantity;
25115
+ const splitType = ratio > 1 ? "Split" : "Reverse Split";
25116
+ const description = escapeDescription(`${splitType}: ${action.symbol} (${action.name})`);
25117
+ logger?.debug(`Generating ${splitType} entry: ${oldQuantity} -> ${newQuantity} ${action.symbol}`);
25118
+ let entry = `${date5} ${description}
25119
+ `;
25120
+ entry += ` ; swissquote:order:${action.orderNum} isin:${action.isin}
25121
+ `;
25122
+ entry += ` ; Ratio: ${ratio.toFixed(4)} (${oldQuantity} -> ${newQuantity})
25123
+ `;
25124
+ entry += ` assets:investments:stocks:${action.symbol} -${formatQuantity(oldQuantity)} ${action.symbol}
25125
+ `;
25126
+ entry += ` equity:conversion ${formatQuantity(oldQuantity)} ${action.symbol}
25127
+ `;
25128
+ entry += ` equity:conversion -${formatQuantity(newQuantity)} ${action.symbol}
25129
+ `;
25130
+ entry += ` assets:investments:stocks:${action.symbol} ${formatQuantity(newQuantity)} ${action.symbol}
25131
+ `;
25132
+ return entry;
25133
+ }
25134
+ function generateWorthlessEntry(action, removedLots, logger) {
25135
+ const date5 = formatDate(action.date);
25136
+ const description = escapeDescription(`Worthless Liquidation: ${action.symbol} - ${action.name}`);
25137
+ const totalQuantity = removedLots.reduce((sum, lot) => sum + lot.quantity, 0);
25138
+ const totalCost = removedLots.reduce((sum, lot) => sum + lot.quantity * lot.costBasis, 0);
25139
+ const currency = removedLots[0]?.currency || "USD";
25140
+ logger?.debug(`Generating Worthless entry: ${totalQuantity} ${action.symbol}, loss: ${totalCost} ${currency}`);
25141
+ let entry = `${date5} ${description}
25142
+ `;
25143
+ entry += ` ; swissquote:order:${action.orderNum} isin:${action.isin}
25144
+ `;
25145
+ entry += ` ; Total loss: ${totalCost.toFixed(2)} ${currency}
25146
+ `;
25147
+ for (const lot of removedLots) {
25148
+ const qty = formatQuantity(lot.quantity);
25149
+ const price = lot.costBasis.toFixed(2);
25150
+ entry += ` assets:investments:stocks:${action.symbol} -${qty} ${action.symbol} @ ${price} ${currency}
25151
+ `;
25152
+ }
25153
+ entry += ` expenses:losses:capital ${formatAmount2(totalCost, currency)}
25154
+ `;
25155
+ return entry;
25156
+ }
25157
+ function formatJournalFile(entries, year, currency) {
25158
+ const header = `; Swissquote ${currency.toUpperCase()} investment transactions for ${year}
25159
+ ; Generated by opencode-accountant
25160
+ ; This file is auto-generated - do not edit manually
25161
+
25162
+ `;
25163
+ return header + entries.join(`
25164
+ `);
25165
+ }
25166
+
25167
+ // src/utils/swissquoteCsvPreprocessor.ts
25168
+ var SWISSQUOTE_HEADER = "Date;Order #;Transaction;Symbol;Name;ISIN;Quantity;Unit price;Costs;Accrued Interest;Net Amount;Balance;Currency";
25169
+ var SIMPLE_TRANSACTION_TYPES = new Set([
25170
+ "Custody Fees",
25171
+ "Forex credit",
25172
+ "Forex debit",
25173
+ "Interest on debits",
25174
+ "Interest on credits",
25175
+ "Exchange fees",
25176
+ "Exchange fees rectif."
25177
+ ]);
25178
+ var TRADE_TYPES = new Set(["Buy", "Sell"]);
25179
+ var DIVIDEND_TYPES = new Set(["Dividend"]);
25180
+ var CORPORATE_ACTION_TYPES = new Set([
25181
+ "Merger",
25182
+ "Reverse Split",
25183
+ "Worthless Liquidation",
25184
+ "Internal exchange of securities"
25185
+ ]);
25186
+ var SKIP_TYPES = new Set([
25187
+ "Internal exchange",
25188
+ "Reversal"
25189
+ ]);
25190
+ function parseTransaction(line) {
25191
+ const fields = [];
25192
+ let current = "";
25193
+ let inQuotes = false;
25194
+ for (const char of line) {
25195
+ if (char === '"') {
25196
+ inQuotes = !inQuotes;
25197
+ } else if (char === ";" && !inQuotes) {
25198
+ fields.push(current.trim());
25199
+ current = "";
25200
+ } else {
25201
+ current += char;
25202
+ }
25203
+ }
25204
+ fields.push(current.trim());
25205
+ if (fields.length < 13) {
25206
+ return null;
25207
+ }
25208
+ return {
25209
+ date: fields[0],
25210
+ orderNum: fields[1],
25211
+ transaction: fields[2],
25212
+ symbol: fields[3],
25213
+ name: fields[4],
25214
+ isin: fields[5],
25215
+ quantity: fields[6],
25216
+ unitPrice: fields[7],
25217
+ costs: fields[8],
25218
+ accruedInterest: fields[9],
25219
+ netAmount: fields[10],
25220
+ balance: fields[11],
25221
+ currency: fields[12]
25222
+ };
25223
+ }
25224
+ function parseNumber(value) {
25225
+ if (!value || value === "-" || value === "") {
25226
+ return 0;
25227
+ }
25228
+ const normalized = value.replace(/,/g, ".");
25229
+ const parsed = parseFloat(normalized);
25230
+ return isNaN(parsed) ? 0 : parsed;
25231
+ }
25232
+ function categorizeTransaction(txn) {
25233
+ const type2 = txn.transaction;
25234
+ if (SKIP_TYPES.has(type2)) {
25235
+ return "skip";
25236
+ }
25237
+ if (SIMPLE_TRANSACTION_TYPES.has(type2)) {
25238
+ return "simple";
25239
+ }
25240
+ if (TRADE_TYPES.has(type2)) {
25241
+ return "trade";
25242
+ }
25243
+ if (DIVIDEND_TYPES.has(type2)) {
25244
+ return "dividend";
25245
+ }
25246
+ if (CORPORATE_ACTION_TYPES.has(type2)) {
25247
+ return "corporate";
25248
+ }
25249
+ if (!txn.netAmount || txn.netAmount === "-") {
25250
+ return "skip";
25251
+ }
25252
+ return "simple";
25253
+ }
25254
+ function toTradeEntry(txn) {
25255
+ return {
25256
+ date: txn.date,
25257
+ orderNum: txn.orderNum,
25258
+ type: txn.transaction,
25259
+ symbol: txn.symbol,
25260
+ name: txn.name,
25261
+ isin: txn.isin,
25262
+ quantity: parseNumber(txn.quantity),
25263
+ unitPrice: parseNumber(txn.unitPrice),
25264
+ costs: parseNumber(txn.costs),
25265
+ netAmount: parseNumber(txn.netAmount),
25266
+ currency: txn.currency
25267
+ };
25268
+ }
25269
+ function toDividendEntry(txn) {
25270
+ const netAmount = parseNumber(txn.netAmount);
25271
+ const withholdingTax = parseNumber(txn.costs);
25272
+ const grossAmount = netAmount + withholdingTax;
25273
+ return {
25274
+ date: txn.date,
25275
+ orderNum: txn.orderNum,
25276
+ symbol: txn.symbol,
25277
+ name: txn.name,
25278
+ isin: txn.isin,
25279
+ grossAmount,
25280
+ withholdingTax,
25281
+ netAmount,
25282
+ currency: txn.currency
25283
+ };
25284
+ }
25285
+ function toCorporateActionEntry(txn) {
25286
+ return {
25287
+ date: txn.date,
25288
+ orderNum: txn.orderNum,
25289
+ type: txn.transaction,
25290
+ symbol: txn.symbol,
25291
+ name: txn.name,
25292
+ isin: txn.isin,
25293
+ quantity: Math.abs(parseNumber(txn.quantity))
25294
+ };
25295
+ }
25296
+ function processCorporateActions(actions, inventory, logger) {
25297
+ const entries = [];
25298
+ const pendingMergers = new Map;
25299
+ actions.sort((a, b) => a.date.localeCompare(b.date));
25300
+ for (const action of actions) {
25301
+ 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
+ case "Reverse Split": {
25330
+ const oldQty = getHeldQuantity(inventory, action.symbol);
25331
+ const newQty = action.quantity;
25332
+ if (oldQty > 0) {
25333
+ const ratio = newQty / oldQty;
25334
+ adjustLotsForSplit(inventory, action.symbol, ratio, logger);
25335
+ const entry = generateSplitEntry(action, oldQty, newQty, logger);
25336
+ entries.push(entry);
25337
+ } else {
25338
+ logger?.warn(`Reverse split for ${action.symbol} but no lots found`);
25339
+ }
25340
+ break;
25341
+ }
25342
+ case "Worthless Liquidation": {
25343
+ const removedLots = removeLots(inventory, action.symbol, logger);
25344
+ if (removedLots.length > 0) {
25345
+ const entry = generateWorthlessEntry(action, removedLots, logger);
25346
+ entries.push(entry);
25347
+ } else {
25348
+ logger?.warn(`Worthless liquidation for ${action.symbol} but no lots found`);
25349
+ }
25350
+ break;
25351
+ }
25352
+ }
25353
+ }
25354
+ for (const [key, pending] of pendingMergers.entries()) {
25355
+ logger?.warn(`Unmatched merger entry: ${key} - ${pending.outgoing.symbol}`);
25356
+ }
25357
+ return entries;
25358
+ }
25359
+ async function preprocessSwissquote(csvPath, projectDir, currency, year, lotInventoryPath = DEFAULT_LOT_INVENTORY_PATH, logger) {
25360
+ logger?.startSection(`Swissquote Preprocessing: ${path13.basename(csvPath)}`);
25361
+ const stats = {
25362
+ totalRows: 0,
25363
+ simpleTransactions: 0,
25364
+ trades: 0,
25365
+ dividends: 0,
25366
+ corporateActions: 0,
25367
+ skipped: 0
25368
+ };
25369
+ if (!fs18.existsSync(csvPath)) {
25370
+ logger?.error(`CSV file not found: ${csvPath}`);
25371
+ logger?.endSection();
25372
+ return {
25373
+ simpleTransactionsCsv: null,
25374
+ journalFile: null,
25375
+ stats,
25376
+ alreadyPreprocessed: false
25377
+ };
25378
+ }
25379
+ logger?.logStep("parse-csv", "start", `Reading ${csvPath}`);
25380
+ const content = fs18.readFileSync(csvPath, "utf-8");
25381
+ const lines = content.split(`
25382
+ `).filter((line) => line.trim());
25383
+ const header = lines[0];
25384
+ if (!header.startsWith("Date;Order #;Transaction")) {
25385
+ logger?.error(`Invalid Swissquote CSV header: ${header.substring(0, 50)}...`);
25386
+ logger?.endSection();
25387
+ return {
25388
+ simpleTransactionsCsv: null,
25389
+ journalFile: null,
25390
+ stats,
25391
+ alreadyPreprocessed: false
25392
+ };
25393
+ }
25394
+ const transactions = [];
25395
+ for (let i2 = 1;i2 < lines.length; i2++) {
25396
+ const txn = parseTransaction(lines[i2]);
25397
+ if (txn) {
25398
+ transactions.push(txn);
25399
+ }
25400
+ }
25401
+ stats.totalRows = transactions.length;
25402
+ logger?.logStep("parse-csv", "success", `Parsed ${stats.totalRows} transactions`);
25403
+ logger?.logStep("load-inventory", "start", "Loading lot inventory");
25404
+ const inventory = loadLotInventory(projectDir, lotInventoryPath, logger);
25405
+ logger?.logStep("load-inventory", "success", "Lot inventory loaded");
25406
+ logger?.startSection("Transaction Routing", 2);
25407
+ const simpleTransactions = [];
25408
+ const trades = [];
25409
+ const dividends = [];
25410
+ const corporateActions = [];
25411
+ for (const txn of transactions) {
25412
+ const category = categorizeTransaction(txn);
25413
+ switch (category) {
25414
+ case "simple":
25415
+ simpleTransactions.push(txn);
25416
+ stats.simpleTransactions++;
25417
+ break;
25418
+ case "trade":
25419
+ trades.push(toTradeEntry(txn));
25420
+ stats.trades++;
25421
+ break;
25422
+ case "dividend":
25423
+ dividends.push(toDividendEntry(txn));
25424
+ stats.dividends++;
25425
+ break;
25426
+ case "corporate":
25427
+ corporateActions.push(toCorporateActionEntry(txn));
25428
+ stats.corporateActions++;
25429
+ break;
25430
+ case "skip":
25431
+ stats.skipped++;
25432
+ break;
25433
+ }
25434
+ }
25435
+ logger?.logStep("route", "success", `Simple: ${stats.simpleTransactions}, Trades: ${stats.trades}, Dividends: ${stats.dividends}, Corporate: ${stats.corporateActions}, Skipped: ${stats.skipped}`);
25436
+ logger?.endSection();
25437
+ const journalEntries = [];
25438
+ if (trades.length > 0) {
25439
+ logger?.startSection("Trade Processing", 2);
25440
+ trades.sort((a, b) => a.date.localeCompare(b.date));
25441
+ for (const trade of trades) {
25442
+ try {
25443
+ if (trade.type === "Buy") {
25444
+ const tradeInfo = {
25445
+ date: formatDate(trade.date),
25446
+ orderNum: trade.orderNum,
25447
+ symbol: trade.symbol,
25448
+ isin: trade.isin,
25449
+ quantity: trade.quantity,
25450
+ unitPrice: trade.unitPrice,
25451
+ currency: trade.currency
25452
+ };
25453
+ addLot(inventory, tradeInfo, logger);
25454
+ const entry = generateBuyEntry(trade, logger);
25455
+ journalEntries.push(entry);
25456
+ logger?.logStep("trade-buy", "success", `Buy ${trade.quantity} ${trade.symbol} @ ${trade.unitPrice} ${trade.currency}`);
25457
+ } else if (trade.type === "Sell") {
25458
+ const consumed = consumeLotsFIFO(inventory, trade.symbol, trade.quantity, logger);
25459
+ const entry = generateSellEntry(trade, consumed, logger);
25460
+ journalEntries.push(entry);
25461
+ const totalCost = consumed.reduce((sum, c) => sum + c.totalCost, 0);
25462
+ const gain = trade.quantity * trade.unitPrice - totalCost;
25463
+ logger?.logStep("trade-sell", "success", `Sell ${trade.quantity} ${trade.symbol} @ ${trade.unitPrice} ${trade.currency}, gain: ${gain.toFixed(2)}`);
25464
+ }
25465
+ } catch (error45) {
25466
+ const message = error45 instanceof Error ? error45.message : String(error45);
25467
+ logger?.logStep("trade", "error", `Failed to process trade: ${message}`);
25468
+ logger?.error(`Trade processing error for ${trade.symbol}`, error45 instanceof Error ? error45 : undefined);
25469
+ }
25470
+ }
25471
+ logger?.endSection();
25472
+ }
25473
+ if (dividends.length > 0) {
25474
+ logger?.startSection("Dividend Processing", 2);
25475
+ for (const dividend of dividends) {
25476
+ const entry = generateDividendEntry(dividend, logger);
25477
+ journalEntries.push(entry);
25478
+ logger?.logStep("dividend", "success", `Dividend ${dividend.symbol}: ${dividend.netAmount} ${dividend.currency} (tax: ${dividend.withholdingTax})`);
25479
+ }
25480
+ logger?.endSection();
25481
+ }
25482
+ if (corporateActions.length > 0) {
25483
+ logger?.startSection("Corporate Actions Processing", 2);
25484
+ const corpEntries = processCorporateActions(corporateActions, inventory, logger);
25485
+ journalEntries.push(...corpEntries);
25486
+ logger?.logStep("corporate", "success", `Processed ${corporateActions.length} corporate actions`);
25487
+ logger?.endSection();
25488
+ }
25489
+ logger?.logStep("save-inventory", "start", "Saving lot inventory");
25490
+ saveLotInventory(projectDir, lotInventoryPath, inventory, logger);
25491
+ logger?.logStep("save-inventory", "success", "Lot inventory saved");
25492
+ let simpleTransactionsCsv = null;
25493
+ let journalFile = null;
25494
+ if (simpleTransactions.length > 0) {
25495
+ const csvDir = path13.dirname(csvPath);
25496
+ const csvBasename = path13.basename(csvPath, ".csv");
25497
+ simpleTransactionsCsv = path13.join(csvDir, `${csvBasename}-simple.csv`);
25498
+ const csvContent = [
25499
+ SWISSQUOTE_HEADER,
25500
+ ...simpleTransactions.map((txn) => [
25501
+ txn.date,
25502
+ txn.orderNum,
25503
+ txn.transaction,
25504
+ txn.symbol,
25505
+ txn.name,
25506
+ txn.isin,
25507
+ txn.quantity,
25508
+ txn.unitPrice,
25509
+ txn.costs,
25510
+ txn.accruedInterest,
25511
+ txn.netAmount,
25512
+ txn.balance,
25513
+ txn.currency
25514
+ ].join(";"))
25515
+ ].join(`
25516
+ `);
25517
+ fs18.writeFileSync(simpleTransactionsCsv, csvContent);
25518
+ logger?.logStep("write-csv", "success", `Wrote ${simpleTransactions.length} simple transactions to ${path13.basename(simpleTransactionsCsv)}`);
25519
+ }
25520
+ if (journalEntries.length > 0) {
25521
+ const investmentsDir = path13.join(projectDir, "ledger", "investments");
25522
+ if (!fs18.existsSync(investmentsDir)) {
25523
+ fs18.mkdirSync(investmentsDir, { recursive: true });
25524
+ }
25525
+ journalFile = path13.join(investmentsDir, `${year}-${currency}.journal`);
25526
+ const journalContent = formatJournalFile(journalEntries, year, currency);
25527
+ if (fs18.existsSync(journalFile)) {
25528
+ fs18.appendFileSync(journalFile, `
25529
+ ` + journalEntries.join(`
25530
+ `));
25531
+ logger?.logStep("write-journal", "success", `Appended ${journalEntries.length} entries to ${path13.basename(journalFile)}`);
25532
+ } else {
25533
+ fs18.writeFileSync(journalFile, journalContent);
25534
+ logger?.logStep("write-journal", "success", `Created ${path13.basename(journalFile)} with ${journalEntries.length} entries`);
25535
+ }
25536
+ }
25537
+ logger?.logResult({
25538
+ totalRows: stats.totalRows,
25539
+ simpleTransactions: stats.simpleTransactions,
25540
+ trades: stats.trades,
25541
+ dividends: stats.dividends,
25542
+ corporateActions: stats.corporateActions,
25543
+ skipped: stats.skipped,
25544
+ journalEntries: journalEntries.length
25545
+ });
25546
+ logger?.endSection();
25547
+ return {
25548
+ simpleTransactionsCsv,
25549
+ journalFile,
25550
+ stats,
25551
+ alreadyPreprocessed: false
25552
+ };
25553
+ }
25554
+
24792
25555
  // src/tools/import-pipeline.ts
24793
25556
  class NoTransactionsError extends Error {
24794
25557
  constructor() {
@@ -24861,7 +25624,7 @@ function executePreprocessFiatStep(context, contextIds, logger) {
24861
25624
  for (const contextId of contextIds) {
24862
25625
  const importCtx = loadContext(context.directory, contextId);
24863
25626
  if (importCtx.provider === "revolut" && importCtx.currency !== "btc") {
24864
- fiatCsvPaths.push(path12.join(context.directory, importCtx.filePath));
25627
+ fiatCsvPaths.push(path14.join(context.directory, importCtx.filePath));
24865
25628
  }
24866
25629
  }
24867
25630
  if (fiatCsvPaths.length === 0) {
@@ -24905,7 +25668,7 @@ function executePreprocessBtcStep(context, contextIds, logger) {
24905
25668
  for (const contextId of contextIds) {
24906
25669
  const importCtx = loadContext(context.directory, contextId);
24907
25670
  if (importCtx.provider === "revolut" && importCtx.currency === "btc") {
24908
- btcCsvPath = path12.join(context.directory, importCtx.filePath);
25671
+ btcCsvPath = path14.join(context.directory, importCtx.filePath);
24909
25672
  break;
24910
25673
  }
24911
25674
  }
@@ -24939,7 +25702,7 @@ async function executeBtcPurchaseStep(context, contextIds, logger) {
24939
25702
  const importCtx = loadContext(context.directory, contextId);
24940
25703
  if (importCtx.provider !== "revolut")
24941
25704
  continue;
24942
- const csvPath = path12.join(context.directory, importCtx.filePath);
25705
+ const csvPath = path14.join(context.directory, importCtx.filePath);
24943
25706
  if (importCtx.currency === "btc") {
24944
25707
  btcCsvPath = csvPath;
24945
25708
  } else {
@@ -24952,7 +25715,7 @@ async function executeBtcPurchaseStep(context, contextIds, logger) {
24952
25715
  }
24953
25716
  logger?.startSection("Step 1b: Generate BTC Purchase Entries");
24954
25717
  logger?.logStep("BTC Purchases", "start");
24955
- const btcFilename = path12.basename(btcCsvPath);
25718
+ const btcFilename = path14.basename(btcCsvPath);
24956
25719
  const yearMatch = btcFilename.match(/(\d{4})-\d{2}-\d{2}/);
24957
25720
  const year = yearMatch ? parseInt(yearMatch[1], 10) : new Date().getFullYear();
24958
25721
  const yearJournalPath = ensureYearJournalExists(context.directory, year);
@@ -24968,13 +25731,91 @@ async function executeBtcPurchaseStep(context, contextIds, logger) {
24968
25731
  unmatchedBtc: result.unmatchedBtc.length
24969
25732
  });
24970
25733
  }
25734
+ async function executeSwissquotePreprocessStep(context, contextIds, logger) {
25735
+ const swissquoteContexts = [];
25736
+ for (const contextId of contextIds) {
25737
+ const importCtx = loadContext(context.directory, contextId);
25738
+ if (importCtx.provider === "swissquote") {
25739
+ 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}/);
25741
+ 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
+ }
25748
+ }
25749
+ swissquoteContexts.push({
25750
+ contextId,
25751
+ csvPath,
25752
+ currency: importCtx.currency,
25753
+ year
25754
+ });
25755
+ }
25756
+ }
25757
+ if (swissquoteContexts.length === 0) {
25758
+ logger?.info("No Swissquote CSV found, skipping preprocessing");
25759
+ return;
25760
+ }
25761
+ logger?.startSection("Step 1d: Preprocess Swissquote CSV");
25762
+ const config2 = context.configLoader(context.directory);
25763
+ const swissquoteProvider = config2.providers?.["swissquote"];
25764
+ const lotInventoryPath = swissquoteProvider?.lotInventoryPath ?? DEFAULT_LOT_INVENTORY_PATH;
25765
+ try {
25766
+ let totalStats = {
25767
+ totalRows: 0,
25768
+ simpleTransactions: 0,
25769
+ trades: 0,
25770
+ dividends: 0,
25771
+ corporateActions: 0,
25772
+ skipped: 0
25773
+ };
25774
+ let lastJournalFile = null;
25775
+ for (const sqCtx of swissquoteContexts) {
25776
+ 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);
25778
+ totalStats.totalRows += result.stats.totalRows;
25779
+ totalStats.simpleTransactions += result.stats.simpleTransactions;
25780
+ totalStats.trades += result.stats.trades;
25781
+ totalStats.dividends += result.stats.dividends;
25782
+ totalStats.corporateActions += result.stats.corporateActions;
25783
+ totalStats.skipped += result.stats.skipped;
25784
+ if (result.journalFile) {
25785
+ lastJournalFile = result.journalFile;
25786
+ }
25787
+ if (result.simpleTransactionsCsv) {
25788
+ updateContext(context.directory, sqCtx.contextId, {
25789
+ filePath: path14.relative(context.directory, result.simpleTransactionsCsv)
25790
+ });
25791
+ logger?.info(`Updated context ${sqCtx.contextId} to use filtered CSV: ${path14.basename(result.simpleTransactionsCsv)}`);
25792
+ }
25793
+ logger?.logStep("Swissquote Preprocess", "success", `Processed: ${result.stats.trades} trades, ${result.stats.dividends} dividends, ${result.stats.corporateActions} corporate actions`);
25794
+ }
25795
+ const message = `Preprocessed ${totalStats.totalRows} rows: ` + `${totalStats.trades} trades, ${totalStats.dividends} dividends, ` + `${totalStats.corporateActions} corporate actions, ${totalStats.simpleTransactions} simple`;
25796
+ context.result.steps.swissquotePreprocess = buildStepResult(true, message, {
25797
+ ...totalStats,
25798
+ journalFile: lastJournalFile
25799
+ });
25800
+ } catch (error45) {
25801
+ const errorMessage = `Swissquote preprocessing failed: ${error45 instanceof Error ? error45.message : String(error45)}`;
25802
+ logger?.error(errorMessage);
25803
+ logger?.logStep("Swissquote Preprocess", "error", errorMessage);
25804
+ context.result.steps.swissquotePreprocess = buildStepResult(false, errorMessage);
25805
+ context.result.error = errorMessage;
25806
+ context.result.hint = "Check the Swissquote CSV format and lot inventory state";
25807
+ throw new Error(errorMessage);
25808
+ } finally {
25809
+ logger?.endSection();
25810
+ }
25811
+ }
24971
25812
  async function executeAccountDeclarationsStep(context, contextId, logger) {
24972
25813
  logger?.startSection("Step 2: Check Account Declarations");
24973
25814
  logger?.logStep("Check Accounts", "start");
24974
25815
  const config2 = context.configLoader(context.directory);
24975
- const rulesDir = path12.join(context.directory, config2.paths.rules);
25816
+ const rulesDir = path14.join(context.directory, config2.paths.rules);
24976
25817
  const importCtx = loadContext(context.directory, contextId);
24977
- const csvPath = path12.join(context.directory, importCtx.filePath);
25818
+ const csvPath = path14.join(context.directory, importCtx.filePath);
24978
25819
  const csvFiles = [csvPath];
24979
25820
  const rulesMapping = loadRulesMapping(rulesDir);
24980
25821
  const matchedRulesFiles = new Set;
@@ -24997,7 +25838,7 @@ async function executeAccountDeclarationsStep(context, contextId, logger) {
24997
25838
  context.result.steps.accountDeclarations = buildStepResult(true, "No accounts found in rules files", {
24998
25839
  accountsAdded: [],
24999
25840
  journalUpdated: "",
25000
- rulesScanned: Array.from(matchedRulesFiles).map((f) => path12.relative(context.directory, f))
25841
+ rulesScanned: Array.from(matchedRulesFiles).map((f) => path14.relative(context.directory, f))
25001
25842
  });
25002
25843
  return;
25003
25844
  }
@@ -25021,7 +25862,7 @@ async function executeAccountDeclarationsStep(context, contextId, logger) {
25021
25862
  context.result.steps.accountDeclarations = buildStepResult(true, "No transactions found, skipping account declarations", {
25022
25863
  accountsAdded: [],
25023
25864
  journalUpdated: "",
25024
- rulesScanned: Array.from(matchedRulesFiles).map((f) => path12.relative(context.directory, f))
25865
+ rulesScanned: Array.from(matchedRulesFiles).map((f) => path14.relative(context.directory, f))
25025
25866
  });
25026
25867
  return;
25027
25868
  }
@@ -25032,12 +25873,12 @@ async function executeAccountDeclarationsStep(context, contextId, logger) {
25032
25873
  context.result.steps.accountDeclarations = buildStepResult(false, `Failed to create year journal: ${error45 instanceof Error ? error45.message : String(error45)}`, {
25033
25874
  accountsAdded: [],
25034
25875
  journalUpdated: "",
25035
- rulesScanned: Array.from(matchedRulesFiles).map((f) => path12.relative(context.directory, f))
25876
+ rulesScanned: Array.from(matchedRulesFiles).map((f) => path14.relative(context.directory, f))
25036
25877
  });
25037
25878
  return;
25038
25879
  }
25039
25880
  const result = ensureAccountDeclarations(yearJournalPath, allAccounts);
25040
- const message = result.added.length > 0 ? `Added ${result.added.length} account declaration(s) to ${path12.relative(context.directory, yearJournalPath)}` : "All required accounts already declared";
25881
+ const message = result.added.length > 0 ? `Added ${result.added.length} account declaration(s) to ${path14.relative(context.directory, yearJournalPath)}` : "All required accounts already declared";
25041
25882
  logger?.logStep("Check Accounts", "success", message);
25042
25883
  if (result.added.length > 0) {
25043
25884
  for (const account of result.added) {
@@ -25046,17 +25887,17 @@ async function executeAccountDeclarationsStep(context, contextId, logger) {
25046
25887
  }
25047
25888
  context.result.steps.accountDeclarations = buildStepResult(true, message, {
25048
25889
  accountsAdded: result.added,
25049
- journalUpdated: path12.relative(context.directory, yearJournalPath),
25050
- rulesScanned: Array.from(matchedRulesFiles).map((f) => path12.relative(context.directory, f))
25890
+ journalUpdated: path14.relative(context.directory, yearJournalPath),
25891
+ rulesScanned: Array.from(matchedRulesFiles).map((f) => path14.relative(context.directory, f))
25051
25892
  });
25052
25893
  logger?.endSection();
25053
25894
  }
25054
25895
  async function buildSuggestionContext(context, contextId, logger) {
25055
25896
  const { loadExistingAccounts: loadExistingAccounts2, extractRulePatternsFromFile: extractRulePatternsFromFile2 } = await Promise.resolve().then(() => (init_accountSuggester(), exports_accountSuggester));
25056
25897
  const config2 = context.configLoader(context.directory);
25057
- const rulesDir = path12.join(context.directory, config2.paths.rules);
25898
+ const rulesDir = path14.join(context.directory, config2.paths.rules);
25058
25899
  const importCtx = loadContext(context.directory, contextId);
25059
- const csvPath = path12.join(context.directory, importCtx.filePath);
25900
+ const csvPath = path14.join(context.directory, importCtx.filePath);
25060
25901
  const rulesMapping = loadRulesMapping(rulesDir);
25061
25902
  const rulesFile = findRulesForCsv(csvPath, rulesMapping);
25062
25903
  if (!rulesFile) {
@@ -25263,6 +26104,7 @@ async function importPipeline(directory, agent, options, configLoader = loadImpo
25263
26104
  executePreprocessFiatStep(context, contextIds, logger);
25264
26105
  executePreprocessBtcStep(context, contextIds, logger);
25265
26106
  await executeBtcPurchaseStep(context, contextIds, logger);
26107
+ await executeSwissquotePreprocessStep(context, contextIds, logger);
25266
26108
  const importConfig = loadImportConfig(context.directory);
25267
26109
  const orderedContextIds = [...contextIds].sort((a, b) => {
25268
26110
  const ctxA = loadContext(context.directory, a);
@@ -25361,16 +26203,16 @@ This tool orchestrates the full import workflow:
25361
26203
  }
25362
26204
  });
25363
26205
  // src/tools/init-directories.ts
25364
- import * as fs18 from "fs";
25365
- import * as path13 from "path";
26206
+ import * as fs20 from "fs";
26207
+ import * as path15 from "path";
25366
26208
  async function initDirectories(directory) {
25367
26209
  try {
25368
26210
  const config2 = loadImportConfig(directory);
25369
26211
  const directoriesCreated = [];
25370
26212
  const gitkeepFiles = [];
25371
- const importBase = path13.join(directory, "import");
25372
- if (!fs18.existsSync(importBase)) {
25373
- fs18.mkdirSync(importBase, { recursive: true });
26213
+ const importBase = path15.join(directory, "import");
26214
+ if (!fs20.existsSync(importBase)) {
26215
+ fs20.mkdirSync(importBase, { recursive: true });
25374
26216
  directoriesCreated.push("import");
25375
26217
  }
25376
26218
  const pathsToCreate = [
@@ -25380,20 +26222,20 @@ async function initDirectories(directory) {
25380
26222
  { key: "unrecognized", path: config2.paths.unrecognized }
25381
26223
  ];
25382
26224
  for (const { path: dirPath } of pathsToCreate) {
25383
- const fullPath = path13.join(directory, dirPath);
25384
- if (!fs18.existsSync(fullPath)) {
25385
- fs18.mkdirSync(fullPath, { recursive: true });
26225
+ const fullPath = path15.join(directory, dirPath);
26226
+ if (!fs20.existsSync(fullPath)) {
26227
+ fs20.mkdirSync(fullPath, { recursive: true });
25386
26228
  directoriesCreated.push(dirPath);
25387
26229
  }
25388
- const gitkeepPath = path13.join(fullPath, ".gitkeep");
25389
- if (!fs18.existsSync(gitkeepPath)) {
25390
- fs18.writeFileSync(gitkeepPath, "");
25391
- gitkeepFiles.push(path13.join(dirPath, ".gitkeep"));
26230
+ const gitkeepPath = path15.join(fullPath, ".gitkeep");
26231
+ if (!fs20.existsSync(gitkeepPath)) {
26232
+ fs20.writeFileSync(gitkeepPath, "");
26233
+ gitkeepFiles.push(path15.join(dirPath, ".gitkeep"));
25392
26234
  }
25393
26235
  }
25394
- const gitignorePath = path13.join(importBase, ".gitignore");
26236
+ const gitignorePath = path15.join(importBase, ".gitignore");
25395
26237
  let gitignoreCreated = false;
25396
- if (!fs18.existsSync(gitignorePath)) {
26238
+ if (!fs20.existsSync(gitignorePath)) {
25397
26239
  const gitignoreContent = `# Ignore CSV/PDF files in temporary directories
25398
26240
  /incoming/*.csv
25399
26241
  /incoming/*.pdf
@@ -25411,7 +26253,7 @@ async function initDirectories(directory) {
25411
26253
  .DS_Store
25412
26254
  Thumbs.db
25413
26255
  `;
25414
- fs18.writeFileSync(gitignorePath, gitignoreContent);
26256
+ fs20.writeFileSync(gitignorePath, gitignoreContent);
25415
26257
  gitignoreCreated = true;
25416
26258
  }
25417
26259
  const parts = [];
@@ -25487,32 +26329,32 @@ You can now drop CSV files into import/incoming/ and run import-pipeline.`);
25487
26329
  }
25488
26330
  });
25489
26331
  // src/tools/generate-btc-purchases.ts
25490
- import * as path14 from "path";
25491
- import * as fs19 from "fs";
26332
+ import * as path16 from "path";
26333
+ import * as fs21 from "fs";
25492
26334
  function findFiatCsvPaths(directory, pendingDir, provider) {
25493
- const providerDir = path14.join(directory, pendingDir, provider);
25494
- if (!fs19.existsSync(providerDir))
26335
+ const providerDir = path16.join(directory, pendingDir, provider);
26336
+ if (!fs21.existsSync(providerDir))
25495
26337
  return [];
25496
26338
  const csvPaths = [];
25497
- const entries = fs19.readdirSync(providerDir, { withFileTypes: true });
26339
+ const entries = fs21.readdirSync(providerDir, { withFileTypes: true });
25498
26340
  for (const entry of entries) {
25499
26341
  if (!entry.isDirectory())
25500
26342
  continue;
25501
26343
  if (entry.name === "btc")
25502
26344
  continue;
25503
- const csvFiles = findCsvFiles(path14.join(providerDir, entry.name), { fullPaths: true });
26345
+ const csvFiles = findCsvFiles(path16.join(providerDir, entry.name), { fullPaths: true });
25504
26346
  csvPaths.push(...csvFiles);
25505
26347
  }
25506
26348
  return csvPaths;
25507
26349
  }
25508
26350
  function findBtcCsvPath(directory, pendingDir, provider) {
25509
- const btcDir = path14.join(directory, pendingDir, provider, "btc");
26351
+ const btcDir = path16.join(directory, pendingDir, provider, "btc");
25510
26352
  const csvFiles = findCsvFiles(btcDir, { fullPaths: true });
25511
26353
  return csvFiles[0];
25512
26354
  }
25513
26355
  function determineYear(csvPaths) {
25514
26356
  for (const csvPath of csvPaths) {
25515
- const match2 = path14.basename(csvPath).match(/(\d{4})-\d{2}-\d{2}/);
26357
+ const match2 = path16.basename(csvPath).match(/(\d{4})-\d{2}-\d{2}/);
25516
26358
  if (match2)
25517
26359
  return parseInt(match2[1], 10);
25518
26360
  }
@@ -25565,7 +26407,7 @@ async function generateBtcPurchases(directory, agent, options = {}, configLoader
25565
26407
  skippedDuplicates: result.skippedDuplicates,
25566
26408
  unmatchedFiat: result.unmatchedFiat.length,
25567
26409
  unmatchedBtc: result.unmatchedBtc.length,
25568
- yearJournal: path14.relative(directory, yearJournalPath)
26410
+ yearJournal: path16.relative(directory, yearJournalPath)
25569
26411
  });
25570
26412
  } catch (err) {
25571
26413
  logger.error("Failed to generate BTC purchases", err);
@@ -25607,8 +26449,8 @@ to produce equity conversion entries for Bitcoin purchases.
25607
26449
  }
25608
26450
  });
25609
26451
  // src/index.ts
25610
- var __dirname2 = dirname4(fileURLToPath3(import.meta.url));
25611
- var AGENT_FILE = join13(__dirname2, "..", "agent", "accountant.md");
26452
+ var __dirname2 = dirname6(fileURLToPath3(import.meta.url));
26453
+ var AGENT_FILE = join15(__dirname2, "..", "agent", "accountant.md");
25612
26454
  var AccountantPlugin = async () => {
25613
26455
  const agent = loadAgent(AGENT_FILE);
25614
26456
  return {
@@ -9,14 +9,15 @@ This tool is **restricted to the accountant agent only**.
9
9
  The pipeline automates these sequential steps:
10
10
 
11
11
  1. **Classify** - Detect provider/currency, create contexts, organize files
12
- 1a. **Preprocess Fiat CSV** _(Revolut only)_ - Add computed `Net_Amount` column (`Amount - Fee`) for hledger rules
13
- 1b. **Preprocess BTC CSV** _(Revolut only)_ - Add computed columns (fees in BTC, total, clean price) for hledger rules
14
- 1c. **Generate BTC Purchases** _(Revolut only)_ - Cross-reference fiat + BTC CSVs for equity conversion entries
12
+ 1a. **Preprocess Fiat CSV** _(Revolut only)_ - Add computed `Net_Amount` column (`Amount - Fee`) for hledger rules
13
+ 1b. **Preprocess BTC CSV** _(Revolut only)_ - Add computed columns (fees in BTC, total, clean price) for hledger rules
14
+ 1c. **Generate BTC Purchases** _(Revolut only)_ - Cross-reference fiat + BTC CSVs for equity conversion entries
15
+ 1d. **Preprocess Swissquote CSV** _(Swissquote only)_ - Generate FIFO capital gains entries for trades, dividends, and corporate actions
15
16
  2. **Account Declarations** - Ensure all accounts exist in year journals
16
17
  3. **Import** - Validate transactions, add to journals, move files to done
17
18
  4. **Reconcile** - Verify balances match expectations
18
19
 
19
- Steps 1a–1c are Revolut-specific and are automatically skipped when no matching Revolut CSVs are present.
20
+ Steps 1a–1c are Revolut-specific and steps 1d is Swissquote-specific. These preprocessing steps are automatically skipped when no matching CSVs are present.
20
21
 
21
22
  **Key behavior**: The pipeline processes files via **import contexts**. Each classified CSV gets a unique context ID, and subsequent steps operate on these contexts **sequentially** with **fail-fast** error handling.
22
23
 
@@ -200,6 +201,27 @@ See [classify-statements](classify-statements.md) for details.
200
201
 
201
202
  **Skipped when**: No fiat+BTC CSV pair is found among the classified files.
202
203
 
204
+ ### Step 1d: Preprocess Swissquote CSV
205
+
206
+ **Purpose**: Generate FIFO capital gains journal entries for Swissquote investment transactions including trades, dividends, and corporate actions
207
+
208
+ **What happens**:
209
+
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
218
+
219
+ **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
+
221
+ **Lot Inventory**: Each symbol's lots are stored in a separate JSON file for version control and easy inspection. The path is configurable via `lotInventoryPath` in `providers.yaml`.
222
+
223
+ **Skipped when**: No Swissquote CSV is present among the classified files.
224
+
203
225
  ### Step 2: Account Declarations
204
226
 
205
227
  **Purpose**: Ensure all accounts referenced in rules files are declared in year journals
@@ -229,7 +251,7 @@ See [classify-statements](classify-statements.md) for details.
229
251
  3. If unknown accounts found, generates account suggestions and **stops pipeline**
230
252
  4. Imports transactions using `hledger import`
231
253
  5. Moves CSV from `pending/` to `done/`
232
- 4. Updates context with:
254
+ 6. Updates context with:
233
255
  - `rulesFile` path
234
256
  - `yearJournal` path
235
257
  - `transactionCount`
@@ -309,6 +331,14 @@ See [reconcile-statement](reconcile-statement.md) for details.
309
331
  │ STEP 1c: Generate BTC Purchase Entries │
310
332
  │ • Cross-reference fiat + BTC CSVs │
311
333
  │ • Generate equity conversion journal entries │
334
+ └──────────────────────────────────────────────────────────────┘
335
+
336
+
337
+ ┌──────────────────────────────────────────────────────────────┐
338
+ │ STEP 1d: Preprocess Swissquote CSV (if swissquote context) │
339
+ │ • FIFO lot tracking for trades │
340
+ │ • Capital gains calculation │
341
+ │ • Investment journal generation │
312
342
  └──────────────────────────────────────────────────────────────┘
313
343
 
314
344
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fuzzle/opencode-accountant",
3
- "version": "0.9.1",
3
+ "version": "0.10.0-next.1",
4
4
  "description": "An OpenCode accounting agent, specialized in double-entry-bookkepping with hledger",
5
5
  "author": {
6
6
  "name": "ali bengali",