@fuzzle/opencode-accountant 0.6.1-next.1 → 0.6.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 +18 -125
- package/docs/tools/import-pipeline.md +1 -46
- 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 = [];
|
|
@@ -24581,7 +24581,7 @@ function formatJournalEntry(match2) {
|
|
|
24581
24581
|
if (hasFees) {
|
|
24582
24582
|
const feeAmount = formatAmount(btcRow.fees.amount);
|
|
24583
24583
|
const feeCurrency = btcRow.fees.currency;
|
|
24584
|
-
lines.push(` expenses:fees:
|
|
24584
|
+
lines.push(` expenses:fees:bitcoin ${feeAmount} ${feeCurrency}`, ` equity:bitcoin:conversion -${feeAmount} ${feeCurrency}`);
|
|
24585
24585
|
}
|
|
24586
24586
|
return lines.join(`
|
|
24587
24587
|
`);
|
|
@@ -24666,80 +24666,6 @@ function generateBtcPurchaseJournal(fiatCsvPaths, btcCsvPath, yearJournalPath, l
|
|
|
24666
24666
|
};
|
|
24667
24667
|
}
|
|
24668
24668
|
|
|
24669
|
-
// src/utils/btcCsvPreprocessor.ts
|
|
24670
|
-
import * as fs15 from "fs";
|
|
24671
|
-
var ORIGINAL_HEADER = "Symbol,Type,Quantity,Price,Value,Fees,Date";
|
|
24672
|
-
var PREPROCESSED_HEADER = "Symbol,Type,Quantity,Price,Value,Fees,Date,Fees_BTC,Total_BTC,Price_Amount";
|
|
24673
|
-
var FEE_BTC_DECIMALS = 8;
|
|
24674
|
-
function calculateFeeBtc(fees, price) {
|
|
24675
|
-
if (price.amount === 0)
|
|
24676
|
-
return 0;
|
|
24677
|
-
return fees.amount / price.amount;
|
|
24678
|
-
}
|
|
24679
|
-
function formatPriceAmount(price) {
|
|
24680
|
-
return `${price.amount} ${price.currency}`;
|
|
24681
|
-
}
|
|
24682
|
-
function preprocessRevolutBtcCsv(csvPath, logger) {
|
|
24683
|
-
const content = fs15.readFileSync(csvPath, "utf-8");
|
|
24684
|
-
const lines = content.trim().split(`
|
|
24685
|
-
`);
|
|
24686
|
-
if (lines.length < 2) {
|
|
24687
|
-
return { rowsProcessed: 0, sendRowsEnriched: 0, alreadyPreprocessed: false };
|
|
24688
|
-
}
|
|
24689
|
-
const header = lines[0].trim();
|
|
24690
|
-
if (header === PREPROCESSED_HEADER) {
|
|
24691
|
-
logger?.info("CSV already preprocessed, skipping");
|
|
24692
|
-
return { rowsProcessed: lines.length - 1, sendRowsEnriched: 0, alreadyPreprocessed: true };
|
|
24693
|
-
}
|
|
24694
|
-
if (header !== ORIGINAL_HEADER) {
|
|
24695
|
-
throw new Error(`Unexpected CSV header. Expected:
|
|
24696
|
-
${ORIGINAL_HEADER}
|
|
24697
|
-
Got:
|
|
24698
|
-
${header}`);
|
|
24699
|
-
}
|
|
24700
|
-
const outputLines = [PREPROCESSED_HEADER];
|
|
24701
|
-
let sendRowsEnriched = 0;
|
|
24702
|
-
for (let i2 = 1;i2 < lines.length; i2++) {
|
|
24703
|
-
const line = lines[i2];
|
|
24704
|
-
if (line.trim() === "")
|
|
24705
|
-
continue;
|
|
24706
|
-
const fields = parseCryptoCsvLine(line);
|
|
24707
|
-
if (fields.length < 7) {
|
|
24708
|
-
outputLines.push(line + ",,,");
|
|
24709
|
-
continue;
|
|
24710
|
-
}
|
|
24711
|
-
const type2 = fields[1];
|
|
24712
|
-
const quantityStr = fields[2];
|
|
24713
|
-
const priceStr = fields[3];
|
|
24714
|
-
const feesStr = fields[5];
|
|
24715
|
-
let feesBtc = "";
|
|
24716
|
-
let totalBtc = "";
|
|
24717
|
-
let priceAmount = "";
|
|
24718
|
-
try {
|
|
24719
|
-
const price = parseBtcPrice(priceStr);
|
|
24720
|
-
priceAmount = formatPriceAmount(price);
|
|
24721
|
-
if (type2 === "Send") {
|
|
24722
|
-
const fees = parseBtcPrice(feesStr);
|
|
24723
|
-
const feeBtc = calculateFeeBtc(fees, price);
|
|
24724
|
-
const quantity = parseFloat(quantityStr);
|
|
24725
|
-
feesBtc = feeBtc.toFixed(FEE_BTC_DECIMALS);
|
|
24726
|
-
totalBtc = (quantity + feeBtc).toFixed(FEE_BTC_DECIMALS);
|
|
24727
|
-
sendRowsEnriched++;
|
|
24728
|
-
}
|
|
24729
|
-
} catch {
|
|
24730
|
-
logger?.debug(`Row ${i2}: could not parse price/fees, leaving computed columns empty`);
|
|
24731
|
-
}
|
|
24732
|
-
outputLines.push(`${line},${feesBtc},${totalBtc},${priceAmount}`);
|
|
24733
|
-
}
|
|
24734
|
-
const outputContent = outputLines.join(`
|
|
24735
|
-
`) + `
|
|
24736
|
-
`;
|
|
24737
|
-
fs15.writeFileSync(csvPath, outputContent);
|
|
24738
|
-
const rowsProcessed = outputLines.length - 1;
|
|
24739
|
-
logger?.info(`Preprocessed ${rowsProcessed} rows (${sendRowsEnriched} Send rows enriched)`);
|
|
24740
|
-
return { rowsProcessed, sendRowsEnriched, alreadyPreprocessed: false };
|
|
24741
|
-
}
|
|
24742
|
-
|
|
24743
24669
|
// src/tools/import-pipeline.ts
|
|
24744
24670
|
class NoTransactionsError extends Error {
|
|
24745
24671
|
constructor() {
|
|
@@ -24807,38 +24733,6 @@ async function executeClassifyStep(context, logger) {
|
|
|
24807
24733
|
logger?.endSection();
|
|
24808
24734
|
return contextIds;
|
|
24809
24735
|
}
|
|
24810
|
-
function executePreprocessBtcStep(context, contextIds, logger) {
|
|
24811
|
-
let btcCsvPath;
|
|
24812
|
-
for (const contextId of contextIds) {
|
|
24813
|
-
const importCtx = loadContext(context.directory, contextId);
|
|
24814
|
-
if (importCtx.provider === "revolut" && importCtx.currency === "btc") {
|
|
24815
|
-
btcCsvPath = path12.join(context.directory, importCtx.filePath);
|
|
24816
|
-
break;
|
|
24817
|
-
}
|
|
24818
|
-
}
|
|
24819
|
-
if (!btcCsvPath) {
|
|
24820
|
-
logger?.info("No revolut BTC CSV found, skipping preprocessing");
|
|
24821
|
-
return;
|
|
24822
|
-
}
|
|
24823
|
-
logger?.startSection("Step 1a: Preprocess BTC CSV");
|
|
24824
|
-
logger?.logStep("BTC Preprocess", "start");
|
|
24825
|
-
try {
|
|
24826
|
-
const result = preprocessRevolutBtcCsv(btcCsvPath, logger);
|
|
24827
|
-
const message = result.alreadyPreprocessed ? "BTC CSV already preprocessed" : `Preprocessed ${result.rowsProcessed} rows (${result.sendRowsEnriched} Send rows enriched)`;
|
|
24828
|
-
logger?.logStep("BTC Preprocess", "success", message);
|
|
24829
|
-
context.result.steps.btcPreprocess = buildStepResult(true, message, result);
|
|
24830
|
-
} catch (error45) {
|
|
24831
|
-
const errorMessage = `BTC CSV preprocessing failed: ${error45 instanceof Error ? error45.message : String(error45)}`;
|
|
24832
|
-
logger?.error(errorMessage);
|
|
24833
|
-
logger?.logStep("BTC Preprocess", "error", errorMessage);
|
|
24834
|
-
context.result.steps.btcPreprocess = buildStepResult(false, errorMessage);
|
|
24835
|
-
context.result.error = errorMessage;
|
|
24836
|
-
context.result.hint = "Check the BTC CSV format \u2014 expected Revolut crypto account statement";
|
|
24837
|
-
throw new Error(errorMessage);
|
|
24838
|
-
} finally {
|
|
24839
|
-
logger?.endSection();
|
|
24840
|
-
}
|
|
24841
|
-
}
|
|
24842
24736
|
async function executeBtcPurchaseStep(context, contextIds, logger) {
|
|
24843
24737
|
const fiatCsvPaths = [];
|
|
24844
24738
|
let btcCsvPath;
|
|
@@ -25183,7 +25077,6 @@ async function importPipeline(directory, agent, options, configLoader = loadImpo
|
|
|
25183
25077
|
logger.info("No files classified, nothing to import");
|
|
25184
25078
|
return buildPipelineSuccessResult(result, "No files to import");
|
|
25185
25079
|
}
|
|
25186
|
-
executePreprocessBtcStep(context, contextIds, logger);
|
|
25187
25080
|
await executeBtcPurchaseStep(context, contextIds, logger);
|
|
25188
25081
|
let totalTransactions = 0;
|
|
25189
25082
|
for (const contextId of contextIds) {
|
|
@@ -25265,7 +25158,7 @@ This tool orchestrates the full import workflow:
|
|
|
25265
25158
|
}
|
|
25266
25159
|
});
|
|
25267
25160
|
// src/tools/init-directories.ts
|
|
25268
|
-
import * as
|
|
25161
|
+
import * as fs16 from "fs";
|
|
25269
25162
|
import * as path13 from "path";
|
|
25270
25163
|
async function initDirectories(directory) {
|
|
25271
25164
|
try {
|
|
@@ -25273,8 +25166,8 @@ async function initDirectories(directory) {
|
|
|
25273
25166
|
const directoriesCreated = [];
|
|
25274
25167
|
const gitkeepFiles = [];
|
|
25275
25168
|
const importBase = path13.join(directory, "import");
|
|
25276
|
-
if (!
|
|
25277
|
-
|
|
25169
|
+
if (!fs16.existsSync(importBase)) {
|
|
25170
|
+
fs16.mkdirSync(importBase, { recursive: true });
|
|
25278
25171
|
directoriesCreated.push("import");
|
|
25279
25172
|
}
|
|
25280
25173
|
const pathsToCreate = [
|
|
@@ -25285,19 +25178,19 @@ async function initDirectories(directory) {
|
|
|
25285
25178
|
];
|
|
25286
25179
|
for (const { path: dirPath } of pathsToCreate) {
|
|
25287
25180
|
const fullPath = path13.join(directory, dirPath);
|
|
25288
|
-
if (!
|
|
25289
|
-
|
|
25181
|
+
if (!fs16.existsSync(fullPath)) {
|
|
25182
|
+
fs16.mkdirSync(fullPath, { recursive: true });
|
|
25290
25183
|
directoriesCreated.push(dirPath);
|
|
25291
25184
|
}
|
|
25292
25185
|
const gitkeepPath = path13.join(fullPath, ".gitkeep");
|
|
25293
|
-
if (!
|
|
25294
|
-
|
|
25186
|
+
if (!fs16.existsSync(gitkeepPath)) {
|
|
25187
|
+
fs16.writeFileSync(gitkeepPath, "");
|
|
25295
25188
|
gitkeepFiles.push(path13.join(dirPath, ".gitkeep"));
|
|
25296
25189
|
}
|
|
25297
25190
|
}
|
|
25298
25191
|
const gitignorePath = path13.join(importBase, ".gitignore");
|
|
25299
25192
|
let gitignoreCreated = false;
|
|
25300
|
-
if (!
|
|
25193
|
+
if (!fs16.existsSync(gitignorePath)) {
|
|
25301
25194
|
const gitignoreContent = `# Ignore CSV/PDF files in temporary directories
|
|
25302
25195
|
/incoming/*.csv
|
|
25303
25196
|
/incoming/*.pdf
|
|
@@ -25315,7 +25208,7 @@ async function initDirectories(directory) {
|
|
|
25315
25208
|
.DS_Store
|
|
25316
25209
|
Thumbs.db
|
|
25317
25210
|
`;
|
|
25318
|
-
|
|
25211
|
+
fs16.writeFileSync(gitignorePath, gitignoreContent);
|
|
25319
25212
|
gitignoreCreated = true;
|
|
25320
25213
|
}
|
|
25321
25214
|
const parts = [];
|
|
@@ -25392,13 +25285,13 @@ You can now drop CSV files into import/incoming/ and run import-pipeline.`);
|
|
|
25392
25285
|
});
|
|
25393
25286
|
// src/tools/generate-btc-purchases.ts
|
|
25394
25287
|
import * as path14 from "path";
|
|
25395
|
-
import * as
|
|
25288
|
+
import * as fs17 from "fs";
|
|
25396
25289
|
function findFiatCsvPaths(directory, pendingDir, provider) {
|
|
25397
25290
|
const providerDir = path14.join(directory, pendingDir, provider);
|
|
25398
|
-
if (!
|
|
25291
|
+
if (!fs17.existsSync(providerDir))
|
|
25399
25292
|
return [];
|
|
25400
25293
|
const csvPaths = [];
|
|
25401
|
-
const entries =
|
|
25294
|
+
const entries = fs17.readdirSync(providerDir, { withFileTypes: true });
|
|
25402
25295
|
for (const entry of entries) {
|
|
25403
25296
|
if (!entry.isDirectory())
|
|
25404
25297
|
continue;
|
|
@@ -6,18 +6,14 @@ This tool is **restricted to the accountant agent only**.
|
|
|
6
6
|
|
|
7
7
|
## Overview
|
|
8
8
|
|
|
9
|
-
The pipeline automates
|
|
9
|
+
The pipeline automates five sequential steps:
|
|
10
10
|
|
|
11
11
|
1. **Classify** - Detect provider/currency, create contexts, organize files
|
|
12
|
-
1a. **Preprocess BTC CSV** _(Revolut only)_ - Add computed columns (fees in BTC, total, clean price) for hledger rules
|
|
13
|
-
1b. **Generate BTC Purchases** _(Revolut only)_ - Cross-reference fiat + BTC CSVs for equity conversion entries
|
|
14
12
|
2. **Account Declarations** - Ensure all accounts exist in year journals
|
|
15
13
|
3. **Dry Run** - Validate transactions, check for unknown accounts
|
|
16
14
|
4. **Import** - Add transactions to journals, move files to done
|
|
17
15
|
5. **Reconcile** - Verify balances match expectations
|
|
18
16
|
|
|
19
|
-
Steps 1a and 1b are Revolut-specific and are automatically skipped when no Revolut BTC CSV is present.
|
|
20
|
-
|
|
21
17
|
**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.
|
|
22
18
|
|
|
23
19
|
## Arguments
|
|
@@ -160,34 +156,6 @@ When reconciliation fails:
|
|
|
160
156
|
|
|
161
157
|
See [classify-statements](classify-statements.md) for details.
|
|
162
158
|
|
|
163
|
-
### Step 1a: Preprocess BTC CSV
|
|
164
|
-
|
|
165
|
-
**Purpose**: Add computed columns to Revolut BTC CSVs for use in hledger rules
|
|
166
|
-
|
|
167
|
-
**What happens**:
|
|
168
|
-
|
|
169
|
-
1. Finds Revolut BTC contexts from classification
|
|
170
|
-
2. For each Send row, calculates: `fee_btc = fees_chf / price_chf_per_btc`
|
|
171
|
-
3. Adds three columns to the CSV (in-place): `Fees_BTC`, `Total_BTC`, `Price_Amount`
|
|
172
|
-
4. Idempotent — skips if CSV is already preprocessed
|
|
173
|
-
|
|
174
|
-
**Why**: hledger CSV rules don't support division. BTC Send transactions need the fee split into a separate BTC amount, which requires dividing the CHF fee by the CHF/BTC price.
|
|
175
|
-
|
|
176
|
-
**Skipped when**: No Revolut BTC CSV is present among the classified files.
|
|
177
|
-
|
|
178
|
-
### Step 1b: Generate BTC Purchase Entries
|
|
179
|
-
|
|
180
|
-
**Purpose**: Cross-reference Revolut fiat and BTC CSVs to generate equity conversion journal entries for BTC purchases
|
|
181
|
-
|
|
182
|
-
**What happens**:
|
|
183
|
-
|
|
184
|
-
1. Finds matching Revolut fiat and BTC CSV pairs from contexts
|
|
185
|
-
2. Matches fiat transfer rows to BTC Buy rows by timestamp
|
|
186
|
-
3. Generates equity conversion journal entries (fiat → equity → BTC)
|
|
187
|
-
4. Appends entries to year journal, skipping duplicates
|
|
188
|
-
|
|
189
|
-
**Skipped when**: No fiat+BTC CSV pair is found among the classified files.
|
|
190
|
-
|
|
191
159
|
### Step 2: Account Declarations
|
|
192
160
|
|
|
193
161
|
**Purpose**: Ensure all accounts referenced in rules files are declared in year journals
|
|
@@ -294,19 +262,6 @@ See [reconcile-statement](reconcile-statement.md) for details.
|
|
|
294
262
|
│ └──────────────────────────────────────────────────────────┘ │
|
|
295
263
|
│ │
|
|
296
264
|
│ OUTPUT: ["uuid-1", "uuid-2"] │
|
|
297
|
-
└────────────────────────────────────────────────────────────────┘
|
|
298
|
-
│
|
|
299
|
-
▼
|
|
300
|
-
┌────────────────────────────────────────────────────────────────┐
|
|
301
|
-
│ STEP 1a: Preprocess BTC CSV (if revolut/btc context exists) │
|
|
302
|
-
│ • Add Fees_BTC, Total_BTC, Price_Amount columns │
|
|
303
|
-
└────────────────────────────────────────────────────────────────┘
|
|
304
|
-
│
|
|
305
|
-
▼
|
|
306
|
-
┌────────────────────────────────────────────────────────────────┐
|
|
307
|
-
│ STEP 1b: Generate BTC Purchase Entries │
|
|
308
|
-
│ • Cross-reference fiat + BTC CSVs │
|
|
309
|
-
│ • Generate equity conversion journal entries │
|
|
310
265
|
└────────────────────────────────────────────────────────────────┘
|
|
311
266
|
│
|
|
312
267
|
▼
|