@fuzzle/opencode-accountant 0.7.4-next.1 → 0.8.0

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 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 = [];
@@ -24736,6 +24736,54 @@ Got:
24736
24736
  return { rowsProcessed, sendRowsEnriched, alreadyPreprocessed: false };
24737
24737
  }
24738
24738
 
24739
+ // src/utils/revolutFiatCsvPreprocessor.ts
24740
+ import * as fs16 from "fs";
24741
+ var ORIGINAL_HEADER2 = "Type,Product,Started Date,Completed Date,Description,Amount,Fee,Currency,State,Balance";
24742
+ var PREPROCESSED_HEADER2 = "Type,Product,Started Date,Completed Date,Description,Amount,Fee,Currency,State,Balance,Net_Amount";
24743
+ function preprocessRevolutFiatCsv(csvPath, logger) {
24744
+ const content = fs16.readFileSync(csvPath, "utf-8");
24745
+ const lines = content.trim().split(`
24746
+ `);
24747
+ if (lines.length < 2) {
24748
+ return { rowsProcessed: 0, sendRowsEnriched: 0, alreadyPreprocessed: false };
24749
+ }
24750
+ const header = lines[0].trim();
24751
+ if (header === PREPROCESSED_HEADER2) {
24752
+ logger?.info("CSV already preprocessed, skipping");
24753
+ return { rowsProcessed: lines.length - 1, sendRowsEnriched: 0, alreadyPreprocessed: true };
24754
+ }
24755
+ if (header !== ORIGINAL_HEADER2) {
24756
+ throw new Error(`Unexpected CSV header. Expected:
24757
+ ${ORIGINAL_HEADER2}
24758
+ Got:
24759
+ ${header}`);
24760
+ }
24761
+ const outputLines = [PREPROCESSED_HEADER2];
24762
+ let enrichedRows = 0;
24763
+ for (let i2 = 1;i2 < lines.length; i2++) {
24764
+ const line = lines[i2];
24765
+ if (line.trim() === "")
24766
+ continue;
24767
+ const fields = line.split(",");
24768
+ if (fields.length < 7) {
24769
+ outputLines.push(line + ",");
24770
+ continue;
24771
+ }
24772
+ const amount = parseFloat(fields[5]);
24773
+ const fee = parseFloat(fields[6]);
24774
+ const netAmount = amount - fee;
24775
+ outputLines.push(`${line},${netAmount.toFixed(2)}`);
24776
+ enrichedRows++;
24777
+ }
24778
+ const outputContent = outputLines.join(`
24779
+ `) + `
24780
+ `;
24781
+ fs16.writeFileSync(csvPath, outputContent);
24782
+ const rowsProcessed = outputLines.length - 1;
24783
+ logger?.info(`Preprocessed ${rowsProcessed} rows (${enrichedRows} rows enriched with Net_Amount)`);
24784
+ return { rowsProcessed, sendRowsEnriched: enrichedRows, alreadyPreprocessed: false };
24785
+ }
24786
+
24739
24787
  // src/tools/import-pipeline.ts
