@fuzzle/opencode-accountant 0.10.8 → 0.11.0-next.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +371 -74
- package/docs/tools/import-pipeline.md +38 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -2739,6 +2739,19 @@ var init_agentLoader = __esm(() => {
|
|
|
2739
2739
|
]);
|
|
2740
2740
|
});
|
|
2741
2741
|
|
|
2742
|
+
// src/utils/journalMatchers.ts
|
|
2743
|
+
function extractAccount(line, pattern) {
|
|
2744
|
+
const match = line.match(pattern);
|
|
2745
|
+
return match ? match[1].trim() : null;
|
|
2746
|
+
}
|
|
2747
|
+
var JOURNAL_ACCOUNT_DECL, RULES_ACCOUNT_DIRECTIVE, RULES_ACCOUNT2_DIRECTIVE, TX_HEADER_PATTERN;
|
|
2748
|
+
var init_journalMatchers = __esm(() => {
|
|
2749
|
+
JOURNAL_ACCOUNT_DECL = /^account\s+(.+)$/;
|
|
2750
|
+
RULES_ACCOUNT_DIRECTIVE = /^account\d+\s+(.+)$/;
|
|
2751
|
+
RULES_ACCOUNT2_DIRECTIVE = /^\s*account2\s+(.+)$/;
|
|
2752
|
+
TX_HEADER_PATTERN = /^(\d{4})-(\d{2}-\d{2})(\s+(.+))?$/;
|
|
2753
|
+
});
|
|
2754
|
+
|
|
2742
2755
|
// node_modules/papaparse/papaparse.js
|
|
2743
2756
|
var require_papaparse = __commonJS((exports, module) => {
|
|
2744
2757
|
(function(root, factory) {
|
|
@@ -4258,19 +4271,6 @@ var require_brace_expansion = __commonJS((exports, module) => {
|
|
|
4258
4271
|
}
|
|
4259
4272
|
});
|
|
4260
4273
|
|
|
4261
|
-
// src/utils/journalMatchers.ts
|
|
4262
|
-
function extractAccount(line, pattern) {
|
|
4263
|
-
const match2 = line.match(pattern);
|
|
4264
|
-
return match2 ? match2[1].trim() : null;
|
|
4265
|
-
}
|
|
4266
|
-
var JOURNAL_ACCOUNT_DECL, RULES_ACCOUNT_DIRECTIVE, RULES_ACCOUNT2_DIRECTIVE, TX_HEADER_PATTERN;
|
|
4267
|
-
var init_journalMatchers = __esm(() => {
|
|
4268
|
-
JOURNAL_ACCOUNT_DECL = /^account\s+(.+)$/;
|
|
4269
|
-
RULES_ACCOUNT_DIRECTIVE = /^account\d+\s+(.+)$/;
|
|
4270
|
-
RULES_ACCOUNT2_DIRECTIVE = /^\s*account2\s+(.+)$/;
|
|
4271
|
-
TX_HEADER_PATTERN = /^(\d{4})-(\d{2}-\d{2})(\s+(.+))?$/;
|
|
4272
|
-
});
|
|
4273
|
-
|
|
4274
4274
|
// src/utils/accountSuggester.ts
|
|
4275
4275
|
var exports_accountSuggester = {};
|
|
4276
4276
|
__export(exports_accountSuggester, {
|
|
@@ -4280,7 +4280,7 @@ __export(exports_accountSuggester, {
|
|
|
4280
4280
|
extractRulePatternsFromFile: () => extractRulePatternsFromFile,
|
|
4281
4281
|
clearSuggestionCache: () => clearSuggestionCache
|
|
4282
4282
|
});
|
|
4283
|
-
import * as
|
|
4283
|
+
import * as fs20 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 (!fs20.existsSync(yearJournalPath)) {
|
|
4294
4294
|
return [];
|
|
4295
4295
|
}
|
|
4296
|
-
const content =
|
|
4296
|
+
const content = fs20.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 (!fs20.existsSync(rulesPath)) {
|
|
4313
4313
|
return [];
|
|
4314
4314
|
}
|
|
4315
|
-
const content =
|
|
4315
|
+
const content = fs20.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
|
|
4550
|
+
import { dirname as dirname8, join as join16 } from "path";
|
|
4551
4551
|
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
4552
4552
|
|
|
4553
4553
|
// node_modules/zod/v4/classic/external.js
|
|
@@ -17012,6 +17012,7 @@ function ensureDirectory(dirPath) {
|
|
|
17012
17012
|
}
|
|
17013
17013
|
|
|
17014
17014
|
// src/utils/journalUtils.ts
|
|
17015
|
+
init_journalMatchers();
|
|
17015
17016
|
function extractDateFromPriceLine(line) {
|
|
17016
17017
|
return line.split(" ")[1];
|
|
17017
17018
|
}
|
|
@@ -17097,6 +17098,37 @@ function ensureCommodityDeclarations(commodityJournalPath, symbols, logger) {
|
|
|
17097
17098
|
logger?.info(`Commodity declarations: added ${missing.length} (${missing.join(", ")})`);
|
|
17098
17099
|
return { added: missing.sort(), updated: true };
|
|
17099
17100
|
}
|
|
17101
|
+
function ensureInvestmentAccountDeclarations(accountJournalPath, accounts, logger) {
|
|
17102
|
+
const existing = new Set;
|
|
17103
|
+
if (fs4.existsSync(accountJournalPath)) {
|
|
17104
|
+
const content2 = fs4.readFileSync(accountJournalPath, "utf-8");
|
|
17105
|
+
for (const line of content2.split(`
|
|
17106
|
+
`)) {
|
|
17107
|
+
const match = line.trim().match(JOURNAL_ACCOUNT_DECL);
|
|
17108
|
+
if (match) {
|
|
17109
|
+
existing.add(match[1]);
|
|
17110
|
+
}
|
|
17111
|
+
}
|
|
17112
|
+
}
|
|
17113
|
+
const missing = [];
|
|
17114
|
+
for (const account of accounts) {
|
|
17115
|
+
if (!existing.has(account)) {
|
|
17116
|
+
missing.push(account);
|
|
17117
|
+
existing.add(account);
|
|
17118
|
+
}
|
|
17119
|
+
}
|
|
17120
|
+
if (missing.length === 0) {
|
|
17121
|
+
return { added: [], updated: false };
|
|
17122
|
+
}
|
|
17123
|
+
const sorted = Array.from(existing).sort((a, b) => a.localeCompare(b));
|
|
17124
|
+
const content = sorted.map((a) => `account ${a}`).join(`
|
|
17125
|
+
`) + `
|
|
17126
|
+
`;
|
|
17127
|
+
ensureDirectory(path3.dirname(accountJournalPath));
|
|
17128
|
+
fs4.writeFileSync(accountJournalPath, content);
|
|
17129
|
+
logger?.info(`Account declarations: added ${missing.length} (${missing.join(", ")})`);
|
|
17130
|
+
return { added: missing.sort(), updated: true };
|
|
17131
|
+
}
|
|
17100
17132
|
|
|
17101
17133
|
// src/utils/dateUtils.ts
|
|
17102
17134
|
function formatDateISO(date5) {
|
|
@@ -24160,8 +24192,8 @@ Note: This tool requires a contextId from a prior classify/import step.`,
|
|
|
24160
24192
|
}
|
|
24161
24193
|
});
|
|
24162
24194
|
// src/tools/import-pipeline.ts
|
|
24163
|
-
import * as
|
|
24164
|
-
import * as
|
|
24195
|
+
import * as fs21 from "fs";
|
|
24196
|
+
import * as path15 from "path";
|
|
24165
24197
|
|
|
24166
24198
|
// src/utils/accountDeclarations.ts
|
|
24167
24199
|
init_journalMatchers();
|
|
@@ -25033,7 +25065,7 @@ function removeLots(inventory, symbol2, logger) {
|
|
|
25033
25065
|
return [];
|
|
25034
25066
|
}
|
|
25035
25067
|
const removedLots = [...lots];
|
|
25036
|
-
|
|
25068
|
+
inventory[symbol2] = [];
|
|
25037
25069
|
const totalQuantity = removedLots.reduce((sum, lot) => sum + lot.quantity, 0);
|
|
25038
25070
|
const totalCost = removedLots.reduce((sum, lot) => sum + lot.quantity * lot.costBasis, 0);
|
|
25039
25071
|
logger?.info(`Removed ${removedLots.length} lots for ${symbol2}: ${totalQuantity} shares, cost basis ${totalCost}`);
|
|
@@ -25202,17 +25234,18 @@ function generateWorthlessEntry(action, removedLots, logger) {
|
|
|
25202
25234
|
`;
|
|
25203
25235
|
return entry;
|
|
25204
25236
|
}
|
|
25205
|
-
function generateMultiWayMergerEntry(group, crossCurrencyOutgoingSymbols, logger) {
|
|
25237
|
+
function generateMultiWayMergerEntry(group, crossCurrencyOutgoingSymbols, crossCurrencyOutgoingIsins, logger) {
|
|
25206
25238
|
const date5 = formatDate(group.date);
|
|
25207
25239
|
const outSymbols = crossCurrencyOutgoingSymbols ?? group.outgoing.map((a) => a.symbol);
|
|
25208
25240
|
const inSymbols = group.incoming.map((a) => a.symbol);
|
|
25209
|
-
const
|
|
25241
|
+
const descriptionParts = outSymbols.join(" + ");
|
|
25242
|
+
const description = escapeDescription(inSymbols.length > 0 ? `Merger: ${descriptionParts} -> ${inSymbols.join(" + ")}` : `Merger: ${descriptionParts}`);
|
|
25210
25243
|
logger?.debug(`Generating multi-way merger entry: ${outSymbols.join(", ")} -> ${inSymbols.join(", ")}`);
|
|
25211
25244
|
let entry = `${date5} ${description}
|
|
25212
25245
|
`;
|
|
25213
25246
|
entry += ` ; swissquote:order:${group.orderNum}
|
|
25214
25247
|
`;
|
|
25215
|
-
const oldIsins = group.outgoing.map((a) => a.isin).filter(Boolean);
|
|
25248
|
+
const oldIsins = (crossCurrencyOutgoingIsins ?? group.outgoing.map((a) => a.isin)).filter(Boolean);
|
|
25216
25249
|
const newIsins = group.incoming.map((a) => a.isin).filter(Boolean);
|
|
25217
25250
|
if (oldIsins.length > 0 || newIsins.length > 0) {
|
|
25218
25251
|
entry += ` ; Old ISIN: ${oldIsins.join(", ") || "n/a"}, New ISINs: ${newIsins.join(", ") || "n/a"}
|
|
@@ -25514,7 +25547,7 @@ function processMultiWayMerger(group, inventory, lotInventoryPath, projectDir, l
|
|
|
25514
25547
|
inventory[inc.symbol].push(lot);
|
|
25515
25548
|
logger?.info(`Cross-currency merger incoming: ${absQty} ${inc.symbol} @ ${costBasisPerUnit.toFixed(2)} ${pendingState.currency}`);
|
|
25516
25549
|
}
|
|
25517
|
-
const entry2 = generateMultiWayMergerEntry(group, pendingState.outgoingSymbols, logger);
|
|
25550
|
+
const entry2 = generateMultiWayMergerEntry(group, pendingState.outgoingSymbols, pendingState.outgoingIsins, logger);
|
|
25518
25551
|
entries.push(entry2);
|
|
25519
25552
|
removePendingMerger(projectDir, lotInventoryPath, group.key, logger);
|
|
25520
25553
|
} else {
|
|
@@ -25545,9 +25578,12 @@ function processMultiWayMerger(group, inventory, lotInventoryPath, projectDir, l
|
|
|
25545
25578
|
date: group.date,
|
|
25546
25579
|
orderNum: group.orderNum,
|
|
25547
25580
|
outgoingSymbols,
|
|
25581
|
+
outgoingIsins: group.outgoing.map((a) => a.isin),
|
|
25548
25582
|
totalCostBasis,
|
|
25549
25583
|
currency: outgoingCurrency || "CAD"
|
|
25550
25584
|
}, logger);
|
|
25585
|
+
const entry2 = generateMultiWayMergerEntry(group, undefined, undefined, logger);
|
|
25586
|
+
entries.push(entry2);
|
|
25551
25587
|
logger?.info(`Cross-currency merger outgoing: ${outgoingSymbols.join(", ")} -> pending (cost basis: ${totalCostBasis.toFixed(2)})`);
|
|
25552
25588
|
return entries;
|
|
25553
25589
|
}
|
|
@@ -25576,7 +25612,7 @@ function processMultiWayMerger(group, inventory, lotInventoryPath, projectDir, l
|
|
|
25576
25612
|
inventory[inc.symbol].push(lot);
|
|
25577
25613
|
logger?.debug(`Merger incoming: added ${absQty} ${inc.symbol} @ ${costBasisPerUnit.toFixed(2)}`);
|
|
25578
25614
|
}
|
|
25579
|
-
const entry = generateMultiWayMergerEntry(group, undefined, logger);
|
|
25615
|
+
const entry = generateMultiWayMergerEntry(group, undefined, undefined, logger);
|
|
25580
25616
|
entries.push(entry);
|
|
25581
25617
|
return entries;
|
|
25582
25618
|
}
|
|
@@ -25830,6 +25866,20 @@ async function preprocessSwissquote(csvPath, projectDir, currency, year, lotInve
|
|
|
25830
25866
|
if (stockSymbols.size > 0) {
|
|
25831
25867
|
const commodityJournalPath = path13.join(projectDir, "ledger", "investments", "commodities.journal");
|
|
25832
25868
|
ensureCommodityDeclarations(commodityJournalPath, stockSymbols, logger);
|
|
25869
|
+
const accountJournalPath = path13.join(projectDir, "ledger", "investments", "accounts.journal");
|
|
25870
|
+
const investmentAccounts = new Set;
|
|
25871
|
+
for (const symbol2 of stockSymbols) {
|
|
25872
|
+
investmentAccounts.add(`assets:investments:stocks:${symbol2}`);
|
|
25873
|
+
investmentAccounts.add(`income:dividends:${symbol2}`);
|
|
25874
|
+
}
|
|
25875
|
+
investmentAccounts.add(`assets:broker:swissquote:${currency.toLowerCase()}`);
|
|
25876
|
+
investmentAccounts.add("expenses:fees:trading:swissquote");
|
|
25877
|
+
investmentAccounts.add("expenses:taxes:withholding");
|
|
25878
|
+
investmentAccounts.add("income:capital-gains:realized");
|
|
25879
|
+
investmentAccounts.add("income:capital-gains:rights-distribution");
|
|
25880
|
+
investmentAccounts.add("expenses:losses:capital");
|
|
25881
|
+
investmentAccounts.add("equity:conversion");
|
|
25882
|
+
ensureInvestmentAccountDeclarations(accountJournalPath, investmentAccounts, logger);
|
|
25833
25883
|
}
|
|
25834
25884
|
logger?.logResult({
|
|
25835
25885
|
totalRows: stats.totalRows,
|
|
@@ -25849,6 +25899,208 @@ async function preprocessSwissquote(csvPath, projectDir, currency, year, lotInve
|
|
|
25849
25899
|
};
|
|
25850
25900
|
}
|
|
25851
25901
|
|
|
25902
|
+
// src/utils/currencyExchangeGenerator.ts
|
|
25903
|
+
import * as fs19 from "fs";
|
|
25904
|
+
import * as path14 from "path";
|
|
25905
|
+
function parseExchangeRows(csvPath) {
|
|
25906
|
+
const content = fs19.readFileSync(csvPath, "utf-8");
|
|
25907
|
+
const lines = content.trim().split(`
|
|
25908
|
+
`);
|
|
25909
|
+
if (lines.length < 2)
|
|
25910
|
+
return [];
|
|
25911
|
+
const rows = [];
|
|
25912
|
+
for (let i2 = 1;i2 < lines.length; i2++) {
|
|
25913
|
+
const line = lines[i2];
|
|
25914
|
+
const fields = line.split(",");
|
|
25915
|
+
if (fields.length < 9)
|
|
25916
|
+
continue;
|
|
25917
|
+
const [type2, , startedDate, , description, amountStr, , currency] = fields;
|
|
25918
|
+
if (type2 !== "EXCHANGE")
|
|
25919
|
+
continue;
|
|
25920
|
+
if (!description?.startsWith("Exchanged to"))
|
|
25921
|
+
continue;
|
|
25922
|
+
const rawAmount = parseFloat(amountStr);
|
|
25923
|
+
const date5 = parseRevolutDatetime(startedDate);
|
|
25924
|
+
const dateIso = formatDateIso2(date5);
|
|
25925
|
+
rows.push({
|
|
25926
|
+
date: date5,
|
|
25927
|
+
dateStr: dateIso,
|
|
25928
|
+
description,
|
|
25929
|
+
amount: rawAmount,
|
|
25930
|
+
currency,
|
|
25931
|
+
lineIndex: i2,
|
|
25932
|
+
csvPath
|
|
25933
|
+
});
|
|
25934
|
+
}
|
|
25935
|
+
return rows;
|
|
25936
|
+
}
|
|
25937
|
+
function matchExchangePairs(rowsByCsv) {
|
|
25938
|
+
const allRows = [];
|
|
25939
|
+
for (const rows of rowsByCsv.values()) {
|
|
25940
|
+
allRows.push(...rows);
|
|
25941
|
+
}
|
|
25942
|
+
const sources = allRows.filter((r) => r.amount > 0);
|
|
25943
|
+
const targets = allRows.filter((r) => r.amount < 0);
|
|
25944
|
+
const matches = [];
|
|
25945
|
+
const matchedSourceIndices = new Set;
|
|
25946
|
+
const matchedTargetIndices = new Set;
|
|
25947
|
+
for (let si = 0;si < sources.length; si++) {
|
|
25948
|
+
const source = sources[si];
|
|
25949
|
+
for (let ti = 0;ti < targets.length; ti++) {
|
|
25950
|
+
if (matchedTargetIndices.has(ti))
|
|
25951
|
+
continue;
|
|
25952
|
+
const target = targets[ti];
|
|
25953
|
+
if (source.currency === target.currency)
|
|
25954
|
+
continue;
|
|
25955
|
+
if (!datesMatchToSecond2(source.date, target.date))
|
|
25956
|
+
continue;
|
|
25957
|
+
const targetCurrency = target.currency;
|
|
25958
|
+
if (!source.description.includes(targetCurrency))
|
|
25959
|
+
continue;
|
|
25960
|
+
matches.push({
|
|
25961
|
+
source,
|
|
25962
|
+
target: { ...target, amount: Math.abs(target.amount) }
|
|
25963
|
+
});
|
|
25964
|
+
matchedSourceIndices.add(si);
|
|
25965
|
+
matchedTargetIndices.add(ti);
|
|
25966
|
+
break;
|
|
25967
|
+
}
|
|
25968
|
+
}
|
|
25969
|
+
return matches;
|
|
25970
|
+
}
|
|
25971
|
+
function datesMatchToSecond2(a, b) {
|
|
25972
|
+
return Math.abs(a.getTime() - b.getTime()) < 1000;
|
|
25973
|
+
}
|
|
25974
|
+
function formatExchangeEntry(match2) {
|
|
25975
|
+
const { source, target } = match2;
|
|
25976
|
+
const date5 = source.dateStr;
|
|
25977
|
+
const description = source.description;
|
|
25978
|
+
const sourceCurrency = source.currency;
|
|
25979
|
+
const targetCurrency = target.currency;
|
|
25980
|
+
const sourceAmount = formatAmount3(source.amount);
|
|
25981
|
+
const targetAmount = formatAmount3(target.amount);
|
|
25982
|
+
const rate = target.amount / source.amount;
|
|
25983
|
+
const rateStr = formatRate(rate);
|
|
25984
|
+
return [
|
|
25985
|
+
`${date5} ${description}`,
|
|
25986
|
+
` assets:bank:revolut:${sourceCurrency.toLowerCase()} -${sourceAmount} ${sourceCurrency} @ ${rateStr} ${targetCurrency}`,
|
|
25987
|
+
` equity:currency:conversion ${sourceAmount} ${sourceCurrency}`,
|
|
25988
|
+
` equity:currency:conversion -${targetAmount} ${targetCurrency}`,
|
|
25989
|
+
` assets:bank:revolut:${targetCurrency.toLowerCase()} ${targetAmount} ${targetCurrency}`
|
|
25990
|
+
].join(`
|
|
25991
|
+
`);
|
|
25992
|
+
}
|
|
25993
|
+
function formatAmount3(amount) {
|
|
25994
|
+
return amount.toFixed(2);
|
|
25995
|
+
}
|
|
25996
|
+
function formatRate(rate) {
|
|
25997
|
+
return rate.toFixed(4);
|
|
25998
|
+
}
|
|
25999
|
+
function isDuplicate2(match2, journalContent) {
|
|
26000
|
+
const date5 = match2.source.dateStr;
|
|
26001
|
+
const description = match2.source.description;
|
|
26002
|
+
const pattern = `${date5} ${description}`;
|
|
26003
|
+
if (!journalContent.includes(pattern))
|
|
26004
|
+
return false;
|
|
26005
|
+
const sourceAmount = formatAmount3(match2.source.amount);
|
|
26006
|
+
const sourceCurrency = match2.source.currency;
|
|
26007
|
+
const amountPattern = `-${sourceAmount} ${sourceCurrency}`;
|
|
26008
|
+
const idx = journalContent.indexOf(pattern);
|
|
26009
|
+
const chunk = journalContent.slice(idx, idx + 500);
|
|
26010
|
+
return chunk.includes(amountPattern);
|
|
26011
|
+
}
|
|
26012
|
+
function parseRevolutDatetime(dateStr) {
|
|
26013
|
+
const [datePart, timePart] = dateStr.split(" ");
|
|
26014
|
+
if (!datePart || !timePart) {
|
|
26015
|
+
throw new Error(`Invalid datetime: ${dateStr}`);
|
|
26016
|
+
}
|
|
26017
|
+
return new Date(`${datePart}T${timePart}`);
|
|
26018
|
+
}
|
|
26019
|
+
function formatDateIso2(date5) {
|
|
26020
|
+
const y = date5.getFullYear();
|
|
26021
|
+
const m = String(date5.getMonth() + 1).padStart(2, "0");
|
|
26022
|
+
const d = String(date5.getDate()).padStart(2, "0");
|
|
26023
|
+
return `${y}-${m}-${d}`;
|
|
26024
|
+
}
|
|
26025
|
+
function filterExchangeRows(csvPath, rowIndicesToRemove, logger) {
|
|
26026
|
+
const content = fs19.readFileSync(csvPath, "utf-8");
|
|
26027
|
+
const lines = content.trim().split(`
|
|
26028
|
+
`);
|
|
26029
|
+
const filteredLines = [lines[0]];
|
|
26030
|
+
for (let i2 = 1;i2 < lines.length; i2++) {
|
|
26031
|
+
if (rowIndicesToRemove.has(i2))
|
|
26032
|
+
continue;
|
|
26033
|
+
filteredLines.push(lines[i2]);
|
|
26034
|
+
}
|
|
26035
|
+
const dir = path14.dirname(csvPath);
|
|
26036
|
+
const basename6 = path14.basename(csvPath, ".csv");
|
|
26037
|
+
const filteredPath = path14.join(dir, `${basename6}-filtered.csv`);
|
|
26038
|
+
fs19.writeFileSync(filteredPath, filteredLines.join(`
|
|
26039
|
+
`) + `
|
|
26040
|
+
`);
|
|
26041
|
+
logger?.info(`Filtered ${rowIndicesToRemove.size} exchange rows from ${path14.basename(csvPath)} \u2192 ${path14.basename(filteredPath)}`);
|
|
26042
|
+
return filteredPath;
|
|
26043
|
+
}
|
|
26044
|
+
function generateCurrencyExchangeJournal(fiatCsvPaths, yearJournalPath, logger) {
|
|
26045
|
+
logger?.info("Parsing fiat CSVs for EXCHANGE rows...");
|
|
26046
|
+
const rowsByCsv = new Map;
|
|
26047
|
+
for (const csvPath of fiatCsvPaths) {
|
|
26048
|
+
const rows = parseExchangeRows(csvPath);
|
|
26049
|
+
if (rows.length > 0) {
|
|
26050
|
+
rowsByCsv.set(csvPath, rows);
|
|
26051
|
+
logger?.info(`Found ${rows.length} EXCHANGE rows in ${path14.basename(csvPath)}`);
|
|
26052
|
+
}
|
|
26053
|
+
}
|
|
26054
|
+
if (rowsByCsv.size < 2) {
|
|
26055
|
+
logger?.info("Need at least 2 CSVs with EXCHANGE rows to match pairs, skipping");
|
|
26056
|
+
return { matchCount: 0, entriesAdded: 0, skippedDuplicates: 0, matchedRowIndices: new Map };
|
|
26057
|
+
}
|
|
26058
|
+
logger?.info("Matching exchange pairs across CSVs...");
|
|
26059
|
+
const matches = matchExchangePairs(rowsByCsv);
|
|
26060
|
+
logger?.info(`Matched ${matches.length} exchange pairs`);
|
|
26061
|
+
let journalContent = "";
|
|
26062
|
+
if (fs19.existsSync(yearJournalPath)) {
|
|
26063
|
+
journalContent = fs19.readFileSync(yearJournalPath, "utf-8");
|
|
26064
|
+
}
|
|
26065
|
+
const newEntries = [];
|
|
26066
|
+
let skippedDuplicates = 0;
|
|
26067
|
+
const sortedMatches = [...matches].sort((a, b) => a.source.date.getTime() - b.source.date.getTime());
|
|
26068
|
+
for (const match2 of sortedMatches) {
|
|
26069
|
+
if (isDuplicate2(match2, journalContent)) {
|
|
26070
|
+
skippedDuplicates++;
|
|
26071
|
+
logger?.debug(`Skipping duplicate: ${match2.source.dateStr} ${formatAmount3(match2.source.amount)} ${match2.source.currency} \u2192 ${formatAmount3(match2.target.amount)} ${match2.target.currency}`);
|
|
26072
|
+
continue;
|
|
26073
|
+
}
|
|
26074
|
+
newEntries.push(formatExchangeEntry(match2));
|
|
26075
|
+
}
|
|
26076
|
+
if (newEntries.length > 0) {
|
|
26077
|
+
const appendContent = `
|
|
26078
|
+
` + newEntries.join(`
|
|
26079
|
+
|
|
26080
|
+
`) + `
|
|
26081
|
+
`;
|
|
26082
|
+
fs19.appendFileSync(yearJournalPath, appendContent);
|
|
26083
|
+
logger?.info(`Appended ${newEntries.length} currency exchange entries to ${path14.basename(yearJournalPath)}`);
|
|
26084
|
+
} else {
|
|
26085
|
+
logger?.info("No new currency exchange entries to add");
|
|
26086
|
+
}
|
|
26087
|
+
const matchedRowIndices = new Map;
|
|
26088
|
+
for (const match2 of matches) {
|
|
26089
|
+
for (const row of [match2.source, match2.target]) {
|
|
26090
|
+
if (!matchedRowIndices.has(row.csvPath)) {
|
|
26091
|
+
matchedRowIndices.set(row.csvPath, new Set);
|
|
26092
|
+
}
|
|
26093
|
+
matchedRowIndices.get(row.csvPath).add(row.lineIndex);
|
|
26094
|
+
}
|
|
26095
|
+
}
|
|
26096
|
+
return {
|
|
26097
|
+
matchCount: matches.length,
|
|
26098
|
+
entriesAdded: newEntries.length,
|
|
26099
|
+
skippedDuplicates,
|
|
26100
|
+
matchedRowIndices
|
|
26101
|
+
};
|
|
26102
|
+
}
|
|
26103
|
+
|
|
25852
26104
|
// src/tools/import-pipeline.ts
|
|
25853
26105
|
class NoTransactionsError extends Error {
|
|
25854
26106
|
constructor() {
|
|
@@ -25921,7 +26173,7 @@ function executePreprocessFiatStep(context, contextIds, logger) {
|
|
|
25921
26173
|
for (const contextId of contextIds) {
|
|
25922
26174
|
const importCtx = loadContext(context.directory, contextId);
|
|
25923
26175
|
if (importCtx.provider === "revolut" && importCtx.currency !== "btc") {
|
|
25924
|
-
fiatCsvPaths.push(
|
|
26176
|
+
fiatCsvPaths.push(path15.join(context.directory, importCtx.filePath));
|
|
25925
26177
|
}
|
|
25926
26178
|
}
|
|
25927
26179
|
if (fiatCsvPaths.length === 0) {
|
|
@@ -25965,7 +26217,7 @@ function executePreprocessBtcStep(context, contextIds, logger) {
|
|
|
25965
26217
|
for (const contextId of contextIds) {
|
|
25966
26218
|
const importCtx = loadContext(context.directory, contextId);
|
|
25967
26219
|
if (importCtx.provider === "revolut" && importCtx.currency === "btc") {
|
|
25968
|
-
btcCsvPath =
|
|
26220
|
+
btcCsvPath = path15.join(context.directory, importCtx.filePath);
|
|
25969
26221
|
break;
|
|
25970
26222
|
}
|
|
25971
26223
|
}
|
|
@@ -25999,7 +26251,7 @@ async function executeBtcPurchaseStep(context, contextIds, logger) {
|
|
|
25999
26251
|
const importCtx = loadContext(context.directory, contextId);
|
|
26000
26252
|
if (importCtx.provider !== "revolut")
|
|
26001
26253
|
continue;
|
|
26002
|
-
const csvPath =
|
|
26254
|
+
const csvPath = path15.join(context.directory, importCtx.filePath);
|
|
26003
26255
|
if (importCtx.currency === "btc") {
|
|
26004
26256
|
btcCsvPath = csvPath;
|
|
26005
26257
|
} else {
|
|
@@ -26012,7 +26264,7 @@ async function executeBtcPurchaseStep(context, contextIds, logger) {
|
|
|
26012
26264
|
}
|
|
26013
26265
|
logger?.startSection("Step 1b: Generate BTC Purchase Entries");
|
|
26014
26266
|
logger?.logStep("BTC Purchases", "start");
|
|
26015
|
-
const btcFilename =
|
|
26267
|
+
const btcFilename = path15.basename(btcCsvPath);
|
|
26016
26268
|
const yearMatch = btcFilename.match(/(\d{4})-\d{2}-\d{2}/);
|
|
26017
26269
|
const year = yearMatch ? parseInt(yearMatch[1], 10) : new Date().getFullYear();
|
|
26018
26270
|
const yearJournalPath = ensureYearJournalExists(context.directory, year);
|
|
@@ -26028,12 +26280,56 @@ async function executeBtcPurchaseStep(context, contextIds, logger) {
|
|
|
26028
26280
|
unmatchedBtc: result.unmatchedBtc.length
|
|
26029
26281
|
});
|
|
26030
26282
|
}
|
|
26283
|
+
async function executeCurrencyExchangeStep(context, contextIds, logger) {
|
|
26284
|
+
const fiatContexts = [];
|
|
26285
|
+
for (const contextId of contextIds) {
|
|
26286
|
+
const importCtx = loadContext(context.directory, contextId);
|
|
26287
|
+
if (importCtx.provider !== "revolut")
|
|
26288
|
+
continue;
|
|
26289
|
+
if (importCtx.currency === "btc")
|
|
26290
|
+
continue;
|
|
26291
|
+
const csvPath = path15.join(context.directory, importCtx.filePath);
|
|
26292
|
+
fiatContexts.push({ contextId, csvPath });
|
|
26293
|
+
}
|
|
26294
|
+
if (fiatContexts.length < 2) {
|
|
26295
|
+
logger?.info("Need at least 2 Revolut fiat CSVs for exchange matching, skipping");
|
|
26296
|
+
return;
|
|
26297
|
+
}
|
|
26298
|
+
logger?.startSection("Step 1e: Generate Currency Exchange Entries");
|
|
26299
|
+
logger?.logStep("Currency Exchanges", "start");
|
|
26300
|
+
const firstFilename = path15.basename(fiatContexts[0].csvPath);
|
|
26301
|
+
const yearMatch = firstFilename.match(/(\d{4})-\d{2}-\d{2}/);
|
|
26302
|
+
const year = yearMatch ? parseInt(yearMatch[1], 10) : new Date().getFullYear();
|
|
26303
|
+
const yearJournalPath = ensureYearJournalExists(context.directory, year);
|
|
26304
|
+
const fiatCsvPaths = fiatContexts.map((c) => c.csvPath);
|
|
26305
|
+
const result = generateCurrencyExchangeJournal(fiatCsvPaths, yearJournalPath, logger);
|
|
26306
|
+
if (result.matchedRowIndices.size > 0) {
|
|
26307
|
+
for (const { contextId, csvPath } of fiatContexts) {
|
|
26308
|
+
const indices = result.matchedRowIndices.get(csvPath);
|
|
26309
|
+
if (!indices || indices.size === 0)
|
|
26310
|
+
continue;
|
|
26311
|
+
const filteredCsvPath = filterExchangeRows(csvPath, indices, logger);
|
|
26312
|
+
updateContext(context.directory, contextId, {
|
|
26313
|
+
filePath: path15.relative(context.directory, filteredCsvPath)
|
|
26314
|
+
});
|
|
26315
|
+
logger?.info(`Updated context ${contextId} to use filtered CSV: ${path15.basename(filteredCsvPath)}`);
|
|
26316
|
+
}
|
|
26317
|
+
}
|
|
26318
|
+
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)`;
|
|
26319
|
+
logger?.logStep("Currency Exchanges", "success", message);
|
|
26320
|
+
logger?.endSection();
|
|
26321
|
+
context.result.steps.currencyExchanges = buildStepResult(true, message, {
|
|
26322
|
+
matchCount: result.matchCount,
|
|
26323
|
+
entriesAdded: result.entriesAdded,
|
|
26324
|
+
skippedDuplicates: result.skippedDuplicates
|
|
26325
|
+
});
|
|
26326
|
+
}
|
|
26031
26327
|
async function executeSwissquotePreprocessStep(context, contextIds, logger) {
|
|
26032
26328
|
const swissquoteContexts = [];
|
|
26033
26329
|
for (const contextId of contextIds) {
|
|
26034
26330
|
const importCtx = loadContext(context.directory, contextId);
|
|
26035
26331
|
if (importCtx.provider === "swissquote") {
|
|
26036
|
-
const csvPath =
|
|
26332
|
+
const csvPath = path15.join(context.directory, importCtx.filePath);
|
|
26037
26333
|
const toDateMatch = importCtx.filename.match(/to-\d{4}(\d{4})\./);
|
|
26038
26334
|
let year = new Date().getFullYear();
|
|
26039
26335
|
if (toDateMatch) {
|
|
@@ -26057,11 +26353,11 @@ async function executeSwissquotePreprocessStep(context, contextIds, logger) {
|
|
|
26057
26353
|
const lotInventoryPath = swissquoteProvider?.lotInventoryPath ?? DEFAULT_LOT_INVENTORY_PATH;
|
|
26058
26354
|
const symbolMapPath = swissquoteProvider?.symbolMapPath ?? "config/import/symbolMap.yaml";
|
|
26059
26355
|
let symbolMap = {};
|
|
26060
|
-
const symbolMapFullPath =
|
|
26061
|
-
if (
|
|
26356
|
+
const symbolMapFullPath = path15.join(context.directory, symbolMapPath);
|
|
26357
|
+
if (fs21.existsSync(symbolMapFullPath)) {
|
|
26062
26358
|
try {
|
|
26063
26359
|
const yaml = await Promise.resolve().then(() => (init_js_yaml(), exports_js_yaml));
|
|
26064
|
-
const content =
|
|
26360
|
+
const content = fs21.readFileSync(symbolMapFullPath, "utf-8");
|
|
26065
26361
|
const parsed = yaml.load(content);
|
|
26066
26362
|
if (parsed && typeof parsed === "object") {
|
|
26067
26363
|
symbolMap = parsed;
|
|
@@ -26083,7 +26379,7 @@ async function executeSwissquotePreprocessStep(context, contextIds, logger) {
|
|
|
26083
26379
|
};
|
|
26084
26380
|
let lastJournalFile = null;
|
|
26085
26381
|
for (const sqCtx of swissquoteContexts) {
|
|
26086
|
-
logger?.logStep("Swissquote Preprocess", "start", `Processing ${
|
|
26382
|
+
logger?.logStep("Swissquote Preprocess", "start", `Processing ${path15.basename(sqCtx.csvPath)}`);
|
|
26087
26383
|
const result = await preprocessSwissquote(sqCtx.csvPath, context.directory, sqCtx.currency, sqCtx.year, lotInventoryPath, symbolMap, logger);
|
|
26088
26384
|
totalStats.totalRows += result.stats.totalRows;
|
|
26089
26385
|
totalStats.simpleTransactions += result.stats.simpleTransactions;
|
|
@@ -26096,9 +26392,9 @@ async function executeSwissquotePreprocessStep(context, contextIds, logger) {
|
|
|
26096
26392
|
}
|
|
26097
26393
|
if (result.simpleTransactionsCsv) {
|
|
26098
26394
|
updateContext(context.directory, sqCtx.contextId, {
|
|
26099
|
-
filePath:
|
|
26395
|
+
filePath: path15.relative(context.directory, result.simpleTransactionsCsv)
|
|
26100
26396
|
});
|
|
26101
|
-
logger?.info(`Updated context ${sqCtx.contextId} to use filtered CSV: ${
|
|
26397
|
+
logger?.info(`Updated context ${sqCtx.contextId} to use filtered CSV: ${path15.basename(result.simpleTransactionsCsv)}`);
|
|
26102
26398
|
}
|
|
26103
26399
|
logger?.logStep("Swissquote Preprocess", "success", `Processed: ${result.stats.trades} trades, ${result.stats.dividends} dividends, ${result.stats.corporateActions} corporate actions`);
|
|
26104
26400
|
}
|
|
@@ -26123,9 +26419,9 @@ async function executeAccountDeclarationsStep(context, contextId, logger) {
|
|
|
26123
26419
|
logger?.startSection("Step 2: Check Account Declarations");
|
|
26124
26420
|
logger?.logStep("Check Accounts", "start");
|
|
26125
26421
|
const config2 = context.configLoader(context.directory);
|
|
26126
|
-
const rulesDir =
|
|
26422
|
+
const rulesDir = path15.join(context.directory, config2.paths.rules);
|
|
26127
26423
|
const importCtx = loadContext(context.directory, contextId);
|
|
26128
|
-
const csvPath =
|
|
26424
|
+
const csvPath = path15.join(context.directory, importCtx.filePath);
|
|
26129
26425
|
const csvFiles = [csvPath];
|
|
26130
26426
|
const rulesMapping = loadRulesMapping(rulesDir);
|
|
26131
26427
|
const matchedRulesFiles = new Set;
|
|
@@ -26148,7 +26444,7 @@ async function executeAccountDeclarationsStep(context, contextId, logger) {
|
|
|
26148
26444
|
context.result.steps.accountDeclarations = buildStepResult(true, "No accounts found in rules files", {
|
|
26149
26445
|
accountsAdded: [],
|
|
26150
26446
|
journalUpdated: "",
|
|
26151
|
-
rulesScanned: Array.from(matchedRulesFiles).map((f) =>
|
|
26447
|
+
rulesScanned: Array.from(matchedRulesFiles).map((f) => path15.relative(context.directory, f))
|
|
26152
26448
|
});
|
|
26153
26449
|
return;
|
|
26154
26450
|
}
|
|
@@ -26172,7 +26468,7 @@ async function executeAccountDeclarationsStep(context, contextId, logger) {
|
|
|
26172
26468
|
context.result.steps.accountDeclarations = buildStepResult(true, "No transactions found, skipping account declarations", {
|
|
26173
26469
|
accountsAdded: [],
|
|
26174
26470
|
journalUpdated: "",
|
|
26175
|
-
rulesScanned: Array.from(matchedRulesFiles).map((f) =>
|
|
26471
|
+
rulesScanned: Array.from(matchedRulesFiles).map((f) => path15.relative(context.directory, f))
|
|
26176
26472
|
});
|
|
26177
26473
|
return;
|
|
26178
26474
|
}
|
|
@@ -26183,12 +26479,12 @@ async function executeAccountDeclarationsStep(context, contextId, logger) {
|
|
|
26183
26479
|
context.result.steps.accountDeclarations = buildStepResult(false, `Failed to create year journal: ${error45 instanceof Error ? error45.message : String(error45)}`, {
|
|
26184
26480
|
accountsAdded: [],
|
|
26185
26481
|
journalUpdated: "",
|
|
26186
|
-
rulesScanned: Array.from(matchedRulesFiles).map((f) =>
|
|
26482
|
+
rulesScanned: Array.from(matchedRulesFiles).map((f) => path15.relative(context.directory, f))
|
|
26187
26483
|
});
|
|
26188
26484
|
return;
|
|
26189
26485
|
}
|
|
26190
26486
|
const result = ensureAccountDeclarations(yearJournalPath, allAccounts);
|
|
26191
|
-
const message = result.added.length > 0 ? `Added ${result.added.length} account declaration(s) to ${
|
|
26487
|
+
const message = result.added.length > 0 ? `Added ${result.added.length} account declaration(s) to ${path15.relative(context.directory, yearJournalPath)}` : "All required accounts already declared";
|
|
26192
26488
|
logger?.logStep("Check Accounts", "success", message);
|
|
26193
26489
|
if (result.added.length > 0) {
|
|
26194
26490
|
for (const account of result.added) {
|
|
@@ -26197,17 +26493,17 @@ async function executeAccountDeclarationsStep(context, contextId, logger) {
|
|
|
26197
26493
|
}
|
|
26198
26494
|
context.result.steps.accountDeclarations = buildStepResult(true, message, {
|
|
26199
26495
|
accountsAdded: result.added,
|
|
26200
|
-
journalUpdated:
|
|
26201
|
-
rulesScanned: Array.from(matchedRulesFiles).map((f) =>
|
|
26496
|
+
journalUpdated: path15.relative(context.directory, yearJournalPath),
|
|
26497
|
+
rulesScanned: Array.from(matchedRulesFiles).map((f) => path15.relative(context.directory, f))
|
|
26202
26498
|
});
|
|
26203
26499
|
logger?.endSection();
|
|
26204
26500
|
}
|
|
26205
26501
|
async function buildSuggestionContext(context, contextId, logger) {
|
|
26206
26502
|
const { loadExistingAccounts: loadExistingAccounts2, extractRulePatternsFromFile: extractRulePatternsFromFile2 } = await Promise.resolve().then(() => (init_accountSuggester(), exports_accountSuggester));
|
|
26207
26503
|
const config2 = context.configLoader(context.directory);
|
|
26208
|
-
const rulesDir =
|
|
26504
|
+
const rulesDir = path15.join(context.directory, config2.paths.rules);
|
|
26209
26505
|
const importCtx = loadContext(context.directory, contextId);
|
|
26210
|
-
const csvPath =
|
|
26506
|
+
const csvPath = path15.join(context.directory, importCtx.filePath);
|
|
26211
26507
|
const rulesMapping = loadRulesMapping(rulesDir);
|
|
26212
26508
|
const rulesFile = findRulesForCsv(csvPath, rulesMapping);
|
|
26213
26509
|
if (!rulesFile) {
|
|
@@ -26414,6 +26710,7 @@ async function importPipeline(directory, agent, options, configLoader = loadImpo
|
|
|
26414
26710
|
executePreprocessFiatStep(context, contextIds, logger);
|
|
26415
26711
|
executePreprocessBtcStep(context, contextIds, logger);
|
|
26416
26712
|
await executeBtcPurchaseStep(context, contextIds, logger);
|
|
26713
|
+
await executeCurrencyExchangeStep(context, contextIds, logger);
|
|
26417
26714
|
await executeSwissquotePreprocessStep(context, contextIds, logger);
|
|
26418
26715
|
const importConfig = loadImportConfig(context.directory);
|
|
26419
26716
|
const orderedContextIds = [...contextIds].sort((a, b) => {
|
|
@@ -26513,16 +26810,16 @@ This tool orchestrates the full import workflow:
|
|
|
26513
26810
|
}
|
|
26514
26811
|
});
|
|
26515
26812
|
// src/tools/init-directories.ts
|
|
26516
|
-
import * as
|
|
26517
|
-
import * as
|
|
26813
|
+
import * as fs22 from "fs";
|
|
26814
|
+
import * as path16 from "path";
|
|
26518
26815
|
async function initDirectories(directory) {
|
|
26519
26816
|
try {
|
|
26520
26817
|
const config2 = loadImportConfig(directory);
|
|
26521
26818
|
const directoriesCreated = [];
|
|
26522
26819
|
const gitkeepFiles = [];
|
|
26523
|
-
const importBase =
|
|
26524
|
-
if (!
|
|
26525
|
-
|
|
26820
|
+
const importBase = path16.join(directory, "import");
|
|
26821
|
+
if (!fs22.existsSync(importBase)) {
|
|
26822
|
+
fs22.mkdirSync(importBase, { recursive: true });
|
|
26526
26823
|
directoriesCreated.push("import");
|
|
26527
26824
|
}
|
|
26528
26825
|
const pathsToCreate = [
|
|
@@ -26532,20 +26829,20 @@ async function initDirectories(directory) {
|
|
|
26532
26829
|
{ key: "unrecognized", path: config2.paths.unrecognized }
|
|
26533
26830
|
];
|
|
26534
26831
|
for (const { path: dirPath } of pathsToCreate) {
|
|
26535
|
-
const fullPath =
|
|
26536
|
-
if (!
|
|
26537
|
-
|
|
26832
|
+
const fullPath = path16.join(directory, dirPath);
|
|
26833
|
+
if (!fs22.existsSync(fullPath)) {
|
|
26834
|
+
fs22.mkdirSync(fullPath, { recursive: true });
|
|
26538
26835
|
directoriesCreated.push(dirPath);
|
|
26539
26836
|
}
|
|
26540
|
-
const gitkeepPath =
|
|
26541
|
-
if (!
|
|
26542
|
-
|
|
26543
|
-
gitkeepFiles.push(
|
|
26837
|
+
const gitkeepPath = path16.join(fullPath, ".gitkeep");
|
|
26838
|
+
if (!fs22.existsSync(gitkeepPath)) {
|
|
26839
|
+
fs22.writeFileSync(gitkeepPath, "");
|
|
26840
|
+
gitkeepFiles.push(path16.join(dirPath, ".gitkeep"));
|
|
26544
26841
|
}
|
|
26545
26842
|
}
|
|
26546
|
-
const gitignorePath =
|
|
26843
|
+
const gitignorePath = path16.join(importBase, ".gitignore");
|
|
26547
26844
|
let gitignoreCreated = false;
|
|
26548
|
-
if (!
|
|
26845
|
+
if (!fs22.existsSync(gitignorePath)) {
|
|
26549
26846
|
const gitignoreContent = `# Ignore CSV/PDF files in temporary directories
|
|
26550
26847
|
/incoming/*.csv
|
|
26551
26848
|
/incoming/*.pdf
|
|
@@ -26563,7 +26860,7 @@ async function initDirectories(directory) {
|
|
|
26563
26860
|
.DS_Store
|
|
26564
26861
|
Thumbs.db
|
|
26565
26862
|
`;
|
|
26566
|
-
|
|
26863
|
+
fs22.writeFileSync(gitignorePath, gitignoreContent);
|
|
26567
26864
|
gitignoreCreated = true;
|
|
26568
26865
|
}
|
|
26569
26866
|
const parts = [];
|
|
@@ -26639,32 +26936,32 @@ You can now drop CSV files into import/incoming/ and run import-pipeline.`);
|
|
|
26639
26936
|
}
|
|
26640
26937
|
});
|
|
26641
26938
|
// src/tools/generate-btc-purchases.ts
|
|
26642
|
-
import * as
|
|
26643
|
-
import * as
|
|
26939
|
+
import * as path17 from "path";
|
|
26940
|
+
import * as fs23 from "fs";
|
|
26644
26941
|
function findFiatCsvPaths(directory, pendingDir, provider) {
|
|
26645
|
-
const providerDir =
|
|
26646
|
-
if (!
|
|
26942
|
+
const providerDir = path17.join(directory, pendingDir, provider);
|
|
26943
|
+
if (!fs23.existsSync(providerDir))
|
|
26647
26944
|
return [];
|
|
26648
26945
|
const csvPaths = [];
|
|
26649
|
-
const entries =
|
|
26946
|
+
const entries = fs23.readdirSync(providerDir, { withFileTypes: true });
|
|
26650
26947
|
for (const entry of entries) {
|
|
26651
26948
|
if (!entry.isDirectory())
|
|
26652
26949
|
continue;
|
|
26653
26950
|
if (entry.name === "btc")
|
|
26654
26951
|
continue;
|
|
26655
|
-
const csvFiles = findCsvFiles(
|
|
26952
|
+
const csvFiles = findCsvFiles(path17.join(providerDir, entry.name), { fullPaths: true });
|
|
26656
26953
|
csvPaths.push(...csvFiles);
|
|
26657
26954
|
}
|
|
26658
26955
|
return csvPaths;
|
|
26659
26956
|
}
|
|
26660
26957
|
function findBtcCsvPath(directory, pendingDir, provider) {
|
|
26661
|
-
const btcDir =
|
|
26958
|
+
const btcDir = path17.join(directory, pendingDir, provider, "btc");
|
|
26662
26959
|
const csvFiles = findCsvFiles(btcDir, { fullPaths: true });
|
|
26663
26960
|
return csvFiles[0];
|
|
26664
26961
|
}
|
|
26665
26962
|
function determineYear(csvPaths) {
|
|
26666
26963
|
for (const csvPath of csvPaths) {
|
|
26667
|
-
const match2 =
|
|
26964
|
+
const match2 = path17.basename(csvPath).match(/(\d{4})-\d{2}-\d{2}/);
|
|
26668
26965
|
if (match2)
|
|
26669
26966
|
return parseInt(match2[1], 10);
|
|
26670
26967
|
}
|
|
@@ -26717,7 +27014,7 @@ async function generateBtcPurchases(directory, agent, options = {}, configLoader
|
|
|
26717
27014
|
skippedDuplicates: result.skippedDuplicates,
|
|
26718
27015
|
unmatchedFiat: result.unmatchedFiat.length,
|
|
26719
27016
|
unmatchedBtc: result.unmatchedBtc.length,
|
|
26720
|
-
yearJournal:
|
|
27017
|
+
yearJournal: path17.relative(directory, yearJournalPath)
|
|
26721
27018
|
});
|
|
26722
27019
|
} catch (err) {
|
|
26723
27020
|
logger.error("Failed to generate BTC purchases", err);
|
|
@@ -26759,8 +27056,8 @@ to produce equity conversion entries for Bitcoin purchases.
|
|
|
26759
27056
|
}
|
|
26760
27057
|
});
|
|
26761
27058
|
// src/index.ts
|
|
26762
|
-
var __dirname2 =
|
|
26763
|
-
var AGENT_FILE =
|
|
27059
|
+
var __dirname2 = dirname8(fileURLToPath3(import.meta.url));
|
|
27060
|
+
var AGENT_FILE = join16(__dirname2, "..", "agent", "accountant.md");
|
|
26764
27061
|
var AccountantPlugin = async () => {
|
|
26765
27062
|
const agent = loadAgent(AGENT_FILE);
|
|
26766
27063
|
return {
|
|
@@ -12,12 +12,13 @@ The pipeline automates these sequential steps:
|
|
|
12
12
|
1a. **Preprocess Fiat CSV** _(Revolut only)_ - Add computed `Net_Amount` column (`Amount - Fee`) for hledger rules
|
|
13
13
|
1b. **Preprocess BTC CSV** _(Revolut only)_ - Add computed columns (fees in BTC, total, clean price) for hledger rules
|
|
14
14
|
1c. **Generate BTC Purchases** _(Revolut only)_ - Cross-reference fiat + BTC CSVs for equity conversion entries
|
|
15
|
+
1e. **Generate Currency Exchanges** _(Revolut only)_ - Cross-reference fiat CSVs for unified multi-currency exchange entries
|
|
15
16
|
1d. **Preprocess Swissquote CSV** _(Swissquote only)_ - Generate FIFO capital gains entries for trades, dividends, and corporate actions
|
|
16
17
|
2. **Account Declarations** - Ensure all accounts exist in year journals
|
|
17
18
|
3. **Import** - Validate transactions, add to journals, move files to done
|
|
18
19
|
4. **Reconcile** - Verify balances match expectations
|
|
19
20
|
|
|
20
|
-
Steps 1a–1c are Revolut-specific and
|
|
21
|
+
Steps 1a–1c and 1e are Revolut-specific and step 1d is Swissquote-specific. These preprocessing steps are automatically skipped when no matching CSVs are present.
|
|
21
22
|
|
|
22
23
|
**Key behavior**: The pipeline processes files via **import contexts**. Each classified CSV gets a unique context ID, and subsequent steps operate on these contexts **sequentially** with **fail-fast** error handling.
|
|
23
24
|
|
|
@@ -201,6 +202,34 @@ See [classify-statements](classify-statements.md) for details.
|
|
|
201
202
|
|
|
202
203
|
**Skipped when**: No fiat+BTC CSV pair is found among the classified files.
|
|
203
204
|
|
|
205
|
+
### Step 1e: Generate Currency Exchange Entries
|
|
206
|
+
|
|
207
|
+
**Purpose**: Cross-reference Revolut fiat CSVs to generate unified 4-posting journal entries for currency exchanges (e.g., CHF to EUR)
|
|
208
|
+
|
|
209
|
+
**What happens**:
|
|
210
|
+
|
|
211
|
+
1. Finds all Revolut fiat contexts (provider=revolut, currency≠btc) from classification
|
|
212
|
+
2. Parses each CSV for `EXCHANGE` rows with "Exchanged to" descriptions
|
|
213
|
+
3. Matches source (debited) and target (credited) rows across CSVs by timestamp (within 1 second)
|
|
214
|
+
4. Generates unified 4-posting journal entries using `equity:currency:conversion` with `@ cost` notation
|
|
215
|
+
5. Appends entries to year journal, skipping duplicates
|
|
216
|
+
6. Filters matched EXCHANGE rows from CSVs (writes `-filtered.csv`) so hledger import doesn't double-count them
|
|
217
|
+
7. Updates import contexts to point to filtered CSVs
|
|
218
|
+
|
|
219
|
+
**Journal entry format**:
|
|
220
|
+
|
|
221
|
+
```journal
|
|
222
|
+
2026-01-15 Exchanged to EUR
|
|
223
|
+
assets:bank:revolut:chf -100.00 CHF @ 1.0713 EUR
|
|
224
|
+
equity:currency:conversion 100.00 CHF
|
|
225
|
+
equity:currency:conversion -107.13 EUR
|
|
226
|
+
assets:bank:revolut:eur 107.13 EUR
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
**Why**: Revolut exports currency exchanges as two separate CSV rows — one per currency. Processing each through its own rules file produces two independent 2-posting transactions with a non-zero `equity:conversion` balance. This step intercepts EXCHANGE rows before hledger import, matches them across currencies, and generates a single unified transaction with explicit cost notation that keeps `equity:currency:conversion` balanced.
|
|
230
|
+
|
|
231
|
+
**Skipped when**: Fewer than 2 Revolut fiat CSVs with EXCHANGE rows are present among the classified files.
|
|
232
|
+
|
|
204
233
|
### Step 1d: Preprocess Swissquote CSV
|
|
205
234
|
|
|
206
235
|
**Purpose**: Generate FIFO capital gains journal entries for Swissquote investment transactions including trades, dividends, and corporate actions
|
|
@@ -356,6 +385,14 @@ See [reconcile-statement](reconcile-statement.md) for details.
|
|
|
356
385
|
│
|
|
357
386
|
▼
|
|
358
387
|
┌──────────────────────────────────────────────────────────────┐
|
|
388
|
+
│ STEP 1e: Generate Currency Exchange Entries │
|
|
389
|
+
│ • Cross-reference fiat CSVs for EXCHANGE rows │
|
|
390
|
+
│ • Generate unified 4-posting entries with @ cost │
|
|
391
|
+
│ • Filter matched rows from CSVs │
|
|
392
|
+
└──────────────────────────────────────────────────────────────┘
|
|
393
|
+
│
|
|
394
|
+
▼
|
|
395
|
+
┌──────────────────────────────────────────────────────────────┐
|
|
359
396
|
│ STEP 1d: Preprocess Swissquote CSV (if swissquote context) │
|
|
360
397
|
│ • FIFO lot tracking for trades │
|
|
361
398
|
│ • Capital gains calculation │
|