@fuzzle/opencode-accountant 0.3.0 → 0.4.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/README.md CHANGED
@@ -201,9 +201,10 @@ The `import-pipeline` tool provides an atomic, safe import workflow using git wo
201
201
  - Creates an isolated git worktree
202
202
  - Syncs CSV files from main repo to worktree
203
203
  - Classifies CSV files by provider/currency
204
+ - Extracts accounts from rules and creates declarations in year journal
204
205
  - Validates all transactions have matching rules
205
206
  - Imports transactions to the appropriate year journal
206
- - Reconciles closing balance (if available in CSV metadata)
207
+ - Reconciles closing balance (auto-detected from CSV metadata or data analysis)
207
208
  - Merges changes back to main branch with `--no-ff`
208
209
  - Deletes processed CSV files from main repo's import/incoming
209
210
  - Cleans up the worktree
@@ -237,13 +238,30 @@ The tool will use that rules file when processing `transactions.csv`.
237
238
 
238
239
  See the hledger documentation for details on rules file format and syntax.
239
240
 
241
+ #### Account Declarations
242
+
243
+ The pipeline automatically manages account declarations:
244
+
245
+ - Scans rules files matched to the CSVs being imported
246
+ - Extracts all accounts (account1 and account2 directives)
247
+ - Creates or updates year journal files with sorted account declarations
248
+ - Ensures `hledger check --strict` validation passes
249
+
250
+ **No manual account setup required.** Account declarations are created proactively before import attempts.
251
+
240
252
  #### Unknown Postings
241
253
 
242
254
  When a transaction doesn't match any `if` pattern in the rules file, hledger assigns it to `income:unknown` or `expenses:unknown` depending on the transaction direction. The pipeline will fail at the validation step, reporting the unknown postings so you can add appropriate rules before retrying.
243
255
 
244
256
  #### Closing Balance Reconciliation
245
257
 
246
- For providers that include closing balance in CSV metadata (e.g., UBS), the tool automatically validates that the imported transactions result in the correct balance. Configure metadata extraction in `providers.yaml`:
258
+ The import pipeline automatically detects closing balance using the following fallback chain:
259
+
260
+ 1. **CSV Metadata Extraction**: For providers with closing balance in metadata (e.g., UBS)
261
+ 2. **CSV Data Analysis**: Extracts balance from the last transaction row if metadata unavailable
262
+ 3. **Manual Override**: Use the `closingBalance` parameter when automatic detection fails
263
+
264
+ Configure metadata extraction in `providers.yaml`:
247
265
 
248
266
  ```yaml
249
267
  metadata:
@@ -258,7 +276,10 @@ metadata:
258
276
  column: 1
259
277
  ```
260
278
 
261
- For providers without closing balance in metadata (e.g., Revolut), provide it manually via the `closingBalance` argument.
279
+ **Note:** For most CSV formats, the closing balance will be detected automatically. Manual override is only needed when:
280
+
281
+ - CSV has no balance information in metadata or data
282
+ - The auto-detected balance has low confidence
262
283
 
263
284
  ## Development
264
285
 
@@ -89,9 +89,10 @@ The `import-pipeline` tool provides an **atomic, safe workflow** using git workt
89
89
  3. **Automatic Processing**: The tool creates an isolated git worktree and:
90
90
  - Syncs CSV files from main repo to worktree
91
91
  - Classifies CSV files by provider/currency
92
+ - Extracts required accounts from rules files and updates year journal
92
93
  - Validates all transactions have matching rules
93
94
  - Imports transactions to the appropriate year journal
94
- - Reconciles closing balance (if available in CSV metadata)
95
+ - Reconciles closing balance (auto-detected from CSV metadata or data, or manual override)
95
96
  - Merges changes back to main branch with `--no-ff`
96
97
  - Deletes processed CSV files from main repo's import/incoming
97
98
  - Cleans up the worktree
@@ -108,6 +109,25 @@ The `import-pipeline` tool provides an **atomic, safe workflow** using git workt
108
109
  - Use field names from the `fields` directive for matching
