@fuzzle/opencode-accountant 0.13.17 → 0.13.18-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 +256 -144
- package/docs/configuration/merger-fmv.md +67 -0
- package/docs/tools/import-pipeline.md +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -4280,7 +4280,7 @@ __export(exports_accountSuggester, {
|
|
|
4280
4280
|
extractRulePatternsFromFile: () => extractRulePatternsFromFile,
|
|
4281
4281
|
clearSuggestionCache: () => clearSuggestionCache
|
|
4282
4282
|
});
|
|
4283
|
-
import * as
|
|
4283
|
+
import * as fs22 from "fs";
|
|
4284
4284
|
import * as crypto from "crypto";
|
|
4285
4285
|
function clearSuggestionCache() {
|
|
4286
4286
|
Object.keys(suggestionCache).forEach((key) => delete suggestionCache[key]);
|
|
@@ -4290,10 +4290,10 @@ function hashTransaction(posting) {
|
|
|
4290
4290
|
return crypto.createHash("md5").update(data).digest("hex");
|
|
4291
4291
|
}
|
|
4292
4292
|
function loadExistingAccounts(yearJournalPath) {
|
|
4293
|
-
if (!
|
|
4293
|
+
if (!fs22.existsSync(yearJournalPath)) {
|
|
4294
4294
|
return [];
|
|
4295
4295
|
}
|
|
4296
|
-
const content =
|
|
4296
|
+
const content = fs22.readFileSync(yearJournalPath, "utf-8");
|
|
4297
4297
|
const lines = content.split(`
|
|
4298
4298
|
`);
|
|
4299
4299
|
const accounts = [];
|
|
@@ -4309,10 +4309,10 @@ function loadExistingAccounts(yearJournalPath) {
|
|
|
4309
4309
|
return accounts.sort();
|
|
4310
4310
|
}
|
|
4311
4311
|
function extractRulePatternsFromFile(rulesPath) {
|
|
4312
|
-
if (!
|
|
4312
|
+
if (!fs22.existsSync(rulesPath)) {
|
|
4313
4313
|
return [];
|
|
4314
4314
|
}
|
|
4315
|
-
const content =
|
|
4315
|
+
const content = fs22.readFileSync(rulesPath, "utf-8");
|
|
4316
4316
|
const lines = content.split(`
|
|
4317
4317
|
`);
|
|
4318
4318
|
const patterns = [];
|
|
@@ -4547,7 +4547,7 @@ var init_accountSuggester = __esm(() => {
|
|
|
4547
4547
|
|
|
4548
4548
|
// src/index.ts
|
|
4549
4549
|
init_agentLoader();
|
|
4550
|
-
import { dirname as dirname9, join as
|
|
4550
|
+
import { dirname as dirname9, join as join17 } from "path";
|
|
4551
4551
|
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
4552
4552
|
|
|
4553
4553
|
// node_modules/zod/v4/classic/external.js
|
|
@@ -24208,8 +24208,8 @@ Note: This tool requires a contextId from a prior classify/import step.`,
|
|
|
24208
24208
|
}
|
|
24209
24209
|
});
|
|
24210
24210
|
// src/tools/import-pipeline.ts
|
|
24211
|
-
import * as
|
|
24212
|
-
import * as
|
|
24211
|
+
import * as fs23 from "fs";
|
|
24212
|
+
import * as path17 from "path";
|
|
24213
24213
|
|
|
24214
24214
|
// src/utils/accountDeclarations.ts
|
|
24215
24215
|
init_journalMatchers();
|
|
@@ -24954,8 +24954,8 @@ Got:
|
|
|
24954
24954
|
}
|
|
24955
24955
|
|
|
24956
24956
|
// src/utils/swissquoteCsvPreprocessor.ts
|
|
24957
|
-
import * as
|
|
24958
|
-
import * as
|
|
24957
|
+
import * as fs19 from "fs";
|
|
24958
|
+
import * as path14 from "path";
|
|
24959
24959
|
|
|
24960
24960
|
// src/utils/symbolNormalizer.ts
|
|
24961
24961
|
function normalizeSymbol(symbol2) {
|
|
@@ -25462,6 +25462,18 @@ function generateMultiWayMergerEntry(group, crossCurrencyOutgoingSymbols, crossC
|
|
|
25462
25462
|
entries.push(sub);
|
|
25463
25463
|
}
|
|
25464
25464
|
}
|
|
25465
|
+
if (group.cashSettlement) {
|
|
25466
|
+
const cashCurrency = group.cashSettlement.currency.toLowerCase();
|
|
25467
|
+
const cashAmount = formatAmount2(group.cashSettlement.amount, group.cashSettlement.currency);
|
|
25468
|
+
let sub = `${date5} ${description}
|
|
25469
|
+
`;
|
|
25470
|
+
sub += metadata;
|
|
25471
|
+
sub += ` assets:broker:swissquote:${cashCurrency} ${cashAmount}
|
|
25472
|
+
`;
|
|
25473
|
+
sub += ` income:capital-gains:realized
|
|
25474
|
+
`;
|
|
25475
|
+
entries.push(sub);
|
|
25476
|
+
}
|
|
25465
25477
|
return entries.join(`
|
|
25466
25478
|
`);
|
|
25467
25479
|
}
|
|
@@ -25532,6 +25544,52 @@ function formatJournalFile(entries, year, currency) {
|
|
|
25532
25544
|
`);
|
|
25533
25545
|
}
|
|
25534
25546
|
|
|
25547
|
+
// src/utils/mergerFmvConfig.ts
|
|
25548
|
+
init_js_yaml();
|
|
25549
|
+
import * as fs18 from "fs";
|
|
25550
|
+
import * as path13 from "path";
|
|
25551
|
+
var CONFIG_FILE3 = "config/import/merger-fmv.yaml";
|
|
25552
|
+
function loadMergerFmvConfig(projectDir) {
|
|
25553
|
+
const configPath = path13.join(projectDir, CONFIG_FILE3);
|
|
25554
|
+
if (!fs18.existsSync(configPath)) {
|
|
25555
|
+
return null;
|
|
25556
|
+
}
|
|
25557
|
+
const content = fs18.readFileSync(configPath, "utf-8");
|
|
25558
|
+
const parsed = jsYaml.load(content);
|
|
25559
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
25560
|
+
return null;
|
|
25561
|
+
}
|
|
25562
|
+
const config2 = {};
|
|
25563
|
+
for (const [rawKey, symbols] of Object.entries(parsed)) {
|
|
25564
|
+
let dateKey;
|
|
25565
|
+
const d = new Date(rawKey);
|
|
25566
|
+
if (!isNaN(d.getTime()) && !/^\d{4}-\d{2}-\d{2}$/.test(rawKey)) {
|
|
25567
|
+
dateKey = d.toISOString().split("T")[0];
|
|
25568
|
+
} else {
|
|
25569
|
+
dateKey = rawKey;
|
|
25570
|
+
}
|
|
25571
|
+
if (typeof symbols !== "object" || symbols === null) {
|
|
25572
|
+
continue;
|
|
25573
|
+
}
|
|
25574
|
+
const symbolMap = {};
|
|
25575
|
+
for (const [symbol2, price] of Object.entries(symbols)) {
|
|
25576
|
+
if (typeof price === "number") {
|
|
25577
|
+
symbolMap[symbol2] = price;
|
|
25578
|
+
}
|
|
25579
|
+
}
|
|
25580
|
+
if (Object.keys(symbolMap).length > 0) {
|
|
25581
|
+
config2[dateKey] = symbolMap;
|
|
25582
|
+
}
|
|
25583
|
+
}
|
|
25584
|
+
return Object.keys(config2).length > 0 ? config2 : null;
|
|
25585
|
+
}
|
|
25586
|
+
function lookupFmvForDate(config2, date5) {
|
|
25587
|
+
if (!config2) {
|
|
25588
|
+
return null;
|
|
25589
|
+
}
|
|
25590
|
+
return config2[date5] ?? null;
|
|
25591
|
+
}
|
|
25592
|
+
|
|
25535
25593
|
// src/utils/swissquoteCsvPreprocessor.ts
|
|
25536
25594
|
var SWISSQUOTE_HEADER = "Date;Order #;Transaction;Symbol;Name;ISIN;Quantity;Unit price;Costs;Accrued Interest;Net Amount;Balance;Currency";
|
|
25537
25595
|
var SIMPLE_TRANSACTION_TYPES = new Set([
|
|
@@ -25742,7 +25800,26 @@ function groupMergerActions(actions) {
|
|
|
25742
25800
|
}
|
|
25743
25801
|
return Array.from(groupMap.values());
|
|
25744
25802
|
}
|
|
25745
|
-
function
|
|
25803
|
+
function computeAllocationProportions(incoming, fmvData, date5, logger) {
|
|
25804
|
+
if (incoming.length <= 1) {
|
|
25805
|
+
return incoming.map(() => 1);
|
|
25806
|
+
}
|
|
25807
|
+
const quantities = incoming.map((a) => Math.abs(a.quantity));
|
|
25808
|
+
const symbols = incoming.map((a) => a.symbol);
|
|
25809
|
+
if (fmvData) {
|
|
25810
|
+
const allCovered = symbols.every((s) => (s in fmvData));
|
|
25811
|
+
if (allCovered) {
|
|
25812
|
+
const fmvValues = symbols.map((s, i2) => fmvData[s] * quantities[i2]);
|
|
25813
|
+
const totalFmv = fmvValues.reduce((sum, v) => sum + v, 0);
|
|
25814
|
+
if (totalFmv > 0) {
|
|
25815
|
+
logger?.info(`Using FMV-based cost allocation for merger (total FMV: ${totalFmv.toFixed(2)})`);
|
|
25816
|
+
return fmvValues.map((v) => v / totalFmv);
|
|
25817
|
+
}
|
|
25818
|
+
}
|
|
25819
|
+
}
|
|
25820
|
+
throw new Error(`Merger on ${date5} has ${incoming.length} incoming symbols (${symbols.join(", ")}) but no FMV data. ` + `Add prices to config/import/merger-fmv.yaml for this date.`);
|
|
25821
|
+
}
|
|
25822
|
+
function processMultiWayMerger(group, inventory, lotInventoryPath, projectDir, mergerFmv, logger) {
|
|
25746
25823
|
const entries = [];
|
|
25747
25824
|
if (group.outgoing.length === 0 && group.incoming.length === 0) {
|
|
25748
25825
|
return entries;
|
|
@@ -25761,12 +25838,14 @@ function processMultiWayMerger(group, inventory, lotInventoryPath, projectDir, l
|
|
|
25761
25838
|
quantity: -(pendingState.outgoingQuantities[i2] || 0)
|
|
25762
25839
|
});
|
|
25763
25840
|
}
|
|
25764
|
-
const
|
|
25841
|
+
const mergerDate = formatDate(group.date);
|
|
25842
|
+
const fmvData2 = lookupFmvForDate(mergerFmv, mergerDate);
|
|
25843
|
+
const proportions2 = computeAllocationProportions(group.incoming, fmvData2, mergerDate, logger);
|
|
25765
25844
|
const incomingTotalCosts2 = [];
|
|
25766
|
-
for (
|
|
25845
|
+
for (let idx = 0;idx < group.incoming.length; idx++) {
|
|
25846
|
+
const inc = group.incoming[idx];
|
|
25767
25847
|
const absQty = Math.abs(inc.quantity);
|
|
25768
|
-
const
|
|
25769
|
-
const allocatedCost = pendingState.totalCostBasis * proportion;
|
|
25848
|
+
const allocatedCost = pendingState.totalCostBasis * proportions2[idx];
|
|
25770
25849
|
const costBasisPerUnit = absQty > 0 ? allocatedCost / absQty : 0;
|
|
25771
25850
|
incomingTotalCosts2.push(allocatedCost);
|
|
25772
25851
|
const lot = {
|
|
@@ -25838,12 +25917,14 @@ function processMultiWayMerger(group, inventory, lotInventoryPath, projectDir, l
|
|
|
25838
25917
|
removeLots(inventory, out.symbol, logger);
|
|
25839
25918
|
logger?.debug(`Merger outgoing: removed ${absQty} ${out.symbol}`);
|
|
25840
25919
|
}
|
|
25841
|
-
const
|
|
25920
|
+
const mergerDateFull = formatDate(group.date);
|
|
25921
|
+
const fmvData = lookupFmvForDate(mergerFmv, mergerDateFull);
|
|
25922
|
+
const proportions = computeAllocationProportions(group.incoming, fmvData, mergerDateFull, logger);
|
|
25842
25923
|
const incomingTotalCosts = [];
|
|
25843
|
-
for (
|
|
25924
|
+
for (let idx = 0;idx < group.incoming.length; idx++) {
|
|
25925
|
+
const inc = group.incoming[idx];
|
|
25844
25926
|
const absQty = Math.abs(inc.quantity);
|
|
25845
|
-
const
|
|
25846
|
-
const allocatedCost = totalCostBasis * proportion;
|
|
25927
|
+
const allocatedCost = totalCostBasis * proportions[idx];
|
|
25847
25928
|
const costBasisPerUnit = absQty > 0 ? allocatedCost / absQty : 0;
|
|
25848
25929
|
incomingTotalCosts.push(allocatedCost);
|
|
25849
25930
|
const lot = {
|
|
@@ -25871,25 +25952,25 @@ function processMultiWayMerger(group, inventory, lotInventoryPath, projectDir, l
|
|
|
25871
25952
|
}
|
|
25872
25953
|
function getPendingMergerDir(projectDir, lotInventoryPath) {
|
|
25873
25954
|
const lotDir = lotInventoryPath.replace(/{symbol}[^/]*$/, "");
|
|
25874
|
-
return
|
|
25955
|
+
return path14.join(projectDir, lotDir, "pending-mergers");
|
|
25875
25956
|
}
|
|
25876
25957
|
function savePendingMerger(projectDir, lotInventoryPath, key, state, logger) {
|
|
25877
25958
|
const dir = getPendingMergerDir(projectDir, lotInventoryPath);
|
|
25878
|
-
if (!
|
|
25879
|
-
|
|
25959
|
+
if (!fs19.existsSync(dir)) {
|
|
25960
|
+
fs19.mkdirSync(dir, { recursive: true });
|
|
25880
25961
|
}
|
|
25881
|
-
const filePath =
|
|
25882
|
-
|
|
25962
|
+
const filePath = path14.join(dir, `${key.replace(/[^a-zA-Z0-9-]/g, "_")}.json`);
|
|
25963
|
+
fs19.writeFileSync(filePath, JSON.stringify(state, null, 2));
|
|
25883
25964
|
logger?.debug(`Saved pending merger state: ${key}`);
|
|
25884
25965
|
}
|
|
25885
25966
|
function loadPendingMerger(projectDir, lotInventoryPath, key, logger) {
|
|
25886
25967
|
const dir = getPendingMergerDir(projectDir, lotInventoryPath);
|
|
25887
|
-
const filePath =
|
|
25888
|
-
if (!
|
|
25968
|
+
const filePath = path14.join(dir, `${key.replace(/[^a-zA-Z0-9-]/g, "_")}.json`);
|
|
25969
|
+
if (!fs19.existsSync(filePath)) {
|
|
25889
25970
|
return null;
|
|
25890
25971
|
}
|
|
25891
25972
|
try {
|
|
25892
|
-
const content =
|
|
25973
|
+
const content = fs19.readFileSync(filePath, "utf-8");
|
|
25893
25974
|
return JSON.parse(content);
|
|
25894
25975
|
} catch (error45) {
|
|
25895
25976
|
const message = error45 instanceof Error ? error45.message : String(error45);
|
|
@@ -25899,27 +25980,27 @@ function loadPendingMerger(projectDir, lotInventoryPath, key, logger) {
|
|
|
25899
25980
|
}
|
|
25900
25981
|
function removePendingMerger(projectDir, lotInventoryPath, key, logger) {
|
|
25901
25982
|
const dir = getPendingMergerDir(projectDir, lotInventoryPath);
|
|
25902
|
-
const filePath =
|
|
25903
|
-
if (
|
|
25904
|
-
|
|
25983
|
+
const filePath = path14.join(dir, `${key.replace(/[^a-zA-Z0-9-]/g, "_")}.json`);
|
|
25984
|
+
if (fs19.existsSync(filePath)) {
|
|
25985
|
+
fs19.unlinkSync(filePath);
|
|
25905
25986
|
logger?.debug(`Removed pending merger state: ${key}`);
|
|
25906
25987
|
}
|
|
25907
25988
|
}
|
|
25908
25989
|
function resolveRemainingPendingMergers(projectDir, lotInventoryPath, logger) {
|
|
25909
25990
|
const pendingDir = getPendingMergerDir(projectDir, lotInventoryPath);
|
|
25910
|
-
if (!
|
|
25991
|
+
if (!fs19.existsSync(pendingDir)) {
|
|
25911
25992
|
return { resolved: 0, journalFilesUpdated: [] };
|
|
25912
25993
|
}
|
|
25913
|
-
const files =
|
|
25994
|
+
const files = fs19.readdirSync(pendingDir).filter((f) => f.endsWith(".json"));
|
|
25914
25995
|
if (files.length === 0) {
|
|
25915
25996
|
return { resolved: 0, journalFilesUpdated: [] };
|
|
25916
25997
|
}
|
|
25917
25998
|
const entriesByJournal = new Map;
|
|
25918
25999
|
const resolvedFiles = [];
|
|
25919
26000
|
for (const file2 of files) {
|
|
25920
|
-
const filePath =
|
|
26001
|
+
const filePath = path14.join(pendingDir, file2);
|
|
25921
26002
|
try {
|
|
25922
|
-
const content =
|
|
26003
|
+
const content = fs19.readFileSync(filePath, "utf-8");
|
|
25923
26004
|
const state = JSON.parse(content);
|
|
25924
26005
|
const dateMatch = state.date.match(/\d{2}-\d{2}-(\d{4})/);
|
|
25925
26006
|
if (!dateMatch) {
|
|
@@ -25928,12 +26009,12 @@ function resolveRemainingPendingMergers(projectDir, lotInventoryPath, logger) {
|
|
|
25928
26009
|
}
|
|
25929
26010
|
const year = parseInt(dateMatch[1], 10);
|
|
25930
26011
|
const entry = generateDirectWorthlessEntry(state.date, state.orderNum, state.outgoingSymbols, state.outgoingIsins, state.outgoingQuantities || [], state.outgoingTotalCosts || [], state.totalCostBasis, state.currency, logger);
|
|
25931
|
-
const journalFile =
|
|
26012
|
+
const journalFile = path14.join(projectDir, "ledger", "investments", `${year}-${state.currency.toLowerCase()}.journal`);
|
|
25932
26013
|
if (!entriesByJournal.has(journalFile)) {
|
|
25933
26014
|
entriesByJournal.set(journalFile, []);
|
|
25934
26015
|
}
|
|
25935
26016
|
entriesByJournal.get(journalFile).push(entry);
|
|
25936
|
-
|
|
26017
|
+
fs19.unlinkSync(filePath);
|
|
25937
26018
|
resolvedFiles.push(file2);
|
|
25938
26019
|
logger?.info(`Resolved pending merger ${file2} as worthless: ${state.outgoingSymbols.join(", ")} (${state.totalCostBasis.toFixed(2)} ${state.currency})`);
|
|
25939
26020
|
} catch (error45) {
|
|
@@ -25943,32 +26024,33 @@ function resolveRemainingPendingMergers(projectDir, lotInventoryPath, logger) {
|
|
|
25943
26024
|
}
|
|
25944
26025
|
const journalFilesUpdated = [];
|
|
25945
26026
|
for (const [journalFile, journalEntries] of entriesByJournal) {
|
|
25946
|
-
if (
|
|
25947
|
-
|
|
26027
|
+
if (fs19.existsSync(journalFile)) {
|
|
26028
|
+
fs19.appendFileSync(journalFile, `
|
|
25948
26029
|
` + journalEntries.join(`
|
|
25949
26030
|
`));
|
|
25950
26031
|
} else {
|
|
25951
|
-
const basename5 =
|
|
26032
|
+
const basename5 = path14.basename(journalFile, ".journal");
|
|
25952
26033
|
const parts = basename5.split("-");
|
|
25953
26034
|
const yearStr = parts[0];
|
|
25954
26035
|
const currency = parts.slice(1).join("-");
|
|
25955
26036
|
const header = formatJournalFile(journalEntries, parseInt(yearStr, 10), currency);
|
|
25956
|
-
|
|
26037
|
+
fs19.writeFileSync(journalFile, header);
|
|
25957
26038
|
}
|
|
25958
26039
|
journalFilesUpdated.push(journalFile);
|
|
25959
|
-
logger?.info(`Updated ${
|
|
26040
|
+
logger?.info(`Updated ${path14.basename(journalFile)} with worthless resolution entries`);
|
|
25960
26041
|
}
|
|
25961
|
-
const remaining =
|
|
26042
|
+
const remaining = fs19.readdirSync(pendingDir).filter((f) => f.endsWith(".json"));
|
|
25962
26043
|
if (remaining.length === 0) {
|
|
25963
26044
|
try {
|
|
25964
|
-
|
|
26045
|
+
fs19.rmdirSync(pendingDir);
|
|
25965
26046
|
logger?.debug("Removed empty pending-mergers directory");
|
|
25966
26047
|
} catch {}
|
|
25967
26048
|
}
|
|
25968
26049
|
return { resolved: resolvedFiles.length, journalFilesUpdated };
|
|
25969
26050
|
}
|
|
25970
26051
|
async function preprocessSwissquote(csvPath, projectDir, currency, year, lotInventoryPath = DEFAULT_LOT_INVENTORY_PATH, symbolMap = {}, logger) {
|
|
25971
|
-
logger?.startSection(`Swissquote Preprocessing: ${
|
|
26052
|
+
logger?.startSection(`Swissquote Preprocessing: ${path14.basename(csvPath)}`);
|
|
26053
|
+
const mergerFmv = loadMergerFmvConfig(projectDir);
|
|
25972
26054
|
const stats = {
|
|
25973
26055
|
totalRows: 0,
|
|
25974
26056
|
simpleTransactions: 0,
|
|
@@ -25978,7 +26060,7 @@ async function preprocessSwissquote(csvPath, projectDir, currency, year, lotInve
|
|
|
25978
26060
|
forexTransactions: 0,
|
|
25979
26061
|
skipped: 0
|
|
25980
26062
|
};
|
|
25981
|
-
if (!
|
|
26063
|
+
if (!fs19.existsSync(csvPath)) {
|
|
25982
26064
|
logger?.error(`CSV file not found: ${csvPath}`);
|
|
25983
26065
|
logger?.endSection();
|
|
25984
26066
|
return {
|
|
@@ -25990,7 +26072,7 @@ async function preprocessSwissquote(csvPath, projectDir, currency, year, lotInve
|
|
|
25990
26072
|
};
|
|
25991
26073
|
}
|
|
25992
26074
|
logger?.logStep("parse-csv", "start", `Reading ${csvPath}`);
|
|
25993
|
-
const content =
|
|
26075
|
+
const content = fs19.readFileSync(csvPath, "utf-8");
|
|
25994
26076
|
const lines = content.split(`
|
|
25995
26077
|
`).filter((line) => line.trim());
|
|
25996
26078
|
const header = lines[0];
|
|
@@ -26034,6 +26116,7 @@ async function preprocessSwissquote(csvPath, projectDir, currency, year, lotInve
|
|
|
26034
26116
|
const dividends = [];
|
|
26035
26117
|
const corporateActions = [];
|
|
26036
26118
|
const forexTransactions = [];
|
|
26119
|
+
const mergerCashSettlements = [];
|
|
26037
26120
|
for (const txn of transactions) {
|
|
26038
26121
|
const category = categorizeTransaction(txn);
|
|
26039
26122
|
switch (category) {
|
|
@@ -26052,8 +26135,12 @@ async function preprocessSwissquote(csvPath, projectDir, currency, year, lotInve
|
|
|
26052
26135
|
case "corporate":
|
|
26053
26136
|
if (!txn.symbol || txn.symbol.trim() === "") {
|
|
26054
26137
|
if (txn.netAmount && txn.netAmount !== "-") {
|
|
26055
|
-
|
|
26056
|
-
|
|
26138
|
+
mergerCashSettlements.push({
|
|
26139
|
+
dateOrderKey: `${txn.date}-${txn.orderNum}`,
|
|
26140
|
+
amount: parseNumber(txn.netAmount),
|
|
26141
|
+
currency: txn.currency
|
|
26142
|
+
});
|
|
26143
|
+
stats.corporateActions++;
|
|
26057
26144
|
} else {
|
|
26058
26145
|
stats.skipped++;
|
|
26059
26146
|
}
|
|
@@ -26097,6 +26184,31 @@ async function preprocessSwissquote(csvPath, projectDir, currency, year, lotInve
|
|
|
26097
26184
|
}
|
|
26098
26185
|
}
|
|
26099
26186
|
const mergerGroups = groupMergerActions(mergerActions);
|
|
26187
|
+
for (const cash of mergerCashSettlements) {
|
|
26188
|
+
const matchingGroup = mergerGroups.find((g) => g.key === cash.dateOrderKey);
|
|
26189
|
+
if (matchingGroup) {
|
|
26190
|
+
matchingGroup.cashSettlement = { amount: cash.amount, currency: cash.currency };
|
|
26191
|
+
} else {
|
|
26192
|
+
simpleTransactions.push({
|
|
26193
|
+
date: cash.dateOrderKey.split("-").slice(0, 3).join("-"),
|
|
26194
|
+
orderNum: cash.dateOrderKey.split("-").slice(3).join("-"),
|
|
26195
|
+
transaction: "Merger",
|
|
26196
|
+
symbol: "",
|
|
26197
|
+
name: "",
|
|
26198
|
+
isin: "",
|
|
26199
|
+
quantity: "",
|
|
26200
|
+
unitPrice: "",
|
|
26201
|
+
costs: "",
|
|
26202
|
+
accruedInterest: "",
|
|
26203
|
+
netAmount: String(cash.amount),
|
|
26204
|
+
balance: "",
|
|
26205
|
+
currency: cash.currency
|
|
26206
|
+
});
|
|
26207
|
+
stats.simpleTransactions++;
|
|
26208
|
+
stats.corporateActions--;
|
|
26209
|
+
logger?.warn(`Cash settlement ${cash.dateOrderKey} has no matching merger group \u2014 routed to simple CSV`);
|
|
26210
|
+
}
|
|
26211
|
+
}
|
|
26100
26212
|
for (const group of mergerGroups) {
|
|
26101
26213
|
timeline.push({ kind: "mergerGroup", sortDate: formatDate(group.date), group });
|
|
26102
26214
|
}
|
|
@@ -26148,7 +26260,7 @@ async function preprocessSwissquote(csvPath, projectDir, currency, year, lotInve
|
|
|
26148
26260
|
break;
|
|
26149
26261
|
}
|
|
26150
26262
|
case "mergerGroup": {
|
|
26151
|
-
const groupEntries = processMultiWayMerger(event.group, inventory, lotInventoryPath, projectDir, logger);
|
|
26263
|
+
const groupEntries = processMultiWayMerger(event.group, inventory, lotInventoryPath, projectDir, mergerFmv, logger);
|
|
26152
26264
|
journalEntries.push(...groupEntries);
|
|
26153
26265
|
break;
|
|
26154
26266
|
}
|
|
@@ -26165,9 +26277,9 @@ async function preprocessSwissquote(csvPath, projectDir, currency, year, lotInve
|
|
|
26165
26277
|
logger?.logStep("save-inventory", "success", "Lot inventory saved");
|
|
26166
26278
|
let simpleTransactionsCsv = null;
|
|
26167
26279
|
let journalFile = null;
|
|
26168
|
-
const csvDir =
|
|
26169
|
-
const csvBasename =
|
|
26170
|
-
simpleTransactionsCsv =
|
|
26280
|
+
const csvDir = path14.dirname(csvPath);
|
|
26281
|
+
const csvBasename = path14.basename(csvPath, ".csv");
|
|
26282
|
+
simpleTransactionsCsv = path14.join(csvDir, `${csvBasename}-simple.csv`);
|
|
26171
26283
|
const csvContent = [
|
|
26172
26284
|
SWISSQUOTE_HEADER,
|
|
26173
26285
|
...simpleTransactions.map((txn) => [
|
|
@@ -26187,23 +26299,23 @@ async function preprocessSwissquote(csvPath, projectDir, currency, year, lotInve
|
|
|
26187
26299
|
].join(";"))
|
|
26188
26300
|
].join(`
|
|
26189
26301
|
`);
|
|
26190
|
-
|
|
26191
|
-
logger?.logStep("write-csv", "success", `Wrote ${simpleTransactions.length} simple transactions to ${
|
|
26302
|
+
fs19.writeFileSync(simpleTransactionsCsv, csvContent);
|
|
26303
|
+
logger?.logStep("write-csv", "success", `Wrote ${simpleTransactions.length} simple transactions to ${path14.basename(simpleTransactionsCsv)}`);
|
|
26192
26304
|
if (journalEntries.length > 0) {
|
|
26193
|
-
const investmentsDir =
|
|
26194
|
-
if (!
|
|
26195
|
-
|
|
26305
|
+
const investmentsDir = path14.join(projectDir, "ledger", "investments");
|
|
26306
|
+
if (!fs19.existsSync(investmentsDir)) {
|
|
26307
|
+
fs19.mkdirSync(investmentsDir, { recursive: true });
|
|
26196
26308
|
}
|
|
26197
|
-
journalFile =
|
|
26309
|
+
journalFile = path14.join(investmentsDir, `${year}-${currency}.journal`);
|
|
26198
26310
|
const journalContent = formatJournalFile(journalEntries, year, currency);
|
|
26199
|
-
if (
|
|
26200
|
-
|
|
26311
|
+
if (fs19.existsSync(journalFile)) {
|
|
26312
|
+
fs19.appendFileSync(journalFile, `
|
|
26201
26313
|
` + journalEntries.join(`
|
|
26202
26314
|
`));
|
|
26203
|
-
logger?.logStep("write-journal", "success", `Appended ${journalEntries.length} entries to ${
|
|
26315
|
+
logger?.logStep("write-journal", "success", `Appended ${journalEntries.length} entries to ${path14.basename(journalFile)}`);
|
|
26204
26316
|
} else {
|
|
26205
|
-
|
|
26206
|
-
logger?.logStep("write-journal", "success", `Created ${
|
|
26317
|
+
fs19.writeFileSync(journalFile, journalContent);
|
|
26318
|
+
logger?.logStep("write-journal", "success", `Created ${path14.basename(journalFile)} with ${journalEntries.length} entries`);
|
|
26207
26319
|
}
|
|
26208
26320
|
}
|
|
26209
26321
|
const stockSymbols = new Set;
|
|
@@ -26220,9 +26332,9 @@ async function preprocessSwissquote(csvPath, projectDir, currency, year, lotInve
|
|
|
26220
26332
|
}
|
|
26221
26333
|
}
|
|
26222
26334
|
if (stockSymbols.size > 0) {
|
|
26223
|
-
const commodityJournalPath =
|
|
26335
|
+
const commodityJournalPath = path14.join(projectDir, "ledger", "investments", "commodities.journal");
|
|
26224
26336
|
ensureCommodityDeclarations(commodityJournalPath, stockSymbols, logger);
|
|
26225
|
-
const accountJournalPath =
|
|
26337
|
+
const accountJournalPath = path14.join(projectDir, "ledger", "investments", "accounts.journal");
|
|
26226
26338
|
const investmentAccounts = new Set;
|
|
26227
26339
|
for (const symbol2 of stockSymbols) {
|
|
26228
26340
|
investmentAccounts.add(`assets:investments:stocks:${symbol2}`);
|
|
@@ -26259,10 +26371,10 @@ async function preprocessSwissquote(csvPath, projectDir, currency, year, lotInve
|
|
|
26259
26371
|
}
|
|
26260
26372
|
|
|
26261
26373
|
// src/utils/currencyExchangeGenerator.ts
|
|
26262
|
-
import * as
|
|
26263
|
-
import * as
|
|
26374
|
+
import * as fs20 from "fs";
|
|
26375
|
+
import * as path15 from "path";
|
|
26264
26376
|
function parseExchangeRows(csvPath) {
|
|
26265
|
-
const content =
|
|
26377
|
+
const content = fs20.readFileSync(csvPath, "utf-8");
|
|
26266
26378
|
const lines = content.trim().split(`
|
|
26267
26379
|
`);
|
|
26268
26380
|
if (lines.length < 2)
|
|
@@ -26386,7 +26498,7 @@ function formatDateIso2(date5) {
|
|
|
26386
26498
|
return `${y}-${m}-${d}`;
|
|
26387
26499
|
}
|
|
26388
26500
|
function filterExchangeRows(csvPath, rowIndicesToRemove, logger) {
|
|
26389
|
-
const content =
|
|
26501
|
+
const content = fs20.readFileSync(csvPath, "utf-8");
|
|
26390
26502
|
const lines = content.trim().split(`
|
|
26391
26503
|
`);
|
|
26392
26504
|
const filteredLines = [lines[0]];
|
|
@@ -26395,13 +26507,13 @@ function filterExchangeRows(csvPath, rowIndicesToRemove, logger) {
|
|
|
26395
26507
|
continue;
|
|
26396
26508
|
filteredLines.push(lines[i2]);
|
|
26397
26509
|
}
|
|
26398
|
-
const dir =
|
|
26399
|
-
const basename6 =
|
|
26400
|
-
const filteredPath =
|
|
26401
|
-
|
|
26510
|
+
const dir = path15.dirname(csvPath);
|
|
26511
|
+
const basename6 = path15.basename(csvPath, ".csv");
|
|
26512
|
+
const filteredPath = path15.join(dir, `${basename6}-filtered.csv`);
|
|
26513
|
+
fs20.writeFileSync(filteredPath, filteredLines.join(`
|
|
26402
26514
|
`) + `
|
|
26403
26515
|
`);
|
|
26404
|
-
logger?.info(`Filtered ${rowIndicesToRemove.size} exchange rows from ${
|
|
26516
|
+
logger?.info(`Filtered ${rowIndicesToRemove.size} exchange rows from ${path15.basename(csvPath)} \u2192 ${path15.basename(filteredPath)}`);
|
|
26405
26517
|
return filteredPath;
|
|
26406
26518
|
}
|
|
26407
26519
|
function generateCurrencyExchangeJournal(fiatCsvPaths, yearJournalPath, logger) {
|
|
@@ -26411,7 +26523,7 @@ function generateCurrencyExchangeJournal(fiatCsvPaths, yearJournalPath, logger)
|
|
|
26411
26523
|
const rows = parseExchangeRows(csvPath);
|
|
26412
26524
|
if (rows.length > 0) {
|
|
26413
26525
|
rowsByCsv.set(csvPath, rows);
|
|
26414
|
-
logger?.info(`Found ${rows.length} EXCHANGE rows in ${
|
|
26526
|
+
logger?.info(`Found ${rows.length} EXCHANGE rows in ${path15.basename(csvPath)}`);
|
|
26415
26527
|
}
|
|
26416
26528
|
}
|
|
26417
26529
|
if (rowsByCsv.size < 2) {
|
|
@@ -26428,8 +26540,8 @@ function generateCurrencyExchangeJournal(fiatCsvPaths, yearJournalPath, logger)
|
|
|
26428
26540
|
const matches = matchExchangePairs(rowsByCsv);
|
|
26429
26541
|
logger?.info(`Matched ${matches.length} exchange pairs`);
|
|
26430
26542
|
let journalContent = "";
|
|
26431
|
-
if (
|
|
26432
|
-
journalContent =
|
|
26543
|
+
if (fs20.existsSync(yearJournalPath)) {
|
|
26544
|
+
journalContent = fs20.readFileSync(yearJournalPath, "utf-8");
|
|
26433
26545
|
}
|
|
26434
26546
|
const newEntries = [];
|
|
26435
26547
|
let skippedDuplicates = 0;
|
|
@@ -26456,8 +26568,8 @@ function generateCurrencyExchangeJournal(fiatCsvPaths, yearJournalPath, logger)
|
|
|
26456
26568
|
|
|
26457
26569
|
`) + `
|
|
26458
26570
|
`;
|
|
26459
|
-
|
|
26460
|
-
logger?.info(`Appended ${newEntries.length} currency exchange entries to ${
|
|
26571
|
+
fs20.appendFileSync(yearJournalPath, appendContent);
|
|
26572
|
+
logger?.info(`Appended ${newEntries.length} currency exchange entries to ${path15.basename(yearJournalPath)}`);
|
|
26461
26573
|
} else {
|
|
26462
26574
|
logger?.info("No new currency exchange entries to add");
|
|
26463
26575
|
}
|
|
@@ -26480,8 +26592,8 @@ function generateCurrencyExchangeJournal(fiatCsvPaths, yearJournalPath, logger)
|
|
|
26480
26592
|
}
|
|
26481
26593
|
|
|
26482
26594
|
// src/utils/swissquoteForexGenerator.ts
|
|
26483
|
-
import * as
|
|
26484
|
-
import * as
|
|
26595
|
+
import * as fs21 from "fs";
|
|
26596
|
+
import * as path16 from "path";
|
|
26485
26597
|
function formatDate2(dateStr) {
|
|
26486
26598
|
const match2 = dateStr.match(/^(\d{2})-(\d{2})-(\d{4})/);
|
|
26487
26599
|
if (!match2) {
|
|
@@ -26588,8 +26700,8 @@ function generateSwissquoteForexJournal(allForexRows, yearJournalPath, logger) {
|
|
|
26588
26700
|
logger?.warn(`${unmatchedCount} forex rows could not be matched`);
|
|
26589
26701
|
}
|
|
26590
26702
|
let journalContent = "";
|
|
26591
|
-
if (
|
|
26592
|
-
journalContent =
|
|
26703
|
+
if (fs21.existsSync(yearJournalPath)) {
|
|
26704
|
+
journalContent = fs21.readFileSync(yearJournalPath, "utf-8");
|
|
26593
26705
|
}
|
|
26594
26706
|
const newEntries = [];
|
|
26595
26707
|
let skippedDuplicates = 0;
|
|
@@ -26611,17 +26723,17 @@ function generateSwissquoteForexJournal(allForexRows, yearJournalPath, logger) {
|
|
|
26611
26723
|
newEntries.push(formatForexEntry(match2));
|
|
26612
26724
|
}
|
|
26613
26725
|
if (newEntries.length > 0) {
|
|
26614
|
-
const dir =
|
|
26615
|
-
if (!
|
|
26616
|
-
|
|
26726
|
+
const dir = path16.dirname(yearJournalPath);
|
|
26727
|
+
if (!fs21.existsSync(dir)) {
|
|
26728
|
+
fs21.mkdirSync(dir, { recursive: true });
|
|
26617
26729
|
}
|
|
26618
26730
|
const appendContent = `
|
|
26619
26731
|
` + newEntries.join(`
|
|
26620
26732
|
|
|
26621
26733
|
`) + `
|
|
26622
26734
|
`;
|
|
26623
|
-
|
|
26624
|
-
logger?.info(`Appended ${newEntries.length} forex entries to ${
|
|
26735
|
+
fs21.appendFileSync(yearJournalPath, appendContent);
|
|
26736
|
+
logger?.info(`Appended ${newEntries.length} forex entries to ${path16.basename(yearJournalPath)}`);
|
|
26625
26737
|
} else {
|
|
26626
26738
|
logger?.info("No new forex entries to add");
|
|
26627
26739
|
}
|
|
@@ -26706,7 +26818,7 @@ function executePreprocessFiatStep(context, contextIds, logger) {
|
|
|
26706
26818
|
for (const contextId of contextIds) {
|
|
26707
26819
|
const importCtx = loadContext(context.directory, contextId);
|
|
26708
26820
|
if (importCtx.provider === "revolut" && importCtx.currency !== "btc") {
|
|
26709
|
-
fiatCsvPaths.push(
|
|
26821
|
+
fiatCsvPaths.push(path17.join(context.directory, importCtx.filePath));
|
|
26710
26822
|
}
|
|
26711
26823
|
}
|
|
26712
26824
|
if (fiatCsvPaths.length === 0) {
|
|
@@ -26750,7 +26862,7 @@ function executePreprocessBtcStep(context, contextIds, logger) {
|
|
|
26750
26862
|
for (const contextId of contextIds) {
|
|
26751
26863
|
const importCtx = loadContext(context.directory, contextId);
|
|
26752
26864
|
if (importCtx.provider === "revolut" && importCtx.currency === "btc") {
|
|
26753
|
-
btcCsvPath =
|
|
26865
|
+
btcCsvPath = path17.join(context.directory, importCtx.filePath);
|
|
26754
26866
|
break;
|
|
26755
26867
|
}
|
|
26756
26868
|
}
|
|
@@ -26784,7 +26896,7 @@ async function executeBtcPurchaseStep(context, contextIds, logger) {
|
|
|
26784
26896
|
const importCtx = loadContext(context.directory, contextId);
|
|
26785
26897
|
if (importCtx.provider !== "revolut")
|
|
26786
26898
|
continue;
|
|
26787
|
-
const csvPath =
|
|
26899
|
+
const csvPath = path17.join(context.directory, importCtx.filePath);
|
|
26788
26900
|
if (importCtx.currency === "btc") {
|
|
26789
26901
|
btcCsvPath = csvPath;
|
|
26790
26902
|
} else {
|
|
@@ -26797,7 +26909,7 @@ async function executeBtcPurchaseStep(context, contextIds, logger) {
|
|
|
26797
26909
|
}
|
|
26798
26910
|
logger?.startSection("Step 1b: Generate BTC Purchase Entries");
|
|
26799
26911
|
logger?.logStep("BTC Purchases", "start");
|
|
26800
|
-
const btcFilename =
|
|
26912
|
+
const btcFilename = path17.basename(btcCsvPath);
|
|
26801
26913
|
const yearMatch = btcFilename.match(/(\d{4})-\d{2}-\d{2}/);
|
|
26802
26914
|
const year = yearMatch ? parseInt(yearMatch[1], 10) : new Date().getFullYear();
|
|
26803
26915
|
const yearJournalPath = ensureYearJournalExists(context.directory, year);
|
|
@@ -26824,7 +26936,7 @@ async function executeCurrencyExchangeStep(context, contextIds, logger) {
|
|
|
26824
26936
|
continue;
|
|
26825
26937
|
if (importCtx.currency === "btc")
|
|
26826
26938
|
continue;
|
|
26827
|
-
const csvPath =
|
|
26939
|
+
const csvPath = path17.join(context.directory, importCtx.filePath);
|
|
26828
26940
|
fiatContexts.push({ contextId, csvPath });
|
|
26829
26941
|
}
|
|
26830
26942
|
if (fiatContexts.length < 2) {
|
|
@@ -26833,7 +26945,7 @@ async function executeCurrencyExchangeStep(context, contextIds, logger) {
|
|
|
26833
26945
|
}
|
|
26834
26946
|
logger?.startSection("Step 1e: Generate Currency Exchange Entries");
|
|
26835
26947
|
logger?.logStep("Currency Exchanges", "start");
|
|
26836
|
-
const firstFilename =
|
|
26948
|
+
const firstFilename = path17.basename(fiatContexts[0].csvPath);
|
|
26837
26949
|
const yearMatch = firstFilename.match(/(\d{4})-\d{2}-\d{2}/);
|
|
26838
26950
|
const year = yearMatch ? parseInt(yearMatch[1], 10) : new Date().getFullYear();
|
|
26839
26951
|
const yearJournalPath = ensureYearJournalExists(context.directory, year);
|
|
@@ -26849,9 +26961,9 @@ async function executeCurrencyExchangeStep(context, contextIds, logger) {
|
|
|
26849
26961
|
continue;
|
|
26850
26962
|
const filteredCsvPath = filterExchangeRows(csvPath, indices, logger);
|
|
26851
26963
|
updateContext(context.directory, contextId, {
|
|
26852
|
-
filePath:
|
|
26964
|
+
filePath: path17.relative(context.directory, filteredCsvPath)
|
|
26853
26965
|
});
|
|
26854
|
-
logger?.info(`Updated context ${contextId} to use filtered CSV: ${
|
|
26966
|
+
logger?.info(`Updated context ${contextId} to use filtered CSV: ${path17.basename(filteredCsvPath)}`);
|
|
26855
26967
|
}
|
|
26856
26968
|
}
|
|
26857
26969
|
const message = result.entriesAdded > 0 ? `Generated ${result.entriesAdded} currency exchange entries (${result.matchCount} matched, ${result.skippedDuplicates} skipped)` : `No new currency exchange entries (${result.matchCount} matched, ${result.skippedDuplicates} duplicates)`;
|
|
@@ -26868,7 +26980,7 @@ async function executeSwissquotePreprocessStep(context, contextIds, logger) {
|
|
|
26868
26980
|
for (const contextId of contextIds) {
|
|
26869
26981
|
const importCtx = loadContext(context.directory, contextId);
|
|
26870
26982
|
if (importCtx.provider === "swissquote") {
|
|
26871
|
-
const csvPath =
|
|
26983
|
+
const csvPath = path17.join(context.directory, importCtx.filePath);
|
|
26872
26984
|
const toDateMatch = importCtx.filename.match(/to-\d{4}(\d{4})\./);
|
|
26873
26985
|
let year = new Date().getFullYear();
|
|
26874
26986
|
if (toDateMatch) {
|
|
@@ -26892,11 +27004,11 @@ async function executeSwissquotePreprocessStep(context, contextIds, logger) {
|
|
|
26892
27004
|
const lotInventoryPath = swissquoteProvider?.lotInventoryPath ?? DEFAULT_LOT_INVENTORY_PATH;
|
|
26893
27005
|
const symbolMapPath = swissquoteProvider?.symbolMapPath ?? "config/import/symbolMap.yaml";
|
|
26894
27006
|
let symbolMap = {};
|
|
26895
|
-
const symbolMapFullPath =
|
|
26896
|
-
if (
|
|
27007
|
+
const symbolMapFullPath = path17.join(context.directory, symbolMapPath);
|
|
27008
|
+
if (fs23.existsSync(symbolMapFullPath)) {
|
|
26897
27009
|
try {
|
|
26898
27010
|
const yaml = await Promise.resolve().then(() => (init_js_yaml(), exports_js_yaml));
|
|
26899
|
-
const content =
|
|
27011
|
+
const content = fs23.readFileSync(symbolMapFullPath, "utf-8");
|
|
26900
27012
|
const parsed = yaml.load(content);
|
|
26901
27013
|
if (parsed && typeof parsed === "object") {
|
|
26902
27014
|
symbolMap = parsed;
|
|
@@ -26920,7 +27032,7 @@ async function executeSwissquotePreprocessStep(context, contextIds, logger) {
|
|
|
26920
27032
|
let lastJournalFile = null;
|
|
26921
27033
|
const allForexRows = [];
|
|
26922
27034
|
for (const sqCtx of swissquoteContexts) {
|
|
26923
|
-
logger?.logStep("Swissquote Preprocess", "start", `Processing ${
|
|
27035
|
+
logger?.logStep("Swissquote Preprocess", "start", `Processing ${path17.basename(sqCtx.csvPath)}`);
|
|
26924
27036
|
const result = await preprocessSwissquote(sqCtx.csvPath, context.directory, sqCtx.currency, sqCtx.year, lotInventoryPath, symbolMap, logger);
|
|
26925
27037
|
totalStats.totalRows += result.stats.totalRows;
|
|
26926
27038
|
totalStats.simpleTransactions += result.stats.simpleTransactions;
|
|
@@ -26935,9 +27047,9 @@ async function executeSwissquotePreprocessStep(context, contextIds, logger) {
|
|
|
26935
27047
|
}
|
|
26936
27048
|
if (result.simpleTransactionsCsv) {
|
|
26937
27049
|
updateContext(context.directory, sqCtx.contextId, {
|
|
26938
|
-
filePath:
|
|
27050
|
+
filePath: path17.relative(context.directory, result.simpleTransactionsCsv)
|
|
26939
27051
|
});
|
|
26940
|
-
logger?.info(`Updated context ${sqCtx.contextId} to use filtered CSV: ${
|
|
27052
|
+
logger?.info(`Updated context ${sqCtx.contextId} to use filtered CSV: ${path17.basename(result.simpleTransactionsCsv)}`);
|
|
26941
27053
|
}
|
|
26942
27054
|
logger?.logStep("Swissquote Preprocess", "success", `Processed: ${result.stats.trades} trades, ${result.stats.dividends} dividends, ${result.stats.corporateActions} corporate actions, ${result.stats.forexTransactions} forex`);
|
|
26943
27055
|
}
|
|
@@ -26947,11 +27059,11 @@ async function executeSwissquotePreprocessStep(context, contextIds, logger) {
|
|
|
26947
27059
|
}
|
|
26948
27060
|
if (allForexRows.length > 0) {
|
|
26949
27061
|
const firstCtx = swissquoteContexts[0];
|
|
26950
|
-
const yearJournalPath =
|
|
27062
|
+
const yearJournalPath = path17.join(context.directory, "ledger", `${firstCtx.year}.journal`);
|
|
26951
27063
|
logger?.info(`Generating forex journal entries for ${allForexRows.length} forex rows...`);
|
|
26952
27064
|
const forexResult = generateSwissquoteForexJournal(allForexRows, yearJournalPath, logger);
|
|
26953
27065
|
if (forexResult.accountsUsed.size > 0) {
|
|
26954
|
-
const accountJournalPath =
|
|
27066
|
+
const accountJournalPath = path17.join(context.directory, "ledger", "investments", "accounts.journal");
|
|
26955
27067
|
ensureInvestmentAccountDeclarations(accountJournalPath, forexResult.accountsUsed, logger);
|
|
26956
27068
|
}
|
|
26957
27069
|
logger?.logStep("Swissquote Forex", "success", `Matched ${forexResult.matchCount} forex pairs, added ${forexResult.entriesAdded} entries` + (forexResult.skippedDuplicates > 0 ? `, skipped ${forexResult.skippedDuplicates} duplicates` : "") + (forexResult.unmatchedRows > 0 ? `, ${forexResult.unmatchedRows} unmatched rows` : ""));
|
|
@@ -26977,9 +27089,9 @@ async function executeAccountDeclarationsStep(context, contextId, logger) {
|
|
|
26977
27089
|
logger?.startSection("Step 2: Check Account Declarations");
|
|
26978
27090
|
logger?.logStep("Check Accounts", "start");
|
|
26979
27091
|
const config2 = context.configLoader(context.directory);
|
|
26980
|
-
const rulesDir =
|
|
27092
|
+
const rulesDir = path17.join(context.directory, config2.paths.rules);
|
|
26981
27093
|
const importCtx = loadContext(context.directory, contextId);
|
|
26982
|
-
const csvPath =
|
|
27094
|
+
const csvPath = path17.join(context.directory, importCtx.filePath);
|
|
26983
27095
|
const csvFiles = [csvPath];
|
|
26984
27096
|
const rulesMapping = loadRulesMapping(rulesDir);
|
|
26985
27097
|
const matchedRulesFiles = new Set;
|
|
@@ -27002,7 +27114,7 @@ async function executeAccountDeclarationsStep(context, contextId, logger) {
|
|
|
27002
27114
|
context.result.steps.accountDeclarations = buildStepResult(true, "No accounts found in rules files", {
|
|
27003
27115
|
accountsAdded: [],
|
|
27004
27116
|
journalUpdated: "",
|
|
27005
|
-
rulesScanned: Array.from(matchedRulesFiles).map((f) =>
|
|
27117
|
+
rulesScanned: Array.from(matchedRulesFiles).map((f) => path17.relative(context.directory, f))
|
|
27006
27118
|
});
|
|
27007
27119
|
return;
|
|
27008
27120
|
}
|
|
@@ -27026,7 +27138,7 @@ async function executeAccountDeclarationsStep(context, contextId, logger) {
|
|
|
27026
27138
|
context.result.steps.accountDeclarations = buildStepResult(true, "No transactions found, skipping account declarations", {
|
|
27027
27139
|
accountsAdded: [],
|
|
27028
27140
|
journalUpdated: "",
|
|
27029
|
-
rulesScanned: Array.from(matchedRulesFiles).map((f) =>
|
|
27141
|
+
rulesScanned: Array.from(matchedRulesFiles).map((f) => path17.relative(context.directory, f))
|
|
27030
27142
|
});
|
|
27031
27143
|
return;
|
|
27032
27144
|
}
|
|
@@ -27037,12 +27149,12 @@ async function executeAccountDeclarationsStep(context, contextId, logger) {
|
|
|
27037
27149
|
context.result.steps.accountDeclarations = buildStepResult(false, `Failed to create year journal: ${error45 instanceof Error ? error45.message : String(error45)}`, {
|
|
27038
27150
|
accountsAdded: [],
|
|
27039
27151
|
journalUpdated: "",
|
|
27040
|
-
rulesScanned: Array.from(matchedRulesFiles).map((f) =>
|
|
27152
|
+
rulesScanned: Array.from(matchedRulesFiles).map((f) => path17.relative(context.directory, f))
|
|
27041
27153
|
});
|
|
27042
27154
|
return;
|
|
27043
27155
|
}
|
|
27044
27156
|
const result = ensureAccountDeclarations(yearJournalPath, allAccounts);
|
|
27045
|
-
const message = result.added.length > 0 ? `Added ${result.added.length} account declaration(s) to ${
|
|
27157
|
+
const message = result.added.length > 0 ? `Added ${result.added.length} account declaration(s) to ${path17.relative(context.directory, yearJournalPath)}` : "All required accounts already declared";
|
|
27046
27158
|
logger?.logStep("Check Accounts", "success", message);
|
|
27047
27159
|
if (result.added.length > 0) {
|
|
27048
27160
|
for (const account of result.added) {
|
|
@@ -27051,17 +27163,17 @@ async function executeAccountDeclarationsStep(context, contextId, logger) {
|
|
|
27051
27163
|
}
|
|
27052
27164
|
context.result.steps.accountDeclarations = buildStepResult(true, message, {
|
|
27053
27165
|
accountsAdded: result.added,
|
|
27054
|
-
journalUpdated:
|
|
27055
|
-
rulesScanned: Array.from(matchedRulesFiles).map((f) =>
|
|
27166
|
+
journalUpdated: path17.relative(context.directory, yearJournalPath),
|
|
27167
|
+
rulesScanned: Array.from(matchedRulesFiles).map((f) => path17.relative(context.directory, f))
|
|
27056
27168
|
});
|
|
27057
27169
|
logger?.endSection();
|
|
27058
27170
|
}
|
|
27059
27171
|
async function buildSuggestionContext(context, contextId, logger) {
|
|
27060
27172
|
const { loadExistingAccounts: loadExistingAccounts2, extractRulePatternsFromFile: extractRulePatternsFromFile2 } = await Promise.resolve().then(() => (init_accountSuggester(), exports_accountSuggester));
|
|
27061
27173
|
const config2 = context.configLoader(context.directory);
|
|
27062
|
-
const rulesDir =
|
|
27174
|
+
const rulesDir = path17.join(context.directory, config2.paths.rules);
|
|
27063
27175
|
const importCtx = loadContext(context.directory, contextId);
|
|
27064
|
-
const csvPath =
|
|
27176
|
+
const csvPath = path17.join(context.directory, importCtx.filePath);
|
|
27065
27177
|
const rulesMapping = loadRulesMapping(rulesDir);
|
|
27066
27178
|
const rulesFile = findRulesForCsv(csvPath, rulesMapping);
|
|
27067
27179
|
if (!rulesFile) {
|
|
@@ -27290,8 +27402,8 @@ async function importPipeline(directory, agent, options, configLoader = loadImpo
|
|
|
27290
27402
|
}
|
|
27291
27403
|
}
|
|
27292
27404
|
const importConfig = loadImportConfig(context.directory);
|
|
27293
|
-
const pendingDir =
|
|
27294
|
-
const doneDir =
|
|
27405
|
+
const pendingDir = path17.join(context.directory, importConfig.paths.pending);
|
|
27406
|
+
const doneDir = path17.join(context.directory, importConfig.paths.done);
|
|
27295
27407
|
const orderedContextIds = [...contextIds].sort((a, b) => {
|
|
27296
27408
|
const ctxA = loadContext(context.directory, a);
|
|
27297
27409
|
const ctxB = loadContext(context.directory, b);
|
|
@@ -27314,8 +27426,8 @@ async function importPipeline(directory, agent, options, configLoader = loadImpo
|
|
|
27314
27426
|
totalTransactions += context.result.steps.import?.details?.summary?.totalTransactions || 0;
|
|
27315
27427
|
} catch (error45) {
|
|
27316
27428
|
if (error45 instanceof NoTransactionsError) {
|
|
27317
|
-
const csvPath =
|
|
27318
|
-
if (
|
|
27429
|
+
const csvPath = path17.join(context.directory, importContext.filePath);
|
|
27430
|
+
if (fs23.existsSync(csvPath)) {
|
|
27319
27431
|
moveCsvToDone(csvPath, pendingDir, doneDir);
|
|
27320
27432
|
logger.info(`No transactions to import for ${importContext.filename}, moved to done`);
|
|
27321
27433
|
} else {
|
|
@@ -27396,16 +27508,16 @@ This tool orchestrates the full import workflow:
|
|
|
27396
27508
|
}
|
|
27397
27509
|
});
|
|
27398
27510
|
// src/tools/init-directories.ts
|
|
27399
|
-
import * as
|
|
27400
|
-
import * as
|
|
27511
|
+
import * as fs24 from "fs";
|
|
27512
|
+
import * as path18 from "path";
|
|
27401
27513
|
async function initDirectories(directory) {
|
|
27402
27514
|
try {
|
|
27403
27515
|
const config2 = loadImportConfig(directory);
|
|
27404
27516
|
const directoriesCreated = [];
|
|
27405
27517
|
const gitkeepFiles = [];
|
|
27406
|
-
const importBase =
|
|
27407
|
-
if (!
|
|
27408
|
-
|
|
27518
|
+
const importBase = path18.join(directory, "import");
|
|
27519
|
+
if (!fs24.existsSync(importBase)) {
|
|
27520
|
+
fs24.mkdirSync(importBase, { recursive: true });
|
|
27409
27521
|
directoriesCreated.push("import");
|
|
27410
27522
|
}
|
|
27411
27523
|
const pathsToCreate = [
|
|
@@ -27415,20 +27527,20 @@ async function initDirectories(directory) {
|
|
|
27415
27527
|
{ key: "unrecognized", path: config2.paths.unrecognized }
|
|
27416
27528
|
];
|
|
27417
27529
|
for (const { path: dirPath } of pathsToCreate) {
|
|
27418
|
-
const fullPath =
|
|
27419
|
-
if (!
|
|
27420
|
-
|
|
27530
|
+
const fullPath = path18.join(directory, dirPath);
|
|
27531
|
+
if (!fs24.existsSync(fullPath)) {
|
|
27532
|
+
fs24.mkdirSync(fullPath, { recursive: true });
|
|
27421
27533
|
directoriesCreated.push(dirPath);
|
|
27422
27534
|
}
|
|
27423
|
-
const gitkeepPath =
|
|
27424
|
-
if (!
|
|
27425
|
-
|
|
27426
|
-
gitkeepFiles.push(
|
|
27535
|
+
const gitkeepPath = path18.join(fullPath, ".gitkeep");
|
|
27536
|
+
if (!fs24.existsSync(gitkeepPath)) {
|
|
27537
|
+
fs24.writeFileSync(gitkeepPath, "");
|
|
27538
|
+
gitkeepFiles.push(path18.join(dirPath, ".gitkeep"));
|
|
27427
27539
|
}
|
|
27428
27540
|
}
|
|
27429
|
-
const gitignorePath =
|
|
27541
|
+
const gitignorePath = path18.join(importBase, ".gitignore");
|
|
27430
27542
|
let gitignoreCreated = false;
|
|
27431
|
-
if (!
|
|
27543
|
+
if (!fs24.existsSync(gitignorePath)) {
|
|
27432
27544
|
const gitignoreContent = `# Ignore CSV/PDF files in temporary directories
|
|
27433
27545
|
/incoming/*.csv
|
|
27434
27546
|
/incoming/*.pdf
|
|
@@ -27446,7 +27558,7 @@ async function initDirectories(directory) {
|
|
|
27446
27558
|
.DS_Store
|
|
27447
27559
|
Thumbs.db
|
|
27448
27560
|
`;
|
|
27449
|
-
|
|
27561
|
+
fs24.writeFileSync(gitignorePath, gitignoreContent);
|
|
27450
27562
|
gitignoreCreated = true;
|
|
27451
27563
|
}
|
|
27452
27564
|
const parts = [];
|
|
@@ -27522,32 +27634,32 @@ You can now drop CSV files into import/incoming/ and run import-pipeline.`);
|
|
|
27522
27634
|
}
|
|
27523
27635
|
});
|
|
27524
27636
|
// src/tools/generate-btc-purchases.ts
|
|
27525
|
-
import * as
|
|
27526
|
-
import * as
|
|
27637
|
+
import * as path19 from "path";
|
|
27638
|
+
import * as fs25 from "fs";
|
|
27527
27639
|
function findFiatCsvPaths(directory, pendingDir, provider) {
|
|
27528
|
-
const providerDir =
|
|
27529
|
-
if (!
|
|
27640
|
+
const providerDir = path19.join(directory, pendingDir, provider);
|
|
27641
|
+
if (!fs25.existsSync(providerDir))
|
|
27530
27642
|
return [];
|
|
27531
27643
|
const csvPaths = [];
|
|
27532
|
-
const entries =
|
|
27644
|
+
const entries = fs25.readdirSync(providerDir, { withFileTypes: true });
|
|
27533
27645
|
for (const entry of entries) {
|
|
27534
27646
|
if (!entry.isDirectory())
|
|
27535
27647
|
continue;
|
|
27536
27648
|
if (entry.name === "btc")
|
|
27537
27649
|
continue;
|
|
27538
|
-
const csvFiles = findCsvFiles(
|
|
27650
|
+
const csvFiles = findCsvFiles(path19.join(providerDir, entry.name), { fullPaths: true });
|
|
27539
27651
|
csvPaths.push(...csvFiles);
|
|
27540
27652
|
}
|
|
27541
27653
|
return csvPaths;
|
|
27542
27654
|
}
|
|
27543
27655
|
function findBtcCsvPath(directory, pendingDir, provider) {
|
|
27544
|
-
const btcDir =
|
|
27656
|
+
const btcDir = path19.join(directory, pendingDir, provider, "btc");
|
|
27545
27657
|
const csvFiles = findCsvFiles(btcDir, { fullPaths: true });
|
|
27546
27658
|
return csvFiles[0];
|
|
27547
27659
|
}
|
|
27548
27660
|
function determineYear(csvPaths) {
|
|
27549
27661
|
for (const csvPath of csvPaths) {
|
|
27550
|
-
const match2 =
|
|
27662
|
+
const match2 = path19.basename(csvPath).match(/(\d{4})-\d{2}-\d{2}/);
|
|
27551
27663
|
if (match2)
|
|
27552
27664
|
return parseInt(match2[1], 10);
|
|
27553
27665
|
}
|
|
@@ -27600,7 +27712,7 @@ async function generateBtcPurchases(directory, agent, options = {}, configLoader
|
|
|
27600
27712
|
skippedDuplicates: result.skippedDuplicates,
|
|
27601
27713
|
unmatchedFiat: result.unmatchedFiat.length,
|
|
27602
27714
|
unmatchedBtc: result.unmatchedBtc.length,
|
|
27603
|
-
yearJournal:
|
|
27715
|
+
yearJournal: path19.relative(directory, yearJournalPath)
|
|
27604
27716
|
});
|
|
27605
27717
|
} catch (err) {
|
|
27606
27718
|
logger.error("Failed to generate BTC purchases", err);
|
|
@@ -27643,7 +27755,7 @@ to produce equity conversion entries for Bitcoin purchases.
|
|
|
27643
27755
|
});
|
|
27644
27756
|
// src/index.ts
|
|
27645
27757
|
var __dirname2 = dirname9(fileURLToPath3(import.meta.url));
|
|
27646
|
-
var AGENT_FILE =
|
|
27758
|
+
var AGENT_FILE = join17(__dirname2, "..", "agent", "accountant.md");
|
|
27647
27759
|
var AccountantPlugin = async () => {
|
|
27648
27760
|
const agent = loadAgent(AGENT_FILE);
|
|
27649
27761
|
return {
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Merger FMV Configuration
|
|
2
|
+
|
|
3
|
+
When a merger or spinoff produces multiple incoming securities, cost basis must be allocated across them. Quantity-proportional allocation is incorrect when the incoming securities have different market values.
|
|
4
|
+
|
|
5
|
+
The `merger-fmv.yaml` file provides fair market value (FMV) prices so cost basis is allocated by market value instead.
|
|
6
|
+
|
|
7
|
+
## File Location
|
|
8
|
+
|
|
9
|
+
`config/import/merger-fmv.yaml` (relative to project root)
|
|
10
|
+
|
|
11
|
+
This file is **required** for any multi-way merger (more than one incoming symbol). The pipeline will fail with an error if a multi-way merger lacks FMV data. Single-symbol mergers (1→1) do not need FMV since 100% of the cost basis transfers to the single incoming symbol.
|
|
12
|
+
|
|
13
|
+
## Format
|
|
14
|
+
|
|
15
|
+
```yaml
|
|
16
|
+
# date (YYYY-MM-DD) -> symbol -> FMV per share
|
|
17
|
+
2015-10-14:
|
|
18
|
+
fr.to: 5.23
|
|
19
|
+
sil.to: 0.17
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
- **Date keys** use `YYYY-MM-DD` format, matching the merger transaction date
|
|
23
|
+
- **Symbol keys** use normalized names (post-symbolMap, lowercase)
|
|
24
|
+
- **Values** are the closing price per share on the distribution date
|
|
25
|
+
|
|
26
|
+
Multiple dates can be specified for different merger events.
|
|
27
|
+
|
|
28
|
+
## How It Works
|
|
29
|
+
|
|
30
|
+
For each merger group, the preprocessor looks up FMV data for the transaction date. If FMV prices are found for **all** incoming symbols, cost basis is allocated by FMV-weighted proportion:
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
proportion[i] = (fmv[symbol] * quantity) / sum(fmv[symbol] * quantity)
|
|
34
|
+
allocated_cost[i] = total_cost_basis * proportion[i]
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
If FMV data is missing or doesn't cover all incoming symbols for a multi-way merger, the pipeline fails with an error directing you to add the missing prices.
|
|
38
|
+
|
|
39
|
+
## Example
|
|
40
|
+
|
|
41
|
+
Given a merger: 3,300 SVL.TO (cost basis 5,660 CAD) → 914 FR.TO + 550 SIL.TO
|
|
42
|
+
|
|
43
|
+
**Without FMV** (quantity-proportional):
|
|
44
|
+
- FR.TO: 5,660 × (914 / 1,464) = 3,533.63 CAD (3.87/share)
|
|
45
|
+
- SIL.TO: 5,660 × (550 / 1,464) = 2,126.37 CAD (3.87/share)
|
|
46
|
+
|
|
47
|
+
Both get the same per-share cost, which is wrong.
|
|
48
|
+
|
|
49
|
+
**With FMV** (FR.TO @ 5.23, SIL.TO @ 0.17):
|
|
50
|
+
- FR.TO FMV total: 914 × 5.23 = 4,780.22
|
|
51
|
+
- SIL.TO FMV total: 550 × 0.17 = 93.50
|
|
52
|
+
- FR.TO: 5,660 × (4,780.22 / 4,873.72) = 5,551.42 CAD (6.07/share)
|
|
53
|
+
- SIL.TO: 5,660 × (93.50 / 4,873.72) = 108.58 CAD (0.20/share)
|
|
54
|
+
|
|
55
|
+
## Finding FMV Prices
|
|
56
|
+
|
|
57
|
+
Use the regular closing price on the distribution date, not the adjusted close (which accounts for later dividends/splits):
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pricehist fetch yahoo AG.TO -t close -s 2015-10-14 -e 2015-10-14
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
For delisted or illiquid securities, check portfolio market-data exports or broker statements.
|
|
64
|
+
|
|
65
|
+
## When Needed
|
|
66
|
+
|
|
67
|
+
FMV config is **required** whenever a merger produces multiple incoming symbols (N>1). The pipeline will refuse to process the merger without it. Single-symbol mergers (1→1) always allocate 100% and don't need FMV.
|
|
@@ -257,7 +257,7 @@ See [classify-statements](classify-statements.md) for details.
|
|
|
257
257
|
|
|
258
258
|
**Per-Year Journals**: Each CSV writes to a journal file named `{year}-{currency}.journal` where the year is extracted from the `to-DDMMYYYY` portion of the filename (e.g., `swissquote-cad-transactions-01012013-to-31122013.csv` → `2013-cad.journal`).
|
|
259
259
|
|
|
260
|
-
**Multi-Way Mergers**: Mergers can involve multiple incoming/outgoing securities (1→N, N→1, N→M). The preprocessor groups all entries sharing the same date+orderNum, collects total cost basis from outgoing lots, and distributes it
|
|
260
|
+
**Multi-Way Mergers**: Mergers can involve multiple incoming/outgoing securities (1→N, N→1, N→M). The preprocessor groups all entries sharing the same date+orderNum, collects total cost basis from outgoing lots, and distributes it across incoming symbols using FMV-weighted proportions from `config/import/merger-fmv.yaml`. FMV data is **required** for multi-way mergers (>1 incoming symbol) — the pipeline fails with an error if it is missing. Single-symbol mergers (1→1) always allocate 100% without needing FMV. See [Merger FMV Configuration](../configuration/merger-fmv.md).
|
|
261
261
|
|
|
262
262
|
**Cross-Currency Mergers**: When the outgoing and incoming sides of a merger appear in different currency CSV files, the outgoing side saves its state to `pending-mergers/` under the lot inventory directory. When the incoming side is processed later, it loads the pending state to create lots with the correct cost basis.
|
|
263
263
|
|
package/package.json
CHANGED