@fuzzle/opencode-accountant 0.11.0 → 0.12.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 +297 -55
- package/docs/tools/import-pipeline.md +38 -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 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
|
|
@@ -24192,8 +24192,8 @@ Note: This tool requires a contextId from a prior classify/import step.`,
|
|
|
24192
24192
|
}
|
|
24193
24193
|
});
|
|
24194
24194
|
// src/tools/import-pipeline.ts
|
|
24195
|
-
import * as
|
|
24196
|
-
import * as
|
|
24195
|
+
import * as fs21 from "fs";
|
|
24196
|
+
import * as path15 from "path";
|
|
24197
24197
|
|
|
24198
24198
|
// src/utils/accountDeclarations.ts
|
|
24199
24199
|
init_journalMatchers();
|
|
@@ -25899,6 +25899,203 @@ async function preprocessSwissquote(csvPath, projectDir, currency, year, lotInve
|
|
|
25899
25899
|
};
|
|
25900
25900
|
}
|
|
25901
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?.toUpperCase() !== "EXCHANGE")
|
|
25919
|
+
continue;
|
|
25920
|
+
if (!/^Exchanged to [A-Z]{3}$/.test(description ?? ""))
|
|
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
|
+
return [
|
|
25983
|
+
`${date5} ${description}`,
|
|
25984
|
+
` assets:bank:revolut:${sourceCurrency.toLowerCase()} -${sourceAmount} ${sourceCurrency}`,
|
|
25985
|
+
` equity:currency:conversion ${sourceAmount} ${sourceCurrency}`,
|
|
25986
|
+
` equity:currency:conversion -${targetAmount} ${targetCurrency}`,
|
|
25987
|
+
` assets:bank:revolut:${targetCurrency.toLowerCase()} ${targetAmount} ${targetCurrency}`
|
|
25988
|
+
].join(`
|
|
25989
|
+
`);
|
|
25990
|
+
}
|
|
25991
|
+
function formatAmount3(amount) {
|
|
25992
|
+
return amount.toFixed(2);
|
|
25993
|
+
}
|
|
25994
|
+
function isDuplicate2(match2, journalContent) {
|
|
25995
|
+
const date5 = match2.source.dateStr;
|
|
25996
|
+
const description = match2.source.description;
|
|
25997
|
+
const pattern = `${date5} ${description}`;
|
|
25998
|
+
if (!journalContent.includes(pattern))
|
|
25999
|
+
return false;
|
|
26000
|
+
const sourceAmount = formatAmount3(match2.source.amount);
|
|
26001
|
+
const sourceCurrency = match2.source.currency;
|
|
26002
|
+
const amountPattern = `-${sourceAmount} ${sourceCurrency}`;
|
|
26003
|
+
const idx = journalContent.indexOf(pattern);
|
|
26004
|
+
const chunk = journalContent.slice(idx, idx + 500);
|
|
26005
|
+
return chunk.includes(amountPattern);
|
|
26006
|
+
}
|
|
26007
|
+
function parseRevolutDatetime(dateStr) {
|
|
26008
|
+
const [datePart, timePart] = dateStr.split(" ");
|
|
26009
|
+
if (!datePart || !timePart) {
|
|
26010
|
+
throw new Error(`Invalid datetime: ${dateStr}`);
|
|
26011
|
+
}
|
|
26012
|
+
return new Date(`${datePart}T${timePart}`);
|
|
26013
|
+
}
|
|
26014
|
+
function formatDateIso2(date5) {
|
|
26015
|
+
const y = date5.getFullYear();
|
|
26016
|
+
const m = String(date5.getMonth() + 1).padStart(2, "0");
|
|
26017
|
+
const d = String(date5.getDate()).padStart(2, "0");
|
|
26018
|
+
return `${y}-${m}-${d}`;
|
|
26019
|
+
}
|
|
26020
|
+
function filterExchangeRows(csvPath, rowIndicesToRemove, logger) {
|
|
26021
|
+
const content = fs19.readFileSync(csvPath, "utf-8");
|
|
26022
|
+
const lines = content.trim().split(`
|
|
26023
|
+
`);
|
|
26024
|
+
const filteredLines = [lines[0]];
|
|
26025
|
+
for (let i2 = 1;i2 < lines.length; i2++) {
|
|
26026
|
+
if (rowIndicesToRemove.has(i2))
|
|
26027
|
+
continue;
|
|
26028
|
+
filteredLines.push(lines[i2]);
|
|
26029
|
+
}
|
|
26030
|
+
const dir = path14.dirname(csvPath);
|
|
26031
|
+
const basename6 = path14.basename(csvPath, ".csv");
|
|
26032
|
+
const filteredPath = path14.join(dir, `${basename6}-filtered.csv`);
|
|
26033
|
+
fs19.writeFileSync(filteredPath, filteredLines.join(`
|
|
26034
|
+
`) + `
|
|
26035
|
+
`);
|
|
26036
|
+
logger?.info(`Filtered ${rowIndicesToRemove.size} exchange rows from ${path14.basename(csvPath)} \u2192 ${path14.basename(filteredPath)}`);
|
|
26037
|
+
return filteredPath;
|
|
26038
|
+
}
|
|
26039
|
+
function generateCurrencyExchangeJournal(fiatCsvPaths, yearJournalPath, logger) {
|
|
26040
|
+
logger?.info("Parsing fiat CSVs for EXCHANGE rows...");
|
|
26041
|
+
const rowsByCsv = new Map;
|
|
26042
|
+
for (const csvPath of fiatCsvPaths) {
|
|
26043
|
+
const rows = parseExchangeRows(csvPath);
|
|
26044
|
+
if (rows.length > 0) {
|
|
26045
|
+
rowsByCsv.set(csvPath, rows);
|
|
26046
|
+
logger?.info(`Found ${rows.length} EXCHANGE rows in ${path14.basename(csvPath)}`);
|
|
26047
|
+
}
|
|
26048
|
+
}
|
|
26049
|
+
if (rowsByCsv.size < 2) {
|
|
26050
|
+
logger?.info("Need at least 2 CSVs with EXCHANGE rows to match pairs, skipping");
|
|
26051
|
+
return { matchCount: 0, entriesAdded: 0, skippedDuplicates: 0, matchedRowIndices: new Map };
|
|
26052
|
+
}
|
|
26053
|
+
logger?.info("Matching exchange pairs across CSVs...");
|
|
26054
|
+
const matches = matchExchangePairs(rowsByCsv);
|
|
26055
|
+
logger?.info(`Matched ${matches.length} exchange pairs`);
|
|
26056
|
+
let journalContent = "";
|
|
26057
|
+
if (fs19.existsSync(yearJournalPath)) {
|
|
26058
|
+
journalContent = fs19.readFileSync(yearJournalPath, "utf-8");
|
|
26059
|
+
}
|
|
26060
|
+
const newEntries = [];
|
|
26061
|
+
let skippedDuplicates = 0;
|
|
26062
|
+
const sortedMatches = [...matches].sort((a, b) => a.source.date.getTime() - b.source.date.getTime());
|
|
26063
|
+
for (const match2 of sortedMatches) {
|
|
26064
|
+
if (isDuplicate2(match2, journalContent)) {
|
|
26065
|
+
skippedDuplicates++;
|
|
26066
|
+
logger?.debug(`Skipping duplicate: ${match2.source.dateStr} ${formatAmount3(match2.source.amount)} ${match2.source.currency} \u2192 ${formatAmount3(match2.target.amount)} ${match2.target.currency}`);
|
|
26067
|
+
continue;
|
|
26068
|
+
}
|
|
26069
|
+
newEntries.push(formatExchangeEntry(match2));
|
|
26070
|
+
}
|
|
26071
|
+
if (newEntries.length > 0) {
|
|
26072
|
+
const appendContent = `
|
|
26073
|
+
` + newEntries.join(`
|
|
26074
|
+
|
|
26075
|
+
`) + `
|
|
26076
|
+
`;
|
|
26077
|
+
fs19.appendFileSync(yearJournalPath, appendContent);
|
|
26078
|
+
logger?.info(`Appended ${newEntries.length} currency exchange entries to ${path14.basename(yearJournalPath)}`);
|
|
26079
|
+
} else {
|
|
26080
|
+
logger?.info("No new currency exchange entries to add");
|
|
26081
|
+
}
|
|
26082
|
+
const matchedRowIndices = new Map;
|
|
26083
|
+
for (const match2 of matches) {
|
|
26084
|
+
for (const row of [match2.source, match2.target]) {
|
|
26085
|
+
if (!matchedRowIndices.has(row.csvPath)) {
|
|
26086
|
+
matchedRowIndices.set(row.csvPath, new Set);
|
|
26087
|
+
}
|
|
26088
|
+
matchedRowIndices.get(row.csvPath).add(row.lineIndex);
|
|
26089
|
+
}
|
|
26090
|
+
}
|
|
26091
|
+
return {
|
|
26092
|
+
matchCount: matches.length,
|
|
26093
|
+
entriesAdded: newEntries.length,
|
|
26094
|
+
skippedDuplicates,
|
|
26095
|
+
matchedRowIndices
|
|
26096
|
+
};
|
|
26097
|
+
}
|
|
26098
|
+
|
|
25902
26099
|
// src/tools/import-pipeline.ts
|
|
25903
26100
|
class NoTransactionsError extends Error {
|
|
25904
26101
|
constructor() {
|
|
@@ -25971,7 +26168,7 @@ function executePreprocessFiatStep(context, contextIds, logger) {
|
|
|
25971
26168
|
for (const contextId of contextIds) {
|
|
25972
26169
|
const importCtx = loadContext(context.directory, contextId);
|
|
25973
26170
|
if (importCtx.provider === "revolut" && importCtx.currency !== "btc") {
|
|
25974
|
-
fiatCsvPaths.push(
|
|
26171
|
+
fiatCsvPaths.push(path15.join(context.directory, importCtx.filePath));
|
|
25975
26172
|
}
|
|
25976
26173
|
}
|
|
25977
26174
|
if (fiatCsvPaths.length === 0) {
|
|
@@ -26015,7 +26212,7 @@ function executePreprocessBtcStep(context, contextIds, logger) {
|
|
|
26015
26212
|
for (const contextId of contextIds) {
|
|
26016
26213
|
const importCtx = loadContext(context.directory, contextId);
|
|
26017
26214
|
if (importCtx.provider === "revolut" && importCtx.currency === "btc") {
|
|
26018
|
-
btcCsvPath =
|
|
26215
|
+
btcCsvPath = path15.join(context.directory, importCtx.filePath);
|
|
26019
26216
|
break;
|
|
26020
26217
|
}
|
|
26021
26218
|
}
|
|
@@ -26049,7 +26246,7 @@ async function executeBtcPurchaseStep(context, contextIds, logger) {
|
|
|
26049
26246
|
const importCtx = loadContext(context.directory, contextId);
|
|
26050
26247
|
if (importCtx.provider !== "revolut")
|
|
26051
26248
|
continue;
|
|
26052
|
-
const csvPath =
|
|
26249
|
+
const csvPath = path15.join(context.directory, importCtx.filePath);
|
|
26053
26250
|
if (importCtx.currency === "btc") {
|
|
26054
26251
|
btcCsvPath = csvPath;
|
|
26055
26252
|
} else {
|
|
@@ -26062,7 +26259,7 @@ async function executeBtcPurchaseStep(context, contextIds, logger) {
|
|
|
26062
26259
|
}
|
|
26063
26260
|
logger?.startSection("Step 1b: Generate BTC Purchase Entries");
|
|
26064
26261
|
logger?.logStep("BTC Purchases", "start");
|
|
26065
|
-
const btcFilename =
|
|
26262
|
+
const btcFilename = path15.basename(btcCsvPath);
|
|
26066
26263
|
const yearMatch = btcFilename.match(/(\d{4})-\d{2}-\d{2}/);
|
|
26067
26264
|
const year = yearMatch ? parseInt(yearMatch[1], 10) : new Date().getFullYear();
|
|
26068
26265
|
const yearJournalPath = ensureYearJournalExists(context.directory, year);
|
|
@@ -26078,12 +26275,56 @@ async function executeBtcPurchaseStep(context, contextIds, logger) {
|
|
|
26078
26275
|
unmatchedBtc: result.unmatchedBtc.length
|
|
26079
26276
|
});
|
|
26080
26277
|
}
|
|
26278
|
+
async function executeCurrencyExchangeStep(context, contextIds, logger) {
|
|
26279
|
+
const fiatContexts = [];
|
|
26280
|
+
for (const contextId of contextIds) {
|
|
26281
|
+
const importCtx = loadContext(context.directory, contextId);
|
|
26282
|
+
if (importCtx.provider !== "revolut")
|
|
26283
|
+
continue;
|
|
26284
|
+
if (importCtx.currency === "btc")
|
|
26285
|
+
continue;
|
|
26286
|
+
const csvPath = path15.join(context.directory, importCtx.filePath);
|
|
26287
|
+
fiatContexts.push({ contextId, csvPath });
|
|
26288
|
+
}
|
|
26289
|
+
if (fiatContexts.length < 2) {
|
|
26290
|
+
logger?.info("Need at least 2 Revolut fiat CSVs for exchange matching, skipping");
|
|
26291
|
+
return;
|
|
26292
|
+
}
|
|
26293
|
+
logger?.startSection("Step 1e: Generate Currency Exchange Entries");
|
|
26294
|
+
logger?.logStep("Currency Exchanges", "start");
|
|
26295
|
+
const firstFilename = path15.basename(fiatContexts[0].csvPath);
|
|
26296
|
+
const yearMatch = firstFilename.match(/(\d{4})-\d{2}-\d{2}/);
|
|
26297
|
+
const year = yearMatch ? parseInt(yearMatch[1], 10) : new Date().getFullYear();
|
|
26298
|
+
const yearJournalPath = ensureYearJournalExists(context.directory, year);
|
|
26299
|
+
const fiatCsvPaths = fiatContexts.map((c) => c.csvPath);
|
|
26300
|
+
const result = generateCurrencyExchangeJournal(fiatCsvPaths, yearJournalPath, logger);
|
|
26301
|
+
if (result.matchedRowIndices.size > 0) {
|
|
26302
|
+
for (const { contextId, csvPath } of fiatContexts) {
|
|
26303
|
+
const indices = result.matchedRowIndices.get(csvPath);
|
|
26304
|
+
if (!indices || indices.size === 0)
|
|
26305
|
+
continue;
|
|
26306
|
+
const filteredCsvPath = filterExchangeRows(csvPath, indices, logger);
|
|
26307
|
+
updateContext(context.directory, contextId, {
|
|
26308
|
+
filePath: path15.relative(context.directory, filteredCsvPath)
|
|
26309
|
+
});
|
|
26310
|
+
logger?.info(`Updated context ${contextId} to use filtered CSV: ${path15.basename(filteredCsvPath)}`);
|
|
26311
|
+
}
|
|
26312
|
+
}
|
|
26313
|
+
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)`;
|
|
26314
|
+
logger?.logStep("Currency Exchanges", "success", message);
|
|
26315
|
+
logger?.endSection();
|
|
26316
|
+
context.result.steps.currencyExchanges = buildStepResult(true, message, {
|
|
26317
|
+
matchCount: result.matchCount,
|
|
26318
|
+
entriesAdded: result.entriesAdded,
|
|
26319
|
+
skippedDuplicates: result.skippedDuplicates
|
|
26320
|
+
});
|
|
26321
|
+
}
|
|
26081
26322
|
async function executeSwissquotePreprocessStep(context, contextIds, logger) {
|
|
26082
26323
|
const swissquoteContexts = [];
|
|
26083
26324
|
for (const contextId of contextIds) {
|
|
26084
26325
|
const importCtx = loadContext(context.directory, contextId);
|
|
26085
26326
|
if (importCtx.provider === "swissquote") {
|
|
26086
|
-
const csvPath =
|
|
26327
|
+
const csvPath = path15.join(context.directory, importCtx.filePath);
|
|
26087
26328
|
const toDateMatch = importCtx.filename.match(/to-\d{4}(\d{4})\./);
|
|
26088
26329
|
let year = new Date().getFullYear();
|
|
26089
26330
|
if (toDateMatch) {
|
|
@@ -26107,11 +26348,11 @@ async function executeSwissquotePreprocessStep(context, contextIds, logger) {
|
|
|
26107
26348
|
const lotInventoryPath = swissquoteProvider?.lotInventoryPath ?? DEFAULT_LOT_INVENTORY_PATH;
|
|
26108
26349
|
const symbolMapPath = swissquoteProvider?.symbolMapPath ?? "config/import/symbolMap.yaml";
|
|
26109
26350
|
let symbolMap = {};
|
|
26110
|
-
const symbolMapFullPath =
|
|
26111
|
-
if (
|
|
26351
|
+
const symbolMapFullPath = path15.join(context.directory, symbolMapPath);
|
|
26352
|
+
if (fs21.existsSync(symbolMapFullPath)) {
|
|
26112
26353
|
try {
|
|
26113
26354
|
const yaml = await Promise.resolve().then(() => (init_js_yaml(), exports_js_yaml));
|
|
26114
|
-
const content =
|
|
26355
|
+
const content = fs21.readFileSync(symbolMapFullPath, "utf-8");
|
|
26115
26356
|
const parsed = yaml.load(content);
|
|
26116
26357
|
if (parsed && typeof parsed === "object") {
|
|
26117
26358
|
symbolMap = parsed;
|
|
@@ -26133,7 +26374,7 @@ async function executeSwissquotePreprocessStep(context, contextIds, logger) {
|
|
|
26133
26374
|
};
|
|
26134
26375
|
let lastJournalFile = null;
|
|
26135
26376
|
for (const sqCtx of swissquoteContexts) {
|
|
26136
|
-
logger?.logStep("Swissquote Preprocess", "start", `Processing ${
|
|
26377
|
+
logger?.logStep("Swissquote Preprocess", "start", `Processing ${path15.basename(sqCtx.csvPath)}`);
|
|
26137
26378
|
const result = await preprocessSwissquote(sqCtx.csvPath, context.directory, sqCtx.currency, sqCtx.year, lotInventoryPath, symbolMap, logger);
|
|
26138
26379
|
totalStats.totalRows += result.stats.totalRows;
|
|
26139
26380
|
totalStats.simpleTransactions += result.stats.simpleTransactions;
|
|
@@ -26146,9 +26387,9 @@ async function executeSwissquotePreprocessStep(context, contextIds, logger) {
|
|
|
26146
26387
|
}
|
|
26147
26388
|
if (result.simpleTransactionsCsv) {
|
|
26148
26389
|
updateContext(context.directory, sqCtx.contextId, {
|
|
26149
|
-
filePath:
|
|
26390
|
+
filePath: path15.relative(context.directory, result.simpleTransactionsCsv)
|
|
26150
26391
|
});
|
|
26151
|
-
logger?.info(`Updated context ${sqCtx.contextId} to use filtered CSV: ${
|
|
26392
|
+
logger?.info(`Updated context ${sqCtx.contextId} to use filtered CSV: ${path15.basename(result.simpleTransactionsCsv)}`);
|
|
26152
26393
|
}
|
|
26153
26394
|
logger?.logStep("Swissquote Preprocess", "success", `Processed: ${result.stats.trades} trades, ${result.stats.dividends} dividends, ${result.stats.corporateActions} corporate actions`);
|
|
26154
26395
|
}
|
|
@@ -26173,9 +26414,9 @@ async function executeAccountDeclarationsStep(context, contextId, logger) {
|
|
|
26173
26414
|
logger?.startSection("Step 2: Check Account Declarations");
|
|
26174
26415
|
logger?.logStep("Check Accounts", "start");
|
|
26175
26416
|
const config2 = context.configLoader(context.directory);
|
|
26176
|
-
const rulesDir =
|
|
26417
|
+
const rulesDir = path15.join(context.directory, config2.paths.rules);
|
|
26177
26418
|
const importCtx = loadContext(context.directory, contextId);
|
|
26178
|
-
const csvPath =
|
|
26419
|
+
const csvPath = path15.join(context.directory, importCtx.filePath);
|
|
26179
26420
|
const csvFiles = [csvPath];
|
|
26180
26421
|
const rulesMapping = loadRulesMapping(rulesDir);
|
|
26181
26422
|
const matchedRulesFiles = new Set;
|
|
@@ -26198,7 +26439,7 @@ async function executeAccountDeclarationsStep(context, contextId, logger) {
|
|
|
26198
26439
|
context.result.steps.accountDeclarations = buildStepResult(true, "No accounts found in rules files", {
|
|
26199
26440
|
accountsAdded: [],
|
|
26200
26441
|
journalUpdated: "",
|
|
26201
|
-
rulesScanned: Array.from(matchedRulesFiles).map((f) =>
|
|
26442
|
+
rulesScanned: Array.from(matchedRulesFiles).map((f) => path15.relative(context.directory, f))
|
|
26202
26443
|
});
|
|
26203
26444
|
return;
|
|
26204
26445
|
}
|
|
@@ -26222,7 +26463,7 @@ async function executeAccountDeclarationsStep(context, contextId, logger) {
|
|
|
26222
26463
|
context.result.steps.accountDeclarations = buildStepResult(true, "No transactions found, skipping account declarations", {
|
|
26223
26464
|
accountsAdded: [],
|
|
26224
26465
|
journalUpdated: "",
|
|
26225
|
-
rulesScanned: Array.from(matchedRulesFiles).map((f) =>
|
|
26466
|
+
rulesScanned: Array.from(matchedRulesFiles).map((f) => path15.relative(context.directory, f))
|
|
26226
26467
|
});
|
|
26227
26468
|
return;
|
|
26228
26469
|
}
|
|
@@ -26233,12 +26474,12 @@ async function executeAccountDeclarationsStep(context, contextId, logger) {
|
|
|
26233
26474
|
context.result.steps.accountDeclarations = buildStepResult(false, `Failed to create year journal: ${error45 instanceof Error ? error45.message : String(error45)}`, {
|
|
26234
26475
|
accountsAdded: [],
|
|
26235
26476
|
journalUpdated: "",
|
|
26236
|
-
rulesScanned: Array.from(matchedRulesFiles).map((f) =>
|
|
26477
|
+
rulesScanned: Array.from(matchedRulesFiles).map((f) => path15.relative(context.directory, f))
|
|
26237
26478
|
});
|
|
26238
26479
|
return;
|
|
26239
26480
|
}
|
|
26240
26481
|
const result = ensureAccountDeclarations(yearJournalPath, allAccounts);
|
|
26241
|
-
const message = result.added.length > 0 ? `Added ${result.added.length} account declaration(s) to ${
|
|
26482
|
+
const message = result.added.length > 0 ? `Added ${result.added.length} account declaration(s) to ${path15.relative(context.directory, yearJournalPath)}` : "All required accounts already declared";
|
|
26242
26483
|
logger?.logStep("Check Accounts", "success", message);
|
|
26243
26484
|
if (result.added.length > 0) {
|
|
26244
26485
|
for (const account of result.added) {
|
|
@@ -26247,17 +26488,17 @@ async function executeAccountDeclarationsStep(context, contextId, logger) {
|
|
|
26247
26488
|
}
|
|
26248
26489
|
context.result.steps.accountDeclarations = buildStepResult(true, message, {
|
|
26249
26490
|
accountsAdded: result.added,
|
|
26250
|
-
journalUpdated:
|
|
26251
|
-
rulesScanned: Array.from(matchedRulesFiles).map((f) =>
|
|
26491
|
+
journalUpdated: path15.relative(context.directory, yearJournalPath),
|
|
26492
|
+
rulesScanned: Array.from(matchedRulesFiles).map((f) => path15.relative(context.directory, f))
|
|
26252
26493
|
});
|
|
26253
26494
|
logger?.endSection();
|
|
26254
26495
|
}
|
|
26255
26496
|
async function buildSuggestionContext(context, contextId, logger) {
|
|
26256
26497
|
const { loadExistingAccounts: loadExistingAccounts2, extractRulePatternsFromFile: extractRulePatternsFromFile2 } = await Promise.resolve().then(() => (init_accountSuggester(), exports_accountSuggester));
|
|
26257
26498
|
const config2 = context.configLoader(context.directory);
|
|
26258
|
-
const rulesDir =
|
|
26499
|
+
const rulesDir = path15.join(context.directory, config2.paths.rules);
|
|
26259
26500
|
const importCtx = loadContext(context.directory, contextId);
|
|
26260
|
-
const csvPath =
|
|
26501
|
+
const csvPath = path15.join(context.directory, importCtx.filePath);
|
|
26261
26502
|
const rulesMapping = loadRulesMapping(rulesDir);
|
|
26262
26503
|
const rulesFile = findRulesForCsv(csvPath, rulesMapping);
|
|
26263
26504
|
if (!rulesFile) {
|
|
@@ -26464,6 +26705,7 @@ async function importPipeline(directory, agent, options, configLoader = loadImpo
|
|
|
26464
26705
|
executePreprocessFiatStep(context, contextIds, logger);
|
|
26465
26706
|
executePreprocessBtcStep(context, contextIds, logger);
|
|
26466
26707
|
await executeBtcPurchaseStep(context, contextIds, logger);
|
|
26708
|
+
await executeCurrencyExchangeStep(context, contextIds, logger);
|
|
26467
26709
|
await executeSwissquotePreprocessStep(context, contextIds, logger);
|
|
26468
26710
|
const importConfig = loadImportConfig(context.directory);
|
|
26469
26711
|
const orderedContextIds = [...contextIds].sort((a, b) => {
|
|
@@ -26563,16 +26805,16 @@ This tool orchestrates the full import workflow:
|
|
|
26563
26805
|
}
|
|
26564
26806
|
});
|
|
26565
26807
|
// src/tools/init-directories.ts
|
|
26566
|
-
import * as
|
|
26567
|
-
import * as
|
|
26808
|
+
import * as fs22 from "fs";
|
|
26809
|
+
import * as path16 from "path";
|
|
26568
26810
|
async function initDirectories(directory) {
|
|
26569
26811
|
try {
|
|
26570
26812
|
const config2 = loadImportConfig(directory);
|
|
26571
26813
|
const directoriesCreated = [];
|
|
26572
26814
|
const gitkeepFiles = [];
|
|
26573
|
-
const importBase =
|
|
26574
|
-
if (!
|
|
26575
|
-
|
|
26815
|
+
const importBase = path16.join(directory, "import");
|
|
26816
|
+
if (!fs22.existsSync(importBase)) {
|
|
26817
|
+
fs22.mkdirSync(importBase, { recursive: true });
|
|
26576
26818
|
directoriesCreated.push("import");
|
|
26577
26819
|
}
|
|
26578
26820
|
const pathsToCreate = [
|
|
@@ -26582,20 +26824,20 @@ async function initDirectories(directory) {
|
|
|
26582
26824
|
{ key: "unrecognized", path: config2.paths.unrecognized }
|
|
26583
26825
|
];
|
|
26584
26826
|
for (const { path: dirPath } of pathsToCreate) {
|
|
26585
|
-
const fullPath =
|
|
26586
|
-
if (!
|
|
26587
|
-
|
|
26827
|
+
const fullPath = path16.join(directory, dirPath);
|
|
26828
|
+
if (!fs22.existsSync(fullPath)) {
|
|
26829
|
+
fs22.mkdirSync(fullPath, { recursive: true });
|
|
26588
26830
|
directoriesCreated.push(dirPath);
|
|
26589
26831
|
}
|
|
26590
|
-
const gitkeepPath =
|
|
26591
|
-
if (!
|
|
26592
|
-
|
|
26593
|
-
gitkeepFiles.push(
|
|
26832
|
+
const gitkeepPath = path16.join(fullPath, ".gitkeep");
|
|
26833
|
+
if (!fs22.existsSync(gitkeepPath)) {
|
|
26834
|
+
fs22.writeFileSync(gitkeepPath, "");
|
|
26835
|
+
gitkeepFiles.push(path16.join(dirPath, ".gitkeep"));
|
|
26594
26836
|
}
|
|
26595
26837
|
}
|
|
26596
|
-
const gitignorePath =
|
|
26838
|
+
const gitignorePath = path16.join(importBase, ".gitignore");
|
|
26597
26839
|
let gitignoreCreated = false;
|
|
26598
|
-
if (!
|
|
26840
|
+
if (!fs22.existsSync(gitignorePath)) {
|
|
26599
26841
|
const gitignoreContent = `# Ignore CSV/PDF files in temporary directories
|
|
26600
26842
|
/incoming/*.csv
|
|
26601
26843
|
/incoming/*.pdf
|
|
@@ -26613,7 +26855,7 @@ async function initDirectories(directory) {
|
|
|
26613
26855
|
.DS_Store
|
|
26614
26856
|
Thumbs.db
|
|
26615
26857
|
`;
|
|
26616
|
-
|
|
26858
|
+
fs22.writeFileSync(gitignorePath, gitignoreContent);
|
|
26617
26859
|
gitignoreCreated = true;
|
|
26618
26860
|
}
|
|
26619
26861
|
const parts = [];
|
|
@@ -26689,32 +26931,32 @@ You can now drop CSV files into import/incoming/ and run import-pipeline.`);
|
|
|
26689
26931
|
}
|
|
26690
26932
|
});
|
|
26691
26933
|
// src/tools/generate-btc-purchases.ts
|
|
26692
|
-
import * as
|
|
26693
|
-
import * as
|
|
26934
|
+
import * as path17 from "path";
|
|
26935
|
+
import * as fs23 from "fs";
|
|
26694
26936
|
function findFiatCsvPaths(directory, pendingDir, provider) {
|
|
26695
|
-
const providerDir =
|
|
26696
|
-
if (!
|
|
26937
|
+
const providerDir = path17.join(directory, pendingDir, provider);
|
|
26938
|
+
if (!fs23.existsSync(providerDir))
|
|
26697
26939
|
return [];
|
|
26698
26940
|
const csvPaths = [];
|
|
26699
|
-
const entries =
|
|
26941
|
+
const entries = fs23.readdirSync(providerDir, { withFileTypes: true });
|
|
26700
26942
|
for (const entry of entries) {
|
|
26701
26943
|
if (!entry.isDirectory())
|
|
26702
26944
|
continue;
|
|
26703
26945
|
if (entry.name === "btc")
|
|
26704
26946
|
continue;
|
|
26705
|
-
const csvFiles = findCsvFiles(
|
|
26947
|
+
const csvFiles = findCsvFiles(path17.join(providerDir, entry.name), { fullPaths: true });
|
|
26706
26948
|
csvPaths.push(...csvFiles);
|
|
26707
26949
|
}
|
|
26708
26950
|
return csvPaths;
|
|
26709
26951
|
}
|
|
26710
26952
|
function findBtcCsvPath(directory, pendingDir, provider) {
|
|
26711
|
-
const btcDir =
|
|
26953
|
+
const btcDir = path17.join(directory, pendingDir, provider, "btc");
|
|
26712
26954
|
const csvFiles = findCsvFiles(btcDir, { fullPaths: true });
|
|
26713
26955
|
return csvFiles[0];
|
|
26714
26956
|
}
|
|
26715
26957
|
function determineYear(csvPaths) {
|
|
26716
26958
|
for (const csvPath of csvPaths) {
|
|
26717
|
-
const match2 =
|
|
26959
|
+
const match2 = path17.basename(csvPath).match(/(\d{4})-\d{2}-\d{2}/);
|
|
26718
26960
|
if (match2)
|
|
26719
26961
|
return parseInt(match2[1], 10);
|
|
26720
26962
|
}
|
|
@@ -26767,7 +27009,7 @@ async function generateBtcPurchases(directory, agent, options = {}, configLoader
|
|
|
26767
27009
|
skippedDuplicates: result.skippedDuplicates,
|
|
26768
27010
|
unmatchedFiat: result.unmatchedFiat.length,
|
|
26769
27011
|
unmatchedBtc: result.unmatchedBtc.length,
|
|
26770
|
-
yearJournal:
|
|
27012
|
+
yearJournal: path17.relative(directory, yearJournalPath)
|
|
26771
27013
|
});
|
|
26772
27014
|
} catch (err) {
|
|
26773
27015
|
logger.error("Failed to generate BTC purchases", err);
|
|
@@ -26809,8 +27051,8 @@ to produce equity conversion entries for Bitcoin purchases.
|
|
|
26809
27051
|
}
|
|
26810
27052
|
});
|
|
26811
27053
|
// src/index.ts
|
|
26812
|
-
var __dirname2 =
|
|
26813
|
-
var AGENT_FILE =
|
|
27054
|
+
var __dirname2 = dirname8(fileURLToPath3(import.meta.url));
|
|
27055
|
+
var AGENT_FILE = join16(__dirname2, "..", "agent", "accountant.md");
|
|
26814
27056
|
var AccountantPlugin = async () => {
|
|
26815
27057
|
const agent = loadAgent(AGENT_FILE);
|
|
26816
27058
|
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 │
|