@fuzzle/opencode-accountant 0.2.0-next.1 → 0.3.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 +24 -3
- package/agent/accountant.md +28 -7
- package/dist/index.js +315 -30
- package/package.json +1 -1
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 (
|
|
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
|
-
|
|
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
|
|
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
|
|
package/agent/accountant.md
CHANGED
|
@@ -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 (
|
|
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.
|
|
142
|
-
5.
|
|
143
|
-
6.
|
|
144
|
-
7.
|
|
145
|
-
8.
|
|
146
|
-
9.
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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 (!
|
|
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 (
|
|
24694
|
+
if (fs13.existsSync(filePath)) {
|
|
24491
24695
|
try {
|
|
24492
|
-
|
|
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
|
|
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
|
|
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
|
|
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 (!
|
|
24770
|
-
|
|
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 (!
|
|
24782
|
-
|
|
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 (!
|
|
24787
|
-
|
|
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 (!
|
|
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
|
-
|
|
25096
|
+
fs14.writeFileSync(gitignorePath, gitignoreContent);
|
|
24812
25097
|
gitignoreCreated = true;
|
|
24813
25098
|
}
|
|
24814
25099
|
const parts = [];
|
package/package.json
CHANGED