@fuzzle/opencode-accountant 0.9.0-next.1 → 0.9.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
@@ -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
@@ -24090,7 +24090,7 @@ Note: This tool requires a contextId from a prior classify/import step.`,
24090
24090
  }
24091
24091
  });
24092
24092
  // src/tools/import-pipeline.ts
24093
- import * as path12 from "path";
24093
+ import * as path14 from "path";
24094
24094
 
24095
24095
  // src/utils/accountDeclarations.ts
24096
24096
  import * as fs12 from "fs";
@@ -24789,6 +24789,767 @@ Got:
24789
24789
  return { rowsProcessed, sendRowsEnriched: enrichedRows, alreadyPreprocessed: false };
24790
24790
  }
24791
24791
 
24792
+ // src/utils/swissquoteCsvPreprocessor.ts
24793
+ import * as fs18 from "fs";
24794
+ import * as path13 from "path";
24795
+
24796
+ // src/utils/swissquoteLotTracker.ts
24797
+ import * as fs17 from "fs";
24798
+ import * as path12 from "path";
24799
+ var DEFAULT_LOT_INVENTORY_PATH = "ledger/investments/lot-inventory/{symbol}-lot.json";
24800
+ function getLotInventoryDir(directory, lotInventoryPath) {
24801
+ const pathWithoutPlaceholder = lotInventoryPath.replace(/{symbol}[^/]*$/, "");
24802
+ return path12.join(directory, pathWithoutPlaceholder);
24803
+ }
24804
+ function getSymbolLotPath(directory, lotInventoryPath, symbol2) {
24805
+ const resolvedPath = lotInventoryPath.replace("{symbol}", symbol2);
24806
+ return path12.join(directory, resolvedPath);
24807
+ }
24808
+ function listSymbolFiles(directory, lotInventoryPath) {
24809
+ const lotDir = getLotInventoryDir(directory, lotInventoryPath);
24810
+ if (!fs17.existsSync(lotDir)) {
24811
+ return [];
24812
+ }
24813
+ const filenamePattern = path12.basename(lotInventoryPath);
24814
+ const patternParts = filenamePattern.split("{symbol}");
24815
+ if (patternParts.length !== 2) {
24816
+ return [];
24817
+ }
24818
+ const [prefix, suffix] = patternParts;
24819
+ return fs17.readdirSync(lotDir).filter((file2) => file2.startsWith(prefix) && file2.endsWith(suffix)).map((file2) => {
24820
+ const symbolPart = file2.slice(prefix.length, -suffix.length);
24821
+ return symbolPart;
24822
+ });
24823
+ }
24824
+ function loadSymbolLots(directory, lotInventoryPath, symbol2, logger) {
24825
+ const filePath = getSymbolLotPath(directory, lotInventoryPath, symbol2);
24826
+ if (!fs17.existsSync(filePath)) {
24827
+ return [];
24828
+ }
24829
+ try {
24830
+ const content = fs17.readFileSync(filePath, "utf-8");
24831
+ const lots = JSON.parse(content);
24832
+ logger?.debug(`Loaded ${lots.length} lots for ${symbol2}`);
24833
+ return lots;
24834
+ } catch (error45) {
24835
+ const message = error45 instanceof Error ? error45.message : String(error45);
24836
+ logger?.warn(`Failed to load lots for ${symbol2}: ${message}`);
24837
+ return [];
24838
+ }
24839
+ }
24840
+ function saveSymbolLots(directory, lotInventoryPath, symbol2, lots, logger) {
24841
+ const filePath = getSymbolLotPath(directory, lotInventoryPath, symbol2);
24842
+ const lotDir = path12.dirname(filePath);
24843
+ const nonEmptyLots = lots.filter((lot) => lot.quantity > 0);
24844
+ if (nonEmptyLots.length === 0) {
24845
+ if (fs17.existsSync(filePath)) {
24846
+ fs17.unlinkSync(filePath);
24847
+ logger?.debug(`Removed lot file for ${symbol2} (no remaining lots)`);
24848
+ }
24849
+ return;
24850
+ }
24851
+ if (!fs17.existsSync(lotDir)) {
24852
+ fs17.mkdirSync(lotDir, { recursive: true });
24853
+ }
24854
+ const content = JSON.stringify(nonEmptyLots, null, 2);
24855
+ fs17.writeFileSync(filePath, content);
24856
+ logger?.debug(`Saved ${nonEmptyLots.length} lots for ${symbol2}`);
24857
+ }
24858
+ function loadLotInventory(directory, lotInventoryPath = DEFAULT_LOT_INVENTORY_PATH, logger) {
24859
+ const symbols = listSymbolFiles(directory, lotInventoryPath);
24860
+ if (symbols.length === 0) {
24861
+ logger?.info("No lot inventory files found, starting fresh");
24862
+ return {};
24863
+ }
24864
+ const inventory = {};
24865
+ let totalLots = 0;
24866
+ for (const symbol2 of symbols) {
24867
+ const lots = loadSymbolLots(directory, lotInventoryPath, symbol2, logger);
24868
+ if (lots.length > 0) {
24869
+ inventory[symbol2] = lots;
24870
+ totalLots += lots.length;
24871
+ }
24872
+ }
24873
+ logger?.info(`Loaded lot inventory: ${symbols.length} symbols, ${totalLots} lots`);
24874
+ return inventory;
24875
+ }
24876
+ function saveLotInventory(directory, lotInventoryPath = DEFAULT_LOT_INVENTORY_PATH, inventory, logger) {
24877
+ let symbolCount = 0;
24878
+ let totalLots = 0;
24879
+ for (const [symbol2, lots] of Object.entries(inventory)) {
24880
+ saveSymbolLots(directory, lotInventoryPath, symbol2, lots, logger);
24881
+ const nonEmptyLots = lots.filter((lot) => lot.quantity > 0);
24882
+ if (nonEmptyLots.length > 0) {
24883
+ symbolCount++;
24884
+ totalLots += nonEmptyLots.length;
24885
+ }
24886
+ }
24887
+ logger?.info(`Saved lot inventory: ${symbolCount} symbols, ${totalLots} lots`);
24888
+ }
24889
+ function addLot(inventory, trade, logger) {
24890
+ const lot = {
24891
+ date: trade.date,
24892
+ quantity: trade.quantity,
24893
+ costBasis: trade.unitPrice,
24894
+ currency: trade.currency,
24895
+ isin: trade.isin,
24896
+ orderNum: trade.orderNum
24897
+ };
24898
+ if (!inventory[trade.symbol]) {
24899
+ inventory[trade.symbol] = [];
24900
+ }
24901
+ inventory[trade.symbol].push(lot);
24902
+ logger?.debug(`Added lot: ${trade.quantity} ${trade.symbol} @ ${trade.unitPrice} ${trade.currency} (${trade.date})`);
24903
+ }
24904
+ function consumeLotsFIFO(inventory, symbol2, quantity, logger) {
24905
+ const lots = inventory[symbol2];
24906
+ if (!lots || lots.length === 0) {
24907
+ throw new Error(`No lots found for symbol ${symbol2}`);
24908
+ }
24909
+ lots.sort((a, b) => a.date.localeCompare(b.date));
24910
+ let remaining = quantity;
24911
+ const consumed = [];
24912
+ for (const lot of lots) {
24913
+ if (remaining <= 0)
24914
+ break;
24915
+ const take = Math.min(remaining, lot.quantity);
24916
+ const totalCost = take * lot.costBasis;
24917
+ consumed.push({
24918
+ lot: { ...lot },
24919
+ quantity: take,
24920
+ totalCost
24921
+ });
24922
+ lot.quantity -= take;
24923
+ remaining -= take;
24924
+ logger?.debug(`FIFO consumed: ${take} ${symbol2} from lot ${lot.date} @ ${lot.costBasis} ${lot.currency}`);
24925
+ }
24926
+ if (remaining > 0) {
24927
+ const availableQty = lots.reduce((sum, lot) => sum + lot.quantity, 0) + quantity - remaining;
24928
+ throw new Error(`Insufficient lots for ${symbol2}: need ${quantity}, have ${availableQty}. ` + `Remaining ${remaining} shares could not be matched.`);
24929
+ }
24930
+ return consumed;
24931
+ }
24932
+ function calculateCapitalGain(consumed, salePrice, quantity) {
24933
+ const totalCostBasis = consumed.reduce((sum, c) => sum + c.totalCost, 0);
24934
+ const saleProceeds = salePrice * quantity;
24935
+ return saleProceeds - totalCostBasis;
24936
+ }
24937
+ function adjustLotsForSplit(inventory, symbol2, ratio, logger) {
24938
+ const lots = inventory[symbol2];
24939
+ if (!lots || lots.length === 0) {
24940
+ logger?.warn(`No lots found for ${symbol2} during split adjustment`);
24941
+ return;
24942
+ }
24943
+ for (const lot of lots) {
24944
+ const oldQuantity = lot.quantity;
24945
+ const oldCostBasis = lot.costBasis;
24946
+ lot.quantity = oldQuantity * ratio;
24947
+ lot.costBasis = oldCostBasis / ratio;
24948
+ logger?.debug(`Split adjusted ${symbol2}: ${oldQuantity} @ ${oldCostBasis} -> ${lot.quantity} @ ${lot.costBasis}`);
24949
+ }
24950
+ logger?.info(`Adjusted ${lots.length} lots for ${symbol2} split (ratio: ${ratio})`);
24951
+ }
24952
+ function adjustLotsForMerger(inventory, oldSymbol, newSymbol, ratio, logger) {
24953
+ const oldLots = inventory[oldSymbol];
24954
+ if (!oldLots || oldLots.length === 0) {
24955
+ logger?.warn(`No lots found for ${oldSymbol} during merger adjustment`);
24956
+ return;
24957
+ }
24958
+ const newLots = oldLots.map((lot) => ({
24959
+ ...lot,
24960
+ quantity: lot.quantity * ratio,
24961
+ costBasis: lot.costBasis / ratio
24962
+ }));
24963
+ if (!inventory[newSymbol]) {
24964
+ inventory[newSymbol] = [];
24965
+ }
24966
+ inventory[newSymbol].push(...newLots);
24967
+ delete inventory[oldSymbol];
24968
+ logger?.info(`Merger: ${oldLots.length} lots moved from ${oldSymbol} to ${newSymbol} (ratio: ${ratio})`);
24969
+ }
24970
+ function removeLots(inventory, symbol2, logger) {
24971
+ const lots = inventory[symbol2];
24972
+ if (!lots || lots.length === 0) {
24973
+ logger?.warn(`No lots found for ${symbol2} during removal`);
24974
+ return [];
24975
+ }
24976
+ const removedLots = [...lots];
24977
+ delete inventory[symbol2];
24978
+ const totalQuantity = removedLots.reduce((sum, lot) => sum + lot.quantity, 0);
24979
+ const totalCost = removedLots.reduce((sum, lot) => sum + lot.quantity * lot.costBasis, 0);
24980
+ logger?.info(`Removed ${removedLots.length} lots for ${symbol2}: ${totalQuantity} shares, cost basis ${totalCost}`);
24981
+ return removedLots;
24982
+ }
24983
+ function getHeldQuantity(inventory, symbol2) {
24984
+ const lots = inventory[symbol2];
24985
+ if (!lots)
24986
+ return 0;
24987
+ return lots.reduce((sum, lot) => sum + lot.quantity, 0);
24988
+ }
24989
+
24990
+ // src/utils/swissquoteJournalGenerator.ts
24991
+ function formatAmount2(amount, currency) {
24992
+ const formatted = Math.abs(amount).toFixed(2);
24993
+ const sign = amount < 0 ? "-" : "";
24994
+ return `${sign}${formatted} ${currency}`;
24995
+ }
24996
+ function formatDate(dateStr) {
24997
+ const match2 = dateStr.match(/^(\d{2})-(\d{2})-(\d{4})/);
24998
+ if (!match2) {
24999
+ throw new Error(`Invalid date format: ${dateStr}`);
25000
+ }
25001
+ const [, day, month, year] = match2;
25002
+ return `${year}-${month}-${day}`;
25003
+ }
25004
+ function escapeDescription(desc) {
25005
+ return desc.replace(/[;|]/g, "-").trim();
25006
+ }
25007
+ function formatQuantity(qty) {
25008
+ return qty.toFixed(6).replace(/\.?0+$/, "");
25009
+ }
25010
+ function generateBuyEntry(trade, logger) {
25011
+ const date5 = formatDate(trade.date);
25012
+ const description = escapeDescription(`Buy ${trade.symbol} - ${trade.name}`);
25013
+ const qty = formatQuantity(trade.quantity);
25014
+ const price = trade.unitPrice.toFixed(2);
25015
+ const totalCost = trade.quantity * trade.unitPrice;
25016
+ const fees = trade.costs;
25017
+ const cashOut = totalCost + fees;
25018
+ logger?.debug(`Generating Buy entry: ${qty} ${trade.symbol} @ ${price} ${trade.currency}`);
25019
+ let entry = `${date5} ${description}
25020
+ `;
25021
+ entry += ` ; swissquote:order:${trade.orderNum} isin:${trade.isin}
25022
+ `;
25023
+ entry += ` assets:investments:stocks:${trade.symbol} ${qty} ${trade.symbol} @ ${price} ${trade.currency}
25024
+ `;
25025
+ if (fees > 0) {
25026
+ entry += ` expenses:fees:trading:swissquote ${formatAmount2(fees, trade.currency)}
25027
+ `;
25028
+ }
25029
+ entry += ` assets:broker:swissquote:${trade.currency.toLowerCase()} ${formatAmount2(-cashOut, trade.currency)}
25030
+ `;
25031
+ return entry;
25032
+ }
25033
+ function generateSellEntry(trade, consumed, logger) {
25034
+ const date5 = formatDate(trade.date);
25035
+ const description = escapeDescription(`Sell ${trade.symbol} - ${trade.name}`);
25036
+ const qty = formatQuantity(trade.quantity);
25037
+ const salePrice = trade.unitPrice;
25038
+ const fees = trade.costs;
25039
+ const saleProceeds = trade.quantity * salePrice;
25040
+ const cashIn = saleProceeds - fees;
25041
+ const gain = calculateCapitalGain(consumed, salePrice, trade.quantity);
25042
+ logger?.debug(`Generating Sell entry: ${qty} ${trade.symbol} @ ${salePrice} ${trade.currency}, gain: ${gain.toFixed(2)}`);
25043
+ let entry = `${date5} ${description}
25044
+ `;
25045
+ entry += ` ; swissquote:order:${trade.orderNum} isin:${trade.isin}
25046
+ `;
25047
+ const lotDetails = consumed.map((c) => `${c.lot.date}: ${formatQuantity(c.quantity)}@${c.lot.costBasis.toFixed(2)}`).join(", ");
25048
+ entry += ` ; FIFO lots: ${lotDetails}
25049
+ `;
25050
+ for (const c of consumed) {
25051
+ const lotQty = formatQuantity(c.quantity);
25052
+ const lotPrice = c.lot.costBasis.toFixed(2);
25053
+ entry += ` assets:investments:stocks:${trade.symbol} -${lotQty} ${trade.symbol} @ ${lotPrice} ${trade.currency}
25054
+ `;
25055
+ }
25056
+ entry += ` assets:broker:swissquote:${trade.currency.toLowerCase()} ${formatAmount2(cashIn, trade.currency)}
25057
+ `;
25058
+ if (fees > 0) {
25059
+ entry += ` expenses:fees:trading:swissquote ${formatAmount2(fees, trade.currency)}
25060
+ `;
25061
+ }
25062
+ if (gain >= 0) {
25063
+ entry += ` income:capital-gains:realized ${formatAmount2(-gain, trade.currency)}
25064
+ `;
25065
+ } else {
25066
+ entry += ` expenses:losses:capital ${formatAmount2(-gain, trade.currency)}
25067
+ `;
25068
+ }
25069
+ return entry;
25070
+ }
25071
+ function generateDividendEntry(dividend, logger) {
25072
+ const date5 = formatDate(dividend.date);
25073
+ const description = escapeDescription(`Dividend ${dividend.symbol} - ${dividend.name}`);
25074
+ logger?.debug(`Generating Dividend entry: ${dividend.symbol}, net: ${dividend.netAmount} ${dividend.currency}`);
25075
+ let entry = `${date5} ${description}
25076
+ `;
25077
+ entry += ` ; swissquote:order:${dividend.orderNum} isin:${dividend.isin}
25078
+ `;
25079
+ entry += ` assets:broker:swissquote:${dividend.currency.toLowerCase()} ${formatAmount2(dividend.netAmount, dividend.currency)}
25080
+ `;
25081
+ if (dividend.withholdingTax > 0) {
25082
+ entry += ` expenses:taxes:withholding ${formatAmount2(dividend.withholdingTax, dividend.currency)}
25083
+ `;
25084
+ }
25085
+ entry += ` income:dividends:${dividend.symbol} ${formatAmount2(-dividend.grossAmount, dividend.currency)}
25086
+ `;
25087
+ return entry;
25088
+ }
25089
+ function generateMergerEntry(action, oldQuantity, newQuantity, logger) {
25090
+ const date5 = formatDate(action.date);
25091
+ const newSymbol = action.newSymbol || "UNKNOWN";
25092
+ const description = escapeDescription(`Merger: ${action.symbol} -> ${newSymbol}`);
25093
+ logger?.debug(`Generating Merger entry: ${oldQuantity} ${action.symbol} -> ${newQuantity} ${newSymbol}`);
25094
+ let entry = `${date5} ${description}
25095
+ `;
25096
+ entry += ` ; swissquote:order:${action.orderNum}
25097
+ `;
25098
+ entry += ` ; Old ISIN: ${action.isin}, New ISIN: ${action.newIsin || "unknown"}
25099
+ `;
25100
+ entry += ` assets:investments:stocks:${action.symbol} -${formatQuantity(oldQuantity)} ${action.symbol}
25101
+ `;
25102
+ entry += ` equity:conversion ${formatQuantity(oldQuantity)} ${action.symbol}
25103
+ `;
25104
+ entry += ` equity:conversion -${formatQuantity(newQuantity)} ${newSymbol}
25105
+ `;
25106
+ entry += ` assets:investments:stocks:${newSymbol} ${formatQuantity(newQuantity)} ${newSymbol}
25107
+ `;
25108
+ return entry;
25109
+ }
25110
+ function generateSplitEntry(action, oldQuantity, newQuantity, logger) {
25111
+ const date5 = formatDate(action.date);
25112
+ const ratio = action.ratio || newQuantity / oldQuantity;
25113
+ const splitType = ratio > 1 ? "Split" : "Reverse Split";
25114
+ const description = escapeDescription(`${splitType}: ${action.symbol} (${action.name})`);
25115
+ logger?.debug(`Generating ${splitType} entry: ${oldQuantity} -> ${newQuantity} ${action.symbol}`);
25116
+ let entry = `${date5} ${description}
25117
+ `;
25118
+ entry += ` ; swissquote:order:${action.orderNum} isin:${action.isin}
25119
+ `;
25120
+ entry += ` ; Ratio: ${ratio.toFixed(4)} (${oldQuantity} -> ${newQuantity})
25121
+ `;
25122
+ entry += ` assets:investments:stocks:${action.symbol} -${formatQuantity(oldQuantity)} ${action.symbol}
25123
+ `;
25124
+ entry += ` equity:conversion ${formatQuantity(oldQuantity)} ${action.symbol}
25125
+ `;
25126
+ entry += ` equity:conversion -${formatQuantity(newQuantity)} ${action.symbol}
25127
+ `;
25128
+ entry += ` assets:investments:stocks:${action.symbol} ${formatQuantity(newQuantity)} ${action.symbol}
25129
+ `;
25130
+ return entry;
25131
+ }
25132
+ function generateWorthlessEntry(action, removedLots, logger) {
25133
+ const date5 = formatDate(action.date);
25134
+ const description = escapeDescription(`Worthless Liquidation: ${action.symbol} - ${action.name}`);
25135
+ const totalQuantity = removedLots.reduce((sum, lot) => sum + lot.quantity, 0);
25136
+ const totalCost = removedLots.reduce((sum, lot) => sum + lot.quantity * lot.costBasis, 0);
25137
+ const currency = removedLots[0]?.currency || "USD";
25138
+ logger?.debug(`Generating Worthless entry: ${totalQuantity} ${action.symbol}, loss: ${totalCost} ${currency}`);
25139
+ let entry = `${date5} ${description}
25140
+ `;
25141
+ entry += ` ; swissquote:order:${action.orderNum} isin:${action.isin}
25142
+ `;
25143
+ entry += ` ; Total loss: ${totalCost.toFixed(2)} ${currency}
25144
+ `;
25145
+ for (const lot of removedLots) {
25146
+ const qty = formatQuantity(lot.quantity);
25147
+ const price = lot.costBasis.toFixed(2);
25148
+ entry += ` assets:investments:stocks:${action.symbol} -${qty} ${action.symbol} @ ${price} ${currency}
25149
+ `;
25150
+ }
25151
+ entry += ` expenses:losses:capital ${formatAmount2(totalCost, currency)}
25152
+ `;
25153
+ return entry;
25154
+ }
25155
+ function formatJournalFile(entries, year, currency) {
25156
+ const header = `; Swissquote ${currency.toUpperCase()} investment transactions for ${year}
25157
+ ; Generated by opencode-accountant
25158
+ ; This file is auto-generated - do not edit manually
25159
+
25160
+ `;
25161
+ return header + entries.join(`
25162
+ `);
25163
+ }
25164
+
25165
+ // src/utils/swissquoteCsvPreprocessor.ts
25166
+ var SWISSQUOTE_HEADER = "Date;Order #;Transaction;Symbol;Name;ISIN;Quantity;Unit price;Costs;Accrued Interest;Net Amount;Balance;Currency";
25167
+ var SIMPLE_TRANSACTION_TYPES = new Set([
25168
+ "Custody Fees",
25169
+ "Forex credit",
25170
+ "Forex debit",
25171
+ "Interest on debits",
25172
+ "Interest on credits",
25173
+ "Exchange fees",
25174
+ "Exchange fees rectif."
25175
+ ]);
25176
+ var TRADE_TYPES = new Set(["Buy", "Sell"]);
25177
+ var DIVIDEND_TYPES = new Set(["Dividend"]);
25178
+ var CORPORATE_ACTION_TYPES = new Set([
25179
+ "Merger",
25180
+ "Reverse Split",
25181
+ "Worthless Liquidation",
25182
+ "Internal exchange of securities"
25183
+ ]);
25184
+ var SKIP_TYPES = new Set([
25185
+ "Internal exchange",
25186
+ "Reversal"
25187
+ ]);
25188
+ function parseTransaction(line) {
25189
+ const fields = [];
25190
+ let current = "";
25191
+ let inQuotes = false;
25192
+ for (const char of line) {
25193
+ if (char === '"') {
25194
+ inQuotes = !inQuotes;
25195
+ } else if (char === ";" && !inQuotes) {
25196
+ fields.push(current.trim());
25197
+ current = "";
25198
+ } else {
25199
+ current += char;
25200
+ }
25201
+ }
25202
+ fields.push(current.trim());
25203
+ if (fields.length < 13) {
25204
+ return null;
25205
+ }
25206
+ return {
25207
+ date: fields[0],
25208
+ orderNum: fields[1],
25209
+ transaction: fields[2],
25210
+ symbol: fields[3],
25211
+ name: fields[4],
25212
+ isin: fields[5],
25213
+ quantity: fields[6],
25214
+ unitPrice: fields[7],
25215
+ costs: fields[8],
25216
+ accruedInterest: fields[9],
25217
+ netAmount: fields[10],
25218
+ balance: fields[11],
25219
+ currency: fields[12]
25220
+ };
25221
+ }
25222
+ function parseNumber(value) {
25223
+ if (!value || value === "-" || value === "") {
25224
+ return 0;
25225
+ }
25226
+ const normalized = value.replace(/,/g, ".");
25227
+ const parsed = parseFloat(normalized);
25228
+ return isNaN(parsed) ? 0 : parsed;
25229
+ }
25230
+ function categorizeTransaction(txn) {
25231
+ const type2 = txn.transaction;
25232
+ if (SKIP_TYPES.has(type2)) {
25233
+ return "skip";
25234
+ }
25235
+ if (SIMPLE_TRANSACTION_TYPES.has(type2)) {
25236
+ return "simple";
25237
+ }
25238
+ if (TRADE_TYPES.has(type2)) {
25239
+ return "trade";
25240
+ }
25241
+ if (DIVIDEND_TYPES.has(type2)) {
25242
+ return "dividend";
25243
+ }
25244
+ if (CORPORATE_ACTION_TYPES.has(type2)) {
25245
+ return "corporate";
25246
+ }
25247
+ if (!txn.netAmount || txn.netAmount === "-") {
25248
+ return "skip";
25249
+ }
25250
+ return "simple";
25251
+ }
25252
+ function toTradeEntry(txn) {
25253
+ return {
25254
+ date: txn.date,
25255
+ orderNum: txn.orderNum,
25256
+ type: txn.transaction,
25257
+ symbol: txn.symbol,
25258
+ name: txn.name,
25259
+ isin: txn.isin,
25260
+ quantity: parseNumber(txn.quantity),
25261
+ unitPrice: parseNumber(txn.unitPrice),
25262
+ costs: parseNumber(txn.costs),
25263
+ netAmount: parseNumber(txn.netAmount),
25264
+ currency: txn.currency
25265
+ };
25266
+ }
25267
+ function toDividendEntry(txn) {
25268
+ const netAmount = parseNumber(txn.netAmount);
25269
+ const withholdingTax = parseNumber(txn.costs);
25270
+ const grossAmount = netAmount + withholdingTax;
25271
+ return {
25272
+ date: txn.date,
25273
+ orderNum: txn.orderNum,
25274
+ symbol: txn.symbol,
25275
+ name: txn.name,
25276
+ isin: txn.isin,
25277
+ grossAmount,
25278
+ withholdingTax,
25279
+ netAmount,
25280
+ currency: txn.currency
25281
+ };
25282
+ }
25283
+ function toCorporateActionEntry(txn) {
25284
+ return {
25285
+ date: txn.date,
25286
+ orderNum: txn.orderNum,
25287
+ type: txn.transaction,
25288
+ symbol: txn.symbol,
25289
+ name: txn.name,
25290
+ isin: txn.isin,
25291
+ quantity: Math.abs(parseNumber(txn.quantity))
25292
+ };
25293
+ }
25294
+ function processCorporateActions(actions, inventory, logger) {
25295
+ const entries = [];
25296
+ const pendingMergers = new Map;
25297
+ actions.sort((a, b) => a.date.localeCompare(b.date));
25298
+ for (const action of actions) {
25299
+ switch (action.type) {
25300
+ case "Merger":
25301
+ case "Internal exchange of securities": {
25302
+ const key = `${action.date}-${action.orderNum}`;
25303
+ if (action.quantity < 0 || pendingMergers.has(key)) {
25304
+ const pending = pendingMergers.get(key);
25305
+ if (pending && pending.outgoing) {
25306
+ pending.incoming = action;
25307
+ const oldQty = Math.abs(parseNumber(String(pending.outgoing.quantity)));
25308
+ const newQty = Math.abs(parseNumber(String(action.quantity)));
25309
+ const ratio = newQty / oldQty;
25310
+ adjustLotsForMerger(inventory, pending.outgoing.symbol, action.symbol, ratio, logger);
25311
+ const entry = generateMergerEntry({
25312
+ ...pending.outgoing,
25313
+ newSymbol: action.symbol,
25314
+ newIsin: action.isin,
25315
+ ratio
25316
+ }, oldQty, newQty, logger);
25317
+ entries.push(entry);
25318
+ pendingMergers.delete(key);
25319
+ } else {
25320
+ pendingMergers.set(key, { outgoing: action });
25321
+ }
25322
+ } else {
25323
+ pendingMergers.set(key, { outgoing: action });
25324
+ }
25325
+ break;
25326
+ }
25327
+ case "Reverse Split": {
25328
+ const oldQty = getHeldQuantity(inventory, action.symbol);
25329
+ const newQty = action.quantity;
25330
+ if (oldQty > 0) {
25331
+ const ratio = newQty / oldQty;
25332
+ adjustLotsForSplit(inventory, action.symbol, ratio, logger);
25333
+ const entry = generateSplitEntry(action, oldQty, newQty, logger);
25334
+ entries.push(entry);
25335
+ } else {
25336
+ logger?.warn(`Reverse split for ${action.symbol} but no lots found`);
25337
+ }
25338
+ break;
25339
+ }
25340
+ case "Worthless Liquidation": {
25341
+ const removedLots = removeLots(inventory, action.symbol, logger);
25342
+ if (removedLots.length > 0) {
25343
+ const entry = generateWorthlessEntry(action, removedLots, logger);
25344
+ entries.push(entry);
25345
+ } else {
25346
+ logger?.warn(`Worthless liquidation for ${action.symbol} but no lots found`);
25347
+ }
25348
+ break;
25349
+ }
25350
+ }
25351
+ }
25352
+ for (const [key, pending] of pendingMergers.entries()) {
25353
+ logger?.warn(`Unmatched merger entry: ${key} - ${pending.outgoing.symbol}`);
25354
+ }
25355
+ return entries;
25356
+ }
25357
+ async function preprocessSwissquote(csvPath, projectDir, currency, year, lotInventoryPath = DEFAULT_LOT_INVENTORY_PATH, logger) {
25358
+ logger?.startSection(`Swissquote Preprocessing: ${path13.basename(csvPath)}`);
25359
+ const stats = {
25360
+ totalRows: 0,
25361
+ simpleTransactions: 0,
25362
+ trades: 0,
25363
+ dividends: 0,
25364
+ corporateActions: 0,
25365
+ skipped: 0
25366
+ };
25367
+ if (!fs18.existsSync(csvPath)) {
25368
+ logger?.error(`CSV file not found: ${csvPath}`);
25369
+ logger?.endSection();
25370
+ return {
25371
+ simpleTransactionsCsv: null,
25372
+ journalFile: null,
25373
+ stats,
25374
+ alreadyPreprocessed: false
25375
+ };
25376
+ }
25377
+ logger?.logStep("parse-csv", "start", `Reading ${csvPath}`);
25378
+ const content = fs18.readFileSync(csvPath, "utf-8");
25379
+ const lines = content.split(`
25380
+ `).filter((line) => line.trim());
25381
+ const header = lines[0];
25382
+ if (!header.startsWith("Date;Order #;Transaction")) {
25383
+ logger?.error(`Invalid Swissquote CSV header: ${header.substring(0, 50)}...`);
25384
+ logger?.endSection();
25385
+ return {
25386
+ simpleTransactionsCsv: null,
25387
+ journalFile: null,
25388
+ stats,
25389
+ alreadyPreprocessed: false
25390
+ };
25391
+ }
25392
+ const transactions = [];
25393
+ for (let i2 = 1;i2 < lines.length; i2++) {
25394
+ const txn = parseTransaction(lines[i2]);
25395
+ if (txn) {
25396
+ transactions.push(txn);
25397
+ }
25398
+ }
25399
+ stats.totalRows = transactions.length;
25400
+ logger?.logStep("parse-csv", "success", `Parsed ${stats.totalRows} transactions`);
25401
+ logger?.logStep("load-inventory", "start", "Loading lot inventory");
25402
+ const inventory = loadLotInventory(projectDir, lotInventoryPath, logger);
25403
+ logger?.logStep("load-inventory", "success", "Lot inventory loaded");
25404
+ logger?.startSection("Transaction Routing", 2);
25405
+ const simpleTransactions = [];
25406
+ const trades = [];
25407
+ const dividends = [];
25408
+ const corporateActions = [];
25409
+ for (const txn of transactions) {
25410
+ const category = categorizeTransaction(txn);
25411
+ switch (category) {
25412
+ case "simple":
25413
+ simpleTransactions.push(txn);
25414
+ stats.simpleTransactions++;
25415
+ break;
25416
+ case "trade":
25417
+ trades.push(toTradeEntry(txn));
25418
+ stats.trades++;
25419
+ break;
25420
+ case "dividend":
25421
+ dividends.push(toDividendEntry(txn));
25422
+ stats.dividends++;
25423
+ break;
25424
+ case "corporate":
25425
+ corporateActions.push(toCorporateActionEntry(txn));
25426
+ stats.corporateActions++;
25427
+ break;
25428
+ case "skip":
25429
+ stats.skipped++;
25430
+ break;
25431
+ }
25432
+ }
25433
+ logger?.logStep("route", "success", `Simple: ${stats.simpleTransactions}, Trades: ${stats.trades}, Dividends: ${stats.dividends}, Corporate: ${stats.corporateActions}, Skipped: ${stats.skipped}`);
25434
+ logger?.endSection();
25435
+ const journalEntries = [];
25436
+ if (trades.length > 0) {
25437
+ logger?.startSection("Trade Processing", 2);
25438
+ trades.sort((a, b) => a.date.localeCompare(b.date));
25439
+ for (const trade of trades) {
25440
+ try {
25441
+ if (trade.type === "Buy") {
25442
+ const tradeInfo = {
25443
+ date: formatDate(trade.date),
25444
+ orderNum: trade.orderNum,
25445
+ symbol: trade.symbol,
25446
+ isin: trade.isin,
25447
+ quantity: trade.quantity,
25448
+ unitPrice: trade.unitPrice,
25449
+ currency: trade.currency
25450
+ };
25451
+ addLot(inventory, tradeInfo, logger);
25452
+ const entry = generateBuyEntry(trade, logger);
25453
+ journalEntries.push(entry);
25454
+ logger?.logStep("trade-buy", "success", `Buy ${trade.quantity} ${trade.symbol} @ ${trade.unitPrice} ${trade.currency}`);
25455
+ } else if (trade.type === "Sell") {
25456
+ const consumed = consumeLotsFIFO(inventory, trade.symbol, trade.quantity, logger);
25457
+ const entry = generateSellEntry(trade, consumed, logger);
25458
+ journalEntries.push(entry);
25459
+ const totalCost = consumed.reduce((sum, c) => sum + c.totalCost, 0);
25460
+ const gain = trade.quantity * trade.unitPrice - totalCost;
25461
+ logger?.logStep("trade-sell", "success", `Sell ${trade.quantity} ${trade.symbol} @ ${trade.unitPrice} ${trade.currency}, gain: ${gain.toFixed(2)}`);
25462
+ }
25463
+ } catch (error45) {
25464
+ const message = error45 instanceof Error ? error45.message : String(error45);
25465
+ logger?.logStep("trade", "error", `Failed to process trade: ${message}`);
25466
+ logger?.error(`Trade processing error for ${trade.symbol}`, error45 instanceof Error ? error45 : undefined);
25467
+ }
25468
+ }
25469
+ logger?.endSection();
25470
+ }
25471
+ if (dividends.length > 0) {
25472
+ logger?.startSection("Dividend Processing", 2);
25473
+ for (const dividend of dividends) {
25474
+ const entry = generateDividendEntry(dividend, logger);
25475
+ journalEntries.push(entry);
25476
+ logger?.logStep("dividend", "success", `Dividend ${dividend.symbol}: ${dividend.netAmount} ${dividend.currency} (tax: ${dividend.withholdingTax})`);
25477
+ }
25478
+ logger?.endSection();
25479
+ }
25480
+ if (corporateActions.length > 0) {
25481
+ logger?.startSection("Corporate Actions Processing", 2);
25482
+ const corpEntries = processCorporateActions(corporateActions, inventory, logger);
25483
+ journalEntries.push(...corpEntries);
25484
+ logger?.logStep("corporate", "success", `Processed ${corporateActions.length} corporate actions`);
25485
+ logger?.endSection();
25486
+ }
25487
+ logger?.logStep("save-inventory", "start", "Saving lot inventory");
25488
+ saveLotInventory(projectDir, lotInventoryPath, inventory, logger);
25489
+ logger?.logStep("save-inventory", "success", "Lot inventory saved");
25490
+ let simpleTransactionsCsv = null;
25491
+ let journalFile = null;
25492
+ if (simpleTransactions.length > 0) {
25493
+ const csvDir = path13.dirname(csvPath);
25494
+ const csvBasename = path13.basename(csvPath, ".csv");
25495
+ simpleTransactionsCsv = path13.join(csvDir, `${csvBasename}-simple.csv`);
25496
+ const csvContent = [
25497
+ SWISSQUOTE_HEADER,
25498
+ ...simpleTransactions.map((txn) => [
25499
+ txn.date,
25500
+ txn.orderNum,
25501
+ txn.transaction,
25502
+ txn.symbol,
25503
+ txn.name,
25504
+ txn.isin,
25505
+ txn.quantity,
25506
+ txn.unitPrice,
25507
+ txn.costs,
25508
+ txn.accruedInterest,
25509
+ txn.netAmount,
25510
+ txn.balance,
25511
+ txn.currency
25512
+ ].join(";"))
25513
+ ].join(`
25514
+ `);
25515
+ fs18.writeFileSync(simpleTransactionsCsv, csvContent);
25516
+ logger?.logStep("write-csv", "success", `Wrote ${simpleTransactions.length} simple transactions to ${path13.basename(simpleTransactionsCsv)}`);
25517
+ }
25518
+ if (journalEntries.length > 0) {
25519
+ const investmentsDir = path13.join(projectDir, "ledger", "investments");
25520
+ if (!fs18.existsSync(investmentsDir)) {
25521
+ fs18.mkdirSync(investmentsDir, { recursive: true });
25522
+ }
25523
+ journalFile = path13.join(investmentsDir, `${year}-${currency}.journal`);
25524
+ const journalContent = formatJournalFile(journalEntries, year, currency);
25525
+ if (fs18.existsSync(journalFile)) {
25526
+ fs18.appendFileSync(journalFile, `
25527
+ ` + journalEntries.join(`
25528
+ `));
25529
+ logger?.logStep("write-journal", "success", `Appended ${journalEntries.length} entries to ${path13.basename(journalFile)}`);
25530
+ } else {
25531
+ fs18.writeFileSync(journalFile, journalContent);
25532
+ logger?.logStep("write-journal", "success", `Created ${path13.basename(journalFile)} with ${journalEntries.length} entries`);
25533
+ }
25534
+ }
25535
+ logger?.logResult({
25536
+ totalRows: stats.totalRows,
25537
+ simpleTransactions: stats.simpleTransactions,
25538
+ trades: stats.trades,
25539
+ dividends: stats.dividends,
25540
+ corporateActions: stats.corporateActions,
25541
+ skipped: stats.skipped,
25542
+ journalEntries: journalEntries.length
25543
+ });
25544
+ logger?.endSection();
25545
+ return {
25546
+ simpleTransactionsCsv,
25547
+ journalFile,
25548
+ stats,
25549
+ alreadyPreprocessed: false
25550
+ };
25551
+ }
25552
+
24792
25553
  // src/tools/import-pipeline.ts
24793
25554
  class NoTransactionsError extends Error {
24794
25555
  constructor() {
@@ -24861,7 +25622,7 @@ function executePreprocessFiatStep(context, contextIds, logger) {
24861
25622
  for (const contextId of contextIds) {
24862
25623
  const importCtx = loadContext(context.directory, contextId);
24863
25624
  if (importCtx.provider === "revolut" && importCtx.currency !== "btc") {
24864
- fiatCsvPaths.push(path12.join(context.directory, importCtx.filePath));
25625
+ fiatCsvPaths.push(path14.join(context.directory, importCtx.filePath));
24865
25626
  }
24866
25627
  }
24867
25628
  if (fiatCsvPaths.length === 0) {
@@ -24905,7 +25666,7 @@ function executePreprocessBtcStep(context, contextIds, logger) {
24905
25666
  for (const contextId of contextIds) {
24906
25667
  const importCtx = loadContext(context.directory, contextId);
24907
25668
  if (importCtx.provider === "revolut" && importCtx.currency === "btc") {
24908
- btcCsvPath = path12.join(context.directory, importCtx.filePath);
25669
+ btcCsvPath = path14.join(context.directory, importCtx.filePath);
24909
25670
  break;
24910
25671
  }
24911
25672
  }
@@ -24939,7 +25700,7 @@ async function executeBtcPurchaseStep(context, contextIds, logger) {
24939
25700
  const importCtx = loadContext(context.directory, contextId);
24940
25701
  if (importCtx.provider !== "revolut")
24941
25702
  continue;
24942
- const csvPath = path12.join(context.directory, importCtx.filePath);
25703
+ const csvPath = path14.join(context.directory, importCtx.filePath);
24943
25704
  if (importCtx.currency === "btc") {
24944
25705
  btcCsvPath = csvPath;
24945
25706
  } else {
@@ -24952,7 +25713,7 @@ async function executeBtcPurchaseStep(context, contextIds, logger) {
24952
25713
  }
24953
25714
  logger?.startSection("Step 1b: Generate BTC Purchase Entries");
24954
25715
  logger?.logStep("BTC Purchases", "start");
24955
- const btcFilename = path12.basename(btcCsvPath);
25716
+ const btcFilename = path14.basename(btcCsvPath);
24956
25717
  const yearMatch = btcFilename.match(/(\d{4})-\d{2}-\d{2}/);
24957
25718
  const year = yearMatch ? parseInt(yearMatch[1], 10) : new Date().getFullYear();
24958
25719
  const yearJournalPath = ensureYearJournalExists(context.directory, year);
@@ -24968,13 +25729,91 @@ async function executeBtcPurchaseStep(context, contextIds, logger) {
24968
25729
  unmatchedBtc: result.unmatchedBtc.length
24969
25730
  });
24970
25731
  }
25732
+ async function executeSwissquotePreprocessStep(context, contextIds, logger) {
25733
+ const swissquoteContexts = [];
25734
+ for (const contextId of contextIds) {
25735
+ const importCtx = loadContext(context.directory, contextId);
25736
+ if (importCtx.provider === "swissquote") {
25737
+ const csvPath = path14.join(context.directory, importCtx.filePath);
25738
+ const filenameMatch = importCtx.filename.match(/from-(\d{2})(\d{2})(\d{4})|(\d{4})-\d{2}-\d{2}/);
25739
+ let year = new Date().getFullYear();
25740
+ if (filenameMatch) {
25741
+ if (filenameMatch[3]) {
25742
+ year = parseInt(filenameMatch[3], 10);
25743
+ } else if (filenameMatch[4]) {
25744
+ year = parseInt(filenameMatch[4], 10);
25745
+ }
25746
+ }
25747
+ swissquoteContexts.push({
25748
+ contextId,
25749
+ csvPath,
25750
+ currency: importCtx.currency,
25751
+ year
25752
+ });
25753
+ }
25754
+ }
25755
+ if (swissquoteContexts.length === 0) {
25756
+ logger?.info("No Swissquote CSV found, skipping preprocessing");
25757
+ return;
25758
+ }
25759
+ logger?.startSection("Step 1d: Preprocess Swissquote CSV");
25760
+ const config2 = context.configLoader(context.directory);
25761
+ const swissquoteProvider = config2.providers?.["swissquote"];
25762
+ const lotInventoryPath = swissquoteProvider?.lotInventoryPath ?? DEFAULT_LOT_INVENTORY_PATH;
25763
+ try {
25764
+ let totalStats = {
25765
+ totalRows: 0,
25766
+ simpleTransactions: 0,
25767
+ trades: 0,
25768
+ dividends: 0,
25769
+ corporateActions: 0,
25770
+ skipped: 0
25771
+ };
25772
+ let lastJournalFile = null;
25773
+ for (const sqCtx of swissquoteContexts) {
25774
+ logger?.logStep("Swissquote Preprocess", "start", `Processing ${path14.basename(sqCtx.csvPath)}`);
25775
+ const result = await preprocessSwissquote(sqCtx.csvPath, context.directory, sqCtx.currency, sqCtx.year, lotInventoryPath, logger);
25776
+ totalStats.totalRows += result.stats.totalRows;
25777
+ totalStats.simpleTransactions += result.stats.simpleTransactions;
25778
+ totalStats.trades += result.stats.trades;
25779
+ totalStats.dividends += result.stats.dividends;
25780
+ totalStats.corporateActions += result.stats.corporateActions;
25781
+ totalStats.skipped += result.stats.skipped;
25782
+ if (result.journalFile) {
25783
+ lastJournalFile = result.journalFile;
25784
+ }
25785
+ if (result.simpleTransactionsCsv) {
25786
+ updateContext(context.directory, sqCtx.contextId, {
25787
+ filePath: path14.relative(context.directory, result.simpleTransactionsCsv)
25788
+ });
25789
+ logger?.info(`Updated context ${sqCtx.contextId} to use filtered CSV: ${path14.basename(result.simpleTransactionsCsv)}`);
25790
+ }
25791
+ logger?.logStep("Swissquote Preprocess", "success", `Processed: ${result.stats.trades} trades, ${result.stats.dividends} dividends, ${result.stats.corporateActions} corporate actions`);
25792
+ }
25793
+ const message = `Preprocessed ${totalStats.totalRows} rows: ` + `${totalStats.trades} trades, ${totalStats.dividends} dividends, ` + `${totalStats.corporateActions} corporate actions, ${totalStats.simpleTransactions} simple`;
25794
+ context.result.steps.swissquotePreprocess = buildStepResult(true, message, {
25795
+ ...totalStats,
25796
+ journalFile: lastJournalFile
25797
+ });
25798
+ } catch (error45) {
25799
+ const errorMessage = `Swissquote preprocessing failed: ${error45 instanceof Error ? error45.message : String(error45)}`;
25800
+ logger?.error(errorMessage);
25801
+ logger?.logStep("Swissquote Preprocess", "error", errorMessage);
25802
+ context.result.steps.swissquotePreprocess = buildStepResult(false, errorMessage);
25803
+ context.result.error = errorMessage;
25804
+ context.result.hint = "Check the Swissquote CSV format and lot inventory state";
25805
+ throw new Error(errorMessage);
25806
+ } finally {
25807
+ logger?.endSection();
25808
+ }
25809
+ }
24971
25810
  async function executeAccountDeclarationsStep(context, contextId, logger) {
24972
25811
  logger?.startSection("Step 2: Check Account Declarations");
24973
25812
  logger?.logStep("Check Accounts", "start");
24974
25813
  const config2 = context.configLoader(context.directory);
24975
- const rulesDir = path12.join(context.directory, config2.paths.rules);
25814
+ const rulesDir = path14.join(context.directory, config2.paths.rules);
24976
25815
  const importCtx = loadContext(context.directory, contextId);
24977
- const csvPath = path12.join(context.directory, importCtx.filePath);
25816
+ const csvPath = path14.join(context.directory, importCtx.filePath);
24978
25817
  const csvFiles = [csvPath];
24979
25818
  const rulesMapping = loadRulesMapping(rulesDir);
24980
25819
  const matchedRulesFiles = new Set;
@@ -24997,7 +25836,7 @@ async function executeAccountDeclarationsStep(context, contextId, logger) {
24997
25836
  context.result.steps.accountDeclarations = buildStepResult(true, "No accounts found in rules files", {
24998
25837
  accountsAdded: [],
24999
25838
  journalUpdated: "",
25000
- rulesScanned: Array.from(matchedRulesFiles).map((f) => path12.relative(context.directory, f))
25839
+ rulesScanned: Array.from(matchedRulesFiles).map((f) => path14.relative(context.directory, f))
25001
25840
  });
25002
25841
  return;
25003
25842
  }
@@ -25021,7 +25860,7 @@ async function executeAccountDeclarationsStep(context, contextId, logger) {
25021
25860
  context.result.steps.accountDeclarations = buildStepResult(true, "No transactions found, skipping account declarations", {
25022
25861
  accountsAdded: [],
25023
25862
  journalUpdated: "",
25024
- rulesScanned: Array.from(matchedRulesFiles).map((f) => path12.relative(context.directory, f))
25863
+ rulesScanned: Array.from(matchedRulesFiles).map((f) => path14.relative(context.directory, f))
25025
25864
  });
25026
25865
  return;
25027
25866
  }
@@ -25032,12 +25871,12 @@ async function executeAccountDeclarationsStep(context, contextId, logger) {
25032
25871
  context.result.steps.accountDeclarations = buildStepResult(false, `Failed to create year journal: ${error45 instanceof Error ? error45.message : String(error45)}`, {
25033
25872
  accountsAdded: [],
25034
25873
  journalUpdated: "",
25035
- rulesScanned: Array.from(matchedRulesFiles).map((f) => path12.relative(context.directory, f))
25874
+ rulesScanned: Array.from(matchedRulesFiles).map((f) => path14.relative(context.directory, f))
25036
25875
  });
25037
25876
  return;
25038
25877
  }
25039
25878
  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";
25879
+ 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
25880
  logger?.logStep("Check Accounts", "success", message);
25042
25881
  if (result.added.length > 0) {
25043
25882
  for (const account of result.added) {
@@ -25046,17 +25885,17 @@ async function executeAccountDeclarationsStep(context, contextId, logger) {
25046
25885
  }
25047
25886
  context.result.steps.accountDeclarations = buildStepResult(true, message, {
25048
25887
  accountsAdded: result.added,
25049
- journalUpdated: path12.relative(context.directory, yearJournalPath),
25050
- rulesScanned: Array.from(matchedRulesFiles).map((f) => path12.relative(context.directory, f))
25888
+ journalUpdated: path14.relative(context.directory, yearJournalPath),
25889
+ rulesScanned: Array.from(matchedRulesFiles).map((f) => path14.relative(context.directory, f))
25051
25890
  });
25052
25891
  logger?.endSection();
25053
25892
  }
25054
25893
  async function buildSuggestionContext(context, contextId, logger) {
25055
25894
  const { loadExistingAccounts: loadExistingAccounts2, extractRulePatternsFromFile: extractRulePatternsFromFile2 } = await Promise.resolve().then(() => (init_accountSuggester(), exports_accountSuggester));
25056
25895
  const config2 = context.configLoader(context.directory);
25057
- const rulesDir = path12.join(context.directory, config2.paths.rules);
25896
+ const rulesDir = path14.join(context.directory, config2.paths.rules);
25058
25897
  const importCtx = loadContext(context.directory, contextId);
25059
- const csvPath = path12.join(context.directory, importCtx.filePath);
25898
+ const csvPath = path14.join(context.directory, importCtx.filePath);
25060
25899
  const rulesMapping = loadRulesMapping(rulesDir);
25061
25900
  const rulesFile = findRulesForCsv(csvPath, rulesMapping);
25062
25901
  if (!rulesFile) {
@@ -25263,6 +26102,7 @@ async function importPipeline(directory, agent, options, configLoader = loadImpo
25263
26102
  executePreprocessFiatStep(context, contextIds, logger);
25264
26103
  executePreprocessBtcStep(context, contextIds, logger);
25265
26104
  await executeBtcPurchaseStep(context, contextIds, logger);
26105
+ await executeSwissquotePreprocessStep(context, contextIds, logger);
25266
26106
  const importConfig = loadImportConfig(context.directory);
25267
26107
  const orderedContextIds = [...contextIds].sort((a, b) => {
25268
26108
  const ctxA = loadContext(context.directory, a);
@@ -25361,16 +26201,16 @@ This tool orchestrates the full import workflow:
25361
26201
  }
25362
26202
  });
25363
26203
  // src/tools/init-directories.ts
25364
- import * as fs18 from "fs";
25365
- import * as path13 from "path";
26204
+ import * as fs20 from "fs";
26205
+ import * as path15 from "path";
25366
26206
  async function initDirectories(directory) {
25367
26207
  try {
25368
26208
  const config2 = loadImportConfig(directory);
25369
26209
  const directoriesCreated = [];
25370
26210
  const gitkeepFiles = [];
25371
- const importBase = path13.join(directory, "import");
25372
- if (!fs18.existsSync(importBase)) {
25373
- fs18.mkdirSync(importBase, { recursive: true });
26211
+ const importBase = path15.join(directory, "import");
26212
+ if (!fs20.existsSync(importBase)) {
26213
+ fs20.mkdirSync(importBase, { recursive: true });
25374
26214
  directoriesCreated.push("import");
25375
26215
  }
25376
26216
  const pathsToCreate = [
@@ -25380,20 +26220,20 @@ async function initDirectories(directory) {
25380
26220
  { key: "unrecognized", path: config2.paths.unrecognized }
25381
26221
  ];
25382
26222
  for (const { path: dirPath } of pathsToCreate) {
25383
- const fullPath = path13.join(directory, dirPath);
25384
- if (!fs18.existsSync(fullPath)) {
25385
- fs18.mkdirSync(fullPath, { recursive: true });
26223
+ const fullPath = path15.join(directory, dirPath);
26224
+ if (!fs20.existsSync(fullPath)) {
26225
+ fs20.mkdirSync(fullPath, { recursive: true });
25386
26226
  directoriesCreated.push(dirPath);
25387
26227
  }
25388
- const gitkeepPath = path13.join(fullPath, ".gitkeep");
25389
- if (!fs18.existsSync(gitkeepPath)) {
25390
- fs18.writeFileSync(gitkeepPath, "");
25391
- gitkeepFiles.push(path13.join(dirPath, ".gitkeep"));
26228
+ const gitkeepPath = path15.join(fullPath, ".gitkeep");
26229
+ if (!fs20.existsSync(gitkeepPath)) {
26230
+ fs20.writeFileSync(gitkeepPath, "");
26231
+ gitkeepFiles.push(path15.join(dirPath, ".gitkeep"));
25392
26232
  }
25393
26233
  }
25394
- const gitignorePath = path13.join(importBase, ".gitignore");
26234
+ const gitignorePath = path15.join(importBase, ".gitignore");
25395
26235
  let gitignoreCreated = false;
25396
- if (!fs18.existsSync(gitignorePath)) {
26236
+ if (!fs20.existsSync(gitignorePath)) {
25397
26237
  const gitignoreContent = `# Ignore CSV/PDF files in temporary directories
