@fuzzle/opencode-accountant 0.13.17-next.1 → 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 +212 -142
- 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) {
|
|
@@ -25544,6 +25544,52 @@ function formatJournalFile(entries, year, currency) {
|
|
|
25544
25544
|
`);
|
|
25545
25545
|
}
|
|
25546
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
|
+
|
|
25547
25593
|
// src/utils/swissquoteCsvPreprocessor.ts
|
|
25548
25594
|
var SWISSQUOTE_HEADER = "Date;Order #;Transaction;Symbol;Name;ISIN;Quantity;Unit price;Costs;Accrued Interest;Net Amount;Balance;Currency";
|
|
25549
25595
|
var SIMPLE_TRANSACTION_TYPES = new Set([
|
|
@@ -25754,7 +25800,26 @@ function groupMergerActions(actions) {
|
|
|
25754
25800
|
}
|
|
25755
25801
|
return Array.from(groupMap.values());
|
|
25756
25802
|
}
|
|
25757
|
-
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) {
|
|
25758
25823
|
const entries = [];
|
|
25759
25824
|
if (group.outgoing.length === 0 && group.incoming.length === 0) {
|
|
25760
25825
|
return entries;
|
|
@@ -25773,12 +25838,14 @@ function processMultiWayMerger(group, inventory, lotInventoryPath, projectDir, l
|
|
|
25773
25838
|
quantity: -(pendingState.outgoingQuantities[i2] || 0)
|
|
25774
25839
|
});
|
|
25775
25840
|
}
|
|
25776
|
-
const
|
|
25841
|
+
const mergerDate = formatDate(group.date);
|
|
25842
|
+
const fmvData2 = lookupFmvForDate(mergerFmv, mergerDate);
|
|
25843
|
+
const proportions2 = computeAllocationProportions(group.incoming, fmvData2, mergerDate, logger);
|
|
25777
25844
|
const incomingTotalCosts2 = [];
|
|
25778
|
-
for (
|
|
25845
|
+
for (let idx = 0;idx < group.incoming.length; idx++) {
|
|
25846
|
+
const inc = group.incoming[idx];
|
|
25779
25847
|
const absQty = Math.abs(inc.quantity);
|
|
25780
|
-
const
|
|
25781
|
-
const allocatedCost = pendingState.totalCostBasis * proportion;
|
|
25848
|
+
const allocatedCost = pendingState.totalCostBasis * proportions2[idx];
|
|
25782
25849
|
const costBasisPerUnit = absQty > 0 ? allocatedCost / absQty : 0;
|
|
25783
25850
|
incomingTotalCosts2.push(allocatedCost);
|
|
25784
25851
|
const lot = {
|
|
@@ -25850,12 +25917,14 @@ function processMultiWayMerger(group, inventory, lotInventoryPath, projectDir, l
|
|
|
25850
25917
|
removeLots(inventory, out.symbol, logger);
|
|
25851
25918
|
logger?.debug(`Merger outgoing: removed ${absQty} ${out.symbol}`);
|
|
25852
25919
|
}
|
|
25853
|
-
const
|
|
25920
|
+
const mergerDateFull = formatDate(group.date);
|
|
25921
|
+
const fmvData = lookupFmvForDate(mergerFmv, mergerDateFull);
|
|
25922
|
+
const proportions = computeAllocationProportions(group.incoming, fmvData, mergerDateFull, logger);
|
|
25854
25923
|
const incomingTotalCosts = [];
|
|
25855
|
-
for (
|
|
25924
|
+
for (let idx = 0;idx < group.incoming.length; idx++) {
|
|
25925
|
+
const inc = group.incoming[idx];
|
|
25856
25926
|
const absQty = Math.abs(inc.quantity);
|
|
25857
|
-
const
|
|
25858
|
-
const allocatedCost = totalCostBasis * proportion;
|
|
25927
|
+
const allocatedCost = totalCostBasis * proportions[idx];
|
|
25859
25928
|
const costBasisPerUnit = absQty > 0 ? allocatedCost / absQty : 0;
|
|
25860
25929
|
incomingTotalCosts.push(allocatedCost);
|
|
25861
25930
|
const lot = {
|
|
@@ -25883,25 +25952,25 @@ function processMultiWayMerger(group, inventory, lotInventoryPath, projectDir, l
|
|
|
25883
25952
|
}
|
|
25884
25953
|
function getPendingMergerDir(projectDir, lotInventoryPath) {
|
|
25885
25954
|
const lotDir = lotInventoryPath.replace(/{symbol}[^/]*$/, "");
|
|
25886
|
-
return
|
|
25955
|
+
return path14.join(projectDir, lotDir, "pending-mergers");
|
|
25887
25956
|
}
|
|
25888
25957
|
function savePendingMerger(projectDir, lotInventoryPath, key, state, logger) {
|
|
25889
25958
|
const dir = getPendingMergerDir(projectDir, lotInventoryPath);
|
|
25890
|
-
if (!
|
|
25891
|
-
|
|
25959
|
+
if (!fs19.existsSync(dir)) {
|
|
25960
|
+
fs19.mkdirSync(dir, { recursive: true });
|
|
25892
25961
|
}
|
|
25893
|
-
const filePath =
|
|
25894
|
-
|
|
25962
|
+
const filePath = path14.join(dir, `${key.replace(/[^a-zA-Z0-9-]/g, "_")}.json`);
|
|
25963
|
+
fs19.writeFileSync(filePath, JSON.stringify(state, null, 2));
|
|
25895
25964
|
logger?.debug(`Saved pending merger state: ${key}`);
|
|
25896
25965
|
}
|
|
25897
25966
|
function loadPendingMerger(projectDir, lotInventoryPath, key, logger) {
|
|
25898
25967
|
const dir = getPendingMergerDir(projectDir, lotInventoryPath);
|
|
25899
|
-
const filePath =
|
|
25900
|
-
if (!
|
|
25968
|
+
const filePath = path14.join(dir, `${key.replace(/[^a-zA-Z0-9-]/g, "_")}.json`);
|
|
25969
|
+
if (!fs19.existsSync(filePath)) {
|
|
25901
25970
|
return null;
|
|
25902
25971
|
}
|
|
25903
25972
|
try {
|
|
25904
|
-
const content =
|
|
25973
|
+
const content = fs19.readFileSync(filePath, "utf-8");
|
|
25905
25974
|
return JSON.parse(content);
|
|
25906
25975
|
} catch (error45) {
|
|
25907
25976
|
const message = error45 instanceof Error ? error45.message : String(error45);
|
|
@@ -25911,27 +25980,27 @@ function loadPendingMerger(projectDir, lotInventoryPath, key, logger) {
|
|
|
25911
25980
|
}
|
|
25912
25981
|
function removePendingMerger(projectDir, lotInventoryPath, key, logger) {
|
|
25913
25982
|
const dir = getPendingMergerDir(projectDir, lotInventoryPath);
|
|
25914
|
-
const filePath =
|
|
25915
|
-
if (
|
|
25916
|
-
|
|
25983
|
+
const filePath = path14.join(dir, `${key.replace(/[^a-zA-Z0-9-]/g, "_")}.json`);
|
|
25984
|
+
if (fs19.existsSync(filePath)) {
|
|
25985
|
+
fs19.unlinkSync(filePath);
|
|
25917
25986
|
logger?.debug(`Removed pending merger state: ${key}`);
|
|
25918
25987
|
}
|
|
25919
25988
|
}
|
|
25920
25989
|
function resolveRemainingPendingMergers(projectDir, lotInventoryPath, logger) {
|
|
25921
25990
|
const pendingDir = getPendingMergerDir(projectDir, lotInventoryPath);
|
|
25922
|
-
if (!
|
|
25991
|
+
if (!fs19.existsSync(pendingDir)) {
|
|
25923
25992
|
return { resolved: 0, journalFilesUpdated: [] };
|
|
25924
25993
|
}
|
|
25925
|
-
const files =
|
|
25994
|
+
const files = fs19.readdirSync(pendingDir).filter((f) => f.endsWith(".json"));
|
|
25926
25995
|
if (files.length === 0) {
|
|
25927
25996
|
return { resolved: 0, journalFilesUpdated: [] };
|
|
25928
25997
|
}
|
|
25929
25998
|
const entriesByJournal = new Map;
|
|
25930
25999
|
const resolvedFiles = [];
|
|
25931
26000
|
for (const file2 of files) {
|
|
25932
|
-
const filePath =
|
|
26001
|
+
const filePath = path14.join(pendingDir, file2);
|
|
25933
26002
|
try {
|
|
25934
|
-
const content =
|
|
26003
|
+
const content = fs19.readFileSync(filePath, "utf-8");
|
|
25935
26004
|
const state = JSON.parse(content);
|
|
25936
26005
|
const dateMatch = state.date.match(/\d{2}-\d{2}-(\d{4})/);
|
|
25937
26006
|
if (!dateMatch) {
|
|
@@ -25940,12 +26009,12 @@ function resolveRemainingPendingMergers(projectDir, lotInventoryPath, logger) {
|
|
|
25940
26009
|
}
|
|
25941
26010
|
const year = parseInt(dateMatch[1], 10);
|
|
25942
26011
|
const entry = generateDirectWorthlessEntry(state.date, state.orderNum, state.outgoingSymbols, state.outgoingIsins, state.outgoingQuantities || [], state.outgoingTotalCosts || [], state.totalCostBasis, state.currency, logger);
|
|
25943
|
-
const journalFile =
|
|
26012
|
+
const journalFile = path14.join(projectDir, "ledger", "investments", `${year}-${state.currency.toLowerCase()}.journal`);
|
|
25944
26013
|
if (!entriesByJournal.has(journalFile)) {
|
|
25945
26014
|
entriesByJournal.set(journalFile, []);
|
|
25946
26015
|
}
|
|
25947
26016
|
entriesByJournal.get(journalFile).push(entry);
|
|
25948
|
-
|
|
26017
|
+
fs19.unlinkSync(filePath);
|
|
25949
26018
|
resolvedFiles.push(file2);
|
|
25950
26019
|
logger?.info(`Resolved pending merger ${file2} as worthless: ${state.outgoingSymbols.join(", ")} (${state.totalCostBasis.toFixed(2)} ${state.currency})`);
|
|
25951
26020
|
} catch (error45) {
|
|
@@ -25955,32 +26024,33 @@ function resolveRemainingPendingMergers(projectDir, lotInventoryPath, logger) {
|
|
|
25955
26024
|
}
|
|
25956
26025
|
const journalFilesUpdated = [];
|
|
25957
26026
|
for (const [journalFile, journalEntries] of entriesByJournal) {
|
|
25958
|
-
if (
|
|
25959
|
-
|
|
26027
|
+
if (fs19.existsSync(journalFile)) {
|
|
26028
|
+
fs19.appendFileSync(journalFile, `
|
|
25960
26029
|
` + journalEntries.join(`
|
|
25961
26030
|
`));
|
|
25962
26031
|
} else {
|
|
25963
|
-
const basename5 =
|
|
26032
|
+
const basename5 = path14.basename(journalFile, ".journal");
|
|
25964
26033
|
const parts = basename5.split("-");
|
|
25965
26034
|
const yearStr = parts[0];
|
|
25966
26035
|
const currency = parts.slice(1).join("-");
|
|
25967
26036
|
const header = formatJournalFile(journalEntries, parseInt(yearStr, 10), currency);
|
|
25968
|
-
|
|
26037
|
+
fs19.writeFileSync(journalFile, header);
|
|
25969
26038
|
}
|
|
25970
26039
|
journalFilesUpdated.push(journalFile);
|
|
25971
|
-
logger?.info(`Updated ${
|
|
26040
|
+
logger?.info(`Updated ${path14.basename(journalFile)} with worthless resolution entries`);
|
|
25972
26041
|
}
|
|
25973
|
-
const remaining =
|
|
26042
|
+
const remaining = fs19.readdirSync(pendingDir).filter((f) => f.endsWith(".json"));
|
|
25974
26043
|
if (remaining.length === 0) {
|
|
25975
26044
|
try {
|
|
25976
|
-
|
|
26045
|
+
fs19.rmdirSync(pendingDir);
|
|
25977
26046
|
logger?.debug("Removed empty pending-mergers directory");
|
|
25978
26047
|
} catch {}
|
|
25979
26048
|
}
|
|
25980
26049
|
return { resolved: resolvedFiles.length, journalFilesUpdated };
|
|
25981
26050
|
}
|
|
25982
26051
|
async function preprocessSwissquote(csvPath, projectDir, currency, year, lotInventoryPath = DEFAULT_LOT_INVENTORY_PATH, symbolMap = {}, logger) {
|
|
25983
|
-
logger?.startSection(`Swissquote Preprocessing: ${
|
|
26052
|
+
logger?.startSection(`Swissquote Preprocessing: ${path14.basename(csvPath)}`);
|
|
26053
|
+
const mergerFmv = loadMergerFmvConfig(projectDir);
|
|
25984
26054
|
const stats = {
|
|
25985
26055
|
totalRows: 0,
|
|
25986
26056
|
simpleTransactions: 0,
|
|
@@ -25990,7 +26060,7 @@ async function preprocessSwissquote(csvPath, projectDir, currency, year, lotInve
|
|
|
25990
26060
|
forexTransactions: 0,
|
|
25991
26061
|
skipped: 0
|
|
25992
26062
|
};
|
|
25993
|
-
if (!
|
|
26063
|
+
if (!fs19.existsSync(csvPath)) {
|
|
25994
26064
|
logger?.error(`CSV file not found: ${csvPath}`);
|
|
25995
26065
|
logger?.endSection();
|
|
25996
26066
|
return {
|
|
@@ -26002,7 +26072,7 @@ async function preprocessSwissquote(csvPath, projectDir, currency, year, lotInve
|
|
|
26002
26072
|
};
|
|
26003
26073
|
}
|
|
26004
26074
|
logger?.logStep("parse-csv", "start", `Reading ${csvPath}`);
|
|
26005
|
-
const content =
|
|
26075
|
+
const content = fs19.readFileSync(csvPath, "utf-8");
|
|
26006
26076
|
const lines = content.split(`
|
|
26007
26077
|
`).filter((line) => line.trim());
|
|
26008
26078
|
const header = lines[0];
|
|
@@ -26190,7 +26260,7 @@ async function preprocessSwissquote(csvPath, projectDir, currency, year, lotInve
|
|
|
26190
26260
|
break;
|
|
26191
26261
|
}
|
|
26192
26262
|
case "mergerGroup": {
|
|
26193
|
-
const groupEntries = processMultiWayMerger(event.group, inventory, lotInventoryPath, projectDir, logger);
|
|
26263
|
+
const groupEntries = processMultiWayMerger(event.group, inventory, lotInventoryPath, projectDir, mergerFmv, logger);
|
|
26194
26264
|
journalEntries.push(...groupEntries);
|
|
26195
26265
|
break;
|
|
26196
26266
|
}
|
|
@@ -26207,9 +26277,9 @@ async function preprocessSwissquote(csvPath, projectDir, currency, year, lotInve
|
|
|
26207
26277
|
logger?.logStep("save-inventory", "success", "Lot inventory saved");
|
|
26208
26278
|
let simpleTransactionsCsv = null;
|
|
26209
26279
|
let journalFile = null;
|
|
26210
|
-
const csvDir =
|
|
26211
|
-
const csvBasename =
|
|
26212
|
-
simpleTransactionsCsv =
|
|
26280
|
+
const csvDir = path14.dirname(csvPath);
|
|
26281
|
+
const csvBasename = path14.basename(csvPath, ".csv");
|
|
26282
|
+
simpleTransactionsCsv = path14.join(csvDir, `${csvBasename}-simple.csv`);
|
|
26213
26283
|
const csvContent = [
|
|
26214
26284
|
SWISSQUOTE_HEADER,
|
|
26215
26285
|
...simpleTransactions.map((txn) => [
|
|
@@ -26229,23 +26299,23 @@ async function preprocessSwissquote(csvPath, projectDir, currency, year, lotInve
|
|
|
26229
26299
|
].join(";"))
|
|
26230
26300
|
].join(`
|
|
26231
26301
|
`);
|
|
26232
|
-
|
|
26233
|
-
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)}`);
|
|
26234
26304
|
if (journalEntries.length > 0) {
|
|
26235
|
-
const investmentsDir =
|
|
26236
|
-
if (!
|
|
26237
|
-
|
|
26305
|
+
const investmentsDir = path14.join(projectDir, "ledger", "investments");
|
|
26306
|
+
if (!fs19.existsSync(investmentsDir)) {
|
|
26307
|
+
fs19.mkdirSync(investmentsDir, { recursive: true });
|
|
26238
26308
|
}
|
|
26239
|
-
journalFile =
|
|
26309
|
+
journalFile = path14.join(investmentsDir, `${year}-${currency}.journal`);
|
|
26240
26310
|
const journalContent = formatJournalFile(journalEntries, year, currency);
|
|
26241
|
-
if (
|
|
26242
|
-
|
|
26311
|
+
if (fs19.existsSync(journalFile)) {
|
|
26312
|
+
fs19.appendFileSync(journalFile, `
|
|
26243
26313
|
` + journalEntries.join(`
|
|
26244
26314
|
`));
|
|
26245
|
-
logger?.logStep("write-journal", "success", `Appended ${journalEntries.length} entries to ${
|
|
26315
|
+
logger?.logStep("write-journal", "success", `Appended ${journalEntries.length} entries to ${path14.basename(journalFile)}`);
|
|
26246
26316
|
} else {
|
|
26247
|
-
|
|
26248
|
-
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`);
|
|
26249
26319
|
}
|
|
26250
26320
|
}
|
|
26251
26321
|
const stockSymbols = new Set;
|
|
@@ -26262,9 +26332,9 @@ async function preprocessSwissquote(csvPath, projectDir, currency, year, lotInve
|
|
|
26262
26332
|
}
|
|
26263
26333
|
}
|
|
26264
26334
|
if (stockSymbols.size > 0) {
|
|
26265
|
-
const commodityJournalPath =
|
|
26335
|
+
const commodityJournalPath = path14.join(projectDir, "ledger", "investments", "commodities.journal");
|
|
26266
26336
|
ensureCommodityDeclarations(commodityJournalPath, stockSymbols, logger);
|
|
26267
|
-
const accountJournalPath =
|
|
26337
|
+
const accountJournalPath = path14.join(projectDir, "ledger", "investments", "accounts.journal");
|
|
26268
26338
|
const investmentAccounts = new Set;
|
|
26269
26339
|
for (const symbol2 of stockSymbols) {
|
|
26270
26340
|
investmentAccounts.add(`assets:investments:stocks:${symbol2}`);
|
|
@@ -26301,10 +26371,10 @@ async function preprocessSwissquote(csvPath, projectDir, currency, year, lotInve
|
|
|
26301
26371
|
}
|
|
26302
26372
|
|
|
26303
26373
|
// src/utils/currencyExchangeGenerator.ts
|
|
26304
|
-
import * as
|
|
26305
|
-
import * as
|
|
26374
|
+
import * as fs20 from "fs";
|
|
26375
|
+
import * as path15 from "path";
|
|
26306
26376
|
function parseExchangeRows(csvPath) {
|
|
26307
|
-
const content =
|
|
26377
|
+
const content = fs20.readFileSync(csvPath, "utf-8");
|
|
26308
26378
|
const lines = content.trim().split(`
|
|
26309
26379
|
`);
|
|
26310
26380
|
if (lines.length < 2)
|
|
@@ -26428,7 +26498,7 @@ function formatDateIso2(date5) {
|
|
|
26428
26498
|
return `${y}-${m}-${d}`;
|
|
26429
26499
|
}
|
|
26430
26500
|
function filterExchangeRows(csvPath, rowIndicesToRemove, logger) {
|
|
26431
|
-
const content =
|
|
26501
|
+
const content = fs20.readFileSync(csvPath, "utf-8");
|
|
26432
26502
|
const lines = content.trim().split(`
|
|
26433
26503
|
`);
|
|
26434
26504
|
const filteredLines = [lines[0]];
|
|
@@ -26437,13 +26507,13 @@ function filterExchangeRows(csvPath, rowIndicesToRemove, logger) {
|
|
|
26437
26507
|
continue;
|
|
26438
26508
|
filteredLines.push(lines[i2]);
|
|
26439
26509
|
}
|
|
26440
|
-
const dir =
|
|
26441
|
-
const basename6 =
|
|
26442
|
-
const filteredPath =
|
|
26443
|
-
|
|
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(`
|
|
26444
26514
|
`) + `
|
|
26445
26515
|
`);
|
|
26446
|
-
logger?.info(`Filtered ${rowIndicesToRemove.size} exchange rows from ${
|
|
26516
|
+
logger?.info(`Filtered ${rowIndicesToRemove.size} exchange rows from ${path15.basename(csvPath)} \u2192 ${path15.basename(filteredPath)}`);
|
|
26447
26517
|
return filteredPath;
|
|
26448
26518
|
}
|
|
26449
26519
|
function generateCurrencyExchangeJournal(fiatCsvPaths, yearJournalPath, logger) {
|
|
@@ -26453,7 +26523,7 @@ function generateCurrencyExchangeJournal(fiatCsvPaths, yearJournalPath, logger)
|
|
|
26453
26523
|
const rows = parseExchangeRows(csvPath);
|
|
26454
26524
|
if (rows.length > 0) {
|
|
26455
26525
|
rowsByCsv.set(csvPath, rows);
|
|
26456
|
-
logger?.info(`Found ${rows.length} EXCHANGE rows in ${
|
|
26526
|
+
logger?.info(`Found ${rows.length} EXCHANGE rows in ${path15.basename(csvPath)}`);
|
|
26457
26527
|
}
|
|
26458
26528
|
}
|
|
26459
26529
|
if (rowsByCsv.size < 2) {
|
|
@@ -26470,8 +26540,8 @@ function generateCurrencyExchangeJournal(fiatCsvPaths, yearJournalPath, logger)
|
|
|
26470
26540
|
const matches = matchExchangePairs(rowsByCsv);
|
|
26471
26541
|
logger?.info(`Matched ${matches.length} exchange pairs`);
|
|
26472
26542
|
let journalContent = "";
|
|
26473
|
-
if (
|
|
26474
|
-
journalContent =
|
|
26543
|
+
if (fs20.existsSync(yearJournalPath)) {
|
|
26544
|
+
journalContent = fs20.readFileSync(yearJournalPath, "utf-8");
|
|
26475
26545
|
}
|
|
26476
26546
|
const newEntries = [];
|
|
26477
26547
|
let skippedDuplicates = 0;
|
|
@@ -26498,8 +26568,8 @@ function generateCurrencyExchangeJournal(fiatCsvPaths, yearJournalPath, logger)
|
|
|
26498
26568
|
|
|
26499
26569
|
`) + `
|
|
26500
26570
|
`;
|
|
26501
|
-
|
|
26502
|
-
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)}`);
|
|
26503
26573
|
} else {
|
|
26504
26574
|
logger?.info("No new currency exchange entries to add");
|
|
26505
26575
|
}
|
|
@@ -26522,8 +26592,8 @@ function generateCurrencyExchangeJournal(fiatCsvPaths, yearJournalPath, logger)
|
|
|
26522
26592
|
}
|
|
26523
26593
|
|
|
26524
26594
|
// src/utils/swissquoteForexGenerator.ts
|
|
26525
|
-
import * as
|
|
26526
|
-
import * as
|
|
26595
|
+
import * as fs21 from "fs";
|
|
26596
|
+
import * as path16 from "path";
|
|
26527
26597
|
function formatDate2(dateStr) {
|
|
26528
26598
|
const match2 = dateStr.match(/^(\d{2})-(\d{2})-(\d{4})/);
|
|
26529
26599
|
if (!match2) {
|
|
@@ -26630,8 +26700,8 @@ function generateSwissquoteForexJournal(allForexRows, yearJournalPath, logger) {
|
|
|
26630
26700
|
logger?.warn(`${unmatchedCount} forex rows could not be matched`);
|
|
26631
26701
|
}
|
|
26632
26702
|
let journalContent = "";
|
|
26633
|
-
if (
|
|
26634
|
-
journalContent =
|
|
26703
|
+
if (fs21.existsSync(yearJournalPath)) {
|
|
26704
|
+
journalContent = fs21.readFileSync(yearJournalPath, "utf-8");
|
|
26635
26705
|
}
|
|
26636
26706
|
const newEntries = [];
|
|
26637
26707
|
let skippedDuplicates = 0;
|
|
@@ -26653,17 +26723,17 @@ function generateSwissquoteForexJournal(allForexRows, yearJournalPath, logger) {
|
|
|
26653
26723
|
newEntries.push(formatForexEntry(match2));
|
|
26654
26724
|
}
|
|
26655
26725
|
if (newEntries.length > 0) {
|
|
26656
|
-
const dir =
|
|
26657
|
-
if (!
|
|
26658
|
-
|
|
26726
|
+
const dir = path16.dirname(yearJournalPath);
|
|
26727
|
+
if (!fs21.existsSync(dir)) {
|
|
26728
|
+
fs21.mkdirSync(dir, { recursive: true });
|
|
26659
26729
|
}
|
|
26660
26730
|
const appendContent = `
|
|
26661
26731
|
` + newEntries.join(`
|
|
26662
26732
|
|
|
26663
26733
|
`) + `
|
|
26664
26734
|
`;
|
|
26665
|
-
|
|
26666
|
-
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)}`);
|
|
26667
26737
|
} else {
|
|
26668
26738
|
logger?.info("No new forex entries to add");
|
|
26669
26739
|
}
|
|
@@ -26748,7 +26818,7 @@ function executePreprocessFiatStep(context, contextIds, logger) {
|
|
|
26748
26818
|
for (const contextId of contextIds) {
|
|
26749
26819
|
const importCtx = loadContext(context.directory, contextId);
|
|
26750
26820
|
if (importCtx.provider === "revolut" && importCtx.currency !== "btc") {
|
|
26751
|
-
fiatCsvPaths.push(
|
|
26821
|
+
fiatCsvPaths.push(path17.join(context.directory, importCtx.filePath));
|
|
26752
26822
|
}
|
|
26753
26823
|
}
|
|
26754
26824
|
if (fiatCsvPaths.length === 0) {
|
|
@@ -26792,7 +26862,7 @@ function executePreprocessBtcStep(context, contextIds, logger) {
|
|
|
26792
26862
|
for (const contextId of contextIds) {
|
|
26793
26863
|
const importCtx = loadContext(context.directory, contextId);
|
|
26794
26864
|
if (importCtx.provider === "revolut" && importCtx.currency === "btc") {
|
|
26795
|
-
btcCsvPath =
|
|
26865
|
+
btcCsvPath = path17.join(context.directory, importCtx.filePath);
|
|
26796
26866
|
break;
|
|
26797
26867
|
}
|
|
26798
26868
|
}
|
|
@@ -26826,7 +26896,7 @@ async function executeBtcPurchaseStep(context, contextIds, logger) {
|
|
|
26826
26896
|
const importCtx = loadContext(context.directory, contextId);
|
|
26827
26897
|
if (importCtx.provider !== "revolut")
|
|
26828
26898
|
continue;
|
|
26829
|
-
const csvPath =
|
|
26899
|
+
const csvPath = path17.join(context.directory, importCtx.filePath);
|
|
26830
26900
|
if (importCtx.currency === "btc") {
|
|
26831
26901
|
btcCsvPath = csvPath;
|
|
26832
26902
|
} else {
|
|
@@ -26839,7 +26909,7 @@ async function executeBtcPurchaseStep(context, contextIds, logger) {
|
|
|
26839
26909
|
}
|
|
26840
26910
|
logger?.startSection("Step 1b: Generate BTC Purchase Entries");
|
|
26841
26911
|
logger?.logStep("BTC Purchases", "start");
|
|
26842
|
-
const btcFilename =
|
|
26912
|
+
const btcFilename = path17.basename(btcCsvPath);
|
|
26843
26913
|
const yearMatch = btcFilename.match(/(\d{4})-\d{2}-\d{2}/);
|
|
26844
26914
|
const year = yearMatch ? parseInt(yearMatch[1], 10) : new Date().getFullYear();
|
|
26845
26915
|
const yearJournalPath = ensureYearJournalExists(context.directory, year);
|
|
@@ -26866,7 +26936,7 @@ async function executeCurrencyExchangeStep(context, contextIds, logger) {
|
|
|
26866
26936
|
continue;
|
|
26867
26937
|
if (importCtx.currency === "btc")
|
|
26868
26938
|
continue;
|
|
26869
|
-
const csvPath =
|
|
26939
|
+
const csvPath = path17.join(context.directory, importCtx.filePath);
|
|
26870
26940
|
fiatContexts.push({ contextId, csvPath });
|
|
26871
26941
|
}
|
|
26872
26942
|
if (fiatContexts.length < 2) {
|
|
@@ -26875,7 +26945,7 @@ async function executeCurrencyExchangeStep(context, contextIds, logger) {
|
|
|
26875
26945
|
}
|
|
26876
26946
|
logger?.startSection("Step 1e: Generate Currency Exchange Entries");
|
|
26877
26947
|
logger?.logStep("Currency Exchanges", "start");
|
|
26878
|
-
const firstFilename =
|
|
26948
|
+
const firstFilename = path17.basename(fiatContexts[0].csvPath);
|
|
26879
26949
|
const yearMatch = firstFilename.match(/(\d{4})-\d{2}-\d{2}/);
|
|
26880
26950
|
const year = yearMatch ? parseInt(yearMatch[1], 10) : new Date().getFullYear();
|
|
26881
26951
|
const yearJournalPath = ensureYearJournalExists(context.directory, year);
|
|
@@ -26891,9 +26961,9 @@ async function executeCurrencyExchangeStep(context, contextIds, logger) {
|
|
|
26891
26961
|
continue;
|
|
26892
26962
|
const filteredCsvPath = filterExchangeRows(csvPath, indices, logger);
|
|
26893
26963
|
updateContext(context.directory, contextId, {
|
|
26894
|
-
filePath:
|
|
26964
|
+
filePath: path17.relative(context.directory, filteredCsvPath)
|
|
26895
26965
|
});
|
|
26896
|
-
logger?.info(`Updated context ${contextId} to use filtered CSV: ${
|
|
26966
|
+
logger?.info(`Updated context ${contextId} to use filtered CSV: ${path17.basename(filteredCsvPath)}`);
|
|
26897
26967
|
}
|
|
26898
26968
|
}
|
|
26899
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)`;
|
|
@@ -26910,7 +26980,7 @@ async function executeSwissquotePreprocessStep(context, contextIds, logger) {
|
|
|
26910
26980
|
for (const contextId of contextIds) {
|
|
26911
26981
|
const importCtx = loadContext(context.directory, contextId);
|
|
26912
26982
|
if (importCtx.provider === "swissquote") {
|
|
26913
|
-
const csvPath =
|
|
26983
|
+
const csvPath = path17.join(context.directory, importCtx.filePath);
|
|
26914
26984
|
const toDateMatch = importCtx.filename.match(/to-\d{4}(\d{4})\./);
|
|
26915
26985
|
let year = new Date().getFullYear();
|
|
26916
26986
|
if (toDateMatch) {
|
|
@@ -26934,11 +27004,11 @@ async function executeSwissquotePreprocessStep(context, contextIds, logger) {
|
|
|
26934
27004
|
const lotInventoryPath = swissquoteProvider?.lotInventoryPath ?? DEFAULT_LOT_INVENTORY_PATH;
|
|
26935
27005
|
const symbolMapPath = swissquoteProvider?.symbolMapPath ?? "config/import/symbolMap.yaml";
|
|
26936
27006
|
let symbolMap = {};
|
|
26937
|
-
const symbolMapFullPath =
|
|
26938
|
-
if (
|
|
27007
|
+
const symbolMapFullPath = path17.join(context.directory, symbolMapPath);
|
|
27008
|
+
if (fs23.existsSync(symbolMapFullPath)) {
|
|
26939
27009
|
try {
|
|
26940
27010
|
const yaml = await Promise.resolve().then(() => (init_js_yaml(), exports_js_yaml));
|
|
26941
|
-
const content =
|
|
27011
|
+
const content = fs23.readFileSync(symbolMapFullPath, "utf-8");
|
|
26942
27012
|
const parsed = yaml.load(content);
|
|
26943
27013
|
if (parsed && typeof parsed === "object") {
|
|
26944
27014
|
symbolMap = parsed;
|
|
@@ -26962,7 +27032,7 @@ async function executeSwissquotePreprocessStep(context, contextIds, logger) {
|
|
|
26962
27032
|
let lastJournalFile = null;
|
|
26963
27033
|
const allForexRows = [];
|
|
26964
27034
|
for (const sqCtx of swissquoteContexts) {
|
|
26965
|
-
logger?.logStep("Swissquote Preprocess", "start", `Processing ${
|
|
27035
|
+
logger?.logStep("Swissquote Preprocess", "start", `Processing ${path17.basename(sqCtx.csvPath)}`);
|
|
26966
27036
|
const result = await preprocessSwissquote(sqCtx.csvPath, context.directory, sqCtx.currency, sqCtx.year, lotInventoryPath, symbolMap, logger);
|
|
26967
27037
|
totalStats.totalRows += result.stats.totalRows;
|
|
26968
27038
|
totalStats.simpleTransactions += result.stats.simpleTransactions;
|
|
@@ -26977,9 +27047,9 @@ async function executeSwissquotePreprocessStep(context, contextIds, logger) {
|
|
|
26977
27047
|
}
|
|
26978
27048
|
if (result.simpleTransactionsCsv) {
|
|
26979
27049
|
updateContext(context.directory, sqCtx.contextId, {
|
|
26980
|
-
filePath:
|
|
27050
|
+
filePath: path17.relative(context.directory, result.simpleTransactionsCsv)
|
|
26981
27051
|
});
|
|
26982
|
-
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)}`);
|
|
26983
27053
|
}
|
|
26984
27054
|
logger?.logStep("Swissquote Preprocess", "success", `Processed: ${result.stats.trades} trades, ${result.stats.dividends} dividends, ${result.stats.corporateActions} corporate actions, ${result.stats.forexTransactions} forex`);
|
|
26985
27055
|
}
|
|
@@ -26989,11 +27059,11 @@ async function executeSwissquotePreprocessStep(context, contextIds, logger) {
|
|
|
26989
27059
|
}
|
|
26990
27060
|
if (allForexRows.length > 0) {
|
|
26991
27061
|
const firstCtx = swissquoteContexts[0];
|
|
26992
|
-
const yearJournalPath =
|
|
27062
|
+
const yearJournalPath = path17.join(context.directory, "ledger", `${firstCtx.year}.journal`);
|
|
26993
27063
|
logger?.info(`Generating forex journal entries for ${allForexRows.length} forex rows...`);
|
|
26994
27064
|
const forexResult = generateSwissquoteForexJournal(allForexRows, yearJournalPath, logger);
|
|
26995
27065
|
if (forexResult.accountsUsed.size > 0) {
|
|
26996
|
-
const accountJournalPath =
|
|
27066
|
+
const accountJournalPath = path17.join(context.directory, "ledger", "investments", "accounts.journal");
|
|
26997
27067
|
ensureInvestmentAccountDeclarations(accountJournalPath, forexResult.accountsUsed, logger);
|
|
26998
27068
|
}
|
|
26999
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` : ""));
|
|
@@ -27019,9 +27089,9 @@ async function executeAccountDeclarationsStep(context, contextId, logger) {
|
|
|
27019
27089
|
logger?.startSection("Step 2: Check Account Declarations");
|
|
27020
27090
|
logger?.logStep("Check Accounts", "start");
|
|
27021
27091
|
const config2 = context.configLoader(context.directory);
|
|
27022
|
-
const rulesDir =
|
|
27092
|
+
const rulesDir = path17.join(context.directory, config2.paths.rules);
|
|
27023
27093
|
const importCtx = loadContext(context.directory, contextId);
|
|
27024
|
-
const csvPath =
|
|
27094
|
+
const csvPath = path17.join(context.directory, importCtx.filePath);
|
|
27025
27095
|
const csvFiles = [csvPath];
|
|
27026
27096
|
const rulesMapping = loadRulesMapping(rulesDir);
|
|
27027
27097
|
const matchedRulesFiles = new Set;
|
|
@@ -27044,7 +27114,7 @@ async function executeAccountDeclarationsStep(context, contextId, logger) {
|
|
|
27044
27114
|
context.result.steps.accountDeclarations = buildStepResult(true, "No accounts found in rules files", {
|
|
27045
27115
|
accountsAdded: [],
|
|
27046
27116
|
journalUpdated: "",
|
|
27047
|
-
rulesScanned: Array.from(matchedRulesFiles).map((f) =>
|
|
27117
|
+
rulesScanned: Array.from(matchedRulesFiles).map((f) => path17.relative(context.directory, f))
|
|
27048
27118
|
});
|
|
27049
27119
|
return;
|
|
27050
27120
|
}
|
|
@@ -27068,7 +27138,7 @@ async function executeAccountDeclarationsStep(context, contextId, logger) {
|
|
|
27068
27138
|
context.result.steps.accountDeclarations = buildStepResult(true, "No transactions found, skipping account declarations", {
|
|
27069
27139
|
accountsAdded: [],
|
|
27070
27140
|
journalUpdated: "",
|
|
27071
|
-
rulesScanned: Array.from(matchedRulesFiles).map((f) =>
|
|
27141
|
+
rulesScanned: Array.from(matchedRulesFiles).map((f) => path17.relative(context.directory, f))
|
|
27072
27142
|
});
|
|
27073
27143
|
return;
|
|
27074
27144
|
}
|
|
@@ -27079,12 +27149,12 @@ async function executeAccountDeclarationsStep(context, contextId, logger) {
|
|
|
27079
27149
|
context.result.steps.accountDeclarations = buildStepResult(false, `Failed to create year journal: ${error45 instanceof Error ? error45.message : String(error45)}`, {
|
|
27080
27150
|
accountsAdded: [],
|
|
27081
27151
|
journalUpdated: "",
|
|
27082
|
-
rulesScanned: Array.from(matchedRulesFiles).map((f) =>
|
|
27152
|
+
rulesScanned: Array.from(matchedRulesFiles).map((f) => path17.relative(context.directory, f))
|
|
27083
27153
|
});
|
|
27084
27154
|
return;
|
|
27085
27155
|
}
|
|
27086
27156
|
const result = ensureAccountDeclarations(yearJournalPath, allAccounts);
|
|
27087
|
-
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";
|
|
27088
27158
|
logger?.logStep("Check Accounts", "success", message);
|
|
27089
27159
|
if (result.added.length > 0) {
|
|
27090
27160
|
for (const account of result.added) {
|
|
@@ -27093,17 +27163,17 @@ async function executeAccountDeclarationsStep(context, contextId, logger) {
|
|
|
27093
27163
|
}
|
|
27094
27164
|
context.result.steps.accountDeclarations = buildStepResult(true, message, {
|
|
27095
27165
|
accountsAdded: result.added,
|
|
27096
|
-
journalUpdated:
|
|
27097
|
-
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))
|
|
27098
27168
|
});
|
|
27099
27169
|
logger?.endSection();
|
|
27100
27170
|
}
|
|
27101
27171
|
async function buildSuggestionContext(context, contextId, logger) {
|
|
27102
27172
|
const { loadExistingAccounts: loadExistingAccounts2, extractRulePatternsFromFile: extractRulePatternsFromFile2 } = await Promise.resolve().then(() => (init_accountSuggester(), exports_accountSuggester));
|
|
27103
27173
|
const config2 = context.configLoader(context.directory);
|
|
27104
|
-
const rulesDir =
|
|
27174
|
+
const rulesDir = path17.join(context.directory, config2.paths.rules);
|
|
27105
27175
|
const importCtx = loadContext(context.directory, contextId);
|
|
27106
|
-
const csvPath =
|
|
27176
|
+
const csvPath = path17.join(context.directory, importCtx.filePath);
|
|
27107
27177
|
const rulesMapping = loadRulesMapping(rulesDir);
|
|
27108
27178
|
const rulesFile = findRulesForCsv(csvPath, rulesMapping);
|
|
27109
27179
|
if (!rulesFile) {
|
|
@@ -27332,8 +27402,8 @@ async function importPipeline(directory, agent, options, configLoader = loadImpo
|
|
|
27332
27402
|
}
|
|
27333
27403
|
}
|
|
27334
27404
|
const importConfig = loadImportConfig(context.directory);
|
|
27335
|
-
const pendingDir =
|
|
27336
|
-
const doneDir =
|
|
27405
|
+
const pendingDir = path17.join(context.directory, importConfig.paths.pending);
|
|
27406
|
+
const doneDir = path17.join(context.directory, importConfig.paths.done);
|
|
27337
27407
|
const orderedContextIds = [...contextIds].sort((a, b) => {
|
|
27338
27408
|
const ctxA = loadContext(context.directory, a);
|
|
27339
27409
|
const ctxB = loadContext(context.directory, b);
|
|
@@ -27356,8 +27426,8 @@ async function importPipeline(directory, agent, options, configLoader = loadImpo
|
|
|
27356
27426
|
totalTransactions += context.result.steps.import?.details?.summary?.totalTransactions || 0;
|
|
27357
27427
|
} catch (error45) {
|
|
27358
27428
|
if (error45 instanceof NoTransactionsError) {
|
|
27359
|
-
const csvPath =
|
|
27360
|
-
if (
|
|
27429
|
+
const csvPath = path17.join(context.directory, importContext.filePath);
|
|
27430
|
+
if (fs23.existsSync(csvPath)) {
|
|
27361
27431
|
moveCsvToDone(csvPath, pendingDir, doneDir);
|
|
27362
27432
|
logger.info(`No transactions to import for ${importContext.filename}, moved to done`);
|
|
27363
27433
|
} else {
|
|
@@ -27438,16 +27508,16 @@ This tool orchestrates the full import workflow:
|
|
|
27438
27508
|
}
|
|
27439
27509
|
});
|
|
27440
27510
|
// src/tools/init-directories.ts
|
|
27441
|
-
import * as
|
|
27442
|
-
import * as
|
|
27511
|
+
import * as fs24 from "fs";
|
|
27512
|
+
import * as path18 from "path";
|
|
27443
27513
|
async function initDirectories(directory) {
|
|
27444
27514
|
try {
|
|
27445
27515
|
const config2 = loadImportConfig(directory);
|
|
27446
27516
|
const directoriesCreated = [];
|
|
27447
27517
|
const gitkeepFiles = [];
|
|
27448
|
-
const importBase =
|
|
27449
|
-
if (!
|
|
27450
|
-
|
|
27518
|
+
const importBase = path18.join(directory, "import");
|
|
27519
|
+
if (!fs24.existsSync(importBase)) {
|
|
27520
|
+
fs24.mkdirSync(importBase, { recursive: true });
|
|
27451
27521
|
directoriesCreated.push("import");
|
|
27452
27522
|
}
|
|
27453
27523
|
const pathsToCreate = [
|
|
@@ -27457,20 +27527,20 @@ async function initDirectories(directory) {
|
|
|
27457
27527
|
{ key: "unrecognized", path: config2.paths.unrecognized }
|
|
27458
27528
|
];
|
|
27459
27529
|
for (const { path: dirPath } of pathsToCreate) {
|
|
27460
|
-
const fullPath =
|
|
27461
|
-
if (!
|
|
27462
|
-
|
|
27530
|
+
const fullPath = path18.join(directory, dirPath);
|
|
27531
|
+
if (!fs24.existsSync(fullPath)) {
|
|
27532
|
+
fs24.mkdirSync(fullPath, { recursive: true });
|
|
27463
27533
|
directoriesCreated.push(dirPath);
|
|
27464
27534
|
}
|
|
27465
|
-
const gitkeepPath =
|
|
27466
|
-
if (!
|
|
27467
|
-
|
|
27468
|
-
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"));
|
|
27469
27539
|
}
|
|
27470
27540
|
}
|
|
27471
|
-
const gitignorePath =
|
|
27541
|
+
const gitignorePath = path18.join(importBase, ".gitignore");
|
|
27472
27542
|
let gitignoreCreated = false;
|
|
27473
|
-
if (!
|
|
27543
|
+
if (!fs24.existsSync(gitignorePath)) {
|
|
27474
27544
|
const gitignoreContent = `# Ignore CSV/PDF files in temporary directories
|
|
27475
27545
|
/incoming/*.csv
|
|
27476
27546
|
/incoming/*.pdf
|
|
@@ -27488,7 +27558,7 @@ async function initDirectories(directory) {
|
|
|
27488
27558
|
.DS_Store
|
|
27489
27559
|
Thumbs.db
|
|
27490
27560
|
`;
|
|
27491
|
-
|
|
27561
|
+
fs24.writeFileSync(gitignorePath, gitignoreContent);
|
|
27492
27562
|
gitignoreCreated = true;
|
|
27493
27563
|
}
|
|
27494
27564
|
const parts = [];
|
|
@@ -27564,32 +27634,32 @@ You can now drop CSV files into import/incoming/ and run import-pipeline.`);
|
|
|
27564
27634
|
}
|
|
27565
27635
|
});
|
|
27566
27636
|
// src/tools/generate-btc-purchases.ts
|
|
27567
|
-
import * as
|
|
27568
|
-
import * as
|
|
27637
|
+
import * as path19 from "path";
|
|
27638
|
+
import * as fs25 from "fs";
|
|
27569
27639
|
function findFiatCsvPaths(directory, pendingDir, provider) {
|
|
27570
|
-
const providerDir =
|
|
27571
|
-
if (!
|
|
27640
|
+
const providerDir = path19.join(directory, pendingDir, provider);
|
|
27641
|
+
if (!fs25.existsSync(providerDir))
|
|
27572
27642
|
return [];
|
|
27573
27643
|
const csvPaths = [];
|
|
27574
|
-
const entries =
|
|
27644
|
+
const entries = fs25.readdirSync(providerDir, { withFileTypes: true });
|
|
27575
27645
|
for (const entry of entries) {
|
|
27576
27646
|
if (!entry.isDirectory())
|
|
27577
27647
|
continue;
|
|
27578
27648
|
if (entry.name === "btc")
|
|
27579
27649
|
continue;
|
|
27580
|
-
const csvFiles = findCsvFiles(
|
|
27650
|
+
const csvFiles = findCsvFiles(path19.join(providerDir, entry.name), { fullPaths: true });
|
|
27581
27651
|
csvPaths.push(...csvFiles);
|
|
27582
27652
|
}
|
|
27583
27653
|
return csvPaths;
|
|
27584
27654
|
}
|
|
27585
27655
|
function findBtcCsvPath(directory, pendingDir, provider) {
|
|
27586
|
-
const btcDir =
|
|
27656
|
+
const btcDir = path19.join(directory, pendingDir, provider, "btc");
|
|
27587
27657
|
const csvFiles = findCsvFiles(btcDir, { fullPaths: true });
|
|
27588
27658
|
return csvFiles[0];
|
|
27589
27659
|
}
|
|
27590
27660
|
function determineYear(csvPaths) {
|
|
27591
27661
|
for (const csvPath of csvPaths) {
|
|
27592
|
-
const match2 =
|
|
27662
|
+
const match2 = path19.basename(csvPath).match(/(\d{4})-\d{2}-\d{2}/);
|
|
27593
27663
|
if (match2)
|
|
27594
27664
|
return parseInt(match2[1], 10);
|
|
27595
27665
|
}
|
|
@@ -27642,7 +27712,7 @@ async function generateBtcPurchases(directory, agent, options = {}, configLoader
|
|
|
27642
27712
|
skippedDuplicates: result.skippedDuplicates,
|
|
27643
27713
|
unmatchedFiat: result.unmatchedFiat.length,
|
|
27644
27714
|
unmatchedBtc: result.unmatchedBtc.length,
|
|
27645
|
-
yearJournal:
|
|
27715
|
+
yearJournal: path19.relative(directory, yearJournalPath)
|
|
27646
27716
|
});
|
|
27647
27717
|
} catch (err) {
|
|
27648
27718
|
logger.error("Failed to generate BTC purchases", err);
|
|
@@ -27685,7 +27755,7 @@ to produce equity conversion entries for Bitcoin purchases.
|
|
|
27685
27755
|
});
|
|
27686
27756
|
// src/index.ts
|
|
27687
27757
|
var __dirname2 = dirname9(fileURLToPath3(import.meta.url));
|
|
27688
|
-
var AGENT_FILE =
|
|
27758
|
+
var AGENT_FILE = join17(__dirname2, "..", "agent", "accountant.md");
|
|
27689
27759
|
var AccountantPlugin = async () => {
|
|
27690
27760
|
const agent = loadAgent(AGENT_FILE);
|
|
27691
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