@fuzzle/opencode-accountant 0.7.4 → 0.8.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/README.md CHANGED
@@ -261,7 +261,7 @@ See the hledger documentation for details on rules file format and syntax.
261
261
  The pipeline automatically manages account declarations:
262
262
 
263
263
  - Scans rules files matched to the CSVs being imported
264
- - Extracts all accounts (account1 and account2 directives)
264
+ - Extracts all accounts (account1, account2, account3, etc. directives)
265
265
  - Creates or updates year journal files with sorted account declarations
266
266
  - Ensures `hledger check --strict` validation passes
267
267
 
@@ -148,7 +148,7 @@ The `import-pipeline` tool operates directly on the working directory:
148
148
 
149
149
  The import pipeline automatically:
150
150
 
151
- - Scans matched rules files for all account references (account1, account2 directives)
151
+ - Scans matched rules files for all account references (account1, account2, account3, etc. directives)
152
152
  - Creates/updates year journal files (e.g., ledger/2026.journal) with sorted account declarations
153
153
  - Prevents `hledger check --strict` failures due to missing account declarations
154
154
  - No manual account setup required
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 fs16 from "fs";
4252
+ import * as fs17 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 (!fs16.existsSync(yearJournalPath)) {
4262
+ if (!fs17.existsSync(yearJournalPath)) {
4263
4263
  return [];
4264
4264
  }
4265
- const content = fs16.readFileSync(yearJournalPath, "utf-8");
4265
+ const content = fs17.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 (!fs16.existsSync(rulesPath)) {
4281
+ if (!fs17.existsSync(rulesPath)) {
4282
4282
  return [];
4283
4283
  }
4284
- const content = fs16.readFileSync(rulesPath, "utf-8");
4284
+ const content = fs17.readFileSync(rulesPath, "utf-8");
4285
4285
  const lines = content.split(`
4286
4286
  `);
4287
4287
  const patterns = [];
@@ -24098,15 +24098,11 @@ function extractAccountsFromRulesFile(rulesPath) {
24098
24098
  if (trimmed.startsWith("#") || trimmed.startsWith(";") || trimmed === "") {
24099
24099
  continue;
24100
24100
  }
24101
- const account1Match = trimmed.match(/^account1\s+(.+?)(?:\s+|$)/);
24102
- if (account1Match) {
24103
- accounts.add(account1Match[1].trim());
24101
+ const accountMatch = trimmed.match(/^account\d+\s+(.+?)(?:\s+|$)/);
24102
+ if (accountMatch) {
24103
+ accounts.add(accountMatch[1].trim());
24104
24104
  continue;
24105
24105
  }
24106
- const account2Match = trimmed.match(/account2\s+(.+?)(?:\s+|$)/);
24107
- if (account2Match) {
24108
- accounts.add(account2Match[1].trim());
24109
- }
24110
24106
  }
24111
24107
  return accounts;
24112
24108
  }
@@ -24736,6 +24732,54 @@ Got:
24736
24732
  return { rowsProcessed, sendRowsEnriched, alreadyPreprocessed: false };
24737
24733
  }
24738
24734
 
24735
+ // src/utils/revolutFiatCsvPreprocessor.ts
24736
+ import * as fs16 from "fs";
24737
+ var ORIGINAL_HEADER2 = "Type,Product,Started Date,Completed Date,Description,Amount,Fee,Currency,State,Balance";
24738
+ var PREPROCESSED_HEADER2 = "Type,Product,Started Date,Completed Date,Description,Amount,Fee,Currency,State,Balance,Net_Amount";
24739
+ function preprocessRevolutFiatCsv(csvPath, logger) {
24740
+ const content = fs16.readFileSync(csvPath, "utf-8");
24741
+ const lines = content.trim().split(`
24742
+ `);
24743
+ if (lines.length < 2) {
24744
+ return { rowsProcessed: 0, sendRowsEnriched: 0, alreadyPreprocessed: false };
24745
+ }
24746
+ const header = lines[0].trim();
24747
+ if (header === PREPROCESSED_HEADER2) {
24748
+ logger?.info("CSV already preprocessed, skipping");
24749
+ return { rowsProcessed: lines.length - 1, sendRowsEnriched: 0, alreadyPreprocessed: true };
24750
+ }
24751
+ if (header !== ORIGINAL_HEADER2) {
24752
+ throw new Error(`Unexpected CSV header. Expected:
24753
+ ${ORIGINAL_HEADER2}
24754
+ Got:
24755
+ ${header}`);
24756
+ }
24757
+ const outputLines = [PREPROCESSED_HEADER2];
24758
+ let enrichedRows = 0;
24759
+ for (let i2 = 1;i2 < lines.length; i2++) {
24760
+ const line = lines[i2];
24761
+ if (line.trim() === "")
24762
+ continue;
24763
+ const fields = line.split(",");
24764
+ if (fields.length < 7) {
24765
+ outputLines.push(line + ",");
24766
+ continue;
24767
+ }
24768
+ const amount = parseFloat(fields[5]);
24769
+ const fee = parseFloat(fields[6]);
24770
+ const netAmount = amount - fee;
24771
+ outputLines.push(`${line},${netAmount.toFixed(2)}`);
24772
+ enrichedRows++;
24773
+ }
24774
+ const outputContent = outputLines.join(`
24775
+ `) + `
24776
+ `;
24777
+ fs16.writeFileSync(csvPath, outputContent);
24778
+ const rowsProcessed = outputLines.length - 1;
24779
+ logger?.info(`Preprocessed ${rowsProcessed} rows (${enrichedRows} rows enriched with Net_Amount)`);
24780
+ return { rowsProcessed, sendRowsEnriched: enrichedRows, alreadyPreprocessed: false };
24781
+ }
24782
+
24739
24783
  // src/tools/import-pipeline.ts
24740
24784
  class NoTransactionsError extends Error {
24741
24785
  constructor() {
@@ -24803,6 +24847,50 @@ async function executeClassifyStep(context, logger) {
24803
24847
  logger?.endSection();
24804
24848
  return contextIds;
24805
24849
  }
24850
+ function executePreprocessFiatStep(context, contextIds, logger) {
24851
+ const fiatCsvPaths = [];
24852
+ for (const contextId of contextIds) {
24853
+ const importCtx = loadContext(context.directory, contextId);
24854
+ if (importCtx.provider === "revolut" && importCtx.currency !== "btc") {
24855
+ fiatCsvPaths.push(path12.join(context.directory, importCtx.filePath));
24856
+ }
24857
+ }
24858
+ if (fiatCsvPaths.length === 0) {
24859
+ logger?.info("No revolut fiat CSV found, skipping fiat preprocessing");
24860
+ return;
24861
+ }
24862
+ logger?.startSection("Step 1a: Preprocess Fiat CSV");
24863
+ logger?.logStep("Fiat Preprocess", "start");
24864
+ try {
24865
+ let totalRows = 0;
24866
+ let totalEnriched = 0;
24867
+ let anyPreprocessed = true;
24868
+ for (const csvPath of fiatCsvPaths) {
24869
+ const result = preprocessRevolutFiatCsv(csvPath, logger);
24870
+ totalRows += result.rowsProcessed;
24871
+ totalEnriched += result.sendRowsEnriched;
24872
+ if (!result.alreadyPreprocessed)
24873
+ anyPreprocessed = false;
24874
+ }
24875
+ const message = anyPreprocessed && fiatCsvPaths.length > 0 && totalEnriched === 0 ? "Fiat CSV(s) already preprocessed" : `Preprocessed ${totalRows} rows (${totalEnriched} enriched with Net_Amount)`;
24876
+ logger?.logStep("Fiat Preprocess", "success", message);
24877
+ context.result.steps.fiatPreprocess = buildStepResult(true, message, {
24878
+ rowsProcessed: totalRows,
24879
+ enrichedRows: totalEnriched,
24880
+ alreadyPreprocessed: anyPreprocessed && fiatCsvPaths.length > 0
24881
+ });
24882
+ } catch (error45) {
24883
+ const errorMessage = `Fiat CSV preprocessing failed: ${error45 instanceof Error ? error45.message : String(error45)}`;
24884
+ logger?.error(errorMessage);
24885
+ logger?.logStep("Fiat Preprocess", "error", errorMessage);
24886
+ context.result.steps.fiatPreprocess = buildStepResult(false, errorMessage);
24887
+ context.result.error = errorMessage;
24888
+ context.result.hint = "Check the fiat CSV format \u2014 expected Revolut account statement";
24889
+ throw new Error(errorMessage);
24890
+ } finally {
24891
+ logger?.endSection();
24892
+ }
24893
+ }
24806
24894
  function executePreprocessBtcStep(context, contextIds, logger) {
24807
24895
  let btcCsvPath;
24808
24896
  for (const contextId of contextIds) {
@@ -25163,10 +25251,18 @@ async function importPipeline(directory, agent, options, configLoader = loadImpo
25163
25251
  logger.info("No files classified, nothing to import");
25164
25252
  return buildPipelineSuccessResult(result, "No files to import");
25165
25253
  }
25254
+ executePreprocessFiatStep(context, contextIds, logger);
25166
25255
  executePreprocessBtcStep(context, contextIds, logger);
25167
25256
  await executeBtcPurchaseStep(context, contextIds, logger);
25257
+ const orderedContextIds = [...contextIds].sort((a, b) => {
25258
+ const ctxA = loadContext(context.directory, a);
25259
+ const ctxB = loadContext(context.directory, b);
25260
+ const isBtcA = ctxA.provider === "revolut" && ctxA.currency === "btc" ? 1 : 0;
25261
+ const isBtcB = ctxB.provider === "revolut" && ctxB.currency === "btc" ? 1 : 0;
25262
+ return isBtcA - isBtcB;
25263
+ });
25168
25264
  let totalTransactions = 0;
25169
- for (const contextId of contextIds) {
25265
+ for (const contextId of orderedContextIds) {
25170
25266
  const importContext = loadContext(context.directory, contextId);
25171
25267
  logger.info(`Processing: ${importContext.filename} (${importContext.accountNumber || "unknown account"})`);
25172
25268
  try {
@@ -25251,7 +25347,7 @@ This tool orchestrates the full import workflow:
25251
25347
  }
25252
25348
  });
25253
25349
  // src/tools/init-directories.ts
25254
- import * as fs17 from "fs";
25350
+ import * as fs18 from "fs";
25255
25351
  import * as path13 from "path";
25256
25352
  async function initDirectories(directory) {
25257
25353
  try {
@@ -25259,8 +25355,8 @@ async function initDirectories(directory) {
25259
25355
  const directoriesCreated = [];
25260
25356
  const gitkeepFiles = [];
25261
25357
  const importBase = path13.join(directory, "import");
25262
- if (!fs17.existsSync(importBase)) {
25263
- fs17.mkdirSync(importBase, { recursive: true });
25358
+ if (!fs18.existsSync(importBase)) {
25359
+ fs18.mkdirSync(importBase, { recursive: true });
25264
25360
  directoriesCreated.push("import");
25265
25361
  }
25266
25362
  const pathsToCreate = [
@@ -25271,19 +25367,19 @@ async function initDirectories(directory) {
25271
25367
  ];
25272
25368
  for (const { path: dirPath } of pathsToCreate) {
25273
25369
  const fullPath = path13.join(directory, dirPath);
25274
- if (!fs17.existsSync(fullPath)) {
25275
- fs17.mkdirSync(fullPath, { recursive: true });
25370
+ if (!fs18.existsSync(fullPath)) {
25371
+ fs18.mkdirSync(fullPath, { recursive: true });
25276
25372
  directoriesCreated.push(dirPath);
25277
25373
  }
25278
25374
  const gitkeepPath = path13.join(fullPath, ".gitkeep");
25279
- if (!fs17.existsSync(gitkeepPath)) {
25280
- fs17.writeFileSync(gitkeepPath, "");
25375
+ if (!fs18.existsSync(gitkeepPath)) {
25376
+ fs18.writeFileSync(gitkeepPath, "");
25281
25377
  gitkeepFiles.push(path13.join(dirPath, ".gitkeep"));
25282
25378
  }
25283
25379
  }
25284
25380
  const gitignorePath = path13.join(importBase, ".gitignore");
25285
25381
  let gitignoreCreated = false;
25286
- if (!fs17.existsSync(gitignorePath)) {
25382
+ if (!fs18.existsSync(gitignorePath)) {
25287
25383
  const gitignoreContent = `# Ignore CSV/PDF files in temporary directories
25288
25384
  /incoming/*.csv
25289
25385
  /incoming/*.pdf
@@ -25301,7 +25397,7 @@ async function initDirectories(directory) {
25301
25397
  .DS_Store
25302
25398
  Thumbs.db
25303
25399
  `;
25304
- fs17.writeFileSync(gitignorePath, gitignoreContent);
25400
+ fs18.writeFileSync(gitignorePath, gitignoreContent);
25305
25401
  gitignoreCreated = true;
25306
25402
  }
25307
25403
  const parts = [];
@@ -25378,13 +25474,13 @@ You can now drop CSV files into import/incoming/ and run import-pipeline.`);
25378
25474
  });
25379
25475
  // src/tools/generate-btc-purchases.ts
25380
25476
  import * as path14 from "path";
25381
- import * as fs18 from "fs";
25477
+ import * as fs19 from "fs";
25382
25478
  function findFiatCsvPaths(directory, pendingDir, provider) {
25383
25479
  const providerDir = path14.join(directory, pendingDir, provider);
25384
- if (!fs18.existsSync(providerDir))
25480
+ if (!fs19.existsSync(providerDir))
25385
25481
  return [];
25386
25482
  const csvPaths = [];
25387
- const entries = fs18.readdirSync(providerDir, { withFileTypes: true });
25483
+ const entries = fs19.readdirSync(providerDir, { withFileTypes: true });
25388
25484
  for (const entry of entries) {
25389
25485
  if (!entry.isDirectory())
25390
25486
  continue;
@@ -9,13 +9,14 @@ This tool is **restricted to the accountant agent only**.
9
9
  The pipeline automates these 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
12
+ 1a. **Preprocess Fiat CSV** _(Revolut only)_ - Add computed `Net_Amount` column (`Amount - Fee`) for hledger rules
13
+ 1b. **Preprocess BTC CSV** _(Revolut only)_ - Add computed columns (fees in BTC, total, clean price) for hledger rules
14
+ 1c. **Generate BTC Purchases** _(Revolut only)_ - Cross-reference fiat + BTC CSVs for equity conversion entries
14
15
  2. **Account Declarations** - Ensure all accounts exist in year journals
15
16
  3. **Import** - Validate transactions, add to journals, move files to done
16
17
  4. **Reconcile** - Verify balances match expectations
17
18
 
18
- Steps 1a and 1b are Revolut-specific and are automatically skipped when no Revolut BTC CSV is present.
19
+ Steps 1a–1c are Revolut-specific and are automatically skipped when no matching Revolut CSVs are present.
19
20
 
20
21
  **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.
21
22
 
@@ -154,7 +155,24 @@ When reconciliation fails:
154
155
 
155
156
  See [classify-statements](classify-statements.md) for details.
156
157
 
157
- ### Step 1a: Preprocess BTC CSV
158
+ ### Step 1a: Preprocess Fiat CSV
159
+
160
+ **Purpose**: Add a computed `Net_Amount` column to Revolut fiat CSVs for use in hledger rules
161
+
162
+ **What happens**:
163
+
164
+ 1. Finds Revolut fiat contexts (provider=revolut, currency≠btc) from classification
165
+ 2. For each row, computes: `Net_Amount = Amount - Fee` (formatted to 2 decimal places)
166
+ 3. Appends `Net_Amount` column to the CSV (in-place)
167
+ 4. Idempotent — skips if CSV is already preprocessed
168
+
169
+ **Why**: Revolut fiat CSVs have a separate `Fee` column (e.g., weekend FX surcharge). The total bank deduction is `Amount - Fee`, but this value isn't in the CSV. hledger rules can't perform arithmetic, so the preprocessor computes it as a new column that rules can reference via `%net_amount`.
170
+
171
+ **Example**: Amount=`-45.32`, Fee=`0.45` → Net_Amount=`-45.77`
172
+
173
+ **Skipped when**: No Revolut fiat CSV is present among the classified files.
174
+
175
+ ### Step 1b: Preprocess BTC CSV
158
176
 
159
177
  **Purpose**: Add computed columns to Revolut BTC CSVs for use in hledger rules
160
178
 
@@ -169,7 +187,7 @@ See [classify-statements](classify-statements.md) for details.
169
187
 
170
188
  **Skipped when**: No Revolut BTC CSV is present among the classified files.
171
189
 
172
- ### Step 1b: Generate BTC Purchase Entries
190
+ ### Step 1c: Generate BTC Purchase Entries
173
191
 
174
192
  **Purpose**: Cross-reference Revolut fiat and BTC CSVs to generate equity conversion journal entries for BTC purchases
175
193
 
@@ -255,37 +273,43 @@ See [reconcile-statement](reconcile-statement.md) for details.
255
273
  ### Visual Flow
256
274
 
257
275
  ```
258
- ┌────────────────────────────────────────────────────────────────┐
259
- │ USER: Drop CSV files into import/incoming/
260
- └────────────────────────────────────────────────────────────────┘
276
+ ┌──────────────────────────────────────────────────────────────┐
277
+ │ USER: Drop CSV files into import/incoming/
278
+ └──────────────────────────────────────────────────────────────┘
279
+
280
+
281
+ ┌──────────────────────────────────────────────────────────────┐
282
+ │ STEP 1: classify-statements │
283
+ │ ┌────────────────────────────────────────────────────────┐ │
284
+ │ │ For each CSV: │ │
285
+ │ │ • Detect provider/currency │ │
286
+ │ │ • Extract metadata (account#, dates, balances) │ │
287
+ │ │ • Generate UUID │ │
288
+ │ │ • CREATE: .memory/{uuid}.json │ │
289
+ │ │ • Move: incoming/ → pending/{provider}/{currency}/ │ │
290
+ │ └────────────────────────────────────────────────────────┘ │
291
+ │ │
292
+ │ OUTPUT: ["uuid-1", "uuid-2"] │
293
+ └──────────────────────────────────────────────────────────────┘
261
294
 
262
295
 
263
- ┌────────────────────────────────────────────────────────────────┐
264
- │ STEP 1: classify-statements
265
- ┌──────────────────────────────────────────────────────────┐
266
- │ │ For each CSV: │ │
267
- │ │ • Detect provider/currency │ │
268
- │ │ • Extract metadata (account#, dates, balances) │ │
269
- │ │ • Generate UUID │ │
270
- │ │ • CREATE: .memory/{uuid}.json │ │
271
- │ │ • Move: incoming/ → pending/{provider}/{currency}/ │ │
272
- │ └──────────────────────────────────────────────────────────┘ │
273
- │ │
274
- │ OUTPUT: ["uuid-1", "uuid-2"] │
275
- └────────────────────────────────────────────────────────────────┘
296
+ ┌──────────────────────────────────────────────────────────────┐
297
+ │ STEP 1a: Preprocess Fiat CSV (if revolut fiat context)
298
+ Add Net_Amount column (Amount - Fee)
299
+ └──────────────────────────────────────────────────────────────┘
276
300
 
277
301
 
278
- ┌────────────────────────────────────────────────────────────────┐
279
- │ STEP 1a: Preprocess BTC CSV (if revolut/btc context exists)
280
- │ • Add Fees_BTC, Total_BTC, Price_Amount columns
281
- └────────────────────────────────────────────────────────────────┘
302
+ ┌──────────────────────────────────────────────────────────────┐
303
+ │ STEP 1b: Preprocess BTC CSV (if revolut/btc context)
304
+ │ • Add Fees_BTC, Total_BTC, Price_Amount columns
305
+ └──────────────────────────────────────────────────────────────┘
282
306
 
283
307
 
284
- ┌────────────────────────────────────────────────────────────────┐
285
- │ STEP 1b: Generate BTC Purchase Entries
286
- │ • Cross-reference fiat + BTC CSVs
287
- │ • Generate equity conversion journal entries
288
- └────────────────────────────────────────────────────────────────┘
308
+ ┌──────────────────────────────────────────────────────────────┐
309
+ │ STEP 1c: Generate BTC Purchase Entries
310
+ │ • Cross-reference fiat + BTC CSVs
311
+ │ • Generate equity conversion journal entries
312
+ └──────────────────────────────────────────────────────────────┘
289
313
 
290
314
 
291
315
  ┌───────────────────┴───────────────────┐
@@ -297,24 +321,23 @@ See [reconcile-statement](reconcile-statement.md) for details.
297
321
  └──────────┘ └──────────┘
298
322
  │ │
299
323
  ▼ │
300
- ┌────────────────────────────────────────┐
301
- │ STEPS 2-4 (for uuid-1):
302
- │ • Account Declarations
303
- │ • Dry Run
304
- │ • Import
305
- │ • Reconcile
306
- • UPDATE: .memory/uuid-1.json │ │
307
- └────────────────────────────────────────┘
308
- │ ✓ Success │
324
+ ┌──────────────────────────────────────┐
325
+ │ STEPS 2-4 (for uuid-1):
326
+ │ • Account Declarations
327
+ │ • Import
328
+ │ • Reconcile
329
+ │ • UPDATE: .memory/uuid-1.json
330
+ └──────────────────────────────────────┘
331
+ ✓ Success │
309
332
  │ │
310
333
  │ ▼
311
- ┌────────────────────────────────────────┐
312
- │ STEPS 2-4 (for uuid-2):
313
- │ • Account Declarations
314
- • Import
315
- │ • Reconcile
316
- │ • UPDATE: .memory/uuid-2.json
317
- └────────────────────────────────────────┘
334
+ ┌──────────────────────────────────────┐
335
+ │ STEPS 2-4 (for uuid-2):
336
+ │ • Account Declarations
337
+ │ • Import
338
+ │ • Reconcile
339
+ │ • UPDATE: .memory/uuid-2.json
340
+ └──────────────────────────────────────┘
318
341
  │ │ ✓ Success
319
342
  │ │
320
343
  ▼ ▼
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fuzzle/opencode-accountant",
3
- "version": "0.7.4",
3
+ "version": "0.8.0-next.1",
4
4
  "description": "An OpenCode accounting agent, specialized in double-entry-bookkepping with hledger",
5
5
  "author": {
6
6
  "name": "ali bengali",