25398
26238
  /incoming/*.csv
25399
26239
  /incoming/*.pdf
@@ -25411,7 +26251,7 @@ async function initDirectories(directory) {
25411
26251
  .DS_Store
25412
26252
  Thumbs.db
25413
26253
  `;
25414
- fs18.writeFileSync(gitignorePath, gitignoreContent);
26254
+ fs20.writeFileSync(gitignorePath, gitignoreContent);
25415
26255
  gitignoreCreated = true;
25416
26256
  }
25417
26257
  const parts = [];
@@ -25487,32 +26327,32 @@ You can now drop CSV files into import/incoming/ and run import-pipeline.`);
25487
26327
  }
25488
26328
  });
25489
26329
  // src/tools/generate-btc-purchases.ts
25490
- import * as path14 from "path";
25491
- import * as fs19 from "fs";
26330
+ import * as path16 from "path";
26331
+ import * as fs21 from "fs";
25492
26332
  function findFiatCsvPaths(directory, pendingDir, provider) {
25493
- const providerDir = path14.join(directory, pendingDir, provider);
25494
- if (!fs19.existsSync(providerDir))
26333
+ const providerDir = path16.join(directory, pendingDir, provider);
26334
+ if (!fs21.existsSync(providerDir))
25495
26335
  return [];
25496
26336
  const csvPaths = [];
25497
- const entries = fs19.readdirSync(providerDir, { withFileTypes: true });
26337
+ const entries = fs21.readdirSync(providerDir, { withFileTypes: true });
25498
26338
  for (const entry of entries) {
25499
26339
  if (!entry.isDirectory())
25500
26340
  continue;
25501
26341
  if (entry.name === "btc")
25502
26342
  continue;
25503
- const csvFiles = findCsvFiles(path14.join(providerDir, entry.name), { fullPaths: true });
26343
+ const csvFiles = findCsvFiles(path16.join(providerDir, entry.name), { fullPaths: true });
25504
26344
  csvPaths.push(...csvFiles);
25505
26345
  }
25506
26346
  return csvPaths;
25507
26347
  }
25508
26348
  function findBtcCsvPath(directory, pendingDir, provider) {
25509
- const btcDir = path14.join(directory, pendingDir, provider, "btc");
26349
+ const btcDir = path16.join(directory, pendingDir, provider, "btc");
25510
26350
  const csvFiles = findCsvFiles(btcDir, { fullPaths: true });
25511
26351
  return csvFiles[0];
25512
26352
  }
25513
26353
  function determineYear(csvPaths) {
25514
26354
  for (const csvPath of csvPaths) {
25515
- const match2 = path14.basename(csvPath).match(/(\d{4})-\d{2}-\d{2}/);
26355
+ const match2 = path16.basename(csvPath).match(/(\d{4})-\d{2}-\d{2}/);
25516
26356
  if (match2)
25517
26357
  return parseInt(match2[1], 10);
25518
26358
  }
@@ -25565,7 +26405,7 @@ async function generateBtcPurchases(directory, agent, options = {}, configLoader
25565
26405
  skippedDuplicates: result.skippedDuplicates,
25566
26406
  unmatchedFiat: result.unmatchedFiat.length,
25567
26407
  unmatchedBtc: result.unmatchedBtc.length,
25568
- yearJournal: path14.relative(directory, yearJournalPath)
26408
+ yearJournal: path16.relative(directory, yearJournalPath)
25569
26409
  });
25570
26410
  } catch (err) {
25571
26411
  logger.error("Failed to generate BTC purchases", err);
@@ -25607,8 +26447,8 @@ to produce equity conversion entries for Bitcoin purchases.
25607
26447
  }
25608
26448
  });
25609
26449
  // src/index.ts
25610
- var __dirname2 = dirname4(fileURLToPath3(import.meta.url));
25611
- var AGENT_FILE = join13(__dirname2, "..", "agent", "accountant.md");
26450
+ var __dirname2 = dirname6(fileURLToPath3(import.meta.url));
26451
+ var AGENT_FILE = join15(__dirname2, "..", "agent", "accountant.md");
25612
26452
  var AccountantPlugin = async () => {
25613
26453
  const agent = loadAgent(AGENT_FILE);
25614
26454
  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.0-next.1",
3
+ "version": "0.9.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",