109
110
  - Unknown account pattern: `income:unknown` (positive amounts) / `expenses:unknown` (negative amounts)
110
111
 
112
+ ### Automatic Account Declarations
113
+
114
+ The import pipeline automatically:
115
+
116
+ - Scans matched rules files for all account references (account1, account2 directives)
117
+ - Creates/updates year journal files (e.g., ledger/2026.journal) with sorted account declarations
118
+ - Prevents `hledger check --strict` failures due to missing account declarations
119
+ - No manual account setup required
120
+
121
+ ### Automatic Balance Detection
122
+
123
+ The reconciliation step attempts to extract closing balance from:
124
+
125
+ 1. CSV header metadata (e.g., UBS "Closing balance:" row)
126
+ 2. CSV data analysis (balance field in last transaction row)
127
+ 3. Manual override via `closingBalance` parameter (fallback)
128
+
129
+ For most providers, manual balance input is no longer required.
130
+
111
131
  ## Tool Usage Reference
112
132
 
113
133
  The following are MCP tools available to you. Always call these tools directly - do not attempt to replicate their behavior with shell commands.
@@ -138,12 +158,13 @@ The following are MCP tools available to you. Always call these tools directly -
138
158
  1. Creates isolated git worktree
139
159
  2. Syncs CSV files from main repo to worktree
140
160
  3. Classifies CSV files (unless `skipClassify: true`)
141
- 4. Validates all transactions have matching rules (dry run)
142
- 5. Imports transactions to year journal
143
- 6. Reconciles closing balance against CSV metadata or manual value
144
- 7. Merges to main with `--no-ff` commit
145
- 8. Deletes processed CSV files from main repo's import/incoming
146
- 9. Cleans up worktree
161
+ 4. Extracts accounts from matched rules and updates year journal with declarations
162
+ 5. Validates all transactions have matching rules (dry run)
163
+ 6. Imports transactions to year journal
164
+ 7. Reconciles closing balance (auto-detected from CSV metadata/data or manual override)
165
+ 8. Merges to main with `--no-ff` commit
166
+ 9. Deletes processed CSV files from main repo's import/incoming
167
+ 10. Cleans up worktree
147
168
 
148
169
  **Output:** Returns step-by-step results with success/failure for each phase
149
170
 
package/dist/index.js CHANGED
@@ -24191,12 +24191,6 @@ function buildErrorResult4(params) {
24191
24191
  ...params
24192
24192
  });
24193
24193
  }
