@fuzzle/opencode-accountant 0.9.0 → 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/README.md +3 -0
- package/dist/index.js +902 -48
- package/docs/tools/import-pipeline.md +40 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -101,6 +101,7 @@ paths:
|
|
|
101
101
|
|
|
102
102
|
providers:
|
|
103
103
|
revolut:
|
|
104
|
+
importOrder: 10
|
|
104
105
|
detect:
|
|
105
106
|
- filenamePattern: '^account-statement_'
|
|
106
107
|
header: 'Type,Product,Started Date,Completed Date,Description,Amount,Fee,Currency,State,Balance'
|
|
@@ -115,6 +116,7 @@ providers:
|
|
|
115
116
|
BTC: btc
|
|
116
117
|
|
|
117
118
|
ubs:
|
|
119
|
+
importOrder: 0
|
|
118
120
|
detect:
|
|
119
121
|
# Note: UBS exports have a trailing semicolon in the header row, which creates
|
|
120
122
|
# an empty field when parsed. The header must include a trailing comma to match.
|
|
@@ -167,6 +169,7 @@ providers:
|
|
|
167
169
|
| `renamePattern` | No | Output filename pattern with `{placeholder}` substitutions |
|
|
168
170
|
| `metadata` | No | Array of metadata extraction rules (see below) |
|
|
169
171
|
| `currencies` | Yes | Map of raw currency values to normalized folder names |
|
|
172
|
+
| `importOrder` | No | Numeric priority for import sequencing (default: `0`, lower imports first) |
|
|
170
173
|
|
|
171
174
|
\* **Note on trailing delimiters:** If the CSV header row ends with a trailing delimiter (e.g., `Field1;Field2;`), this creates an empty field when parsed. The `header` config must include a trailing comma to account for this (e.g., `Field1,Field2,`).
|
|
172
175
|
|
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
|
|
@@ -17314,7 +17314,16 @@ function validateProviderConfig(name, config2) {
|
|
|
17314
17314
|
if (Object.keys(currencies).length === 0) {
|
|
17315
17315
|
throw new Error(`Invalid config for provider '${name}': 'currencies' must contain at least one mapping`);
|
|
17316
17316
|
}
|
|
17317
|
-
|
|
17317
|
+
if (configObj.importOrder !== undefined) {
|
|
17318
|
+
if (typeof configObj.importOrder !== "number") {
|
|
17319
|
+
throw new Error(`Invalid config for provider '${name}': 'importOrder' must be a number`);
|
|
17320
|
+
}
|
|
17321
|
+
}
|
|
17322
|
+
return {
|
|
17323
|
+
detect,
|
|
17324
|
+
currencies,
|
|
17325
|
+
importOrder: configObj.importOrder
|
|
17326
|
+
};
|
|
17318
17327
|
}
|
|
17319
17328
|
function loadImportConfig(directory) {
|
|
17320
17329
|
return loadYamlConfig(directory, CONFIG_FILE2, (parsedObj) => {
|
|
@@ -24081,7 +24090,7 @@ Note: This tool requires a contextId from a prior classify/import step.`,
|
|
|
24081
24090
|
}
|
|
24082
24091
|
});
|
|
24083
24092
|
// src/tools/import-pipeline.ts
|
|
24084
|
-
import * as
|
|
24093
|
+
import * as path14 from "path";
|
|
24085
24094
|
|
|
24086
24095
|
// src/utils/accountDeclarations.ts
|
|
24087
24096
|
import * as fs12 from "fs";
|
|
@@ -24780,6 +24789,767 @@ Got:
|
|
|
24780
24789
|
return { rowsProcessed, sendRowsEnriched: enrichedRows, alreadyPreprocessed: false };
|
|
24781
24790
|
}
|
|
24782
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
|
+
|
|
24783
25553
|
// src/tools/import-pipeline.ts
|
|
24784
25554
|
class NoTransactionsError extends Error {
|
|
24785
25555
|
constructor() {
|
|
@@ -24852,7 +25622,7 @@ function executePreprocessFiatStep(context, contextIds, logger) {
|
|
|
24852
25622
|
for (const contextId of contextIds) {
|
|
24853
25623
|
const importCtx = loadContext(context.directory, contextId);
|
|
24854
25624
|
if (importCtx.provider === "revolut" && importCtx.currency !== "btc") {
|
|
24855
|
-
fiatCsvPaths.push(
|
|
25625
|
+
fiatCsvPaths.push(path14.join(context.directory, importCtx.filePath));
|
|
24856
25626
|
}
|
|
24857
25627
|
}
|
|
24858
25628
|
if (fiatCsvPaths.length === 0) {
|
|
@@ -24896,7 +25666,7 @@ function executePreprocessBtcStep(context, contextIds, logger) {
|
|
|
24896
25666
|
for (const contextId of contextIds) {
|
|
24897
25667
|
const importCtx = loadContext(context.directory, contextId);
|
|
24898
25668
|
if (importCtx.provider === "revolut" && importCtx.currency === "btc") {
|
|
24899
|
-
btcCsvPath =
|
|
25669
|
+
btcCsvPath = path14.join(context.directory, importCtx.filePath);
|
|
24900
25670
|
break;
|
|
24901
25671
|
}
|
|
24902
25672
|
}
|
|
@@ -24930,7 +25700,7 @@ async function executeBtcPurchaseStep(context, contextIds, logger) {
|
|
|
24930
25700
|
const importCtx = loadContext(context.directory, contextId);
|
|
24931
25701
|
if (importCtx.provider !== "revolut")
|
|
24932
25702
|
continue;
|
|
24933
|
-
const csvPath =
|
|
25703
|
+
const csvPath = path14.join(context.directory, importCtx.filePath);
|
|
24934
25704
|
if (importCtx.currency === "btc") {
|
|
24935
25705
|
btcCsvPath = csvPath;
|
|
24936
25706
|
} else {
|
|
@@ -24943,7 +25713,7 @@ async function executeBtcPurchaseStep(context, contextIds, logger) {
|
|
|
24943
25713
|
}
|
|
24944
25714
|
logger?.startSection("Step 1b: Generate BTC Purchase Entries");
|
|
24945
25715
|
logger?.logStep("BTC Purchases", "start");
|
|
24946
|
-
const btcFilename =
|
|
25716
|
+
const btcFilename = path14.basename(btcCsvPath);
|
|
24947
25717
|
const yearMatch = btcFilename.match(/(\d{4})-\d{2}-\d{2}/);
|
|
24948
25718
|
const year = yearMatch ? parseInt(yearMatch[1], 10) : new Date().getFullYear();
|
|
24949
25719
|
const yearJournalPath = ensureYearJournalExists(context.directory, year);
|
|
@@ -24959,13 +25729,91 @@ async function executeBtcPurchaseStep(context, contextIds, logger) {
|
|
|
24959
25729
|
unmatchedBtc: result.unmatchedBtc.length
|
|
24960
25730
|
});
|
|
24961
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
|
+
}
|
|
24962
25810
|
async function executeAccountDeclarationsStep(context, contextId, logger) {
|
|
24963
25811
|
logger?.startSection("Step 2: Check Account Declarations");
|
|
24964
25812
|
logger?.logStep("Check Accounts", "start");
|
|
24965
25813
|
const config2 = context.configLoader(context.directory);
|
|
24966
|
-
const rulesDir =
|
|
25814
|
+
const rulesDir = path14.join(context.directory, config2.paths.rules);
|
|
24967
25815
|
const importCtx = loadContext(context.directory, contextId);
|
|
24968
|
-
const csvPath =
|
|
25816
|
+
const csvPath = path14.join(context.directory, importCtx.filePath);
|
|
24969
25817
|
const csvFiles = [csvPath];
|
|
24970
25818
|
const rulesMapping = loadRulesMapping(rulesDir);
|
|
24971
25819
|
const matchedRulesFiles = new Set;
|
|
@@ -24988,7 +25836,7 @@ async function executeAccountDeclarationsStep(context, contextId, logger) {
|
|
|
24988
25836
|
context.result.steps.accountDeclarations = buildStepResult(true, "No accounts found in rules files", {
|
|
24989
25837
|
accountsAdded: [],
|
|
24990
25838
|
journalUpdated: "",
|
|
24991
|
-
rulesScanned: Array.from(matchedRulesFiles).map((f) =>
|
|
25839
|
+
rulesScanned: Array.from(matchedRulesFiles).map((f) => path14.relative(context.directory, f))
|
|
24992
25840
|
});
|
|
24993
25841
|
return;
|
|
24994
25842
|
}
|
|
@@ -25012,7 +25860,7 @@ async function executeAccountDeclarationsStep(context, contextId, logger) {
|
|
|
25012
25860
|
context.result.steps.accountDeclarations = buildStepResult(true, "No transactions found, skipping account declarations", {
|
|
25013
25861
|
accountsAdded: [],
|
|
25014
25862
|
journalUpdated: "",
|
|
25015
|
-
rulesScanned: Array.from(matchedRulesFiles).map((f) =>
|
|
25863
|
+
rulesScanned: Array.from(matchedRulesFiles).map((f) => path14.relative(context.directory, f))
|
|
25016
25864
|
});
|
|
25017
25865
|
return;
|
|
25018
25866
|
}
|
|
@@ -25023,12 +25871,12 @@ async function executeAccountDeclarationsStep(context, contextId, logger) {
|
|
|
25023
25871
|
context.result.steps.accountDeclarations = buildStepResult(false, `Failed to create year journal: ${error45 instanceof Error ? error45.message : String(error45)}`, {
|
|
25024
25872
|
accountsAdded: [],
|
|
25025
25873
|
journalUpdated: "",
|
|
25026
|
-
rulesScanned: Array.from(matchedRulesFiles).map((f) =>
|
|
25874
|
+
rulesScanned: Array.from(matchedRulesFiles).map((f) => path14.relative(context.directory, f))
|
|
25027
25875
|
});
|
|
25028
25876
|
return;
|
|
25029
25877
|
}
|
|
25030
25878
|
const result = ensureAccountDeclarations(yearJournalPath, allAccounts);
|
|
25031
|
-
const message = result.added.length > 0 ? `Added ${result.added.length} account declaration(s) to ${
|
|
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";
|
|
25032
25880
|
logger?.logStep("Check Accounts", "success", message);
|
|
25033
25881
|
if (result.added.length > 0) {
|
|
25034
25882
|
for (const account of result.added) {
|
|
@@ -25037,17 +25885,17 @@ async function executeAccountDeclarationsStep(context, contextId, logger) {
|
|
|
25037
25885
|
}
|
|
25038
25886
|
context.result.steps.accountDeclarations = buildStepResult(true, message, {
|
|
25039
25887
|
accountsAdded: result.added,
|
|
25040
|
-
journalUpdated:
|
|
25041
|
-
rulesScanned: Array.from(matchedRulesFiles).map((f) =>
|
|
25888
|
+
journalUpdated: path14.relative(context.directory, yearJournalPath),
|
|
25889
|
+
rulesScanned: Array.from(matchedRulesFiles).map((f) => path14.relative(context.directory, f))
|
|
25042
25890
|
});
|
|
25043
25891
|
logger?.endSection();
|
|
25044
25892
|
}
|
|
25045
25893
|
async function buildSuggestionContext(context, contextId, logger) {
|
|
25046
25894
|
const { loadExistingAccounts: loadExistingAccounts2, extractRulePatternsFromFile: extractRulePatternsFromFile2 } = await Promise.resolve().then(() => (init_accountSuggester(), exports_accountSuggester));
|
|
25047
25895
|
const config2 = context.configLoader(context.directory);
|
|
25048
|
-
const rulesDir =
|
|
25896
|
+
const rulesDir = path14.join(context.directory, config2.paths.rules);
|
|
25049
25897
|
const importCtx = loadContext(context.directory, contextId);
|
|
25050
|
-
const csvPath =
|
|
25898
|
+
const csvPath = path14.join(context.directory, importCtx.filePath);
|
|
25051
25899
|
const rulesMapping = loadRulesMapping(rulesDir);
|
|
25052
25900
|
const rulesFile = findRulesForCsv(csvPath, rulesMapping);
|
|
25053
25901
|
if (!rulesFile) {
|
|
@@ -25254,9 +26102,15 @@ async function importPipeline(directory, agent, options, configLoader = loadImpo
|
|
|
25254
26102
|
executePreprocessFiatStep(context, contextIds, logger);
|
|
25255
26103
|
executePreprocessBtcStep(context, contextIds, logger);
|
|
25256
26104
|
await executeBtcPurchaseStep(context, contextIds, logger);
|
|
26105
|
+
await executeSwissquotePreprocessStep(context, contextIds, logger);
|
|
26106
|
+
const importConfig = loadImportConfig(context.directory);
|
|
25257
26107
|
const orderedContextIds = [...contextIds].sort((a, b) => {
|
|
25258
26108
|
const ctxA = loadContext(context.directory, a);
|
|
25259
26109
|
const ctxB = loadContext(context.directory, b);
|
|
26110
|
+
const orderA = importConfig.providers[ctxA.provider]?.importOrder ?? 0;
|
|
26111
|
+
const orderB = importConfig.providers[ctxB.provider]?.importOrder ?? 0;
|
|
26112
|
+
if (orderA !== orderB)
|
|
26113
|
+
return orderA - orderB;
|
|
25260
26114
|
const isBtcA = ctxA.provider === "revolut" && ctxA.currency === "btc" ? 1 : 0;
|
|
25261
26115
|
const isBtcB = ctxB.provider === "revolut" && ctxB.currency === "btc" ? 1 : 0;
|
|
25262
26116
|
return isBtcA - isBtcB;
|
|
@@ -25347,16 +26201,16 @@ This tool orchestrates the full import workflow:
|
|
|
25347
26201
|
}
|
|
25348
26202
|
});
|
|
25349
26203
|
// src/tools/init-directories.ts
|
|
25350
|
-
import * as
|
|
25351
|
-
import * as
|
|
26204
|
+
import * as fs20 from "fs";
|
|
26205
|
+
import * as path15 from "path";
|
|
25352
26206
|
async function initDirectories(directory) {
|
|
25353
26207
|
try {
|
|
25354
26208
|
const config2 = loadImportConfig(directory);
|
|
25355
26209
|
const directoriesCreated = [];
|
|
25356
26210
|
const gitkeepFiles = [];
|
|
25357
|
-
const importBase =
|
|
25358
|
-
if (!
|
|
25359
|
-
|
|
26211
|
+
const importBase = path15.join(directory, "import");
|
|
26212
|
+
if (!fs20.existsSync(importBase)) {
|
|
26213
|
+
fs20.mkdirSync(importBase, { recursive: true });
|
|
25360
26214
|
directoriesCreated.push("import");
|
|
25361
26215
|
}
|
|
25362
26216
|
const pathsToCreate = [
|
|
@@ -25366,20 +26220,20 @@ async function initDirectories(directory) {
|
|
|
25366
26220
|
{ key: "unrecognized", path: config2.paths.unrecognized }
|
|
25367
26221
|
];
|
|
25368
26222
|
for (const { path: dirPath } of pathsToCreate) {
|
|
25369
|
-
const fullPath =
|
|
25370
|
-
if (!
|
|
25371
|
-
|
|
26223
|
+
const fullPath = path15.join(directory, dirPath);
|
|
26224
|
+
if (!fs20.existsSync(fullPath)) {
|
|
26225
|
+
fs20.mkdirSync(fullPath, { recursive: true });
|
|
25372
26226
|
directoriesCreated.push(dirPath);
|
|
25373
26227
|
}
|
|
25374
|
-
const gitkeepPath =
|
|
25375
|
-
if (!
|
|
25376
|
-
|
|
25377
|
-
gitkeepFiles.push(
|
|
26228
|
+
const gitkeepPath = path15.join(fullPath, ".gitkeep");
|
|
26229
|
+
if (!fs20.existsSync(gitkeepPath)) {
|
|
26230
|
+
fs20.writeFileSync(gitkeepPath, "");
|
|
26231
|
+
gitkeepFiles.push(path15.join(dirPath, ".gitkeep"));
|
|
25378
26232
|
}
|
|
25379
26233
|
}
|
|
25380
|
-
const gitignorePath =
|
|
26234
|
+
const gitignorePath = path15.join(importBase, ".gitignore");
|
|
25381
26235
|
let gitignoreCreated = false;
|
|
25382
|
-
if (!
|
|
26236
|
+
if (!fs20.existsSync(gitignorePath)) {
|
|
25383
26237
|
const gitignoreContent = `# Ignore CSV/PDF files in temporary directories
|
|
25384
26238
|
/incoming/*.csv
|
|
25385
26239
|
/incoming/*.pdf
|
|
@@ -25397,7 +26251,7 @@ async function initDirectories(directory) {
|
|
|
25397
26251
|
.DS_Store
|
|
25398
26252
|
Thumbs.db
|
|
25399
26253
|
`;
|
|
25400
|
-
|
|
26254
|
+
fs20.writeFileSync(gitignorePath, gitignoreContent);
|
|
25401
26255
|
gitignoreCreated = true;
|
|
25402
26256
|
}
|
|
25403
26257
|
const parts = [];
|
|
@@ -25473,32 +26327,32 @@ You can now drop CSV files into import/incoming/ and run import-pipeline.`);
|
|
|
25473
26327
|
}
|
|
25474
26328
|
});
|
|
25475
26329
|
// src/tools/generate-btc-purchases.ts
|
|
25476
|
-
import * as
|
|
25477
|
-
import * as
|
|
26330
|
+
import * as path16 from "path";
|
|
26331
|
+
import * as fs21 from "fs";
|
|
25478
26332
|
function findFiatCsvPaths(directory, pendingDir, provider) {
|
|
25479
|
-
const providerDir =
|
|
25480
|
-
if (!
|
|
26333
|
+
const providerDir = path16.join(directory, pendingDir, provider);
|
|
26334
|
+
if (!fs21.existsSync(providerDir))
|
|
25481
26335
|
return [];
|
|
25482
26336
|
const csvPaths = [];
|
|
25483
|
-
const entries =
|
|
26337
|
+
const entries = fs21.readdirSync(providerDir, { withFileTypes: true });
|
|
25484
26338
|
for (const entry of entries) {
|
|
25485
26339
|
if (!entry.isDirectory())
|
|
25486
26340
|
continue;
|
|
25487
26341
|
if (entry.name === "btc")
|
|
25488
26342
|
continue;
|
|
25489
|
-
const csvFiles = findCsvFiles(
|
|
26343
|
+
const csvFiles = findCsvFiles(path16.join(providerDir, entry.name), { fullPaths: true });
|
|
25490
26344
|
csvPaths.push(...csvFiles);
|
|
25491
26345
|
}
|
|
25492
26346
|
return csvPaths;
|
|
25493
26347
|
}
|
|
25494
26348
|
function findBtcCsvPath(directory, pendingDir, provider) {
|
|
25495
|
-
const btcDir =
|
|
26349
|
+
const btcDir = path16.join(directory, pendingDir, provider, "btc");
|
|
25496
26350
|
const csvFiles = findCsvFiles(btcDir, { fullPaths: true });
|
|
25497
26351
|
return csvFiles[0];
|
|
25498
26352
|
}
|
|
25499
26353
|
function determineYear(csvPaths) {
|
|
25500
26354
|
for (const csvPath of csvPaths) {
|
|
25501
|
-
const match2 =
|
|
26355
|
+
const match2 = path16.basename(csvPath).match(/(\d{4})-\d{2}-\d{2}/);
|
|
25502
26356
|
if (match2)
|
|
25503
26357
|
return parseInt(match2[1], 10);
|
|
25504
26358
|
}
|
|
@@ -25551,7 +26405,7 @@ async function generateBtcPurchases(directory, agent, options = {}, configLoader
|
|
|
25551
26405
|
skippedDuplicates: result.skippedDuplicates,
|
|
25552
26406
|
unmatchedFiat: result.unmatchedFiat.length,
|
|
25553
26407
|
unmatchedBtc: result.unmatchedBtc.length,
|
|
25554
|
-
yearJournal:
|
|
26408
|
+
yearJournal: path16.relative(directory, yearJournalPath)
|
|
25555
26409
|
});
|
|
25556
26410
|
} catch (err) {
|
|
25557
26411
|
logger.error("Failed to generate BTC purchases", err);
|
|
@@ -25593,8 +26447,8 @@ to produce equity conversion entries for Bitcoin purchases.
|
|
|
25593
26447
|
}
|
|
25594
26448
|
});
|
|
25595
26449
|
// src/index.ts
|
|
25596
|
-
var __dirname2 =
|
|
25597
|
-
var AGENT_FILE =
|
|
26450
|
+
var __dirname2 = dirname6(fileURLToPath3(import.meta.url));
|
|
26451
|
+
var AGENT_FILE = join15(__dirname2, "..", "agent", "accountant.md");
|
|
25598
26452
|
var AccountantPlugin = async () => {
|
|
25599
26453
|
const agent = loadAgent(AGENT_FILE);
|
|
25600
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
|
|
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
|
▼
|
|
@@ -348,10 +378,13 @@ See [reconcile-statement](reconcile-statement.md) for details.
|
|
|
348
378
|
|
|
349
379
|
### Sequential Processing (Fail-Fast)
|
|
350
380
|
|
|
351
|
-
The pipeline processes contexts **one at a time
|
|
381
|
+
The pipeline processes contexts **one at a time**, ordered by:
|
|
382
|
+
|
|
383
|
+
1. **Provider `importOrder`** — lower values first (default: `0`). Configure in `providers.yaml` to control which provider imports first (e.g., UBS before Revolut).
|
|
384
|
+
2. **BTC-last** — within the same `importOrder`, Revolut BTC contexts are sorted after fiat contexts (needed for fiat→BTC purchase matching).
|
|
352
385
|
|
|
353
386
|
```
|
|
354
|
-
for each contextId:
|
|
387
|
+
for each contextId (sorted by importOrder, then BTC-last):
|
|
355
388
|
1. Load context
|
|
356
389
|
2. Run steps 2-4 for this context
|
|
357
390
|
3. If ANY step fails → STOP PIPELINE
|