@rvanbaalen/ofxreader 1.2.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/LICENSE +21 -0
- package/README.md +185 -0
- package/bin/ofx-mcp.ts +7 -0
- package/bin/ofxreader.ts +4 -0
- package/package.json +60 -0
- package/src/cli.ts +181 -0
- package/src/dates.ts +51 -0
- package/src/help.ts +130 -0
- package/src/mcp.ts +259 -0
- package/src/model.ts +137 -0
- package/src/output.ts +9 -0
- package/src/parser.ts +85 -0
- package/src/query.ts +61 -0
- package/src/report.ts +90 -0
- package/src/vendors/match.ts +88 -0
- package/src/vendors/normalize.ts +35 -0
- package/src/vendors/resolve.ts +41 -0
- package/src/vendors/store.ts +93 -0
- package/src/version.ts +9 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Robin van Baalen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# ofxreader
|
|
2
|
+
|
|
3
|
+
[](https://github.com/rvanbaalen/ofxreader/actions/workflows/release-please.yml)
|
|
4
|
+
[](./LICENSE)
|
|
5
|
+
|
|
6
|
+
A command-line tool **and MCP server** for reading and querying **OFX 2.x (XML)**
|
|
7
|
+
financial files (bank and credit-card statement exports). Built for LLM/agent
|
|
8
|
+
use: deterministic JSON output, composable filters, structured errors, and a
|
|
9
|
+
self-documenting `--llm` mode.
|
|
10
|
+
|
|
11
|
+
> Package name: **`@rvanbaalen/ofxreader`** — published to GitHub Packages.
|
|
12
|
+
|
|
13
|
+
## Requirements
|
|
14
|
+
|
|
15
|
+
- **Node.js ≥ 24** (uses native TypeScript type-stripping — no build step).
|
|
16
|
+
Works on Node ≥ 22.18 too. An `.nvmrc` pins the project to Node 24: `nvm use`.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
### From GitHub Packages
|
|
21
|
+
|
|
22
|
+
The package is published to GitHub Packages as `@rvanbaalen/ofxreader`. Point the
|
|
23
|
+
`@rvanbaalen` scope at the GitHub registry and authenticate with a token that has
|
|
24
|
+
the `read:packages` scope (GitHub Packages requires auth even for public
|
|
25
|
+
packages):
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
# ~/.npmrc
|
|
29
|
+
@rvanbaalen:registry=https://npm.pkg.github.com
|
|
30
|
+
//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
npm install -g @rvanbaalen/ofxreader # installs the `ofxreader` and `ofx-mcp` bins
|
|
35
|
+
ofxreader --llm
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### From source
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
git clone https://github.com/rvanbaalen/ofxreader.git
|
|
42
|
+
cd ofxreader
|
|
43
|
+
nvm use # Node 24 (see .nvmrc)
|
|
44
|
+
npm install # deps: fast-xml-parser, MCP SDK, zod
|
|
45
|
+
|
|
46
|
+
# Symlink `ofxreader` into /usr/local/bin so you can run it anywhere.
|
|
47
|
+
# /usr/local/bin usually needs sudo on macOS:
|
|
48
|
+
sudo npm run install-cli
|
|
49
|
+
|
|
50
|
+
# ...or install into a writable directory of your choice:
|
|
51
|
+
OFXREADER_BIN_DIR="$HOME/.local/bin" npm run install-cli
|
|
52
|
+
|
|
53
|
+
npm run uninstall-cli # remove the symlink
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Without installing, run it directly: `node bin/ofxreader.ts <command> <file>`.
|
|
57
|
+
|
|
58
|
+
## Usage
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
ofxreader <command> <file.ofx> [options]
|
|
62
|
+
ofxreader --llm # full machine-readable usage guide (for LLMs/agents)
|
|
63
|
+
ofxreader --help # short usage
|
|
64
|
+
ofxreader --version
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Commands
|
|
68
|
+
|
|
69
|
+
| Command | Output |
|
|
70
|
+
|------------------------|--------|
|
|
71
|
+
| `summary <file>` | JSON array — one object per statement: account, currency, statement period, ledger & available balance, transaction counts, and totals (credits / debits / net). |
|
|
72
|
+
| `accounts <file>` | JSON array of accounts (`id`, `type`, `bankId`, `branchId`). |
|
|
73
|
+
| `transactions <file>` | `{ total, count, transactions: [...] }` — `total` is matches found, `count` is rows returned (differs when `--limit` is set). |
|
|
74
|
+
| `vendors` | List learned vendor aliases (no file argument). |
|
|
75
|
+
| `vendor-learn "<vendor>" "<descriptor>" […]` | Teach raw descriptors for a vendor (no file argument). |
|
|
76
|
+
|
|
77
|
+
### Transaction filters
|
|
78
|
+
|
|
79
|
+
| Flag | Meaning |
|
|
80
|
+
|------|---------|
|
|
81
|
+
| `--from YYYY-MM-DD` / `--to YYYY-MM-DD` | Posted-date range (inclusive). |
|
|
82
|
+
| `--min N` / `--max N` | Signed-amount range. Use the `=` form for negatives: `--min=-100`. |
|
|
83
|
+
| `--type debit\|credit` | `debit` = amount < 0 (money out); `credit` = amount > 0 (money in). |
|
|
84
|
+
| `--search TEXT` | Case-insensitive match over name + memo + payee. |
|
|
85
|
+
| `--regex` | Treat `--search` as a JavaScript regular expression. |
|
|
86
|
+
| `--account ACCTID` | Restrict to one account. |
|
|
87
|
+
| `--vendor "Name"` | Resolve a learned vendor alias. Result holds only confirmed matches, plus a `vendorCandidates` list (fuzzy, unconfirmed) to learn from. See [Vendor learning](#vendor-learning). |
|
|
88
|
+
| `--limit N` | Return at most N rows. |
|
|
89
|
+
| `--pretty` | Indent JSON (default is compact). |
|
|
90
|
+
|
|
91
|
+
### Examples
|
|
92
|
+
|
|
93
|
+
```sh
|
|
94
|
+
ofxreader summary statement.ofx --pretty
|
|
95
|
+
ofxreader transactions statement.ofx --from 2024-01-01 --to 2024-03-31
|
|
96
|
+
ofxreader transactions statement.ofx --type debit --search amazon
|
|
97
|
+
ofxreader transactions statement.ofx --search "^ACME" --regex --limit 50
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Output contract
|
|
101
|
+
|
|
102
|
+
- **Success** → JSON on **stdout**, exit `0`.
|
|
103
|
+
- **Failure** → `{"error":{"code","message"}}` on **stderr**, non-zero exit.
|
|
104
|
+
Codes: `USAGE` (2), `FILE_NOT_FOUND` (1), `READ_ERROR` (1),
|
|
105
|
+
`NOT_OFX2` (1 — OFX 1.x SGML is rejected), `PARSE_ERROR` (1).
|
|
106
|
+
|
|
107
|
+
Amounts are signed numbers; dates are ISO 8601 strings.
|
|
108
|
+
|
|
109
|
+
## Vendor learning
|
|
110
|
+
|
|
111
|
+
OFX descriptors are noisy and rarely equal the brand name (`SQ *JASONS CARO 0123`,
|
|
112
|
+
`TST* JASONSCAROUSEL`). ofxreader keeps a local **vendor alias store** mapping a
|
|
113
|
+
canonical vendor name to the descriptors that belong to it, so a question like
|
|
114
|
+
*"what did I spend at Jason's Carousel in April"* resolves to exactly the right records.
|
|
115
|
+
|
|
116
|
+
- A `--vendor` query returns **only confirmed** matches, plus a `vendorCandidates` list
|
|
117
|
+
(fuzzy, unconfirmed descriptors) ranked by similarity.
|
|
118
|
+
- Confirm a candidate with the user, persist it, and future queries are deterministic.
|
|
119
|
+
|
|
120
|
+
```sh
|
|
121
|
+
# 1. Ask — confirmed matches + candidates to confirm
|
|
122
|
+
ofxreader transactions statement.ofx --vendor "Jason's Carousel" --from 2024-04-01 --to 2024-04-30
|
|
123
|
+
|
|
124
|
+
# 2. Teach the confirmed descriptors
|
|
125
|
+
ofxreader vendor-learn "Jason's Carousel" "SQ *JASONS CARO 0123" "TST* JASONSCAROUSEL"
|
|
126
|
+
|
|
127
|
+
# 3. Re-run step 1 — now deterministic
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
The store lives at `$OFXREADER_VENDORS`, else `$XDG_CONFIG_HOME/ofxreader/vendors.json`
|
|
131
|
+
(fallback `~/.config/ofxreader/vendors.json`). It is the source of truth; nothing is
|
|
132
|
+
sent anywhere.
|
|
133
|
+
|
|
134
|
+
## Use as an MCP server
|
|
135
|
+
|
|
136
|
+
The same parser is exposed as a local [Model Context Protocol](https://modelcontextprotocol.io)
|
|
137
|
+
server (stdio transport) so Claude can work with OFX files directly. It registers
|
|
138
|
+
these tools covering every CLI capability:
|
|
139
|
+
|
|
140
|
+
| Tool | Input | Returns |
|
|
141
|
+
|------|-------|---------|
|
|
142
|
+
| `ofx_summary` | `path` | per-statement summaries |
|
|
143
|
+
| `ofx_accounts` | `path` | account list |
|
|
144
|
+
| `ofx_transactions` | `path` + filters (`from`, `to`, `min`, `max`, `type`, `search`, `regex`, `account`, `limit`, `vendor`) | `{ total, count, transactions[] }` (plus `vendorCandidates` when `vendor` is set) |
|
|
145
|
+
| `ofx_vendor_learn` | `vendor`, `descriptors[]` | the updated vendor entry |
|
|
146
|
+
| `ofx_vendors` | — | learned vendor aliases |
|
|
147
|
+
|
|
148
|
+
It also exposes one **resource** template:
|
|
149
|
+
|
|
150
|
+
| Resource | URI | Returns |
|
|
151
|
+
|----------|-----|---------|
|
|
152
|
+
| `ofx-balances` | `ofx:/absolute/path/to/file.ofx` | Ledger & available balance per account, each stated with its as-of date — e.g. `Balance at 2024-03-31 is 4327.87 USD` |
|
|
153
|
+
|
|
154
|
+
Run it directly with `npm run mcp` (or `node bin/ofx-mcp.ts`).
|
|
155
|
+
|
|
156
|
+
### Claude Desktop
|
|
157
|
+
|
|
158
|
+
Add it to `~/Library/Application Support/Claude/claude_desktop_config.json`, then
|
|
159
|
+
restart Claude Desktop. Use an **absolute** path to a Node ≥ 24 binary — the
|
|
160
|
+
desktop app launches with a minimal PATH that won't include an nvm-managed `node`:
|
|
161
|
+
|
|
162
|
+
```json
|
|
163
|
+
{
|
|
164
|
+
"mcpServers": {
|
|
165
|
+
"ofxreader": {
|
|
166
|
+
"command": "/Users/robin/.nvm/versions/node/v24.13.1/bin/node",
|
|
167
|
+
"args": ["/Users/robin/Sites/projects/ofxreader/bin/ofx-mcp.ts"]
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### Claude Code
|
|
174
|
+
|
|
175
|
+
```sh
|
|
176
|
+
claude mcp add ofxreader -- /Users/robin/.nvm/versions/node/v24.13.1/bin/node \
|
|
177
|
+
/Users/robin/Sites/projects/ofxreader/bin/ofx-mcp.ts
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## Development
|
|
181
|
+
|
|
182
|
+
```sh
|
|
183
|
+
npm test # node:test suite (runs .ts directly)
|
|
184
|
+
npm run typecheck # tsc --noEmit (type-check only; never emits)
|
|
185
|
+
```
|
package/bin/ofx-mcp.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { createServer } from "../src/mcp.ts";
|
|
4
|
+
|
|
5
|
+
const server = createServer();
|
|
6
|
+
const transport = new StdioServerTransport();
|
|
7
|
+
await server.connect(transport);
|
package/bin/ofxreader.ts
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rvanbaalen/ofxreader",
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "CLI for reading and querying OFX 2.x (XML) financial files. Built for LLM/agent use: deterministic JSON output and a self-documenting --llm mode.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"ofxreader": "bin/ofxreader.ts",
|
|
8
|
+
"ofx-mcp": "bin/ofx-mcp.ts"
|
|
9
|
+
},
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=24"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"bin",
|
|
15
|
+
"src",
|
|
16
|
+
"README.md",
|
|
17
|
+
"LICENSE"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"ofxreader": "node bin/ofxreader.ts",
|
|
21
|
+
"mcp": "node bin/ofx-mcp.ts",
|
|
22
|
+
"install-cli": "node scripts/install.ts",
|
|
23
|
+
"uninstall-cli": "node scripts/install.ts uninstall",
|
|
24
|
+
"typecheck": "tsc --noEmit",
|
|
25
|
+
"test": "node --test test/*.test.ts"
|
|
26
|
+
},
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/rvanbaalen/ofxreader.git"
|
|
30
|
+
},
|
|
31
|
+
"homepage": "https://github.com/rvanbaalen/ofxreader#readme",
|
|
32
|
+
"bugs": {
|
|
33
|
+
"url": "https://github.com/rvanbaalen/ofxreader/issues"
|
|
34
|
+
},
|
|
35
|
+
"author": "Robin van Baalen",
|
|
36
|
+
"keywords": [
|
|
37
|
+
"ofx",
|
|
38
|
+
"qfx",
|
|
39
|
+
"cli",
|
|
40
|
+
"mcp",
|
|
41
|
+
"finance",
|
|
42
|
+
"bank",
|
|
43
|
+
"transactions",
|
|
44
|
+
"llm",
|
|
45
|
+
"agent"
|
|
46
|
+
],
|
|
47
|
+
"license": "MIT",
|
|
48
|
+
"publishConfig": {
|
|
49
|
+
"access": "public"
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
53
|
+
"fast-xml-parser": "^5.8.0",
|
|
54
|
+
"zod": "^4.4.3"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@types/node": "^25.9.1",
|
|
58
|
+
"typescript": "^6.0.3"
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { parseArgs } from "node:util";
|
|
2
|
+
|
|
3
|
+
import { readOfxFile, parseOfx, OfxError } from "./parser.ts";
|
|
4
|
+
import { buildDocument } from "./model.ts";
|
|
5
|
+
import { filterTransactions } from "./query.ts";
|
|
6
|
+
import type { TransactionFilters } from "./query.ts";
|
|
7
|
+
import { summaries, uniqueAccounts } from "./report.ts";
|
|
8
|
+
import { resolveVendorQuery } from "./vendors/resolve.ts";
|
|
9
|
+
import { load as loadVendors, save as saveVendors, learn as learnVendor, today } from "./vendors/store.ts";
|
|
10
|
+
import { emit, emitError } from "./output.ts";
|
|
11
|
+
import { HELP_TEXT, LLM_INSTRUCTIONS } from "./help.ts";
|
|
12
|
+
import { getVersion } from "./version.ts";
|
|
13
|
+
|
|
14
|
+
const FILE_COMMANDS = ["summary", "accounts", "transactions"] as const;
|
|
15
|
+
const ALL_COMMANDS = "summary | accounts | transactions | vendors | vendor-learn";
|
|
16
|
+
|
|
17
|
+
const OPTIONS = {
|
|
18
|
+
from: { type: "string" },
|
|
19
|
+
to: { type: "string" },
|
|
20
|
+
min: { type: "string" },
|
|
21
|
+
max: { type: "string" },
|
|
22
|
+
type: { type: "string" },
|
|
23
|
+
search: { type: "string" },
|
|
24
|
+
regex: { type: "boolean" },
|
|
25
|
+
account: { type: "string" },
|
|
26
|
+
limit: { type: "string" },
|
|
27
|
+
vendor: { type: "string" },
|
|
28
|
+
pretty: { type: "boolean" },
|
|
29
|
+
llm: { type: "boolean" },
|
|
30
|
+
help: { type: "boolean", short: "h" },
|
|
31
|
+
version: { type: "boolean", short: "v" },
|
|
32
|
+
} as const;
|
|
33
|
+
|
|
34
|
+
/** Run the CLI. Returns the process exit code. */
|
|
35
|
+
export function run(argv: string[]): number {
|
|
36
|
+
let values: Record<string, string | boolean | undefined>;
|
|
37
|
+
let positionals: string[];
|
|
38
|
+
try {
|
|
39
|
+
const parsed = parseArgs({ args: argv, allowPositionals: true, options: OPTIONS });
|
|
40
|
+
values = parsed.values;
|
|
41
|
+
positionals = parsed.positionals;
|
|
42
|
+
} catch (err) {
|
|
43
|
+
emitError("USAGE", (err as Error).message);
|
|
44
|
+
return 2;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const pretty = values.pretty === true;
|
|
48
|
+
|
|
49
|
+
if (values.version === true) {
|
|
50
|
+
process.stdout.write(getVersion() + "\n");
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
if (values.llm === true) {
|
|
54
|
+
process.stdout.write(LLM_INSTRUCTIONS + "\n");
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
if (values.help === true) {
|
|
58
|
+
process.stdout.write(HELP_TEXT + "\n");
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const command = positionals[0];
|
|
63
|
+
if (command == null) {
|
|
64
|
+
process.stderr.write(HELP_TEXT + "\n");
|
|
65
|
+
return 2;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Store-based commands (no OFX file argument).
|
|
69
|
+
if (command === "vendors") {
|
|
70
|
+
try {
|
|
71
|
+
emit(loadVendors().vendors, pretty);
|
|
72
|
+
return 0;
|
|
73
|
+
} catch (err) {
|
|
74
|
+
return reportError(err);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (command === "vendor-learn") {
|
|
78
|
+
const name = positionals[1];
|
|
79
|
+
const descriptors = positionals.slice(2);
|
|
80
|
+
if (name == null || descriptors.length === 0) {
|
|
81
|
+
emitError("USAGE", 'Usage: ofxreader vendor-learn "<vendor>" "<descriptor>" [more...]');
|
|
82
|
+
return 2;
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
const store = loadVendors();
|
|
86
|
+
const entry = learnVendor(store, name, descriptors, today());
|
|
87
|
+
saveVendors(store);
|
|
88
|
+
emit({ vendor: name, ...entry }, pretty);
|
|
89
|
+
return 0;
|
|
90
|
+
} catch (err) {
|
|
91
|
+
return reportError(err);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!(FILE_COMMANDS as readonly string[]).includes(command)) {
|
|
96
|
+
emitError("USAGE", `Unknown command "${command}". Expected: ${ALL_COMMANDS}. Run --llm for help.`);
|
|
97
|
+
return 2;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const file = positionals[1];
|
|
101
|
+
if (file == null) {
|
|
102
|
+
emitError("USAGE", `Missing file argument. Usage: ofxreader ${command} <file.ofx>`);
|
|
103
|
+
return 2;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
const doc = buildDocument(parseOfx(readOfxFile(file)));
|
|
108
|
+
|
|
109
|
+
if (command === "summary") {
|
|
110
|
+
emit(summaries(doc.statements), pretty);
|
|
111
|
+
} else if (command === "accounts") {
|
|
112
|
+
emit(uniqueAccounts(doc.statements), pretty);
|
|
113
|
+
} else {
|
|
114
|
+
const filters = buildFilters(values);
|
|
115
|
+
if (typeof values.vendor === "string") {
|
|
116
|
+
emit(resolveVendorQuery(loadVendors(), doc.statements, values.vendor, filters), pretty);
|
|
117
|
+
} else {
|
|
118
|
+
const all = doc.statements.flatMap((s) => s.transactions);
|
|
119
|
+
emit(filterTransactions(all, filters), pretty);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return 0;
|
|
123
|
+
} catch (err) {
|
|
124
|
+
return reportError(err);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function reportError(err: unknown): number {
|
|
129
|
+
if (err instanceof OfxError) {
|
|
130
|
+
emitError(err.code, err.message);
|
|
131
|
+
return err.code === "USAGE" ? 2 : 1;
|
|
132
|
+
}
|
|
133
|
+
emitError("INTERNAL", (err as Error).message);
|
|
134
|
+
return 1;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function buildFilters(values: Record<string, string | boolean | undefined>): TransactionFilters {
|
|
138
|
+
const f: TransactionFilters = {};
|
|
139
|
+
|
|
140
|
+
if (typeof values.from === "string") f.from = checkDate(values.from, "--from");
|
|
141
|
+
if (typeof values.to === "string") f.to = checkDate(values.to, "--to");
|
|
142
|
+
if (typeof values.min === "string") f.min = parseNum(values.min, "--min");
|
|
143
|
+
if (typeof values.max === "string") f.max = parseNum(values.max, "--max");
|
|
144
|
+
|
|
145
|
+
if (typeof values.type === "string") {
|
|
146
|
+
if (values.type !== "debit" && values.type !== "credit") {
|
|
147
|
+
throw new OfxError("USAGE", `--type must be "debit" or "credit", got "${values.type}".`);
|
|
148
|
+
}
|
|
149
|
+
f.type = values.type;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (typeof values.search === "string") f.search = values.search;
|
|
153
|
+
if (values.regex === true) f.regex = true;
|
|
154
|
+
if (typeof values.account === "string") f.account = values.account;
|
|
155
|
+
|
|
156
|
+
if (typeof values.limit === "string") {
|
|
157
|
+
const n = parseNum(values.limit, "--limit");
|
|
158
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
159
|
+
throw new OfxError("USAGE", `--limit must be a non-negative integer, got "${values.limit}".`);
|
|
160
|
+
}
|
|
161
|
+
f.limit = n;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (f.regex && f.search == null) {
|
|
165
|
+
throw new OfxError("USAGE", "--regex requires --search.");
|
|
166
|
+
}
|
|
167
|
+
return f;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function parseNum(value: string, flag: string): number {
|
|
171
|
+
const n = Number(value);
|
|
172
|
+
if (Number.isNaN(n)) throw new OfxError("USAGE", `${flag} must be a number, got "${value}".`);
|
|
173
|
+
return n;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function checkDate(value: string, flag: string): string {
|
|
177
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
178
|
+
throw new OfxError("USAGE", `${flag} must be YYYY-MM-DD, got "${value}".`);
|
|
179
|
+
}
|
|
180
|
+
return value;
|
|
181
|
+
}
|
package/src/dates.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OFX datetime helpers.
|
|
3
|
+
*
|
|
4
|
+
* OFX dates look like: YYYYMMDD[HHMMSS[.SSS]][[+/-OFFSET[:TZNAME]]]
|
|
5
|
+
* Examples:
|
|
6
|
+
* "20240115"
|
|
7
|
+
* "20240115120000"
|
|
8
|
+
* "20240115120000.000"
|
|
9
|
+
* "20240115120000.000[-5:EST]"
|
|
10
|
+
* "20240115120000[+5.5:IST]"
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const OFX_DATE_RE =
|
|
14
|
+
/^(\d{4})(\d{2})(\d{2})(?:(\d{2})(\d{2})(\d{2})(?:\.(\d{1,3}))?)?(?:\[\s*([+-]?\d+(?:\.\d+)?)\s*(?::([^\]]*))?\])?$/;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Convert an OFX datetime to ISO 8601.
|
|
18
|
+
* Returns a full timestamp when a time component is present, otherwise a
|
|
19
|
+
* date-only string (YYYY-MM-DD). Returns null when the value can't be parsed.
|
|
20
|
+
*/
|
|
21
|
+
export function ofxToIso(value: string | null | undefined): string | null {
|
|
22
|
+
if (value == null) return null;
|
|
23
|
+
const raw = String(value).trim();
|
|
24
|
+
const m = OFX_DATE_RE.exec(raw);
|
|
25
|
+
if (m == null) return null;
|
|
26
|
+
|
|
27
|
+
const [, y, mo, d, hh, mm, ss, ms, tz] = m;
|
|
28
|
+
if (hh == null) return `${y}-${mo}-${d}`;
|
|
29
|
+
|
|
30
|
+
const millis = ms != null ? `.${ms.padEnd(3, "0")}` : "";
|
|
31
|
+
const offset = tz != null ? formatOffset(tz) : "";
|
|
32
|
+
return `${y}-${mo}-${d}T${hh}:${mm}:${ss}${millis}${offset}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Date-only portion (YYYY-MM-DD) used for inclusive range comparisons. */
|
|
36
|
+
export function ofxToDateOnly(value: string | null | undefined): string | null {
|
|
37
|
+
const iso = ofxToIso(value);
|
|
38
|
+
return iso == null ? null : iso.slice(0, 10);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function formatOffset(tz: string): string {
|
|
42
|
+
const hours = Number(tz);
|
|
43
|
+
if (Number.isNaN(hours)) return "";
|
|
44
|
+
const sign = hours < 0 ? "-" : "+";
|
|
45
|
+
const abs = Math.abs(hours);
|
|
46
|
+
const wholeHours = Math.floor(abs);
|
|
47
|
+
const minutes = Math.round((abs - wholeHours) * 60);
|
|
48
|
+
const hh = String(wholeHours).padStart(2, "0");
|
|
49
|
+
const mm = String(minutes).padStart(2, "0");
|
|
50
|
+
return `${sign}${hh}:${mm}`;
|
|
51
|
+
}
|
package/src/help.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
export const HELP_TEXT = `ofxreader — read & query OFX 2.x (XML) files. JSON in, JSON out.
|
|
2
|
+
|
|
3
|
+
USAGE
|
|
4
|
+
ofxreader <command> <file.ofx> [options]
|
|
5
|
+
ofxreader --llm # full machine-readable usage guide (for LLMs/agents)
|
|
6
|
+
ofxreader --version
|
|
7
|
+
ofxreader --help
|
|
8
|
+
|
|
9
|
+
COMMANDS
|
|
10
|
+
summary <file> Per-statement overview (account, period, balances, totals)
|
|
11
|
+
accounts <file> Accounts found in the file
|
|
12
|
+
transactions <file> Transactions, with optional filters
|
|
13
|
+
vendors List learned vendor aliases
|
|
14
|
+
vendor-learn "<vendor>" "<descriptor>" [more...] Teach descriptors for a vendor
|
|
15
|
+
|
|
16
|
+
TRANSACTION FILTERS
|
|
17
|
+
--from YYYY-MM-DD --to YYYY-MM-DD posted-date range (inclusive)
|
|
18
|
+
--min N --max N signed-amount range (e.g. --min=-100)
|
|
19
|
+
--type debit|credit debit = money out, credit = money in
|
|
20
|
+
--search TEXT --regex match name + memo + payee
|
|
21
|
+
--account ACCTID restrict to one account
|
|
22
|
+
--vendor "Name" resolve a learned vendor alias (+ candidates)
|
|
23
|
+
--limit N cap rows returned
|
|
24
|
+
|
|
25
|
+
GLOBAL
|
|
26
|
+
--pretty indent JSON (default: compact)
|
|
27
|
+
|
|
28
|
+
Run "ofxreader --llm" for output shapes, exit codes, and examples.`;
|
|
29
|
+
|
|
30
|
+
export const LLM_INSTRUCTIONS = `ofxreader — instructions for LLM/agent use
|
|
31
|
+
==========================================
|
|
32
|
+
|
|
33
|
+
PURPOSE
|
|
34
|
+
Parse a bank or credit-card OFX 2.x (XML) export and return structured JSON for
|
|
35
|
+
accounts, balances, statement periods, and transactions — optionally filtered.
|
|
36
|
+
Output is deterministic JSON on stdout, so you can parse it directly.
|
|
37
|
+
|
|
38
|
+
INVOCATION
|
|
39
|
+
ofxreader <command> <file.ofx> [options]
|
|
40
|
+
|
|
41
|
+
COMMANDS
|
|
42
|
+
summary <file>
|
|
43
|
+
Per-statement overview. Returns a JSON array, one object per statement:
|
|
44
|
+
{
|
|
45
|
+
"account": { "id", "type", "bankId", "branchId" },
|
|
46
|
+
"currency": "USD" | null,
|
|
47
|
+
"period": { "start": ISO8601|null, "end": ISO8601|null },
|
|
48
|
+
"balance": { "amount": number, "asOf": ISO8601|null } | null,
|
|
49
|
+
"available": { "amount": number, "asOf": ISO8601|null } | null,
|
|
50
|
+
"counts": { "transactions": int, "credits": int, "debits": int },
|
|
51
|
+
"totals": { "credits": number, "debits": number, "net": number }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
accounts <file>
|
|
55
|
+
JSON array of the accounts in the file:
|
|
56
|
+
{ "id", "type", "bankId", "branchId" }
|
|
57
|
+
|
|
58
|
+
transactions <file> [filters]
|
|
59
|
+
JSON object:
|
|
60
|
+
{ "total": int, "count": int, "transactions": [ Transaction, ... ] }
|
|
61
|
+
"total" = matches found; "count" = rows returned (differs when --limit is set).
|
|
62
|
+
Transaction:
|
|
63
|
+
{
|
|
64
|
+
"account": acctid, "id": fitid, "date": ISO8601|null,
|
|
65
|
+
"amount": number, // signed; negative = money out
|
|
66
|
+
"trnType": "DEBIT"|"CREDIT"|"CHECK"|"POS"|"FEE"|..., // raw OFX type
|
|
67
|
+
"name": string, "memo": string|null,
|
|
68
|
+
"payee": string|null, "checkNumber": string|null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
vendors
|
|
72
|
+
JSON object mapping canonical vendor name -> { signatures[], raw[], updatedAt }.
|
|
73
|
+
|
|
74
|
+
vendor-learn "<vendor>" "<descriptor>" [more...]
|
|
75
|
+
Persist that these raw descriptors belong to the vendor (normalized into
|
|
76
|
+
signatures for deterministic matching). Returns the updated vendor entry.
|
|
77
|
+
|
|
78
|
+
TRANSACTION FILTERS (transactions command only; all optional, all combinable)
|
|
79
|
+
--from YYYY-MM-DD posted on/after this date (inclusive)
|
|
80
|
+
--to YYYY-MM-DD posted on/before this date (inclusive)
|
|
81
|
+
--min N signed amount >= N (use = for negatives, e.g. --min=-100)
|
|
82
|
+
--max N signed amount <= N (e.g. --max=-0.01 for outflows only)
|
|
83
|
+
--type debit|credit debit = amount < 0 (money out); credit = amount > 0 (money in)
|
|
84
|
+
--search TEXT case-insensitive substring over name + memo + payee
|
|
85
|
+
--regex treat --search as a JavaScript regular expression
|
|
86
|
+
--account ACCTID restrict to a single account (id from "accounts"/"summary")
|
|
87
|
+
--limit N return at most N rows ("total" still reports all matches)
|
|
88
|
+
--vendor "Name" resolve a learned vendor alias; results hold only CONFIRMED
|
|
89
|
+
matches and add "vendorCandidates" (fuzzy) to learn from
|
|
90
|
+
|
|
91
|
+
GLOBAL OPTIONS
|
|
92
|
+
--pretty indent JSON for humans (default is compact, token-efficient)
|
|
93
|
+
--llm print this guide
|
|
94
|
+
--version print version
|
|
95
|
+
--help print short usage
|
|
96
|
+
|
|
97
|
+
VENDOR LEARNING (match messy descriptors to a real vendor)
|
|
98
|
+
OFX descriptors are noisy ("SQ *JASONS CARO 0123") and rarely equal the brand name.
|
|
99
|
+
To answer "what did I spend at Jason's Carousel in April":
|
|
100
|
+
1) transactions stmt.ofx --vendor "Jason's Carousel" --from 2024-04-01 --to 2024-04-30
|
|
101
|
+
-> "transactions" = confirmed matches; "vendorCandidates" = fuzzy guesses, each
|
|
102
|
+
{ "normalized", "examples": [raw...], "count", "similarity" }.
|
|
103
|
+
2) If a candidate is right, confirm with the user, then persist it:
|
|
104
|
+
vendor-learn "Jason's Carousel" "SQ *JASONS CARO 0123" "TST* JASONSCAROUSEL"
|
|
105
|
+
3) Re-run step 1 — matches are now deterministic.
|
|
106
|
+
Store: $OFXREADER_VENDORS, else ~/.config/ofxreader/vendors.json.
|
|
107
|
+
|
|
108
|
+
OUTPUT CONTRACT
|
|
109
|
+
Success: JSON on stdout, exit code 0.
|
|
110
|
+
Failure: JSON on stderr as {"error":{"code","message"}}, non-zero exit code.
|
|
111
|
+
USAGE (exit 2) bad/unknown arguments or missing file
|
|
112
|
+
FILE_NOT_FOUND (exit 1) path does not exist
|
|
113
|
+
READ_ERROR (exit 1) path unreadable / is a directory
|
|
114
|
+
NOT_OFX2 (exit 1) not an OFX 2.x XML file (OFX 1.x SGML is rejected)
|
|
115
|
+
PARSE_ERROR (exit 1) malformed XML / no <OFX> root
|
|
116
|
+
VENDOR_STORE_ERROR (exit 1) vendor alias store is unreadable / invalid JSON
|
|
117
|
+
Notes: amounts are signed numbers; dates are ISO 8601 strings; flags accept
|
|
118
|
+
both "--flag value" and "--flag=value" (use the "=" form for negative numbers).
|
|
119
|
+
|
|
120
|
+
EXAMPLES
|
|
121
|
+
ofxreader summary statement.ofx
|
|
122
|
+
ofxreader accounts statement.ofx --pretty
|
|
123
|
+
ofxreader transactions statement.ofx --from 2024-01-01 --to 2024-03-31
|
|
124
|
+
ofxreader transactions statement.ofx --type debit --search amazon
|
|
125
|
+
ofxreader transactions statement.ofx --min=-50 --max=-0.01 # small outflows
|
|
126
|
+
ofxreader transactions statement.ofx --search "^ACME" --regex --limit 50
|
|
127
|
+
ofxreader transactions statement.ofx --account 1234567890
|
|
128
|
+
ofxreader transactions statement.ofx --vendor "Jason's Carousel" --from 2024-04-01 --to 2024-04-30
|
|
129
|
+
ofxreader vendor-learn "Jason's Carousel" "SQ *JASONS CARO 0123"
|
|
130
|
+
ofxreader vendors`;
|