24194
- function buildSuccessResult4(params) {
24195
- return JSON.stringify({
24196
- success: true,
24197
- ...params
24198
- });
24199
- }
24200
24194
  function validateWorktree(directory, worktreeChecker) {
24201
24195
  if (!worktreeChecker(directory)) {
24202
24196
  return buildErrorResult4({
@@ -24235,7 +24229,7 @@ function findCsvToReconcile(doneDir, options) {
24235
24229
  const relativePath = path11.relative(path11.dirname(path11.dirname(doneDir)), csvFile);
24236
24230
  return { csvFile, relativePath };
24237
24231
  }
24238
- function determineClosingBalance(csvFile, config2, options, relativeCsvPath) {
24232
+ function determineClosingBalance(csvFile, config2, options, relativeCsvPath, rulesDir) {
24239
24233
  let metadata;
24240
24234
  try {
24241
24235
  const content = fs11.readFileSync(csvFile, "utf-8");
@@ -24254,17 +24248,41 @@ function determineClosingBalance(csvFile, config2, options, relativeCsvPath) {
24254
24248
  }
24255
24249
  }
24256
24250
  if (!closingBalance) {
24251
+ const csvAnalysis = tryExtractClosingBalanceFromCSV(csvFile, rulesDir);
24252
+ if (csvAnalysis && csvAnalysis.confidence === "high") {
24253
+ closingBalance = csvAnalysis.balance;
24254
+ return { closingBalance, metadata, fromCSVAnalysis: true };
24255
+ }
24256
+ }
24257
+ if (!closingBalance) {
24258
+ const retryCmd = buildRetryCommand(options, "CHF 2324.79", options.account);
24257
24259
  return {
24258
24260
  error: buildErrorResult4({
24259
24261
  csvFile: relativeCsvPath,
24260
- error: "No closing balance found in CSV metadata",
24261
- hint: "Provide closingBalance parameter manually",
24262
+ error: "No closing balance found in CSV metadata or data",
24263
+ hint: `Provide closingBalance parameter manually. Example retry: ${retryCmd}`,
24262
24264
  metadata
24263
24265
  })
24264
24266
  };
24265
24267
  }
24266
24268
  return { closingBalance, metadata };
24267
24269
  }
24270
+ function buildRetryCommand(options, closingBalance, account) {
24271
+ const parts = ["import-pipeline"];
24272
+ if (options.provider) {
24273
+ parts.push(`--provider ${options.provider}`);
24274
+ }
24275
+ if (options.currency) {
24276
+ parts.push(`--currency ${options.currency}`);
24277
+ }
24278
+ if (closingBalance) {
24279
+ parts.push(`--closingBalance "${closingBalance}"`);
24280
+ }
24281
+ if (account) {
24282
+ parts.push(`--account "${account}"`);
24283
+ }
24284
+ return parts.join(" ");
24285
+ }
24268
24286
  function determineAccount(csvFile, rulesDir, options, relativeCsvPath, metadata) {
24269
24287
  let account = options.account;
24270
24288
  if (!account) {
@@ -24277,7 +24295,7 @@ function determineAccount(csvFile, rulesDir, options, relativeCsvPath, metadata)
24277
24295
  if (!account) {
24278
24296
  const rulesMapping = loadRulesMapping(rulesDir);
24279
24297
  const rulesFile = findRulesForCsv(csvFile, rulesMapping);
24280
- const rulesHint = rulesFile ? `Add 'account1 assets:bank:...' to ${rulesFile} or use --account parameter` : `Create a rules file in ${rulesDir} with 'account1' directive or use --account parameter`;
24298
+ const rulesHint = rulesFile ? `Add 'account1 assets:bank:...' to ${rulesFile} or retry with: ${buildRetryCommand(options, undefined, "assets:bank:...")}` : `Create a rules file in ${rulesDir} with 'account1' directive or retry with: ${buildRetryCommand(options, undefined, "assets:bank:...")}`;
24281
24299
  return {
24282
24300
  error: buildErrorResult4({
24283
24301
  csvFile: relativeCsvPath,
@@ -24289,6 +24307,70 @@ function determineAccount(csvFile, rulesDir, options, relativeCsvPath, metadata)
24289
24307
  }
24290
24308
  return { account };
24291
24309
  }
24310
+ function tryExtractClosingBalanceFromCSV(csvFile, rulesDir) {
24311
+ try {
24312
+ const rulesMapping = loadRulesMapping(rulesDir);
24313
+ const rulesFile = findRulesForCsv(csvFile, rulesMapping);
24314
+ if (!rulesFile) {
24315
+ return null;
24316
+ }
24317
+ const rulesContent = fs11.readFileSync(rulesFile, "utf-8");
24318
+ const rulesConfig = parseRulesFile(rulesContent);
24319
+ const csvRows = parseCsvFile(csvFile, rulesConfig);
24320
+ if (csvRows.length === 0) {
24321
+ return null;
24322
+ }
24323
+ const balanceFieldNames = [
24324
+ "balance",
24325
+ "Balance",
24326
+ "BALANCE",
24327
+ "closing_balance",
24328
+ "Closing Balance",
24329
+ "account_balance",
24330
+ "Account Balance",
24331
+ "saldo",
24332
+ "Saldo",
24333
+ "SALDO"
24334
+ ];
24335
+ const lastRow = csvRows[csvRows.length - 1];
24336
+ let balanceField;
24337
+ let balanceValue;
24338
+ for (const fieldName of balanceFieldNames) {
24339
+ if (lastRow[fieldName] !== undefined && lastRow[fieldName].trim() !== "") {
24340
+ balanceField = fieldName;
24341
+ balanceValue = lastRow[fieldName];
24342
+ break;
24343
+ }
24344
+ }
24345
+ if (balanceValue && balanceField) {
24346
+ const numericValue = parseAmountValue(balanceValue);
24347
+ let currency = "";
24348
+ const balanceCurrencyMatch = balanceValue.match(/[A-Z]{3}/);
24349
+ if (balanceCurrencyMatch) {
24350
+ currency = balanceCurrencyMatch[0];
24351
+ }
24352
+ if (!currency) {
24353
+ const amountField = rulesConfig.amountFields.single || rulesConfig.amountFields.credit || rulesConfig.amountFields.debit;
24354
+ if (amountField) {
24355
+ const amountStr = lastRow[amountField] || "";
24356
+ const currencyMatch = amountStr.match(/[A-Z]{3}/);
24357
+ if (currencyMatch) {
24358
+ currency = currencyMatch[0];
24359
+ }
24360
+ }
24361
+ }
24362
+ const balanceStr = currency ? `${currency} ${numericValue.toFixed(2)}` : numericValue.toFixed(2);
24363
+ return {
24364
+ balance: balanceStr,
24365
+ confidence: "high",
24366
+ method: `Extracted from ${balanceField} field in last CSV row`
24367
+ };
24368
+ }
24369
+ return null;
24370
+ } catch {
24371
+ return null;
24372
+ }
24373
+ }
24292
24374
  async function reconcileStatement(directory, agent, options, configLoader = loadImportConfig, hledgerExecutor = defaultHledgerExecutor, worktreeChecker = isInWorktree) {
24293
24375
  const restrictionError = checkAccountantAgent(agent, "reconcile statement");
24294
24376
  if (restrictionError) {
@@ -24311,11 +24393,11 @@ async function reconcileStatement(directory, agent, options, configLoader = load
24311
24393
  return csvResult.error;
24312
24394
  }
24313
24395
  const { csvFile, relativePath: relativeCsvPath } = csvResult;
24314
- const balanceResult = determineClosingBalance(csvFile, config2, options, relativeCsvPath);
24396
+ const balanceResult = determineClosingBalance(csvFile, config2, options, relativeCsvPath, rulesDir);
24315
24397
  if ("error" in balanceResult) {
24316
24398
  return balanceResult.error;
24317
24399
  }
24318
- const { closingBalance, metadata } = balanceResult;
24400
+ const { closingBalance, metadata, fromCSVAnalysis } = balanceResult;
24319
24401
  const accountResult = determineAccount(csvFile, rulesDir, options, relativeCsvPath, metadata);
24320
24402
  if ("error" in accountResult) {
24321
24403
  return accountResult.error;
@@ -24357,14 +24439,19 @@ async function reconcileStatement(directory, agent, options, configLoader = load
24357
24439
  });
24358
24440
  }
24359
24441
  if (doBalancesMatch) {
24360
- return buildSuccessResult4({
24442
+ const result = {
24443
+ success: true,
24361
24444
  csvFile: relativeCsvPath,
24362
24445
  account,
24363
24446
  lastTransactionDate,
24364
24447
  expectedBalance: closingBalance,
24365
24448
  actualBalance,
24366
24449
  metadata
24367
- });
24450
+ };
24451
+ if (fromCSVAnalysis) {
24452
+ result.note = `Closing balance auto-detected from CSV data (no metadata available). Account: ${account}`;
24453
+ }
24454
+ return JSON.stringify(result);
24368
24455
  }
24369
24456
  let difference;
24370
24457
  try {
@@ -24429,8 +24516,125 @@ It must be run inside an import worktree (use import-pipeline for the full workf
24429
24516
  }
24430
24517
  });
24431
24518
  // src/tools/import-pipeline.ts
24432
- import * as fs12 from "fs";
24519
+ import * as fs13 from "fs";
24433
24520
  import * as path12 from "path";
24521
+
24522
+ // src/utils/accountDeclarations.ts
24523
+ import * as fs12 from "fs";
24524
+ function extractAccountsFromRulesFile(rulesPath) {
24525
+ const accounts = new Set;
24526
+ if (!fs12.existsSync(rulesPath)) {
24527
+ return accounts;
24528
+ }
24529
+ const content = fs12.readFileSync(rulesPath, "utf-8");
24530
+ const lines = content.split(`
24531
+ `);
24532
+ for (const line of lines) {
24533
+ const trimmed = line.trim();
24534
+ if (trimmed.startsWith("#") || trimmed.startsWith(";") || trimmed === "") {
24535
+ continue;
24536
+ }
24537
+ const account1Match = trimmed.match(/^account1\s+(.+?)(?:\s+|$)/);
24538
+ if (account1Match) {
24539
+ accounts.add(account1Match[1].trim());
24540
+ continue;
24541
+ }
24542
+ const account2Match = trimmed.match(/account2\s+(.+?)(?:\s+|$)/);
24543
+ if (account2Match) {
24544
+ accounts.add(account2Match[1].trim());
24545
+ continue;
24546
+ }
24547
+ }
24548
+ return accounts;
24549
+ }
24550
+ function getAllAccountsFromRules(rulesPaths) {
24551
+ const allAccounts = new Set;
24552
+ for (const rulesPath of rulesPaths) {
24553
+ const accounts = extractAccountsFromRulesFile(rulesPath);
24554
+ for (const account of accounts) {
24555
+ allAccounts.add(account);
24556
+ }
24557
+ }
24558
+ return allAccounts;
24559
+ }
24560
+ function sortAccountDeclarations(accounts) {
24561
+ return Array.from(accounts).sort((a, b) => a.localeCompare(b));
24562
+ }
24563
+ function ensureAccountDeclarations(yearJournalPath, accounts) {
24564
+ if (!fs12.existsSync(yearJournalPath)) {
24565
+ throw new Error(`Year journal not found: ${yearJournalPath}`);
24566
+ }
24567
+ const content = fs12.readFileSync(yearJournalPath, "utf-8");
24568
+ const lines = content.split(`
24569
+ `);
24570
+ const existingAccounts = new Set;
24571
+ const commentLines = [];
24572
+ const accountLines = [];
24573
+ const otherLines = [];
24574
+ let inAccountSection = false;
24575
+ let accountSectionEnded = false;
24576
+ for (const line of lines) {
24577
+ const trimmed = line.trim();
24578
+ if (trimmed.startsWith(";") || trimmed.startsWith("#")) {
24579
+ if (!accountSectionEnded) {
24580
+ commentLines.push(line);
24581
+ } else {
24582
+ otherLines.push(line);
24583
+ }
24584
+ continue;
24585
+ }
24586
+ if (trimmed.startsWith("account ")) {
24587
+ inAccountSection = true;
24588
+ const accountMatch = trimmed.match(/^account\s+(.+?)(?:\s+|$)/);
24589
+ if (accountMatch) {
24590
+ const accountName = accountMatch[1].trim();
24591
+ existingAccounts.add(accountName);
24592
+ accountLines.push(line);
24593
+ }
24594
+ continue;
24595
+ }
24596
+ if (trimmed === "") {
24597
+ if (inAccountSection && !accountSectionEnded) {
24598
+ accountLines.push(line);
24599
+ } else {
24600
+ otherLines.push(line);
24601
+ }
24602
+ continue;
24603
+ }
24604
+ if (inAccountSection) {
24605
+ accountSectionEnded = true;
24606
+ }
24607
+ otherLines.push(line);
24608
+ }
24609
+ const missingAccounts = new Set;
24610
+ for (const account of accounts) {
24611
+ if (!existingAccounts.has(account)) {
24612
+ missingAccounts.add(account);
24613
+ }
24614
+ }
24615
+ if (missingAccounts.size === 0) {
24616
+ return { added: [], updated: false };
24617
+ }
24618
+ const allAccounts = new Set([...existingAccounts, ...missingAccounts]);
24619
+ const sortedAccounts = sortAccountDeclarations(allAccounts);
24620
+ const newAccountLines = sortedAccounts.map((account) => `account ${account}`);
24621
+ const newContent = [];
24622
+ newContent.push(...commentLines);
24623
+ if (newAccountLines.length > 0) {
24624
+ newContent.push("");
24625
+ newContent.push(...newAccountLines);
24626
+ newContent.push("");
24627
+ }
24628
+ newContent.push(...otherLines);
24629
+ fs12.writeFileSync(yearJournalPath, newContent.join(`
24630
+ `));
24631
+ return {
24632
+ added: Array.from(missingAccounts).sort(),
24633
+ updated: true
24634
+ };
24635
+ }
24636
+
24637
+ // src/tools/import-pipeline.ts
24434
24638
  class NoTransactionsError extends Error {
24435
24639
  constructor() {
24436
24640
  super("No transactions to import");
@@ -24444,7 +24648,7 @@ function buildStepResult(success2, message, details) {
24444
24648
  }
24445
24649
  return result;
24446
24650
  }
24447
- function buildSuccessResult5(result, summary) {
24651
+ function buildSuccessResult4(result, summary) {
24448
24652
  result.success = true;
24449
24653
  result.summary = summary;
24450
24654
  return JSON.stringify(result);
@@ -24470,7 +24674,7 @@ function buildCommitMessage(provider, currency, fromDate, untilDate, transaction
24470
24674
  }
24471
24675
  function cleanupIncomingFiles(worktree, context) {
24472
24676
  const incomingDir = path12.join(worktree.mainRepoPath, "import/incoming");
24473
- if (!fs12.existsSync(incomingDir)) {
24677
+ if (!fs13.existsSync(incomingDir)) {
24474
24678
  return;
24475
24679
  }
24476
24680
  const importStep = context.result.steps.import;
@@ -24487,9 +24691,9 @@ function cleanupIncomingFiles(worktree, context) {
24487
24691
  continue;
24488
24692
  const filename = path12.basename(fileResult.csv);
24489
24693
  const filePath = path12.join(incomingDir, filename);
24490
- if (fs12.existsSync(filePath)) {
24694
+ if (fs13.existsSync(filePath)) {
24491
24695
  try {
24492
- fs12.unlinkSync(filePath);
24696
+ fs13.unlinkSync(filePath);
24493
24697
  deletedCount++;
24494
24698
  } catch (error45) {
24495
24699
  console.error(`[ERROR] Failed to delete ${filename}: ${error45 instanceof Error ? error45.message : String(error45)}`);
@@ -24520,6 +24724,86 @@ async function executeClassifyStep(context, worktree) {
24520
24724
  };
24521
24725
  context.result.steps.classify = buildStepResult(success2, message, details);
24522
24726
  }
24727
+ async function executeAccountDeclarationsStep(context, worktree) {
24728
+ const config2 = context.configLoader(worktree.path);
24729
+ const pendingDir = path12.join(worktree.path, config2.paths.pending);
24730
+ const rulesDir = path12.join(worktree.path, config2.paths.rules);
24731
+ const csvFiles = findCsvFiles(pendingDir, context.options.provider, context.options.currency);
24732
+ if (csvFiles.length === 0) {
24733
+ context.result.steps.accountDeclarations = buildStepResult(true, "No CSV files to process", {
24734
+ accountsAdded: [],
24735
+ journalUpdated: "",
24736
+ rulesScanned: []
24737
+ });
24738
+ return;
24739
+ }
24740
+ const rulesMapping = loadRulesMapping(rulesDir);
24741
+ const matchedRulesFiles = new Set;
24742
+ for (const csvFile of csvFiles) {
24743
+ const rulesFile = findRulesForCsv(csvFile, rulesMapping);
24744
+ if (rulesFile) {
24745
+ matchedRulesFiles.add(rulesFile);
24746
+ }
24747
+ }
24748
+ if (matchedRulesFiles.size === 0) {
24749
+ context.result.steps.accountDeclarations = buildStepResult(true, "No matching rules files found", {
24750
+ accountsAdded: [],
24751
+ journalUpdated: "",
24752
+ rulesScanned: []
24753
+ });
24754
+ return;
24755
+ }
24756
+ const allAccounts = getAllAccountsFromRules(Array.from(matchedRulesFiles));
24757
+ if (allAccounts.size === 0) {
24758
+ context.result.steps.accountDeclarations = buildStepResult(true, "No accounts found in rules files", {
24759
+ accountsAdded: [],
24760
+ journalUpdated: "",
24761
+ rulesScanned: Array.from(matchedRulesFiles).map((f) => path12.relative(worktree.path, f))
24762
+ });
24763
+ return;
24764
+ }
24765
+ let transactionYear;
24766
+ for (const rulesFile of matchedRulesFiles) {
24767
+ try {
24768
+ const result2 = await context.hledgerExecutor(["print", "-f", rulesFile]);
24769
+ if (result2.exitCode === 0) {
24770
+ const years = extractTransactionYears(result2.stdout);
24771
+ if (years.size > 0) {
24772
+ transactionYear = Array.from(years)[0];
24773
+ break;
24774
+ }
24775
+ }
24776
+ } catch {
24777
+ continue;
24778
+ }
24779
+ }
24780
+ if (!transactionYear) {
24781
+ context.result.steps.accountDeclarations = buildStepResult(false, "Could not determine transaction year from CSV files", {
24782
+ accountsAdded: [],
24783
+ journalUpdated: "",
24784
+ rulesScanned: Array.from(matchedRulesFiles).map((f) => path12.relative(worktree.path, f))
24785
+ });
24786
+ return;
24787
+ }
24788
+ let yearJournalPath;
24789
+ try {
24790
+ yearJournalPath = ensureYearJournalExists(worktree.path, transactionYear);
24791
+ } catch (error45) {
24792
+ context.result.steps.accountDeclarations = buildStepResult(false, `Failed to create year journal: ${error45 instanceof Error ? error45.message : String(error45)}`, {
24793
+ accountsAdded: [],
24794
+ journalUpdated: "",
24795
+ rulesScanned: Array.from(matchedRulesFiles).map((f) => path12.relative(worktree.path, f))
24796
+ });
24797
+ return;
24798
+ }
24799
+ const result = ensureAccountDeclarations(yearJournalPath, allAccounts);
24800
+ const message = result.added.length > 0 ? `Added ${result.added.length} account declaration(s) to ${path12.relative(worktree.path, yearJournalPath)}` : "All required accounts already declared";
24801
+ context.result.steps.accountDeclarations = buildStepResult(true, message, {
24802
+ accountsAdded: result.added,
24803
+ journalUpdated: path12.relative(worktree.path, yearJournalPath),
24804
+ rulesScanned: Array.from(matchedRulesFiles).map((f) => path12.relative(worktree.path, f))
24805
+ });
24806
+ }
24523
24807
  async function executeDryRunStep(context, worktree) {
24524
24808
  const inWorktree = () => true;
24525
24809
  const dryRunResult = await importStatements(worktree.path, context.agent, {
@@ -24612,7 +24896,7 @@ function handleNoTransactions(result) {
24612
24896
  result.steps.import = buildStepResult(true, "No transactions to import");
24613
24897
  result.steps.reconcile = buildStepResult(true, "Reconciliation skipped (no transactions)");
24614
24898
  result.steps.merge = buildStepResult(true, "Merge skipped (no changes)");
24615
- return buildSuccessResult5(result, "No transactions found to import");
24899
+ return buildSuccessResult4(result, "No transactions found to import");
24616
24900
  }
24617
24901
  async function importPipeline(directory, agent, options, configLoader = loadImportConfig, hledgerExecutor = defaultHledgerExecutor) {
24618
24902
  const restrictionError = checkAccountantAgent(agent, "import pipeline");
@@ -24656,6 +24940,7 @@ async function importPipeline(directory, agent, options, configLoader = loadImpo
24656
24940
  }
24657
24941
  try {
24658
24942
  await executeClassifyStep(context, worktree);
24943
+ await executeAccountDeclarationsStep(context, worktree);
24659
24944
  await executeDryRunStep(context, worktree);
24660
24945
  await executeImportStep(context, worktree);
24661
24946
  await executeReconcileStep(context, worktree);
@@ -24693,7 +24978,7 @@ async function importPipeline(directory, agent, options, configLoader = loadImpo
24693
24978
  };
24694
24979
  }
24695
24980
  const transactionCount = context.result.steps.import?.details?.summary?.totalTransactions || 0;
24696
- return buildSuccessResult5(result, `Successfully imported ${transactionCount} transaction(s)`);
24981
+ return buildSuccessResult4(result, `Successfully imported ${transactionCount} transaction(s)`);
24697
24982
  } catch (error45) {
24698
24983
  result.steps.cleanup = buildStepResult(true, "Worktree cleaned up after failure (CSV files preserved for retry)", {
24699
24984
  cleanedAfterFailure: true,
@@ -24758,7 +25043,7 @@ This tool orchestrates the full import workflow in an isolated git worktree:
24758
25043
  }
24759
25044
  });
24760
25045
  // src/tools/init-directories.ts
24761
- import * as fs13 from "fs";
25046
+ import * as fs14 from "fs";
24762
25047
  import * as path13 from "path";
24763
25048
  async function initDirectories(directory) {
24764
25049
  try {
@@ -24766,8 +25051,8 @@ async function initDirectories(directory) {
24766
25051
  const directoriesCreated = [];
24767
25052
  const gitkeepFiles = [];
24768
25053
  const importBase = path13.join(directory, "import");
24769
- if (!fs13.existsSync(importBase)) {
24770
- fs13.mkdirSync(importBase, { recursive: true });
25054
+ if (!fs14.existsSync(importBase)) {
25055
+ fs14.mkdirSync(importBase, { recursive: true });
24771
25056
  directoriesCreated.push("import");
24772
25057
  }
24773
25058
  const pathsToCreate = [
@@ -24778,19 +25063,19 @@ async function initDirectories(directory) {
24778
25063
  ];
24779
25064
  for (const { path: dirPath } of pathsToCreate) {
24780
25065
  const fullPath = path13.join(directory, dirPath);
24781
- if (!fs13.existsSync(fullPath)) {
24782
- fs13.mkdirSync(fullPath, { recursive: true });
25066
+ if (!fs14.existsSync(fullPath)) {
25067
+ fs14.mkdirSync(fullPath, { recursive: true });
24783
25068
  directoriesCreated.push(dirPath);
24784
25069
  }
24785
25070
  const gitkeepPath = path13.join(fullPath, ".gitkeep");
24786
- if (!fs13.existsSync(gitkeepPath)) {
24787
- fs13.writeFileSync(gitkeepPath, "");
25071
+ if (!fs14.existsSync(gitkeepPath)) {
25072
+ fs14.writeFileSync(gitkeepPath, "");
24788
25073
  gitkeepFiles.push(path13.join(dirPath, ".gitkeep"));
24789
25074
  }
24790
25075
  }
24791
25076
  const gitignorePath = path13.join(importBase, ".gitignore");
24792
25077
  let gitignoreCreated = false;
24793
- if (!fs13.existsSync(gitignorePath)) {
25078
+ if (!fs14.existsSync(gitignorePath)) {
24794
25079
  const gitignoreContent = `# Ignore CSV/PDF files in temporary directories
24795
25080
  /incoming/*.csv
24796
25081
  /incoming/*.pdf
@@ -24808,7 +25093,7 @@ async function initDirectories(directory) {
24808
25093
  .DS_Store
24809
25094
  Thumbs.db
24810
25095
  `;
24811
- fs13.writeFileSync(gitignorePath, gitignoreContent);
25096
+ fs14.writeFileSync(gitignorePath, gitignoreContent);
24812
25097
  gitignoreCreated = true;
24813
25098
  }
24814
25099
  const parts = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fuzzle/opencode-accountant",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "An OpenCode accounting agent, specialized in double-entry-bookkepping with hledger",
5
5
  "author": {
6
6
  "name": "ali bengali",