@fuzzle/opencode-accountant 0.0.12 → 0.0.13-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 +23 -25
- package/agent/accountant.md +105 -5
- package/dist/index.js +100 -7
- package/docs/tools/classify-statements.md +404 -0
- package/docs/tools/import-statements.md +305 -0
- package/docs/tools/update-prices.md +581 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -93,10 +93,10 @@ The `classify-statements` tool classifies bank statement CSV files by provider a
|
|
|
93
93
|
|
|
94
94
|
```yaml
|
|
95
95
|
paths:
|
|
96
|
-
import:
|
|
97
|
-
pending:
|
|
98
|
-
done:
|
|
99
|
-
unrecognized:
|
|
96
|
+
import: import/incoming
|
|
97
|
+
pending: import/pending
|
|
98
|
+
done: import/done
|
|
99
|
+
unrecognized: import/unrecognized
|
|
100
100
|
rules: ledger/rules
|
|
101
101
|
|
|
102
102
|
providers:
|
|
@@ -176,35 +176,31 @@ providers:
|
|
|
176
176
|
your-project/
|
|
177
177
|
├── config/
|
|
178
178
|
│ └── import/
|
|
179
|
-
│ └── providers.yaml
|
|
179
|
+
│ └── providers.yaml # Configures all paths below
|
|
180
180
|
├── ledger/
|
|
181
|
-
│ └── rules/
|
|
182
|
-
│ └── <provider>-{account-number}.rules
|
|
183
|
-
├──
|
|
184
|
-
│
|
|
185
|
-
│
|
|
186
|
-
└──
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
└── done/
|
|
193
|
-
└── import/
|
|
194
|
-
└── <provider>/
|
|
195
|
-
└── <currency>/
|
|
181
|
+
│ └── rules/ # {paths.rules}
|
|
182
|
+
│ └── <provider>-{account-number}.rules
|
|
183
|
+
├── import/
|
|
184
|
+
│ ├── incoming/ # {paths.import} - Drop CSV files here
|
|
185
|
+
│ ├── pending/ # {paths.pending} - Classified files
|
|
186
|
+
│ │ └── <provider>/ # e.g., revolut, ubs
|
|
187
|
+
│ │ └── <currency>/ # e.g., chf, eur, usd, btc
|
|
188
|
+
│ ├── done/ # {paths.done} - Processed files
|
|
189
|
+
│ │ └── <provider>/
|
|
190
|
+
│ │ └── <currency>/
|
|
191
|
+
│ └── unrecognized/ # {paths.unrecognized} - Unknown files
|
|
196
192
|
```
|
|
197
193
|
|
|
198
194
|
#### Workflow
|
|
199
195
|
|
|
200
|
-
1. Drop CSV files into `
|
|
196
|
+
1. Drop CSV files into `{paths.import}` (default: `import/incoming/`)
|
|
201
197
|
2. Run `classify-statements` tool
|
|
202
|
-
3. Files are moved to `
|
|
203
|
-
4. Unrecognized files are moved to `
|
|
198
|
+
3. Files are moved to `{paths.pending}/<provider>/<currency>/` (default: `import/pending/`)
|
|
199
|
+
4. Unrecognized files are moved to `{paths.unrecognized}/` (default: `import/unrecognized/`)
|
|
204
200
|
5. Run `import-statements` with `checkOnly: true` to validate transactions
|
|
205
201
|
6. If unknown postings found: Add rules to the `.rules` file, repeat step 5
|
|
206
202
|
7. Once all transactions match: Run `import-statements` with `checkOnly: false`
|
|
207
|
-
8. Transactions are imported to journal, CSV files moved to `
|
|
203
|
+
8. Transactions are imported to journal, CSV files moved to `{paths.done}/<provider>/<currency>/` (default: `import/done/`)
|
|
208
204
|
|
|
209
205
|
### Statement Import
|
|
210
206
|
|
|
@@ -223,11 +219,13 @@ The `import-statements` tool imports classified CSV statements into hledger usin
|
|
|
223
219
|
The tool matches CSV files to their rules files by parsing the `source` directive in each `.rules` file. For example, if `ubs-account.rules` contains:
|
|
224
220
|
|
|
225
221
|
```
|
|
226
|
-
source ../../
|
|
222
|
+
source ../../import/pending/ubs/chf/transactions.csv
|
|
227
223
|
```
|
|
228
224
|
|
|
229
225
|
The tool will use that rules file when processing `transactions.csv`.
|
|
230
226
|
|
|
227
|
+
**Note:** The `source` path should match your configured `{paths.pending}` directory structure.
|
|
228
|
+
|
|
231
229
|
See the hledger documentation for details on rules file format and syntax.
|
|
232
230
|
|
|
233
231
|
#### Unknown Postings
|
package/agent/accountant.md
CHANGED
|
@@ -8,6 +8,7 @@ tools:
|
|
|
8
8
|
bash: true
|
|
9
9
|
edit: true
|
|
10
10
|
write: true
|
|
11
|
+
# MCP tools available: classify-statements, import-statements, update-prices
|
|
11
12
|
permission:
|
|
12
13
|
bash: allow
|
|
13
14
|
edit: allow
|
|
@@ -49,15 +50,42 @@ When working with accounting tasks:
|
|
|
49
50
|
1. **File organization** - Keep transactions in appropriate year journals
|
|
50
51
|
1. **Duplicate checking** - Take extra care to avoid duplicate transactions
|
|
51
52
|
1. **Unintended edits** - If a balance is off, check the journal for unintended edits
|
|
52
|
-
1. **Statement tracking** - Move processed statements to `statements/{provider}/YYYY`
|
|
53
53
|
1. **Consistency** - Maintain consistent formatting and naming conventions across all files
|
|
54
54
|
|
|
55
|
+
## Required Tools
|
|
56
|
+
|
|
57
|
+
You have access to specialized MCP tools that MUST be used for their designated tasks. Do NOT attempt to replicate their functionality with bash commands, direct hledger CLI calls, or manual file edits.
|
|
58
|
+
|
|
59
|
+
| Tool | Use For | NEVER Do Instead |
|
|
60
|
+
| --------------------- | ---------------------------------- | ------------------------------------------ |
|
|
61
|
+
| `classify-statements` | Organizing incoming CSV files | Manual file moves or bash `mv` commands |
|
|
62
|
+
| `import-statements` | Importing transactions to journals | `hledger import`, manual journal edits |
|
|
63
|
+
| `update-prices` | Fetching exchange rates | `curl` to price APIs, manual price entries |
|
|
64
|
+
|
|
65
|
+
These tools handle validation, deduplication, error checking, and file organization automatically. Bypassing them risks data corruption, duplicate transactions, and inconsistent state.
|
|
66
|
+
|
|
67
|
+
## Bash Usage Policy
|
|
68
|
+
|
|
69
|
+
Bash is allowed ONLY for:
|
|
70
|
+
|
|
71
|
+
- Validation commands: `hledger check`, `hledger-fmt`, `hledger bal`
|
|
72
|
+
- Read-only queries: `hledger print`, `hledger reg`, `hledger accounts`
|
|
73
|
+
- File inspection: `cat`, `head`, `tail` (read-only)
|
|
74
|
+
|
|
75
|
+
Bash is FORBIDDEN for:
|
|
76
|
+
|
|
77
|
+
- `hledger import` - use `import-statements` tool instead
|
|
78
|
+
- Moving/copying CSV files - use `classify-statements` tool instead
|
|
79
|
+
- Editing journal files directly - use `edit` tool only for rules files
|
|
80
|
+
- Fetching prices - use `update-prices` tool instead
|
|
81
|
+
|
|
55
82
|
## Statement Import Workflow
|
|
56
83
|
|
|
57
|
-
|
|
84
|
+
**IMPORTANT:** You MUST use the MCP tools below for statement imports. Do NOT edit journals manually, run `hledger import` directly, or move files with bash commands. The workflow:
|
|
58
85
|
|
|
59
|
-
1. **Prepare**: Drop CSV files into
|
|
60
|
-
2. **Classify**: Run `classify-statements` tool to
|
|
86
|
+
1. **Prepare**: Drop CSV files into `{paths.import}` (configured in `config/import/providers.yaml`, default: `import/incoming`)
|
|
87
|
+
2. **Classify**: Run `classify-statements` tool to organize files by provider/currency
|
|
88
|
+
- Files moved to `{paths.pending}/<provider>/<currency>/`
|
|
61
89
|
3. **Validate (check mode)**: Run `import-statements(checkOnly: true)` to validate transactions
|
|
62
90
|
4. **Handle unknowns**: If unknown postings found:
|
|
63
91
|
- Tool returns full CSV row data for each unknown posting
|
|
@@ -65,7 +93,7 @@ Use the `import-statements` tool to import bank statements. Do not edit the ledg
|
|
|
65
93
|
- Create or update rules file with `if` directives to match the transaction
|
|
66
94
|
- Repeat step 3 until all postings are matched
|
|
67
95
|
5. **Import**: Once all transactions have matching rules, run `import-statements(checkOnly: false)`
|
|
68
|
-
6. **Complete**: Transactions imported to journal, CSVs moved to `
|
|
96
|
+
6. **Complete**: Transactions imported to journal, CSVs moved to `{paths.done}/<provider>/<currency>/`
|
|
69
97
|
|
|
70
98
|
### Rules Files
|
|
71
99
|
|
|
@@ -73,3 +101,75 @@ Use the `import-statements` tool to import bank statements. Do not edit the ledg
|
|
|
73
101
|
- Match CSV to rules file via the `source` directive in each `.rules` file
|
|
74
102
|
- Use field names from the `fields` directive for matching
|
|
75
103
|
- Unknown account pattern: `income:unknown` (positive amounts) / `expenses:unknown` (negative amounts)
|
|
104
|
+
|
|
105
|
+
## Tool Usage Reference
|
|
106
|
+
|
|
107
|
+
The following are MCP tools available to you. Always call these tools directly - do not attempt to replicate their behavior with shell commands.
|
|
108
|
+
|
|
109
|
+
### classify-statements
|
|
110
|
+
|
|
111
|
+
**Purpose:** Organizes CSV files by auto-detecting provider and currency.
|
|
112
|
+
|
|
113
|
+
**Usage:** `classify-statements()` (no arguments)
|
|
114
|
+
|
|
115
|
+
**Behavior:**
|
|
116
|
+
|
|
117
|
+
- Scans `{paths.import}` for CSV files
|
|
118
|
+
- Detects provider using header matching + filename patterns
|
|
119
|
+
- Moves classified files to `{paths.pending}/<provider>/<currency>/`
|
|
120
|
+
- Moves unrecognized files to `{paths.unrecognized}/`
|
|
121
|
+
- Aborts if any file collision detected (no partial moves)
|
|
122
|
+
|
|
123
|
+
**Output:** Returns classified/unrecognized file lists with target paths
|
|
124
|
+
|
|
125
|
+
**Common issues:**
|
|
126
|
+
|
|
127
|
+
- Unrecognized files → Add provider config to `config/import/providers.yaml`
|
|
128
|
+
- Collisions → Move/rename existing pending files before re-running
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
### import-statements
|
|
133
|
+
|
|
134
|
+
**Purpose:** Imports classified CSV transactions into hledger journals.
|
|
135
|
+
|
|
136
|
+
**Usage:**
|
|
137
|
+
|
|
138
|
+
- Check mode (default): `import-statements(checkOnly: true)` or `import-statements()`
|
|
139
|
+
- Import mode: `import-statements(checkOnly: false)`
|
|
140
|
+
|
|
141
|
+
**Behavior:**
|
|
142
|
+
|
|
143
|
+
- Processes CSV files in `{paths.pending}/<provider>/<currency>/`
|
|
144
|
+
- Matches each CSV to rules file via `source` directive
|
|
145
|
+
- Check mode: Validates transactions, reports unknown postings with full CSV row data
|
|
146
|
+
- Import mode: Only proceeds if ALL transactions have known accounts, moves CSVs to `{paths.done}/`
|
|
147
|
+
|
|
148
|
+
**Output:** Returns per-file results with transaction counts and unknown postings (if any)
|
|
149
|
+
|
|
150
|
+
**Required for import:**
|
|
151
|
+
|
|
152
|
+
- All transactions must have matching rules (no `income:unknown` or `expenses:unknown`)
|
|
153
|
+
- Each CSV must have a corresponding `.rules` file in `{paths.rules}`
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
### update-prices
|
|
158
|
+
|
|
159
|
+
**Purpose:** Fetches currency exchange rates and updates `ledger/currencies/` journals.
|
|
160
|
+
|
|
161
|
+
**Usage:**
|
|
162
|
+
|
|
163
|
+
- Daily mode (default): `update-prices()` or `update-prices(backfill: false)`
|
|
164
|
+
- Backfill mode: `update-prices(backfill: true)`
|
|
165
|
+
|
|
166
|
+
**Behavior:**
|
|
167
|
+
|
|
168
|
+
- Daily mode: Fetches yesterday's prices only
|
|
169
|
+
- Backfill mode: Fetches from `backfill_date` (or Jan 1 of current year) to yesterday
|
|
170
|
+
- Updates journal files in-place with deduplication (newer prices overwrite older for same date)
|
|
171
|
+
- Processes all currencies independently (partial failures possible)
|
|
172
|
+
|
|
173
|
+
**Output:** Returns per-currency results with latest price line or error message
|
|
174
|
+
|
|
175
|
+
**Configuration:** `config/prices.yaml` defines currencies, sources, pairs, and backfill dates
|
package/dist/index.js
CHANGED
|
@@ -17689,6 +17689,18 @@ function countTransactions(hledgerOutput) {
|
|
|
17689
17689
|
}
|
|
17690
17690
|
return count;
|
|
17691
17691
|
}
|
|
17692
|
+
function extractTransactionYears(hledgerOutput) {
|
|
17693
|
+
const years = new Set;
|
|
17694
|
+
const lines = hledgerOutput.split(`
|
|
17695
|
+
`);
|
|
17696
|
+
for (const line of lines) {
|
|
17697
|
+
const match = line.match(/^(\d{4})-\d{2}-\d{2}\s+/);
|
|
17698
|
+
if (match) {
|
|
17699
|
+
years.add(parseInt(match[1], 10));
|
|
17700
|
+
}
|
|
17701
|
+
}
|
|
17702
|
+
return years;
|
|
17703
|
+
}
|
|
17692
17704
|
|
|
17693
17705
|
// src/utils/rulesParser.ts
|
|
17694
17706
|
function parseSkipRows(rulesContent) {
|
|
@@ -17903,6 +17915,30 @@ function findMatchingCsvRow(posting, csvRows, config2) {
|
|
|
17903
17915
|
}
|
|
17904
17916
|
|
|
17905
17917
|
// src/tools/import-statements.ts
|
|
17918
|
+
function ensureYearJournalExists(directory, year) {
|
|
17919
|
+
const ledgerDir = path6.join(directory, "ledger");
|
|
17920
|
+
const yearJournalPath = path6.join(ledgerDir, `${year}.journal`);
|
|
17921
|
+
const mainJournalPath = path6.join(directory, ".hledger.journal");
|
|
17922
|
+
if (!fs7.existsSync(ledgerDir)) {
|
|
17923
|
+
fs7.mkdirSync(ledgerDir, { recursive: true });
|
|
17924
|
+
}
|
|
17925
|
+
if (!fs7.existsSync(yearJournalPath)) {
|
|
17926
|
+
fs7.writeFileSync(yearJournalPath, `; ${year} transactions
|
|
17927
|
+
`);
|
|
17928
|
+
}
|
|
17929
|
+
if (!fs7.existsSync(mainJournalPath)) {
|
|
17930
|
+
throw new Error(`.hledger.journal not found at ${mainJournalPath}. Create it first with appropriate includes.`);
|
|
17931
|
+
}
|
|
17932
|
+
const mainJournalContent = fs7.readFileSync(mainJournalPath, "utf-8");
|
|
17933
|
+
const includeDirective = `include ledger/${year}.journal`;
|
|
17934
|
+
if (!mainJournalContent.includes(includeDirective)) {
|
|
17935
|
+
const newContent = mainJournalContent.trimEnd() + `
|
|
17936
|
+
` + includeDirective + `
|
|
17937
|
+
`;
|
|
17938
|
+
fs7.writeFileSync(mainJournalPath, newContent);
|
|
17939
|
+
}
|
|
17940
|
+
return yearJournalPath;
|
|
17941
|
+
}
|
|
17906
17942
|
function findPendingCsvFiles(pendingDir, provider, currency) {
|
|
17907
17943
|
const csvFiles = [];
|
|
17908
17944
|
if (!fs7.existsSync(pendingDir)) {
|
|
@@ -18006,18 +18042,32 @@ async function importStatementsCore(directory, agent, options, configLoader = lo
|
|
|
18006
18042
|
const unknownPostings = parseUnknownPostings(result.stdout);
|
|
18007
18043
|
const transactionCount = countTransactions(result.stdout);
|
|
18008
18044
|
const matchedCount = transactionCount - unknownPostings.length;
|
|
18045
|
+
const years = extractTransactionYears(result.stdout);
|
|
18046
|
+
if (years.size > 1) {
|
|
18047
|
+
const yearList = Array.from(years).sort().join(", ");
|
|
18048
|
+
filesWithErrors++;
|
|
18049
|
+
fileResults.push({
|
|
18050
|
+
csv: path6.relative(directory, csvFile),
|
|
18051
|
+
rulesFile: path6.relative(directory, rulesFile),
|
|
18052
|
+
totalTransactions: transactionCount,
|
|
18053
|
+
matchedTransactions: matchedCount,
|
|
18054
|
+
unknownPostings: [],
|
|
18055
|
+
error: `CSV contains transactions from multiple years (${yearList}). Split the CSV by year before importing.`
|
|
18056
|
+
});
|
|
18057
|
+
continue;
|
|
18058
|
+
}
|
|
18059
|
+
const transactionYear = years.size === 1 ? Array.from(years)[0] : undefined;
|
|
18009
18060
|
if (unknownPostings.length > 0) {
|
|
18010
18061
|
try {
|
|
18011
18062
|
const rulesContent = fs7.readFileSync(rulesFile, "utf-8");
|
|
18012
18063
|
const rulesConfig = parseRulesFile(rulesContent);
|
|
18013
18064
|
const csvRows = parseCsvFile(csvFile, rulesConfig);
|
|
18014
18065
|
for (const posting of unknownPostings) {
|
|
18015
|
-
|
|
18066
|
+
posting.csvRow = findMatchingCsvRow({
|
|
18016
18067
|
date: posting.date,
|
|
18017
18068
|
description: posting.description,
|
|
18018
18069
|
amount: posting.amount
|
|
18019
18070
|
}, csvRows, rulesConfig);
|
|
18020
|
-
posting.csvRow = csvRow;
|
|
18021
18071
|
}
|
|
18022
18072
|
} catch {
|
|
18023
18073
|
for (const posting of unknownPostings) {
|
|
@@ -18033,7 +18083,8 @@ async function importStatementsCore(directory, agent, options, configLoader = lo
|
|
|
18033
18083
|
rulesFile: path6.relative(directory, rulesFile),
|
|
18034
18084
|
totalTransactions: transactionCount,
|
|
18035
18085
|
matchedTransactions: matchedCount,
|
|
18036
|
-
unknownPostings
|
|
18086
|
+
unknownPostings,
|
|
18087
|
+
transactionYear
|
|
18037
18088
|
});
|
|
18038
18089
|
}
|
|
18039
18090
|
const hasUnknowns = totalUnknown > 0;
|
|
@@ -18077,11 +18128,53 @@ async function importStatementsCore(directory, agent, options, configLoader = lo
|
|
|
18077
18128
|
});
|
|
18078
18129
|
}
|
|
18079
18130
|
const importedFiles = [];
|
|
18080
|
-
for (const
|
|
18081
|
-
const
|
|
18131
|
+
for (const fileResult of fileResults) {
|
|
18132
|
+
const csvFile = path6.join(directory, fileResult.csv);
|
|
18133
|
+
const rulesFile = fileResult.rulesFile ? path6.join(directory, fileResult.rulesFile) : null;
|
|
18082
18134
|
if (!rulesFile)
|
|
18083
18135
|
continue;
|
|
18084
|
-
const
|
|
18136
|
+
const year = fileResult.transactionYear;
|
|
18137
|
+
if (!year) {
|
|
18138
|
+
return JSON.stringify({
|
|
18139
|
+
success: false,
|
|
18140
|
+
files: fileResults,
|
|
18141
|
+
summary: {
|
|
18142
|
+
filesProcessed: csvFiles.length,
|
|
18143
|
+
filesWithErrors: 1,
|
|
18144
|
+
filesWithoutRules,
|
|
18145
|
+
totalTransactions,
|
|
18146
|
+
matched: totalMatched,
|
|
18147
|
+
unknown: totalUnknown
|
|
18148
|
+
},
|
|
18149
|
+
error: `No transactions found in ${fileResult.csv}`
|
|
18150
|
+
});
|
|
18151
|
+
}
|
|
18152
|
+
let yearJournalPath;
|
|
18153
|
+
try {
|
|
18154
|
+
yearJournalPath = ensureYearJournalExists(directory, year);
|
|
18155
|
+
} catch (error45) {
|
|
18156
|
+
return JSON.stringify({
|
|
18157
|
+
success: false,
|
|
18158
|
+
files: fileResults,
|
|
18159
|
+
summary: {
|
|
18160
|
+
filesProcessed: csvFiles.length,
|
|
18161
|
+
filesWithErrors: 1,
|
|
18162
|
+
filesWithoutRules,
|
|
18163
|
+
totalTransactions,
|
|
18164
|
+
matched: totalMatched,
|
|
18165
|
+
unknown: totalUnknown
|
|
18166
|
+
},
|
|
18167
|
+
error: error45 instanceof Error ? error45.message : String(error45)
|
|
18168
|
+
});
|
|
18169
|
+
}
|
|
18170
|
+
const result = await hledgerExecutor([
|
|
18171
|
+
"import",
|
|
18172
|
+
"-f",
|
|
18173
|
+
yearJournalPath,
|
|
18174
|
+
csvFile,
|
|
18175
|
+
"--rules-file",
|
|
18176
|
+
rulesFile
|
|
18177
|
+
]);
|
|
18085
18178
|
if (result.exitCode !== 0) {
|
|
18086
18179
|
return JSON.stringify({
|
|
18087
18180
|
success: false,
|
|
@@ -18094,7 +18187,7 @@ async function importStatementsCore(directory, agent, options, configLoader = lo
|
|
|
18094
18187
|
matched: totalMatched,
|
|
18095
18188
|
unknown: totalUnknown
|
|
18096
18189
|
},
|
|
18097
|
-
error: `Import failed for ${
|
|
18190
|
+
error: `Import failed for ${fileResult.csv}: ${result.stderr.trim()}`
|
|
18098
18191
|
});
|
|
18099
18192
|
}
|
|
18100
18193
|
importedFiles.push(csvFile);
|