@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 +890 -48
- package/docs/tools/import-pipeline.md +35 -5
- package/package.json +1 -1
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
|
|
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 (!
|
|
4262
|
+
if (!fs19.existsSync(yearJournalPath)) {
|
|
4263
4263
|
return [];
|
|
4264
4264
|
}
|
|
4265
|
-
const content =
|
|
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 (!
|
|
4281
|
+
if (!fs19.existsSync(rulesPath)) {
|
|
4282
4282
|
return [];
|
|
4283
4283
|
}
|
|
4284
|
-
const content =
|
|
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
|
|
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
|
-
|
|
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
|
|
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(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
25816
|
+
const rulesDir = path14.join(context.directory, config2.paths.rules);
|
|
24976
25817
|
const importCtx = loadContext(context.directory, contextId);
|
|
24977
|
-
const csvPath =
|
|
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) =>
|
|
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) =>
|
|
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) =>
|
|
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 ${
|
|
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:
|
|
25050
|
-
rulesScanned: Array.from(matchedRulesFiles).map((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 =
|
|
25898
|
+
const rulesDir = path14.join(context.directory, config2.paths.rules);
|
|
25058
25899
|
const importCtx = loadContext(context.directory, contextId);
|
|
25059
|
-
const csvPath =
|
|
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
|
|
25365
|
-
import * as
|
|
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 =
|
|
25372
|
-
if (!
|
|
25373
|
-
|
|
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 =
|
|
25384
|
-
if (!
|
|
25385
|
-
|
|
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 =
|
|
25389
|
-
if (!
|
|
25390
|
-
|
|
25391
|
-
gitkeepFiles.push(
|
|
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 =
|
|
26236
|
+
const gitignorePath = path15.join(importBase, ".gitignore");
|
|
25395
26237
|
let gitignoreCreated = false;
|
|
25396
|
-
if (!
|
|
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
|
-
|
|
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
|
|
25491
|
-
import * as
|
|
26332
|
+
import * as path16 from "path";
|
|
26333
|
+
import * as fs21 from "fs";
|
|
25492
26334
|
function findFiatCsvPaths(directory, pendingDir, provider) {
|
|
25493
|
-
const providerDir =
|
|
25494
|
-
if (!
|
|
26335
|
+
const providerDir = path16.join(directory, pendingDir, provider);
|
|
26336
|
+
if (!fs21.existsSync(providerDir))
|
|
25495
26337
|
return [];
|
|
25496
26338
|
const csvPaths = [];
|
|
25497
|
-
const entries =
|
|
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(
|
|
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 =
|
|
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 =
|
|
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:
|
|
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 =
|
|
25611
|
-
var AGENT_FILE =
|
|
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
|
|
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
|
-
|
|
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
|
▼
|