24740
24788
  class NoTransactionsError extends Error {
24741
24789
  constructor() {
@@ -24803,6 +24851,50 @@ async function executeClassifyStep(context, logger) {
24803
24851
  logger?.endSection();
24804
24852
  return contextIds;
24805
24853
  }
24854
+ function executePreprocessFiatStep(context, contextIds, logger) {
24855
+ const fiatCsvPaths = [];
24856
+ for (const contextId of contextIds) {
24857
+ const importCtx = loadContext(context.directory, contextId);
24858
+ if (importCtx.provider === "revolut" && importCtx.currency !== "btc") {
24859
+ fiatCsvPaths.push(path12.join(context.directory, importCtx.filePath));
24860
+ }
24861
+ }
24862
+ if (fiatCsvPaths.length === 0) {
24863
+ logger?.info("No revolut fiat CSV found, skipping fiat preprocessing");
24864
+ return;
24865
+ }
24866
+ logger?.startSection("Step 1a: Preprocess Fiat CSV");
24867
+ logger?.logStep("Fiat Preprocess", "start");
24868
+ try {
24869
+ let totalRows = 0;
24870
+ let totalEnriched = 0;
24871
+ let anyPreprocessed = true;
24872
+ for (const csvPath of fiatCsvPaths) {
24873
+ const result = preprocessRevolutFiatCsv(csvPath, logger);
24874
+ totalRows += result.rowsProcessed;
24875
+ totalEnriched += result.sendRowsEnriched;
24876
+ if (!result.alreadyPreprocessed)
24877
+ anyPreprocessed = false;
24878
+ }
24879
+ const message = anyPreprocessed && fiatCsvPaths.length > 0 && totalEnriched === 0 ? "Fiat CSV(s) already preprocessed" : `Preprocessed ${totalRows} rows (${totalEnriched} enriched with Net_Amount)`;
24880
+ logger?.logStep("Fiat Preprocess", "success", message);
24881
+ context.result.steps.fiatPreprocess = buildStepResult(true, message, {
24882
+ rowsProcessed: totalRows,
24883
+ enrichedRows: totalEnriched,
24884
+ alreadyPreprocessed: anyPreprocessed && fiatCsvPaths.length > 0
24885
+ });
24886
+ } catch (error45) {
24887
+ const errorMessage = `Fiat CSV preprocessing failed: ${error45 instanceof Error ? error45.message : String(error45)}`;
24888
+ logger?.error(errorMessage);
24889
+ logger?.logStep("Fiat Preprocess", "error", errorMessage);
24890
+ context.result.steps.fiatPreprocess = buildStepResult(false, errorMessage);
24891
+ context.result.error = errorMessage;
24892
+ context.result.hint = "Check the fiat CSV format \u2014 expected Revolut account statement";
24893
+ throw new Error(errorMessage);
24894
+ } finally {
24895
+ logger?.endSection();
24896
+ }
24897
+ }
24806
24898
  function executePreprocessBtcStep(context, contextIds, logger) {
24807
24899
  let btcCsvPath;
24808
24900
  for (const contextId of contextIds) {
@@ -25163,6 +25255,7 @@ async function importPipeline(directory, agent, options, configLoader = loadImpo
25163
25255
  logger.info("No files classified, nothing to import");
25164
25256
  return buildPipelineSuccessResult(result, "No files to import");
25165
25257
  }
25258
+ executePreprocessFiatStep(context, contextIds, logger);
25166
25259
  executePreprocessBtcStep(context, contextIds, logger);
25167
25260
  await executeBtcPurchaseStep(context, contextIds, logger);
25168
25261
  const orderedContextIds = [...contextIds].sort((a, b) => {
@@ -25258,7 +25351,7 @@ This tool orchestrates the full import workflow:
25258
25351
  }
25259
25352
  });
25260
25353
  // src/tools/init-directories.ts
25261
- import * as fs17 from "fs";
25354
+ import * as fs18 from "fs";
25262
25355
  import * as path13 from "path";
25263
25356
  async function initDirectories(directory) {
25264
25357
  try {
@@ -25266,8 +25359,8 @@ async function initDirectories(directory) {
25266
25359
  const directoriesCreated = [];
25267
25360
  const gitkeepFiles = [];
25268
25361
  const importBase = path13.join(directory, "import");
25269
- if (!fs17.existsSync(importBase)) {
25270
- fs17.mkdirSync(importBase, { recursive: true });
25362
+ if (!fs18.existsSync(importBase)) {
25363
+ fs18.mkdirSync(importBase, { recursive: true });
25271
25364
  directoriesCreated.push("import");
25272
25365
  }
25273
25366
  const pathsToCreate = [
@@ -25278,19 +25371,19 @@ async function initDirectories(directory) {
25278
25371
  ];
25279
25372
  for (const { path: dirPath } of pathsToCreate) {
25280
25373
  const fullPath = path13.join(directory, dirPath);
25281
- if (!fs17.existsSync(fullPath)) {
25282
- fs17.mkdirSync(fullPath, { recursive: true });
25374
+ if (!fs18.existsSync(fullPath)) {
25375
+ fs18.mkdirSync(fullPath, { recursive: true });
25283
25376
  directoriesCreated.push(dirPath);
25284
25377
  }
25285
25378
  const gitkeepPath = path13.join(fullPath, ".gitkeep");
25286
- if (!fs17.existsSync(gitkeepPath)) {
25287
- fs17.writeFileSync(gitkeepPath, "");
25379
+ if (!fs18.existsSync(gitkeepPath)) {
25380
+ fs18.writeFileSync(gitkeepPath, "");
25288
25381
  gitkeepFiles.push(path13.join(dirPath, ".gitkeep"));
25289
25382
  }
25290
25383
  }
25291
25384
  const gitignorePath = path13.join(importBase, ".gitignore");
25292
25385
  let gitignoreCreated = false;
25293
- if (!fs17.existsSync(gitignorePath)) {
25386
+ if (!fs18.existsSync(gitignorePath)) {
25294
25387
  const gitignoreContent = `# Ignore CSV/PDF files in temporary directories
25295
25388
  /incoming/*.csv
25296
25389
  /incoming/*.pdf
@@ -25308,7 +25401,7 @@ async function initDirectories(directory) {
25308
25401
  .DS_Store
25309
25402
  Thumbs.db
25310
25403
  `;
25311
- fs17.writeFileSync(gitignorePath, gitignoreContent);
25404
+ fs18.writeFileSync(gitignorePath, gitignoreContent);
25312
25405
  gitignoreCreated = true;
25313
25406
  }
25314
25407
  const parts = [];
@@ -25385,13 +25478,13 @@ You can now drop CSV files into import/incoming/ and run import-pipeline.`);
25385
25478
  });
25386
25479
  // src/tools/generate-btc-purchases.ts
25387
25480
  import * as path14 from "path";
25388
- import * as fs18 from "fs";
25481
+ import * as fs19 from "fs";
25389
25482
  function findFiatCsvPaths(directory, pendingDir, provider) {
25390
25483
  const providerDir = path14.join(directory, pendingDir, provider);
25391
- if (!fs18.existsSync(providerDir))
25484
+ if (!fs19.existsSync(providerDir))
25392
25485
  return [];
25393
25486
  const csvPaths = [];
25394
- const entries = fs18.readdirSync(providerDir, { withFileTypes: true });
25487
+ const entries = fs19.readdirSync(providerDir, { withFileTypes: true });
25395
25488
  for (const entry of entries) {
25396
25489
  if (!entry.isDirectory())
25397
25490
  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-next.1",
3
+ "version": "0.8.0",
4
4
  "description": "An OpenCode accounting agent, specialized in double-entry-bookkepping with hledger",
5
5
  "author": {
6
6
  "name": "ali bengali",