@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/src/mcp.ts ADDED
@@ -0,0 +1,259 @@
1
+ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+
4
+ import { readOfxFile, parseOfx, OfxError } from "./parser.ts";
5
+ import { buildDocument } from "./model.ts";
6
+ import type { OfxDocument, Statement } from "./model.ts";
7
+ import { summaries, uniqueAccounts, balances } from "./report.ts";
8
+ import type { BalancePoint } from "./report.ts";
9
+ import { filterTransactions } from "./query.ts";
10
+ import type { TransactionFilters } from "./query.ts";
11
+ import { resolveVendorQuery } from "./vendors/resolve.ts";
12
+ import { load as loadVendors, save as saveVendors, learn as learnVendor, today } from "./vendors/store.ts";
13
+ import { getVersion } from "./version.ts";
14
+
15
+ type ToolResult = {
16
+ content: Array<{ type: "text"; text: string }>;
17
+ isError?: boolean;
18
+ };
19
+
20
+ function load(path: string): OfxDocument {
21
+ return buildDocument(parseOfx(readOfxFile(path)));
22
+ }
23
+
24
+ function ok(data: unknown): ToolResult {
25
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
26
+ }
27
+
28
+ function fail(err: unknown): ToolResult {
29
+ const code = err instanceof OfxError ? err.code : "INTERNAL";
30
+ const message = err instanceof Error ? err.message : String(err);
31
+ return {
32
+ content: [{ type: "text", text: JSON.stringify({ error: { code, message } }) }],
33
+ isError: true,
34
+ };
35
+ }
36
+
37
+ const PATH_FIELD = z
38
+ .string()
39
+ .describe("Path to an OFX 2.x (XML) file, e.g. /Users/me/statement.ofx");
40
+
41
+ /** Build the ofxreader MCP server with one tool per CLI capability. */
42
+ export function createServer(): McpServer {
43
+ const server = new McpServer({ name: "ofxreader", version: getVersion() });
44
+
45
+ server.registerTool(
46
+ "ofx_summary",
47
+ {
48
+ title: "Summarize OFX statements",
49
+ description:
50
+ "Summarize each statement in an OFX 2.x (XML) bank or credit-card file: account, " +
51
+ "currency, statement period, ledger & available balance, transaction counts, and " +
52
+ "totals (credits, debits, net). Use this for any .ofx file.",
53
+ inputSchema: { path: PATH_FIELD },
54
+ },
55
+ async ({ path }) => {
56
+ try {
57
+ return ok(summaries(load(path).statements));
58
+ } catch (err) {
59
+ return fail(err);
60
+ }
61
+ },
62
+ );
63
+
64
+ server.registerTool(
65
+ "ofx_accounts",
66
+ {
67
+ title: "List OFX accounts",
68
+ description:
69
+ "List the accounts found in an OFX 2.x (XML) file: id, type (CHECKING, SAVINGS, " +
70
+ "CREDITCARD, ...), bankId, and branchId.",
71
+ inputSchema: { path: PATH_FIELD },
72
+ },
73
+ async ({ path }) => {
74
+ try {
75
+ return ok(uniqueAccounts(load(path).statements));
76
+ } catch (err) {
77
+ return fail(err);
78
+ }
79
+ },
80
+ );
81
+
82
+ server.registerTool(
83
+ "ofx_transactions",
84
+ {
85
+ title: "Query OFX transactions",
86
+ description:
87
+ "Read transactions from an OFX 2.x (XML) file with optional filters: posted-date " +
88
+ "range, signed-amount range, debit/credit direction, case-insensitive text or regex " +
89
+ "search over name+memo+payee, single account, and a row limit. Returns " +
90
+ "{ total, count, transactions[] } where total is the match count and count is the " +
91
+ "number of rows returned. Use this to find or extract specific transactions. " +
92
+ "Pass `vendor` to resolve a learned vendor alias: results are restricted to " +
93
+ "confirmed matches and the response also includes `vendorCandidates` (fuzzy, " +
94
+ "unconfirmed descriptors) to propose to the user and persist via ofx_vendor_learn.",
95
+ inputSchema: {
96
+ path: PATH_FIELD,
97
+ from: z
98
+ .string()
99
+ .regex(/^\d{4}-\d{2}-\d{2}$/)
100
+ .optional()
101
+ .describe("Inclusive start date, YYYY-MM-DD"),
102
+ to: z
103
+ .string()
104
+ .regex(/^\d{4}-\d{2}-\d{2}$/)
105
+ .optional()
106
+ .describe("Inclusive end date, YYYY-MM-DD"),
107
+ min: z.number().optional().describe("Minimum signed amount (negative = money out)"),
108
+ max: z.number().optional().describe("Maximum signed amount"),
109
+ type: z
110
+ .enum(["debit", "credit"])
111
+ .optional()
112
+ .describe("debit = amount < 0 (money out); credit = amount > 0 (money in)"),
113
+ search: z
114
+ .string()
115
+ .optional()
116
+ .describe("Case-insensitive match over name + memo + payee"),
117
+ regex: z
118
+ .boolean()
119
+ .optional()
120
+ .describe("Treat search as a JavaScript regular expression"),
121
+ account: z.string().optional().describe("Restrict to a single account id"),
122
+ limit: z
123
+ .number()
124
+ .int()
125
+ .nonnegative()
126
+ .optional()
127
+ .describe("Maximum rows to return (total still reports all matches)"),
128
+ vendor: z
129
+ .string()
130
+ .optional()
131
+ .describe(
132
+ "Canonical vendor name to resolve via the learned alias store. Restricts " +
133
+ "results to confirmed matches and returns vendorCandidates to learn from.",
134
+ ),
135
+ },
136
+ },
137
+ async ({ path, regex, search, from, to, min, max, type, account, limit, vendor }) => {
138
+ try {
139
+ if (regex && search == null) {
140
+ return fail(new OfxError("USAGE", "regex requires search."));
141
+ }
142
+ const filters: TransactionFilters = {
143
+ from,
144
+ to,
145
+ min,
146
+ max,
147
+ type,
148
+ account,
149
+ limit,
150
+ search,
151
+ regex,
152
+ };
153
+ const { statements } = load(path);
154
+ if (vendor != null) {
155
+ return ok(resolveVendorQuery(loadVendors(), statements, vendor, filters));
156
+ }
157
+ const all = statements.flatMap((s) => s.transactions);
158
+ return ok(filterTransactions(all, filters));
159
+ } catch (err) {
160
+ return fail(err);
161
+ }
162
+ },
163
+ );
164
+
165
+ server.registerTool(
166
+ "ofx_vendor_learn",
167
+ {
168
+ title: "Learn a vendor alias",
169
+ description:
170
+ "Persist that the given raw OFX descriptors belong to a vendor. Descriptors are " +
171
+ "normalized into signatures so future ofx_transactions(vendor) queries match them " +
172
+ "deterministically. Use after the user confirms candidate descriptors.",
173
+ inputSchema: {
174
+ vendor: z.string().describe('Canonical vendor name, e.g. "Jason\'s Carousel"'),
175
+ descriptors: z
176
+ .array(z.string())
177
+ .min(1)
178
+ .describe("Confirmed raw descriptors (transaction name/memo/payee) for this vendor"),
179
+ },
180
+ },
181
+ async ({ vendor, descriptors }) => {
182
+ try {
183
+ const store = loadVendors();
184
+ const entry = learnVendor(store, vendor, descriptors, today());
185
+ saveVendors(store);
186
+ return ok({ vendor, ...entry });
187
+ } catch (err) {
188
+ return fail(err);
189
+ }
190
+ },
191
+ );
192
+
193
+ server.registerTool(
194
+ "ofx_vendors",
195
+ {
196
+ title: "List learned vendors",
197
+ description:
198
+ "List the learned vendor aliases (canonical name -> signatures + raw descriptors).",
199
+ inputSchema: {},
200
+ },
201
+ async () => {
202
+ try {
203
+ return ok(loadVendors().vendors);
204
+ } catch (err) {
205
+ return fail(err);
206
+ }
207
+ },
208
+ );
209
+
210
+ // Resource: statement balances for an OFX file, each stated with its as-of date.
211
+ // Read URI: ofx:/absolute/path/to/file.ofx
212
+ server.registerResource(
213
+ "ofx-balances",
214
+ new ResourceTemplate("ofx:{+path}", { list: undefined }),
215
+ {
216
+ title: "OFX statement balances",
217
+ description:
218
+ "Ledger and available balances for each account in an OFX 2.x file, each stated " +
219
+ 'with its as-of date (e.g. "Balance at 2024-03-31 is 4327.87 USD"). ' +
220
+ "Read it with the URI ofx:/absolute/path/to/file.ofx",
221
+ mimeType: "text/plain",
222
+ },
223
+ async (uri, variables) => {
224
+ const rawPath = Array.isArray(variables.path) ? variables.path[0] : variables.path;
225
+ const path = decodeURIComponent(rawPath ?? "");
226
+ const doc = load(path); // throws OfxError -> surfaced as a read error
227
+ return {
228
+ contents: [{ uri: uri.href, mimeType: "text/plain", text: formatBalances(doc.statements) }],
229
+ };
230
+ },
231
+ );
232
+
233
+ return server;
234
+ }
235
+
236
+ /** Human-readable balance report: one block per statement, each balance dated. */
237
+ function formatBalances(statements: Statement[]): string {
238
+ const accounts = balances(statements);
239
+ if (accounts.length === 0) return "No statements found in this OFX file.";
240
+
241
+ return accounts
242
+ .map((a) => {
243
+ const cur = a.currency ? ` ${a.currency}` : "";
244
+ const header = `Account ${a.account} — ${a.accountType}${a.currency ? ` (${a.currency})` : ""}`;
245
+ const lines = [header];
246
+ if (a.ledger) lines.push(` ${balanceLine("Balance", a.ledger, cur)}`);
247
+ if (a.available) lines.push(` ${balanceLine("Available balance", a.available, cur)}`);
248
+ if (!a.ledger && !a.available) lines.push(" No balance reported.");
249
+ return lines.join("\n");
250
+ })
251
+ .join("\n\n");
252
+ }
253
+
254
+ function balanceLine(label: string, point: BalancePoint, currency: string): string {
255
+ const amount = Number.isFinite(point.amount) ? point.amount.toFixed(2) : "unknown";
256
+ return point.date != null
257
+ ? `${label} at ${point.date} is ${amount}${currency}`
258
+ : `${label} is ${amount}${currency} (as-of date unknown)`;
259
+ }
package/src/model.ts ADDED
@@ -0,0 +1,137 @@
1
+ import { ofxToIso } from "./dates.ts";
2
+
3
+ export type Account = {
4
+ id: string;
5
+ type: string; // CHECKING | SAVINGS | CREDITCARD | MONEYMRKT | ...
6
+ bankId: string | null;
7
+ branchId: string | null;
8
+ };
9
+
10
+ export type Money = {
11
+ amount: number;
12
+ asOf: string | null; // ISO 8601
13
+ };
14
+
15
+ export type Transaction = {
16
+ account: string; // owning ACCTID
17
+ id: string; // FITID
18
+ date: string | null; // ISO 8601 (from DTPOSTED)
19
+ amount: number; // signed (TRNAMT); negative = outflow
20
+ trnType: string; // raw OFX TRNTYPE (DEBIT, CREDIT, CHECK, POS, FEE, ...)
21
+ name: string;
22
+ memo: string | null;
23
+ payee: string | null;
24
+ checkNumber: string | null;
25
+ };
26
+
27
+ export type Statement = {
28
+ account: Account;
29
+ currency: string | null;
30
+ period: { start: string | null; end: string | null };
31
+ balance: Money | null; // LEDGERBAL
32
+ available: Money | null; // AVAILBAL
33
+ transactions: Transaction[];
34
+ };
35
+
36
+ export type OfxDocument = {
37
+ statements: Statement[];
38
+ };
39
+
40
+ /* eslint-disable @typescript-eslint/no-explicit-any */
41
+ type Raw = any;
42
+
43
+ function toArray<T>(x: T | T[] | null | undefined): T[] {
44
+ if (x == null) return [];
45
+ return Array.isArray(x) ? x : [x];
46
+ }
47
+
48
+ /** Coerce a parsed node value to a trimmed string, or null when empty/absent. */
49
+ function str(v: unknown): string | null {
50
+ if (v == null || typeof v === "object") return null;
51
+ const s = String(v).trim();
52
+ return s === "" ? null : s;
53
+ }
54
+
55
+ /** Coerce a parsed node value to a number; NaN when absent or non-numeric. */
56
+ function num(v: unknown): number {
57
+ const s = str(v);
58
+ return s == null ? NaN : Number(s);
59
+ }
60
+
61
+ /** Normalize a raw parsed OFX object into the canonical document model. */
62
+ export function buildDocument(raw: Raw): OfxDocument {
63
+ const ofx: Raw = raw?.OFX ?? {};
64
+ const statements: Statement[] = [];
65
+
66
+ for (const trnrs of toArray<Raw>(ofx.BANKMSGSRSV1?.STMTTRNRS)) {
67
+ for (const stmt of toArray<Raw>(trnrs?.STMTRS)) {
68
+ statements.push(bankStatement(stmt));
69
+ }
70
+ }
71
+ for (const trnrs of toArray<Raw>(ofx.CREDITCARDMSGSRSV1?.CCSTMTTRNRS)) {
72
+ for (const stmt of toArray<Raw>(trnrs?.CCSTMTRS)) {
73
+ statements.push(creditCardStatement(stmt));
74
+ }
75
+ }
76
+
77
+ return { statements };
78
+ }
79
+
80
+ function bankStatement(stmt: Raw): Statement {
81
+ const acct: Raw = stmt?.BANKACCTFROM ?? {};
82
+ const account: Account = {
83
+ id: str(acct.ACCTID) ?? "",
84
+ type: str(acct.ACCTTYPE) ?? "UNKNOWN",
85
+ bankId: str(acct.BANKID),
86
+ branchId: str(acct.BRANCHID),
87
+ };
88
+ return assemble(stmt, account);
89
+ }
90
+
91
+ function creditCardStatement(stmt: Raw): Statement {
92
+ const acct: Raw = stmt?.CCACCTFROM ?? {};
93
+ const account: Account = {
94
+ id: str(acct.ACCTID) ?? "",
95
+ type: "CREDITCARD",
96
+ bankId: null,
97
+ branchId: null,
98
+ };
99
+ return assemble(stmt, account);
100
+ }
101
+
102
+ function assemble(stmt: Raw, account: Account): Statement {
103
+ const tranlist: Raw = stmt?.BANKTRANLIST ?? {};
104
+ const transactions = toArray<Raw>(tranlist.STMTTRN).map((t) => transaction(t, account.id));
105
+ return {
106
+ account,
107
+ currency: str(stmt?.CURDEF),
108
+ period: {
109
+ start: ofxToIso(str(tranlist.DTSTART)),
110
+ end: ofxToIso(str(tranlist.DTEND)),
111
+ },
112
+ balance: balance(stmt?.LEDGERBAL),
113
+ available: balance(stmt?.AVAILBAL),
114
+ transactions,
115
+ };
116
+ }
117
+
118
+ function balance(node: Raw): Money | null {
119
+ if (node == null) return null;
120
+ return { amount: num(node.BALAMT), asOf: ofxToIso(str(node.DTASOF)) };
121
+ }
122
+
123
+ function transaction(t: Raw, accountId: string): Transaction {
124
+ const payee =
125
+ t?.PAYEE != null && typeof t.PAYEE === "object" ? str(t.PAYEE.NAME) : str(t?.PAYEE);
126
+ return {
127
+ account: accountId,
128
+ id: str(t?.FITID) ?? "",
129
+ date: ofxToIso(str(t?.DTPOSTED)),
130
+ amount: num(t?.TRNAMT),
131
+ trnType: str(t?.TRNTYPE) ?? "OTHER",
132
+ name: str(t?.NAME) ?? "",
133
+ memo: str(t?.MEMO),
134
+ payee,
135
+ checkNumber: str(t?.CHECKNUM),
136
+ };
137
+ }
package/src/output.ts ADDED
@@ -0,0 +1,9 @@
1
+ /** Write a successful result as JSON to stdout. */
2
+ export function emit(data: unknown, pretty: boolean): void {
3
+ process.stdout.write(JSON.stringify(data, null, pretty ? 2 : 0) + "\n");
4
+ }
5
+
6
+ /** Write a structured error as JSON to stderr. */
7
+ export function emitError(code: string, message: string): void {
8
+ process.stderr.write(JSON.stringify({ error: { code, message } }) + "\n");
9
+ }
package/src/parser.ts ADDED
@@ -0,0 +1,85 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { XMLParser } from "fast-xml-parser";
3
+
4
+ /** Structured error carrying a machine-readable code for the CLI's JSON output. */
5
+ export class OfxError extends Error {
6
+ code: string;
7
+ constructor(code: string, message: string) {
8
+ super(message);
9
+ this.name = "OfxError";
10
+ this.code = code;
11
+ }
12
+ }
13
+
14
+ const HEADER_SCAN_LEN = 1024;
15
+
16
+ /**
17
+ * Sniff which OFX generation a file is.
18
+ * "2" -> OFX 2.x (XML) (supported)
19
+ * "1" -> OFX 1.x (SGML) (rejected)
20
+ * null -> not recognizable as OFX
21
+ */
22
+ export function detectOfxVersion(text: string): "1" | "2" | null {
23
+ const head = text.slice(0, HEADER_SCAN_LEN);
24
+ // OFX 2.x carries an XML processing instruction: <?OFX OFXHEADER="200" ...?>
25
+ if (/<\?OFX[^>]*OFXHEADER\s*=\s*"\s*2\d*\s*"/i.test(head)) return "2";
26
+ // Some 2.x exports drop the <?OFX?> PI but are still XML with an <OFX> root.
27
+ if (/<\?xml[\s?]/i.test(head) && /<OFX[\s>]/i.test(text)) return "2";
28
+ // OFX 1.x uses a colon-delimited SGML header: OFXHEADER:100
29
+ if (/OFXHEADER\s*:\s*\d+/i.test(head)) return "1";
30
+ return null;
31
+ }
32
+
33
+ /** Read a file from disk, mapping fs errors to OfxError codes. */
34
+ export function readOfxFile(path: string): string {
35
+ try {
36
+ return readFileSync(path, "utf8");
37
+ } catch (err) {
38
+ const e = err as NodeJS.ErrnoException;
39
+ if (e.code === "ENOENT") {
40
+ throw new OfxError("FILE_NOT_FOUND", `File not found: ${path}`);
41
+ }
42
+ if (e.code === "EISDIR") {
43
+ throw new OfxError("READ_ERROR", `Path is a directory, not a file: ${path}`);
44
+ }
45
+ throw new OfxError("READ_ERROR", `Could not read file ${path}: ${e.message}`);
46
+ }
47
+ }
48
+
49
+ /** Parse OFX 2.x text into a raw nested object. Throws OfxError on any problem. */
50
+ export function parseOfx(text: string): unknown {
51
+ const version = detectOfxVersion(text);
52
+ if (version === "1") {
53
+ throw new OfxError(
54
+ "NOT_OFX2",
55
+ "File looks like OFX 1.x (SGML). This tool only supports OFX 2.x (XML).",
56
+ );
57
+ }
58
+ if (version == null) {
59
+ throw new OfxError("NOT_OFX2", "File is not recognizable as OFX 2.x (XML).");
60
+ }
61
+
62
+ // Strip every processing instruction (<?xml?>, <?OFX?>) so the parser only
63
+ // ever sees the <OFX> element tree.
64
+ const cleaned = text.replace(/<\?[\s\S]*?\?>/g, "");
65
+
66
+ const parser = new XMLParser({
67
+ ignoreAttributes: true,
68
+ ignoreDeclaration: true,
69
+ parseTagValue: false, // keep amounts/dates/ids as raw strings; we normalize ourselves
70
+ trimValues: true,
71
+ processEntities: true,
72
+ });
73
+
74
+ let raw: unknown;
75
+ try {
76
+ raw = parser.parse(cleaned);
77
+ } catch (err) {
78
+ throw new OfxError("PARSE_ERROR", `Failed to parse OFX XML: ${(err as Error).message}`);
79
+ }
80
+
81
+ if (raw == null || typeof raw !== "object" || !("OFX" in (raw as object))) {
82
+ throw new OfxError("PARSE_ERROR", "Parsed file has no <OFX> root element.");
83
+ }
84
+ return raw;
85
+ }
package/src/query.ts ADDED
@@ -0,0 +1,61 @@
1
+ import type { Transaction } from "./model.ts";
2
+
3
+ export type TransactionFilters = {
4
+ from?: string; // YYYY-MM-DD inclusive lower bound on posted date
5
+ to?: string; // YYYY-MM-DD inclusive upper bound
6
+ min?: number; // signed amount >=
7
+ max?: number; // signed amount <=
8
+ type?: "debit" | "credit"; // debit: amount < 0, credit: amount > 0
9
+ search?: string; // matched against name + memo + payee
10
+ regex?: boolean; // treat search as a RegExp
11
+ account?: string; // restrict to one ACCTID
12
+ limit?: number; // cap returned rows (matches still counted in `total`)
13
+ };
14
+
15
+ export type QueryResult = {
16
+ total: number; // matches before --limit
17
+ count: number; // rows returned after --limit
18
+ transactions: Transaction[];
19
+ };
20
+
21
+ export function filterTransactions(
22
+ txns: Transaction[],
23
+ filters: TransactionFilters,
24
+ ): QueryResult {
25
+ const matcher = buildMatcher(filters);
26
+
27
+ const matched = txns.filter((t) => {
28
+ if (filters.account != null && t.account !== filters.account) return false;
29
+
30
+ const date = t.date == null ? null : t.date.slice(0, 10);
31
+ if (filters.from != null && (date == null || date < filters.from)) return false;
32
+ if (filters.to != null && (date == null || date > filters.to)) return false;
33
+
34
+ if (filters.min != null && !(t.amount >= filters.min)) return false;
35
+ if (filters.max != null && !(t.amount <= filters.max)) return false;
36
+
37
+ if (filters.type === "debit" && !(t.amount < 0)) return false;
38
+ if (filters.type === "credit" && !(t.amount > 0)) return false;
39
+
40
+ if (matcher != null) {
41
+ const haystack = `${t.name} ${t.memo ?? ""} ${t.payee ?? ""}`;
42
+ if (!matcher.test(haystack)) return false;
43
+ }
44
+ return true;
45
+ });
46
+
47
+ const transactions =
48
+ filters.limit != null ? matched.slice(0, filters.limit) : matched;
49
+
50
+ return { total: matched.length, count: transactions.length, transactions };
51
+ }
52
+
53
+ function buildMatcher(filters: TransactionFilters): RegExp | null {
54
+ if (filters.search == null || filters.search === "") return null;
55
+ const source = filters.regex ? filters.search : escapeRegExp(filters.search);
56
+ return new RegExp(source, "i");
57
+ }
58
+
59
+ function escapeRegExp(s: string): string {
60
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
61
+ }
package/src/report.ts ADDED
@@ -0,0 +1,90 @@
1
+ import type { Account, Money, Statement } from "./model.ts";
2
+
3
+ export type Summary = {
4
+ account: Account;
5
+ currency: string | null;
6
+ period: { start: string | null; end: string | null };
7
+ balance: Money | null;
8
+ available: Money | null;
9
+ counts: { transactions: number; credits: number; debits: number };
10
+ totals: { credits: number; debits: number; net: number };
11
+ };
12
+
13
+ /** One summary object per statement. */
14
+ export function summaries(statements: Statement[]): Summary[] {
15
+ return statements.map(summarize);
16
+ }
17
+
18
+ /** Accounts across all statements, deduplicated by type + id. */
19
+ export function uniqueAccounts(statements: Statement[]): Account[] {
20
+ const seen = new Map<string, Account>();
21
+ for (const s of statements) {
22
+ const key = `${s.account.type}:${s.account.id}`;
23
+ if (!seen.has(key)) seen.set(key, s.account);
24
+ }
25
+ return [...seen.values()];
26
+ }
27
+
28
+ function summarize(stmt: Statement): Summary {
29
+ let credits = 0;
30
+ let debits = 0;
31
+ let creditSum = 0;
32
+ let debitSum = 0;
33
+ for (const t of stmt.transactions) {
34
+ if (t.amount > 0) {
35
+ credits++;
36
+ creditSum += t.amount;
37
+ } else if (t.amount < 0) {
38
+ debits++;
39
+ debitSum += t.amount;
40
+ }
41
+ }
42
+ return {
43
+ account: stmt.account,
44
+ currency: stmt.currency,
45
+ period: stmt.period,
46
+ balance: stmt.balance,
47
+ available: stmt.available,
48
+ counts: { transactions: stmt.transactions.length, credits, debits },
49
+ totals: {
50
+ credits: round2(creditSum),
51
+ debits: round2(debitSum),
52
+ net: round2(creditSum + debitSum),
53
+ },
54
+ };
55
+ }
56
+
57
+ function round2(n: number): number {
58
+ if (!Number.isFinite(n)) return 0;
59
+ return Math.round((n + Number.EPSILON) * 100) / 100;
60
+ }
61
+
62
+ export type BalancePoint = {
63
+ amount: number;
64
+ asOf: string | null; // full ISO 8601 timestamp from DTASOF
65
+ date: string | null; // the as-of date only (YYYY-MM-DD)
66
+ };
67
+
68
+ export type AccountBalances = {
69
+ account: string;
70
+ accountType: string;
71
+ currency: string | null;
72
+ ledger: BalancePoint | null; // LEDGERBAL
73
+ available: BalancePoint | null; // AVAILBAL
74
+ };
75
+
76
+ /** Ledger and available balances per statement, each carrying its as-of date. */
77
+ export function balances(statements: Statement[]): AccountBalances[] {
78
+ return statements.map((s) => ({
79
+ account: s.account.id,
80
+ accountType: s.account.type,
81
+ currency: s.currency,
82
+ ledger: toPoint(s.balance),
83
+ available: toPoint(s.available),
84
+ }));
85
+ }
86
+
87
+ function toPoint(b: Money | null): BalancePoint | null {
88
+ if (b == null) return null;
89
+ return { amount: b.amount, asOf: b.asOf, date: b.asOf == null ? null : b.asOf.slice(0, 10) };
90
+ }