@fuzzle/opencode-accountant 0.5.6 → 0.6.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 +490 -17
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -4249,7 +4249,7 @@ __export(exports_accountSuggester, {
|
|
|
4249
4249
|
extractRulePatternsFromFile: () => extractRulePatternsFromFile,
|
|
4250
4250
|
clearSuggestionCache: () => clearSuggestionCache
|
|
4251
4251
|
});
|
|
4252
|
-
import * as
|
|
4252
|
+
import * as fs15 from "fs";
|
|
4253
4253
|
import * as crypto from "crypto";
|
|
4254
4254
|
function clearSuggestionCache() {
|
|
4255
4255
|
Object.keys(suggestionCache).forEach((key) => delete suggestionCache[key]);
|
|
@@ -4259,10 +4259,10 @@ function hashTransaction(posting) {
|
|
|
4259
4259
|
return crypto.createHash("md5").update(data).digest("hex");
|
|
4260
4260
|
}
|
|
4261
4261
|
function loadExistingAccounts(yearJournalPath) {
|
|
4262
|
-
if (!
|
|
4262
|
+
if (!fs15.existsSync(yearJournalPath)) {
|
|
4263
4263
|
return [];
|
|
4264
4264
|
}
|
|
4265
|
-
const content =
|
|
4265
|
+
const content = fs15.readFileSync(yearJournalPath, "utf-8");
|
|
4266
4266
|
const lines = content.split(`
|
|
4267
4267
|
`);
|
|
4268
4268
|
const accounts = [];
|
|
@@ -4278,10 +4278,10 @@ function loadExistingAccounts(yearJournalPath) {
|
|
|
4278
4278
|
return accounts.sort();
|
|
4279
4279
|
}
|
|
4280
4280
|
function extractRulePatternsFromFile(rulesPath) {
|
|
4281
|
-
if (!
|
|
4281
|
+
if (!fs15.existsSync(rulesPath)) {
|
|
4282
4282
|
return [];
|
|
4283
4283
|
}
|
|
4284
|
-
const content =
|
|
4284
|
+
const content = fs15.readFileSync(rulesPath, "utf-8");
|
|
4285
4285
|
const lines = content.split(`
|
|
4286
4286
|
`);
|
|
4287
4287
|
const patterns = [];
|
|
@@ -4515,7 +4515,7 @@ var init_accountSuggester = __esm(() => {
|
|
|
4515
4515
|
|
|
4516
4516
|
// src/index.ts
|
|
4517
4517
|
init_agentLoader();
|
|
4518
|
-
import { dirname as dirname4, join as
|
|
4518
|
+
import { dirname as dirname4, join as join13 } from "path";
|
|
4519
4519
|
import { fileURLToPath as fileURLToPath3 } from "url";
|
|
4520
4520
|
|
|
4521
4521
|
// node_modules/zod/v4/classic/external.js
|
|
@@ -24351,6 +24351,321 @@ function createImportLogger(directory, worktreeId, provider) {
|
|
|
24351
24351
|
return logger;
|
|
24352
24352
|
}
|
|
24353
24353
|
|
|
24354
|
+
// src/utils/btcPurchaseGenerator.ts
|
|
24355
|
+
import * as fs14 from "fs";
|
|
24356
|
+
function parseRevolutFiatDatetime(dateStr) {
|
|
24357
|
+
const [datePart, timePart] = dateStr.split(" ");
|
|
24358
|
+
if (!datePart || !timePart) {
|
|
24359
|
+
throw new Error(`Invalid fiat datetime: ${dateStr}`);
|
|
24360
|
+
}
|
|
24361
|
+
return new Date(`${datePart}T${timePart}`);
|
|
24362
|
+
}
|
|
24363
|
+
function parseRevolutCryptoDate(dateStr) {
|
|
24364
|
+
const match2 = dateStr.match(/^(\w+)\s+(\d+),\s+(\d{4}),\s+(\d+):(\d+):(\d+)\s+(AM|PM)$/);
|
|
24365
|
+
if (!match2) {
|
|
24366
|
+
throw new Error(`Invalid crypto date: ${dateStr}`);
|
|
24367
|
+
}
|
|
24368
|
+
const [, monthName, day, year, hours, minutes, seconds, ampm] = match2;
|
|
24369
|
+
const months = {
|
|
24370
|
+
Jan: 0,
|
|
24371
|
+
Feb: 1,
|
|
24372
|
+
Mar: 2,
|
|
24373
|
+
Apr: 3,
|
|
24374
|
+
May: 4,
|
|
24375
|
+
Jun: 5,
|
|
24376
|
+
Jul: 6,
|
|
24377
|
+
Aug: 7,
|
|
24378
|
+
Sep: 8,
|
|
24379
|
+
Oct: 9,
|
|
24380
|
+
Nov: 10,
|
|
24381
|
+
Dec: 11
|
|
24382
|
+
};
|
|
24383
|
+
const month = months[monthName];
|
|
24384
|
+
if (month === undefined) {
|
|
24385
|
+
throw new Error(`Unknown month: ${monthName}`);
|
|
24386
|
+
}
|
|
24387
|
+
let hour = parseInt(hours, 10);
|
|
24388
|
+
if (ampm === "PM" && hour !== 12)
|
|
24389
|
+
hour += 12;
|
|
24390
|
+
if (ampm === "AM" && hour === 12)
|
|
24391
|
+
hour = 0;
|
|
24392
|
+
return new Date(parseInt(year, 10), month, parseInt(day, 10), hour, parseInt(minutes, 10), parseInt(seconds, 10));
|
|
24393
|
+
}
|
|
24394
|
+
function parseBtcPrice(priceStr) {
|
|
24395
|
+
const trimmed = priceStr.trim();
|
|
24396
|
+
if (trimmed.startsWith("\u20AC")) {
|
|
24397
|
+
const numStr2 = trimmed.slice(1).replace(/,/g, "");
|
|
24398
|
+
return { amount: parseFloat(numStr2), currency: "EUR" };
|
|
24399
|
+
}
|
|
24400
|
+
const match2 = trimmed.match(/^([\d,.]+)\s+(\w+)$/);
|
|
24401
|
+
if (!match2) {
|
|
24402
|
+
throw new Error(`Invalid price format: ${priceStr}`);
|
|
24403
|
+
}
|
|
24404
|
+
const numStr = match2[1].replace(/,/g, "");
|
|
24405
|
+
return { amount: parseFloat(numStr), currency: match2[2] };
|
|
24406
|
+
}
|
|
24407
|
+
function parseBtcAmount(amountStr) {
|
|
24408
|
+
return parseBtcPrice(amountStr);
|
|
24409
|
+
}
|
|
24410
|
+
function parseFiatTransfers(csvPath) {
|
|
24411
|
+
const content = fs14.readFileSync(csvPath, "utf-8");
|
|
24412
|
+
const lines = content.trim().split(`
|
|
24413
|
+
`);
|
|
24414
|
+
if (lines.length < 2)
|
|
24415
|
+
return [];
|
|
24416
|
+
const rows = [];
|
|
24417
|
+
for (let i2 = 1;i2 < lines.length; i2++) {
|
|
24418
|
+
const line = lines[i2];
|
|
24419
|
+
const fields = parseSimpleCsvLine(line);
|
|
24420
|
+
if (fields.length < 9)
|
|
24421
|
+
continue;
|
|
24422
|
+
const [type2, , startedDate, , description, amount, , currency] = fields;
|
|
24423
|
+
const isTransfer = type2 === "Exchange" && description === "Transfer to Revolut Digital Assets Europe Ltd" || type2 === "REVX_TRANSFER";
|
|
24424
|
+
if (!isTransfer)
|
|
24425
|
+
continue;
|
|
24426
|
+
const date5 = parseRevolutFiatDatetime(startedDate);
|
|
24427
|
+
const dateIso = formatDateIso(date5);
|
|
24428
|
+
rows.push({
|
|
24429
|
+
type: type2,
|
|
24430
|
+
date: date5,
|
|
24431
|
+
dateStr: dateIso,
|
|
24432
|
+
description,
|
|
24433
|
+
amount: Math.abs(parseFloat(amount)),
|
|
24434
|
+
currency
|
|
24435
|
+
});
|
|
24436
|
+
}
|
|
24437
|
+
return rows;
|
|
24438
|
+
}
|
|
24439
|
+
function parseBtcRows(csvPath) {
|
|
24440
|
+
const content = fs14.readFileSync(csvPath, "utf-8");
|
|
24441
|
+
const lines = content.trim().split(`
|
|
24442
|
+
`);
|
|
24443
|
+
if (lines.length < 2)
|
|
24444
|
+
return [];
|
|
24445
|
+
const rows = [];
|
|
24446
|
+
for (let i2 = 1;i2 < lines.length; i2++) {
|
|
24447
|
+
const line = lines[i2];
|
|
24448
|
+
const fields = parseCryptoCsvLine(line);
|
|
24449
|
+
if (fields.length < 7)
|
|
24450
|
+
continue;
|
|
24451
|
+
const [symbol2, type2, quantity, price, value, fees, dateStr] = fields;
|
|
24452
|
+
if (type2 !== "Buy" && type2 !== "Other")
|
|
24453
|
+
continue;
|
|
24454
|
+
const date5 = parseRevolutCryptoDate(dateStr);
|
|
24455
|
+
const dateIso = formatDateIso(date5);
|
|
24456
|
+
if (type2 === "Other") {
|
|
24457
|
+
rows.push({
|
|
24458
|
+
symbol: symbol2,
|
|
24459
|
+
type: type2,
|
|
24460
|
+
quantity: 0,
|
|
24461
|
+
price: { amount: 0, currency: "" },
|
|
24462
|
+
value: parseBtcAmount(value),
|
|
24463
|
+
fees: parseBtcAmount(fees),
|
|
24464
|
+
date: date5,
|
|
24465
|
+
dateStr: dateIso
|
|
24466
|
+
});
|
|
24467
|
+
} else {
|
|
24468
|
+
rows.push({
|
|
24469
|
+
symbol: symbol2,
|
|
24470
|
+
type: type2,
|
|
24471
|
+
quantity: parseFloat(quantity),
|
|
24472
|
+
price: parseBtcPrice(price),
|
|
24473
|
+
value: parseBtcAmount(value),
|
|
24474
|
+
fees: parseBtcAmount(fees),
|
|
24475
|
+
date: date5,
|
|
24476
|
+
dateStr: dateIso
|
|
24477
|
+
});
|
|
24478
|
+
}
|
|
24479
|
+
}
|
|
24480
|
+
return rows;
|
|
24481
|
+
}
|
|
24482
|
+
function parseSimpleCsvLine(line) {
|
|
24483
|
+
return line.split(",");
|
|
24484
|
+
}
|
|
24485
|
+
function parseCryptoCsvLine(line) {
|
|
24486
|
+
const fields = [];
|
|
24487
|
+
let current = "";
|
|
24488
|
+
let inQuotes = false;
|
|
24489
|
+
for (const char of line) {
|
|
24490
|
+
if (char === '"') {
|
|
24491
|
+
inQuotes = !inQuotes;
|
|
24492
|
+
} else if (char === "," && !inQuotes) {
|
|
24493
|
+
fields.push(current);
|
|
24494
|
+
current = "";
|
|
24495
|
+
} else {
|
|
24496
|
+
current += char;
|
|
24497
|
+
}
|
|
24498
|
+
}
|
|
24499
|
+
fields.push(current);
|
|
24500
|
+
return fields;
|
|
24501
|
+
}
|
|
24502
|
+
function matchTransfers(fiatRows, btcRows) {
|
|
24503
|
+
const matches = [];
|
|
24504
|
+
const matchedFiatIndices = new Set;
|
|
24505
|
+
const matchedBtcIndices = new Set;
|
|
24506
|
+
for (let fi = 0;fi < fiatRows.length; fi++) {
|
|
24507
|
+
const fiat = fiatRows[fi];
|
|
24508
|
+
if (fiat.type !== "Exchange")
|
|
24509
|
+
continue;
|
|
24510
|
+
for (let bi = 0;bi < btcRows.length; bi++) {
|
|
24511
|
+
if (matchedBtcIndices.has(bi))
|
|
24512
|
+
continue;
|
|
24513
|
+
const btc = btcRows[bi];
|
|
24514
|
+
if (btc.type !== "Buy")
|
|
24515
|
+
continue;
|
|
24516
|
+
if (datesMatchToSecond(fiat.date, btc.date)) {
|
|
24517
|
+
matches.push({ fiatRow: fiat, btcRow: btc });
|
|
24518
|
+
matchedFiatIndices.add(fi);
|
|
24519
|
+
matchedBtcIndices.add(bi);
|
|
24520
|
+
break;
|
|
24521
|
+
}
|
|
24522
|
+
}
|
|
24523
|
+
}
|
|
24524
|
+
for (let fi = 0;fi < fiatRows.length; fi++) {
|
|
24525
|
+
if (matchedFiatIndices.has(fi))
|
|
24526
|
+
continue;
|
|
24527
|
+
const fiat = fiatRows[fi];
|
|
24528
|
+
if (fiat.type !== "REVX_TRANSFER")
|
|
24529
|
+
continue;
|
|
24530
|
+
for (let bi = 0;bi < btcRows.length; bi++) {
|
|
24531
|
+
if (matchedBtcIndices.has(bi))
|
|
24532
|
+
continue;
|
|
24533
|
+
const other = btcRows[bi];
|
|
24534
|
+
if (other.type !== "Other")
|
|
24535
|
+
continue;
|
|
24536
|
+
if (!datesMatchToSecond(fiat.date, other.date))
|
|
24537
|
+
continue;
|
|
24538
|
+
for (let bj = 0;bj < btcRows.length; bj++) {
|
|
24539
|
+
if (matchedBtcIndices.has(bj))
|
|
24540
|
+
continue;
|
|
24541
|
+
const buy = btcRows[bj];
|
|
24542
|
+
if (buy.type !== "Buy")
|
|
24543
|
+
continue;
|
|
24544
|
+
const timeDiff = Math.abs(buy.date.getTime() - other.date.getTime()) / 1000;
|
|
24545
|
+
if (timeDiff > 120)
|
|
24546
|
+
continue;
|
|
24547
|
+
if (Math.abs(buy.value.amount - fiat.amount) < 0.01) {
|
|
24548
|
+
matches.push({ fiatRow: fiat, btcRow: buy });
|
|
24549
|
+
matchedFiatIndices.add(fi);
|
|
24550
|
+
matchedBtcIndices.add(bi);
|
|
24551
|
+
matchedBtcIndices.add(bj);
|
|
24552
|
+
break;
|
|
24553
|
+
}
|
|
24554
|
+
}
|
|
24555
|
+
break;
|
|
24556
|
+
}
|
|
24557
|
+
}
|
|
24558
|
+
const unmatchedFiat = fiatRows.filter((_, i2) => !matchedFiatIndices.has(i2));
|
|
24559
|
+
const unmatchedBtc = btcRows.filter((_, i2) => !matchedBtcIndices.has(i2));
|
|
24560
|
+
return { matches, unmatchedFiat, unmatchedBtc };
|
|
24561
|
+
}
|
|
24562
|
+
function datesMatchToSecond(a, b) {
|
|
24563
|
+
return Math.abs(a.getTime() - b.getTime()) < 1000;
|
|
24564
|
+
}
|
|
24565
|
+
function formatJournalEntry(match2) {
|
|
24566
|
+
const { fiatRow, btcRow } = match2;
|
|
24567
|
+
const date5 = fiatRow.dateStr;
|
|
24568
|
+
const fiatCurrency = fiatRow.currency;
|
|
24569
|
+
const fiatAmount = formatAmount(fiatRow.amount);
|
|
24570
|
+
const btcQuantity = formatBtcQuantity(btcRow.quantity);
|
|
24571
|
+
const btcPrice = formatAmount(btcRow.price.amount);
|
|
24572
|
+
const priceCurrency = btcRow.price.currency;
|
|
24573
|
+
const hasFees = btcRow.fees.amount > 0;
|
|
24574
|
+
const lines = [
|
|
24575
|
+
`${date5} Bitcoin purchase`,
|
|
24576
|
+
` assets:bank:revolut:${fiatCurrency.toLowerCase()} -${fiatAmount} ${fiatCurrency}`,
|
|
24577
|
+
` equity:bitcoin:conversion ${fiatAmount} ${fiatCurrency}`,
|
|
24578
|
+
` equity:bitcoin:conversion -${btcQuantity} BTC @ ${btcPrice} ${priceCurrency}`,
|
|
24579
|
+
` assets:bank:revolut:btc ${btcQuantity} BTC @ ${btcPrice} ${priceCurrency}`
|
|
24580
|
+
];
|
|
24581
|
+
if (hasFees) {
|
|
24582
|
+
const feeAmount = formatAmount(btcRow.fees.amount);
|
|
24583
|
+
const feeCurrency = btcRow.fees.currency;
|
|
24584
|
+
lines.push(` expenses:fees:bitcoin ${feeAmount} ${feeCurrency}`, ` equity:bitcoin:conversion -${feeAmount} ${feeCurrency}`);
|
|
24585
|
+
}
|
|
24586
|
+
return lines.join(`
|
|
24587
|
+
`);
|
|
24588
|
+
}
|
|
24589
|
+
function formatAmount(amount) {
|
|
24590
|
+
return amount.toFixed(2);
|
|
24591
|
+
}
|
|
24592
|
+
function formatBtcQuantity(quantity) {
|
|
24593
|
+
const str2 = quantity.toFixed(8);
|
|
24594
|
+
return str2;
|
|
24595
|
+
}
|
|
24596
|
+
function formatDateIso(date5) {
|
|
24597
|
+
const y = date5.getFullYear();
|
|
24598
|
+
const m = String(date5.getMonth() + 1).padStart(2, "0");
|
|
24599
|
+
const d = String(date5.getDate()).padStart(2, "0");
|
|
24600
|
+
return `${y}-${m}-${d}`;
|
|
24601
|
+
}
|
|
24602
|
+
function isDuplicate(match2, journalContent) {
|
|
24603
|
+
const date5 = match2.fiatRow.dateStr;
|
|
24604
|
+
const amount = formatAmount(match2.fiatRow.amount);
|
|
24605
|
+
const currency = match2.fiatRow.currency;
|
|
24606
|
+
const pattern = `${date5} Bitcoin purchase`;
|
|
24607
|
+
if (!journalContent.includes(pattern))
|
|
24608
|
+
return false;
|
|
24609
|
+
const amountPattern = `-${amount} ${currency}`;
|
|
24610
|
+
const idx = journalContent.indexOf(pattern);
|
|
24611
|
+
const chunk = journalContent.slice(idx, idx + 500);
|
|
24612
|
+
return chunk.includes(amountPattern);
|
|
24613
|
+
}
|
|
24614
|
+
function generateBtcPurchaseJournal(fiatCsvPaths, btcCsvPath, yearJournalPath, logger) {
|
|
24615
|
+
logger?.info("Parsing BTC crypto CSV...");
|
|
24616
|
+
const allBtcRows = parseBtcRows(btcCsvPath);
|
|
24617
|
+
logger?.info(`Found ${allBtcRows.length} Buy/Other rows in BTC CSV`);
|
|
24618
|
+
logger?.info("Parsing fiat CSVs...");
|
|
24619
|
+
const allFiatRows = [];
|
|
24620
|
+
for (const csvPath of fiatCsvPaths) {
|
|
24621
|
+
const fiatRows = parseFiatTransfers(csvPath);
|
|
24622
|
+
logger?.info(`Found ${fiatRows.length} BTC transfer rows in ${csvPath}`);
|
|
24623
|
+
allFiatRows.push(...fiatRows);
|
|
24624
|
+
}
|
|
24625
|
+
logger?.info("Matching fiat transfers to BTC purchases...");
|
|
24626
|
+
const { matches, unmatchedFiat, unmatchedBtc } = matchTransfers(allFiatRows, allBtcRows);
|
|
24627
|
+
logger?.info(`Matched ${matches.length} BTC purchases`);
|
|
24628
|
+
if (unmatchedFiat.length > 0) {
|
|
24629
|
+
logger?.warn(`${unmatchedFiat.length} unmatched fiat transfer(s)`);
|
|
24630
|
+
}
|
|
24631
|
+
if (unmatchedBtc.length > 0) {
|
|
24632
|
+
logger?.info(`${unmatchedBtc.length} unmatched BTC row(s) (may be Send, or non-fiat)`);
|
|
24633
|
+
}
|
|
24634
|
+
let journalContent = "";
|
|
24635
|
+
if (fs14.existsSync(yearJournalPath)) {
|
|
24636
|
+
journalContent = fs14.readFileSync(yearJournalPath, "utf-8");
|
|
24637
|
+
}
|
|
24638
|
+
const newEntries = [];
|
|
24639
|
+
let skippedDuplicates = 0;
|
|
24640
|
+
const sortedMatches = [...matches].sort((a, b) => a.fiatRow.date.getTime() - b.fiatRow.date.getTime());
|
|
24641
|
+
for (const match2 of sortedMatches) {
|
|
24642
|
+
if (isDuplicate(match2, journalContent)) {
|
|
24643
|
+
skippedDuplicates++;
|
|
24644
|
+
logger?.debug(`Skipping duplicate: ${match2.fiatRow.dateStr} ${formatAmount(match2.fiatRow.amount)} ${match2.fiatRow.currency}`);
|
|
24645
|
+
continue;
|
|
24646
|
+
}
|
|
24647
|
+
newEntries.push(formatJournalEntry(match2));
|
|
24648
|
+
}
|
|
24649
|
+
if (newEntries.length > 0) {
|
|
24650
|
+
const appendContent = `
|
|
24651
|
+
` + newEntries.join(`
|
|
24652
|
+
|
|
24653
|
+
`) + `
|
|
24654
|
+
`;
|
|
24655
|
+
fs14.appendFileSync(yearJournalPath, appendContent);
|
|
24656
|
+
logger?.info(`Appended ${newEntries.length} new BTC purchase entries to ${yearJournalPath}`);
|
|
24657
|
+
} else {
|
|
24658
|
+
logger?.info("No new BTC purchase entries to add");
|
|
24659
|
+
}
|
|
24660
|
+
return {
|
|
24661
|
+
matchCount: matches.length,
|
|
24662
|
+
entriesAdded: newEntries.length,
|
|
24663
|
+
skippedDuplicates,
|
|
24664
|
+
unmatchedFiat,
|
|
24665
|
+
unmatchedBtc
|
|
24666
|
+
};
|
|
24667
|
+
}
|
|
24668
|
+
|
|
24354
24669
|
// src/tools/import-pipeline.ts
|
|
24355
24670
|
class NoTransactionsError extends Error {
|
|
24356
24671
|
constructor() {
|
|
@@ -24418,6 +24733,42 @@ async function executeClassifyStep(context, logger) {
|
|
|
24418
24733
|
logger?.endSection();
|
|
24419
24734
|
return contextIds;
|
|
24420
24735
|
}
|
|
24736
|
+
async function executeBtcPurchaseStep(context, contextIds, logger) {
|
|
24737
|
+
const fiatCsvPaths = [];
|
|
24738
|
+
let btcCsvPath;
|
|
24739
|
+
for (const contextId of contextIds) {
|
|
24740
|
+
const importCtx = loadContext(context.directory, contextId);
|
|
24741
|
+
if (importCtx.provider !== "revolut")
|
|
24742
|
+
continue;
|
|
24743
|
+
const csvPath = path12.join(context.directory, importCtx.filePath);
|
|
24744
|
+
if (importCtx.currency === "btc") {
|
|
24745
|
+
btcCsvPath = csvPath;
|
|
24746
|
+
} else {
|
|
24747
|
+
fiatCsvPaths.push(csvPath);
|
|
24748
|
+
}
|
|
24749
|
+
}
|
|
24750
|
+
if (!btcCsvPath || fiatCsvPaths.length === 0) {
|
|
24751
|
+
logger?.info("No revolut fiat+btc CSV pair found, skipping BTC purchase generation");
|
|
24752
|
+
return;
|
|
24753
|
+
}
|
|
24754
|
+
logger?.startSection("Step 1b: Generate BTC Purchase Entries");
|
|
24755
|
+
logger?.logStep("BTC Purchases", "start");
|
|
24756
|
+
const btcFilename = path12.basename(btcCsvPath);
|
|
24757
|
+
const yearMatch = btcFilename.match(/(\d{4})-\d{2}-\d{2}/);
|
|
24758
|
+
const year = yearMatch ? parseInt(yearMatch[1], 10) : new Date().getFullYear();
|
|
24759
|
+
const yearJournalPath = ensureYearJournalExists(context.directory, year);
|
|
24760
|
+
const result = generateBtcPurchaseJournal(fiatCsvPaths, btcCsvPath, yearJournalPath, logger);
|
|
24761
|
+
const message = result.entriesAdded > 0 ? `Generated ${result.entriesAdded} BTC purchase entries (${result.matchCount} matched, ${result.skippedDuplicates} skipped)` : `No new BTC purchase entries (${result.matchCount} matched, ${result.skippedDuplicates} duplicates)`;
|
|
24762
|
+
logger?.logStep("BTC Purchases", "success", message);
|
|
24763
|
+
logger?.endSection();
|
|
24764
|
+
context.result.steps.btcPurchases = buildStepResult(true, message, {
|
|
24765
|
+
matchCount: result.matchCount,
|
|
24766
|
+
entriesAdded: result.entriesAdded,
|
|
24767
|
+
skippedDuplicates: result.skippedDuplicates,
|
|
24768
|
+
unmatchedFiat: result.unmatchedFiat.length,
|
|
24769
|
+
unmatchedBtc: result.unmatchedBtc.length
|
|
24770
|
+
});
|
|
24771
|
+
}
|
|
24421
24772
|
async function executeAccountDeclarationsStep(context, contextId, logger) {
|
|
24422
24773
|
logger?.startSection("Step 2: Check Account Declarations");
|
|
24423
24774
|
logger?.logStep("Check Accounts", "start");
|
|
@@ -24726,6 +25077,7 @@ async function importPipeline(directory, agent, options, configLoader = loadImpo
|
|
|
24726
25077
|
logger.info("No files classified, nothing to import");
|
|
24727
25078
|
return buildPipelineSuccessResult(result, "No files to import");
|
|
24728
25079
|
}
|
|
25080
|
+
await executeBtcPurchaseStep(context, contextIds, logger);
|
|
24729
25081
|
let totalTransactions = 0;
|
|
24730
25082
|
for (const contextId of contextIds) {
|
|
24731
25083
|
const importContext = loadContext(context.directory, contextId);
|
|
@@ -24806,7 +25158,7 @@ This tool orchestrates the full import workflow:
|
|
|
24806
25158
|
}
|
|
24807
25159
|
});
|
|
24808
25160
|
// src/tools/init-directories.ts
|
|
24809
|
-
import * as
|
|
25161
|
+
import * as fs16 from "fs";
|
|
24810
25162
|
import * as path13 from "path";
|
|
24811
25163
|
async function initDirectories(directory) {
|
|
24812
25164
|
try {
|
|
@@ -24814,8 +25166,8 @@ async function initDirectories(directory) {
|
|
|
24814
25166
|
const directoriesCreated = [];
|
|
24815
25167
|
const gitkeepFiles = [];
|
|
24816
25168
|
const importBase = path13.join(directory, "import");
|
|
24817
|
-
if (!
|
|
24818
|
-
|
|
25169
|
+
if (!fs16.existsSync(importBase)) {
|
|
25170
|
+
fs16.mkdirSync(importBase, { recursive: true });
|
|
24819
25171
|
directoriesCreated.push("import");
|
|
24820
25172
|
}
|
|
24821
25173
|
const pathsToCreate = [
|
|
@@ -24826,19 +25178,19 @@ async function initDirectories(directory) {
|
|
|
24826
25178
|
];
|
|
24827
25179
|
for (const { path: dirPath } of pathsToCreate) {
|
|
24828
25180
|
const fullPath = path13.join(directory, dirPath);
|
|
24829
|
-
if (!
|
|
24830
|
-
|
|
25181
|
+
if (!fs16.existsSync(fullPath)) {
|
|
25182
|
+
fs16.mkdirSync(fullPath, { recursive: true });
|
|
24831
25183
|
directoriesCreated.push(dirPath);
|
|
24832
25184
|
}
|
|
24833
25185
|
const gitkeepPath = path13.join(fullPath, ".gitkeep");
|
|
24834
|
-
if (!
|
|
24835
|
-
|
|
25186
|
+
if (!fs16.existsSync(gitkeepPath)) {
|
|
25187
|
+
fs16.writeFileSync(gitkeepPath, "");
|
|
24836
25188
|
gitkeepFiles.push(path13.join(dirPath, ".gitkeep"));
|
|
24837
25189
|
}
|
|
24838
25190
|
}
|
|
24839
25191
|
const gitignorePath = path13.join(importBase, ".gitignore");
|
|
24840
25192
|
let gitignoreCreated = false;
|
|
24841
|
-
if (!
|
|
25193
|
+
if (!fs16.existsSync(gitignorePath)) {
|
|
24842
25194
|
const gitignoreContent = `# Ignore CSV/PDF files in temporary directories
|
|
24843
25195
|
/incoming/*.csv
|
|
24844
25196
|
/incoming/*.pdf
|
|
@@ -24856,7 +25208,7 @@ async function initDirectories(directory) {
|
|
|
24856
25208
|
.DS_Store
|
|
24857
25209
|
Thumbs.db
|
|
24858
25210
|
`;
|
|
24859
|
-
|
|
25211
|
+
fs16.writeFileSync(gitignorePath, gitignoreContent);
|
|
24860
25212
|
gitignoreCreated = true;
|
|
24861
25213
|
}
|
|
24862
25214
|
const parts = [];
|
|
@@ -24931,9 +25283,129 @@ You can now drop CSV files into import/incoming/ and run import-pipeline.`);
|
|
|
24931
25283
|
`);
|
|
24932
25284
|
}
|
|
24933
25285
|
});
|
|
25286
|
+
// src/tools/generate-btc-purchases.ts
|
|
25287
|
+
import * as path14 from "path";
|
|
25288
|
+
import * as fs17 from "fs";
|
|
25289
|
+
function findFiatCsvPaths(directory, pendingDir, provider) {
|
|
25290
|
+
const providerDir = path14.join(directory, pendingDir, provider);
|
|
25291
|
+
if (!fs17.existsSync(providerDir))
|
|
25292
|
+
return [];
|
|
25293
|
+
const csvPaths = [];
|
|
25294
|
+
const entries = fs17.readdirSync(providerDir, { withFileTypes: true });
|
|
25295
|
+
for (const entry of entries) {
|
|
25296
|
+
if (!entry.isDirectory())
|
|
25297
|
+
continue;
|
|
25298
|
+
if (entry.name === "btc")
|
|
25299
|
+
continue;
|
|
25300
|
+
const csvFiles = findCsvFiles(path14.join(providerDir, entry.name), { fullPaths: true });
|
|
25301
|
+
csvPaths.push(...csvFiles);
|
|
25302
|
+
}
|
|
25303
|
+
return csvPaths;
|
|
25304
|
+
}
|
|
25305
|
+
function findBtcCsvPath(directory, pendingDir, provider) {
|
|
25306
|
+
const btcDir = path14.join(directory, pendingDir, provider, "btc");
|
|
25307
|
+
const csvFiles = findCsvFiles(btcDir, { fullPaths: true });
|
|
25308
|
+
return csvFiles[0];
|
|
25309
|
+
}
|
|
25310
|
+
function determineYear(csvPaths) {
|
|
25311
|
+
for (const csvPath of csvPaths) {
|
|
25312
|
+
const match2 = path14.basename(csvPath).match(/(\d{4})-\d{2}-\d{2}/);
|
|
25313
|
+
if (match2)
|
|
25314
|
+
return parseInt(match2[1], 10);
|
|
25315
|
+
}
|
|
25316
|
+
return new Date().getFullYear();
|
|
25317
|
+
}
|
|
25318
|
+
async function generateBtcPurchases(directory, agent, options = {}, configLoader = loadImportConfig) {
|
|
25319
|
+
const restrictionError = checkAccountantAgent(agent, "generate BTC purchases");
|
|
25320
|
+
if (restrictionError)
|
|
25321
|
+
return restrictionError;
|
|
25322
|
+
const logger = createImportLogger(directory, undefined, "btc-purchases");
|
|
25323
|
+
try {
|
|
25324
|
+
logger.startSection("Generate BTC Purchase Entries", 1);
|
|
25325
|
+
let config2;
|
|
25326
|
+
try {
|
|
25327
|
+
config2 = configLoader(directory);
|
|
25328
|
+
} catch (err) {
|
|
25329
|
+
return buildToolErrorResult(err instanceof Error ? err.message : String(err));
|
|
25330
|
+
}
|
|
25331
|
+
const provider = options.provider || "revolut";
|
|
25332
|
+
const fiatCsvPaths = findFiatCsvPaths(directory, config2.paths.pending, provider);
|
|
25333
|
+
if (fiatCsvPaths.length === 0) {
|
|
25334
|
+
logger.info("No fiat CSV files found in pending directories");
|
|
25335
|
+
return buildToolSuccessResult({
|
|
25336
|
+
message: "No fiat CSV files found in pending directories",
|
|
25337
|
+
matchCount: 0,
|
|
25338
|
+
entriesAdded: 0
|
|
25339
|
+
});
|
|
25340
|
+
}
|
|
25341
|
+
logger.info(`Found ${fiatCsvPaths.length} fiat CSV file(s)`);
|
|
25342
|
+
const btcCsvPath = findBtcCsvPath(directory, config2.paths.pending, provider);
|
|
25343
|
+
if (!btcCsvPath) {
|
|
25344
|
+
logger.info("No BTC CSV file found in pending btc directory");
|
|
25345
|
+
return buildToolSuccessResult({
|
|
25346
|
+
message: "No BTC CSV file found in pending btc directory",
|
|
25347
|
+
matchCount: 0,
|
|
25348
|
+
entriesAdded: 0
|
|
25349
|
+
});
|
|
25350
|
+
}
|
|
25351
|
+
logger.info(`Found BTC CSV: ${btcCsvPath}`);
|
|
25352
|
+
const allCsvPaths = [...fiatCsvPaths, btcCsvPath];
|
|
25353
|
+
const year = determineYear(allCsvPaths);
|
|
25354
|
+
const yearJournalPath = ensureYearJournalExists(directory, year);
|
|
25355
|
+
logger.info(`Year journal: ${yearJournalPath}`);
|
|
25356
|
+
const result = generateBtcPurchaseJournal(fiatCsvPaths, btcCsvPath, yearJournalPath, logger);
|
|
25357
|
+
logger.logStep("Generate BTC Purchases", "success", `${result.entriesAdded} entries added`);
|
|
25358
|
+
logger.endSection();
|
|
25359
|
+
return buildToolSuccessResult({
|
|
25360
|
+
matchCount: result.matchCount,
|
|
25361
|
+
entriesAdded: result.entriesAdded,
|
|
25362
|
+
skippedDuplicates: result.skippedDuplicates,
|
|
25363
|
+
unmatchedFiat: result.unmatchedFiat.length,
|
|
25364
|
+
unmatchedBtc: result.unmatchedBtc.length,
|
|
25365
|
+
yearJournal: path14.relative(directory, yearJournalPath)
|
|
25366
|
+
});
|
|
25367
|
+
} catch (err) {
|
|
25368
|
+
logger.error("Failed to generate BTC purchases", err);
|
|
25369
|
+
return buildToolErrorResult(err instanceof Error ? err.message : String(err));
|
|
25370
|
+
} finally {
|
|
25371
|
+
logger.endSection();
|
|
25372
|
+
await logger.flush();
|
|
25373
|
+
}
|
|
25374
|
+
}
|
|
25375
|
+
var generate_btc_purchases_default = tool({
|
|
25376
|
+
description: `ACCOUNTANT AGENT ONLY: Generate BTC purchase journal entries from Revolut CSVs.
|
|
25377
|
+
|
|
25378
|
+
Cross-references Revolut fiat account CSVs (CHF/EUR/USD) with BTC crypto CSV
|
|
25379
|
+
to produce equity conversion entries for Bitcoin purchases.
|
|
25380
|
+
|
|
25381
|
+
**What it does:**
|
|
25382
|
+
- Scans pending directories for fiat and BTC CSV files
|
|
25383
|
+
- Matches fiat "Transfer to Digital Assets" rows with BTC "Buy" rows by timestamp
|
|
25384
|
+
- Handles REVX_TRANSFER \u2192 Other \u2192 Buy multi-step purchase flows
|
|
25385
|
+
- Generates hledger journal entries with equity conversion postings
|
|
25386
|
+
- Deduplicates to prevent double entries on re-run
|
|
25387
|
+
- Appends entries to the year journal
|
|
25388
|
+
|
|
25389
|
+
**Prerequisites:**
|
|
25390
|
+
- CSV files must be classified (in import/pending/revolut/{currency}/)
|
|
25391
|
+
- hledger rules files should have skip directives for BTC-related rows
|
|
25392
|
+
|
|
25393
|
+
**Usage:**
|
|
25394
|
+
- generate-btc-purchases (scans revolut pending dirs)
|
|
25395
|
+
- generate-btc-purchases --provider revolut`,
|
|
25396
|
+
args: {
|
|
25397
|
+
provider: tool.schema.string().optional().describe('Provider name (default: "revolut")')
|
|
25398
|
+
},
|
|
25399
|
+
async execute(params, context) {
|
|
25400
|
+
const { directory, agent } = context;
|
|
25401
|
+
return generateBtcPurchases(directory, agent, {
|
|
25402
|
+
provider: params.provider
|
|
25403
|
+
});
|
|
25404
|
+
}
|
|
25405
|
+
});
|
|
24934
25406
|
// src/index.ts
|
|
24935
25407
|
var __dirname2 = dirname4(fileURLToPath3(import.meta.url));
|
|
24936
|
-
var AGENT_FILE =
|
|
25408
|
+
var AGENT_FILE = join13(__dirname2, "..", "agent", "accountant.md");
|
|
24937
25409
|
var AccountantPlugin = async () => {
|
|
24938
25410
|
const agent = loadAgent(AGENT_FILE);
|
|
24939
25411
|
return {
|
|
@@ -24942,7 +25414,8 @@ var AccountantPlugin = async () => {
|
|
|
24942
25414
|
"classify-statements": classify_statements_default,
|
|
24943
25415
|
"import-statements": import_statements_default,
|
|
24944
25416
|
"reconcile-statements": reconcile_statement_default,
|
|
24945
|
-
"import-pipeline": import_pipeline_default
|
|
25417
|
+
"import-pipeline": import_pipeline_default,
|
|
25418
|
+
"generate-btc-purchases": generate_btc_purchases_default
|
|
24946
25419
|
},
|
|
24947
25420
|
config: async (config2) => {
|
|
24948
25421
|
if (agent) {
|