@f-o-t/ofx 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -123,6 +123,112 @@ console.log({
123
123
  });
124
124
  ```
125
125
 
126
+ ### Generation Functions
127
+
128
+ #### `generateBankStatement(options: GenerateBankStatementOptions): string`
129
+
130
+ Generates a complete OFX bank statement file.
131
+
132
+ ```typescript
133
+ import { generateBankStatement } from "@fot/ofx";
134
+
135
+ const statement = generateBankStatement({
136
+ bankId: "123456",
137
+ accountId: "987654321",
138
+ accountType: "CHECKING",
139
+ currency: "USD",
140
+ startDate: new Date("2025-01-01"),
141
+ endDate: new Date("2025-01-31"),
142
+ transactions: [
143
+ {
144
+ type: "CREDIT",
145
+ datePosted: new Date(),
146
+ amount: 1000,
147
+ fitId: "1",
148
+ name: "Deposit",
149
+ },
150
+ ],
151
+ });
152
+
153
+ console.log(statement);
154
+ ```
155
+
156
+ #### `generateCreditCardStatement(options: GenerateCreditCardStatementOptions): string`
157
+
158
+ Generates a complete OFX credit card statement file.
159
+
160
+ ```typescript
161
+ import { generateCreditCardStatement } from "@fot/ofx";
162
+
163
+ const statement = generateCreditCardStatement({
164
+ accountId: "123456789",
165
+ currency: "USD",
166
+ startDate: new Date("2025-01-01"),
167
+ endDate: new Date("2025-01-31"),
168
+ transactions: [
169
+ {
170
+ type: "DEBIT",
171
+ datePosted: new Date(),
172
+ amount: -75.5,
173
+ fitId: "2",
174
+ name: "Purchase at a store",
175
+ },
176
+ ],
177
+ });
178
+
179
+ console.log(statement);
180
+ ```
181
+
182
+ ### Streaming Functions
183
+
184
+ For processing large OFX files with low memory footprint.
185
+
186
+ #### `parseStream(input): AsyncGenerator<StreamEvent>`
187
+
188
+ Parses an OFX file as a stream, yielding events as they are parsed.
189
+
190
+ ```typescript
191
+ import { parseStream } from "@fot/ofx";
192
+
193
+ // From a ReadableStream (e.g., fetch response)
194
+ const response = await fetch("https://example.com/statement.ofx");
195
+ for await (const event of parseStream(response.body)) {
196
+ switch (event.type) {
197
+ case "header":
198
+ console.log("OFX Version:", event.data.VERSION);
199
+ break;
200
+ case "account":
201
+ console.log("Account:", event.data.ACCTID);
202
+ break;
203
+ case "transaction":
204
+ console.log("Transaction:", event.data.NAME, event.data.TRNAMT);
205
+ break;
206
+ case "balance":
207
+ console.log("Ledger Balance:", event.data.ledger?.BALAMT);
208
+ break;
209
+ case "complete":
210
+ console.log("Total transactions:", event.transactionCount);
211
+ break;
212
+ }
213
+ }
214
+ ```
215
+
216
+ #### `parseStreamToArray(input): Promise<StreamResult>`
217
+
218
+ Collects all stream events into arrays for easier processing.
219
+
220
+ ```typescript
221
+ import { parseStreamToArray } from "@fot/ofx";
222
+
223
+ const response = await fetch("https://example.com/statement.ofx");
224
+ const result = await parseStreamToArray(response.body);
225
+
226
+ console.log("Header:", result.header);
227
+ console.log("Transactions:", result.transactions.length);
228
+ console.log("Accounts:", result.accounts);
229
+ console.log("Balances:", result.balances);
230
+ ```
231
+
126
232
  ## Types
127
233
 
128
234
  ### OFXTransaction
@@ -227,6 +333,17 @@ interface BalanceInfo {
227
333
  }
228
334
  ```
229
335
 
336
+ ### StreamEvent
337
+
338
+ ```typescript
339
+ type StreamEvent =
340
+ | { type: "header"; data: OFXHeader }
341
+ | { type: "transaction"; data: OFXTransaction }
342
+ | { type: "account"; data: OFXBankAccount | OFXCreditCardAccount }
343
+ | { type: "balance"; data: { ledger?: OFXBalance; available?: OFXBalance } }
344
+ | { type: "complete"; transactionCount: number };
345
+ ```
346
+
230
347
  ## Schemas
231
348
 
232
349
  All Zod schemas are exported for custom validation:
@@ -264,13 +381,17 @@ Tested on realistic business statement sizes:
264
381
 
265
382
  | Transactions | File Size | Parse Time |
266
383
  | ------------ | --------- | ---------- |
267
- | ~10,000 | 2.5 MB | ~800ms |
268
- | ~25,000 | 5.4 MB | ~1.3s |
269
- | ~50,000 | 10.4 MB | ~4.3s |
270
- | ~100,000 | 20.5 MB | ~27s |
384
+ | ~5,000 | 1.2 MB | ~37ms |
385
+ | ~10,000 | 2.5 MB | ~108ms |
386
+ | ~25,000 | 5.4 MB | ~230ms |
387
+ | ~50,000 | 10.4 MB | ~450ms |
271
388
 
272
389
  Extraction operations (`getTransactions`, `getBalance`, etc.) are sub-millisecond even on large datasets.
273
390
 
391
+ ### Streaming Performance
392
+
393
+ The streaming API achieves ~55,000-66,000 transactions/sec throughput with minimal memory overhead, making it ideal for processing very large files or network streams.
394
+
274
395
  ## License
275
396
 
276
397
  MIT
package/dist/index.d.ts CHANGED
@@ -42,21 +42,115 @@ type OFXHeader = z.infer<typeof ofxHeaderSchema>;
42
42
  declare const ofxDocumentSchema: unknown;
43
43
  type OFXDocument = z.infer<typeof ofxDocumentSchema>;
44
44
  declare const schemas: {};
45
+ interface BalanceInfo {
46
+ ledger?: OFXBalance;
47
+ available?: OFXBalance;
48
+ }
49
+ declare function getTransactions(document: OFXDocument): OFXTransaction[];
50
+ declare function getAccountInfo(document: OFXDocument): (OFXBankAccount | OFXCreditCardAccount)[];
51
+ declare function getBalance(document: OFXDocument): BalanceInfo[];
52
+ declare function getSignOnInfo(document: OFXDocument): OFXSignOnResponse;
53
+ interface GenerateHeaderOptions {
54
+ version?: string;
55
+ encoding?: string;
56
+ charset?: string;
57
+ }
58
+ declare function generateHeader(options?: GenerateHeaderOptions): string;
59
+ interface GenerateTransactionInput {
60
+ type: OFXTransactionType;
61
+ datePosted: Date;
62
+ amount: number;
63
+ fitId: string;
64
+ name?: string;
65
+ memo?: string;
66
+ checkNum?: string;
67
+ refNum?: string;
68
+ }
69
+ interface GenerateBankStatementOptions {
70
+ bankId: string;
71
+ accountId: string;
72
+ accountType: OFXAccountType;
73
+ currency: string;
74
+ startDate: Date;
75
+ endDate: Date;
76
+ transactions: GenerateTransactionInput[];
77
+ ledgerBalance?: {
78
+ amount: number;
79
+ asOfDate: Date;
80
+ };
81
+ availableBalance?: {
82
+ amount: number;
83
+ asOfDate: Date;
84
+ };
85
+ financialInstitution?: {
86
+ org?: string;
87
+ fid?: string;
88
+ };
89
+ language?: string;
90
+ }
91
+ declare function generateBankStatement(options: GenerateBankStatementOptions): string;
92
+ interface GenerateCreditCardStatementOptions {
93
+ accountId: string;
94
+ currency: string;
95
+ startDate: Date;
96
+ endDate: Date;
97
+ transactions: GenerateTransactionInput[];
98
+ ledgerBalance?: {
99
+ amount: number;
100
+ asOfDate: Date;
101
+ };
102
+ availableBalance?: {
103
+ amount: number;
104
+ asOfDate: Date;
105
+ };
106
+ financialInstitution?: {
107
+ org?: string;
108
+ fid?: string;
109
+ };
110
+ language?: string;
111
+ }
112
+ declare function generateCreditCardStatement(options: GenerateCreditCardStatementOptions): string;
113
+ import { z as z2 } from "zod";
45
114
  type ParseResult<T> = {
46
115
  success: true;
47
116
  data: T;
48
117
  } | {
49
118
  success: false;
50
- error: z.ZodError;
119
+ error: z2.ZodError;
51
120
  };
52
121
  declare function parse(content: string): ParseResult<OFXDocument>;
53
122
  declare function parseOrThrow(content: string): OFXDocument;
54
- declare function getTransactions(document: OFXDocument): OFXTransaction[];
55
- declare function getAccountInfo(document: OFXDocument): (OFXBankAccount | OFXCreditCardAccount)[];
56
- interface BalanceInfo {
57
- ledger?: OFXBalance;
58
- available?: OFXBalance;
59
- }
60
- declare function getBalance(document: OFXDocument): BalanceInfo[];
61
- declare function getSignOnInfo(document: OFXDocument): OFXSignOnResponse;
62
- export { schemas, parseOrThrow, parse, getTransactions, getSignOnInfo, getBalance, getAccountInfo, ParseResult, OFXTransactionType, OFXTransactionList, OFXTransaction, OFXStatus, OFXSignOnResponse, OFXSignOnMessageSetResponse, OFXResponse, OFXHeader, OFXFinancialInstitution, OFXDocument, OFXDate, OFXCreditCardStatementTransactionResponse, OFXCreditCardStatementResponse, OFXCreditCardMessageSetResponse, OFXCreditCardAccount, OFXBankStatementTransactionResponse, OFXBankStatementResponse, OFXBankMessageSetResponse, OFXBankAccount, OFXBalance, OFXAccountType, BalanceInfo };
123
+ type StreamEvent = {
124
+ type: "header";
125
+ data: OFXHeader;
126
+ } | {
127
+ type: "transaction";
128
+ data: OFXTransaction;
129
+ } | {
130
+ type: "account";
131
+ data: OFXBankAccount | OFXCreditCardAccount;
132
+ } | {
133
+ type: "balance";
134
+ data: {
135
+ ledger?: OFXBalance;
136
+ available?: OFXBalance;
137
+ };
138
+ } | {
139
+ type: "complete";
140
+ transactionCount: number;
141
+ };
142
+ declare function parseStream(input: ReadableStream<Uint8Array> | AsyncIterable<string>): AsyncGenerator<StreamEvent>;
143
+ declare function parseStreamToArray(input: ReadableStream<Uint8Array> | AsyncIterable<string>): Promise<{
144
+ header?: OFXHeader;
145
+ transactions: OFXTransaction[];
146
+ accounts: (OFXBankAccount | OFXCreditCardAccount)[];
147
+ balances: {
148
+ ledger?: OFXBalance;
149
+ available?: OFXBalance;
150
+ }[];
151
+ }>;
152
+ declare function formatOfxDate(date: Date, timezone?: {
153
+ offset: number;
154
+ name: string;
155
+ }): string;
156
+ export { schemas, parseStreamToArray, parseStream, parseOrThrow, parse, getTransactions, getSignOnInfo, getBalance, getAccountInfo, generateHeader, generateCreditCardStatement, generateBankStatement, formatOfxDate, StreamEvent, ParseResult, OFXTransactionType, OFXTransactionList, OFXTransaction, OFXStatus, OFXSignOnResponse, OFXSignOnMessageSetResponse, OFXResponse, OFXHeader, OFXFinancialInstitution, OFXDocument, OFXDate, OFXCreditCardStatementTransactionResponse, OFXCreditCardStatementResponse, OFXCreditCardMessageSetResponse, OFXCreditCardAccount, OFXBankStatementTransactionResponse, OFXBankStatementResponse, OFXBankMessageSetResponse, OFXBankAccount, OFXBalance, OFXAccountType, GenerateTransactionInput, GenerateHeaderOptions, GenerateCreditCardStatementOptions, GenerateBankStatementOptions, BalanceInfo };
package/dist/index.js CHANGED
@@ -1,23 +1,296 @@
1
- // src/index.ts
1
+ // src/utils.ts
2
+ var toArray = (value) => Array.isArray(value) ? value : [value];
3
+ var pad = (n, width = 2) => n.toString().padStart(width, "0");
4
+ function escapeOfxText(text) {
5
+ if (!text.includes("&") && !text.includes("<") && !text.includes(">")) {
6
+ return text;
7
+ }
8
+ return text.replace(/[&<>]/g, (c) => c === "&" ? "&amp;" : c === "<" ? "&lt;" : "&gt;");
9
+ }
10
+ function formatAmount(amount) {
11
+ return amount.toFixed(2);
12
+ }
13
+ function formatOfxDate(date, timezone) {
14
+ const tz = timezone ?? { name: "GMT", offset: 0 };
15
+ const offsetMs = tz.offset * 60 * 60 * 1000;
16
+ const adjustedDate = new Date(date.getTime() + offsetMs);
17
+ const year = adjustedDate.getUTCFullYear();
18
+ const month = pad(adjustedDate.getUTCMonth() + 1);
19
+ const day = pad(adjustedDate.getUTCDate());
20
+ const hour = pad(adjustedDate.getUTCHours());
21
+ const minute = pad(adjustedDate.getUTCMinutes());
22
+ const second = pad(adjustedDate.getUTCSeconds());
23
+ const sign = tz.offset >= 0 ? "+" : "";
24
+ return `${year}${month}${day}${hour}${minute}${second}[${sign}${tz.offset}:${tz.name}]`;
25
+ }
26
+
27
+ // src/extractors.ts
28
+ function getTransactions(document) {
29
+ const results = [];
30
+ const bankResponse = document.OFX.BANKMSGSRSV1?.STMTTRNRS;
31
+ if (bankResponse) {
32
+ for (const r of toArray(bankResponse)) {
33
+ const txns = r.STMTRS?.BANKTRANLIST?.STMTTRN;
34
+ if (txns)
35
+ results.push(...txns);
36
+ }
37
+ }
38
+ const ccResponse = document.OFX.CREDITCARDMSGSRSV1?.CCSTMTTRNRS;
39
+ if (ccResponse) {
40
+ for (const r of toArray(ccResponse)) {
41
+ const txns = r.CCSTMTRS?.BANKTRANLIST?.STMTTRN;
42
+ if (txns)
43
+ results.push(...txns);
44
+ }
45
+ }
46
+ return results;
47
+ }
48
+ function getAccountInfo(document) {
49
+ const results = [];
50
+ const bankResponse = document.OFX.BANKMSGSRSV1?.STMTTRNRS;
51
+ if (bankResponse) {
52
+ for (const r of toArray(bankResponse)) {
53
+ const account = r.STMTRS?.BANKACCTFROM;
54
+ if (account)
55
+ results.push(account);
56
+ }
57
+ }
58
+ const ccResponse = document.OFX.CREDITCARDMSGSRSV1?.CCSTMTTRNRS;
59
+ if (ccResponse) {
60
+ for (const r of toArray(ccResponse)) {
61
+ const account = r.CCSTMTRS?.CCACCTFROM;
62
+ if (account)
63
+ results.push(account);
64
+ }
65
+ }
66
+ return results;
67
+ }
68
+ function getBalance(document) {
69
+ const results = [];
70
+ const bankResponse = document.OFX.BANKMSGSRSV1?.STMTTRNRS;
71
+ if (bankResponse) {
72
+ for (const r of toArray(bankResponse)) {
73
+ if (r.STMTRS) {
74
+ results.push({
75
+ available: r.STMTRS.AVAILBAL,
76
+ ledger: r.STMTRS.LEDGERBAL
77
+ });
78
+ }
79
+ }
80
+ }
81
+ const ccResponse = document.OFX.CREDITCARDMSGSRSV1?.CCSTMTTRNRS;
82
+ if (ccResponse) {
83
+ for (const r of toArray(ccResponse)) {
84
+ if (r.CCSTMTRS) {
85
+ results.push({
86
+ available: r.CCSTMTRS.AVAILBAL,
87
+ ledger: r.CCSTMTRS.LEDGERBAL
88
+ });
89
+ }
90
+ }
91
+ }
92
+ return results;
93
+ }
94
+ function getSignOnInfo(document) {
95
+ return document.OFX.SIGNONMSGSRSV1.SONRS;
96
+ }
97
+ // src/generator.ts
98
+ function generateHeader(options) {
99
+ const version = options?.version ?? "100";
100
+ const encoding = options?.encoding ?? "USASCII";
101
+ const charset = options?.charset ?? "1252";
102
+ return [
103
+ "OFXHEADER:100",
104
+ "DATA:OFXSGML",
105
+ `VERSION:${version}`,
106
+ "SECURITY:NONE",
107
+ `ENCODING:${encoding}`,
108
+ `CHARSET:${charset}`,
109
+ "COMPRESSION:NONE",
110
+ "OLDFILEUID:NONE",
111
+ "NEWFILEUID:NONE",
112
+ ""
113
+ ].join(`
114
+ `);
115
+ }
116
+ function generateTransaction(trn) {
117
+ const lines = [
118
+ "<STMTTRN>",
119
+ `<TRNTYPE>${trn.type}`,
120
+ `<DTPOSTED>${formatOfxDate(trn.datePosted)}`,
121
+ `<TRNAMT>${formatAmount(trn.amount)}`,
122
+ `<FITID>${escapeOfxText(trn.fitId)}`
123
+ ];
124
+ if (trn.name) {
125
+ lines.push(`<NAME>${escapeOfxText(trn.name)}`);
126
+ }
127
+ if (trn.memo) {
128
+ lines.push(`<MEMO>${escapeOfxText(trn.memo)}`);
129
+ }
130
+ if (trn.checkNum) {
131
+ lines.push(`<CHECKNUM>${escapeOfxText(trn.checkNum)}`);
132
+ }
133
+ if (trn.refNum) {
134
+ lines.push(`<REFNUM>${escapeOfxText(trn.refNum)}`);
135
+ }
136
+ lines.push("</STMTTRN>");
137
+ return lines.join(`
138
+ `);
139
+ }
140
+ function generateBankStatement(options) {
141
+ const parts = [generateHeader()];
142
+ const serverDate = formatOfxDate(new Date);
143
+ const language = options.language ?? "POR";
144
+ parts.push(`<OFX>
145
+ <SIGNONMSGSRSV1>
146
+ <SONRS>
147
+ <STATUS>
148
+ <CODE>0
149
+ <SEVERITY>INFO
150
+ </STATUS>
151
+ <DTSERVER>${serverDate}
152
+ <LANGUAGE>${language}`);
153
+ if (options.financialInstitution) {
154
+ parts.push("<FI>");
155
+ if (options.financialInstitution.org) {
156
+ parts.push(`<ORG>${escapeOfxText(options.financialInstitution.org)}`);
157
+ }
158
+ if (options.financialInstitution.fid) {
159
+ parts.push(`<FID>${escapeOfxText(options.financialInstitution.fid)}`);
160
+ }
161
+ parts.push("</FI>");
162
+ }
163
+ parts.push(`</SONRS>
164
+ </SIGNONMSGSRSV1>
165
+ <BANKMSGSRSV1>
166
+ <STMTTRNRS>
167
+ <TRNUID>0
168
+ <STATUS>
169
+ <CODE>0
170
+ <SEVERITY>INFO
171
+ </STATUS>
172
+ <STMTRS>
173
+ <CURDEF>${options.currency}
174
+ <BANKACCTFROM>
175
+ <BANKID>${escapeOfxText(options.bankId)}
176
+ <ACCTID>${escapeOfxText(options.accountId)}
177
+ <ACCTTYPE>${options.accountType}
178
+ </BANKACCTFROM>
179
+ <BANKTRANLIST>
180
+ <DTSTART>${formatOfxDate(options.startDate)}
181
+ <DTEND>${formatOfxDate(options.endDate)}`);
182
+ for (const trn of options.transactions) {
183
+ parts.push(generateTransaction(trn));
184
+ }
185
+ parts.push("</BANKTRANLIST>");
186
+ if (options.ledgerBalance) {
187
+ parts.push(`<LEDGERBAL>
188
+ <BALAMT>${formatAmount(options.ledgerBalance.amount)}
189
+ <DTASOF>${formatOfxDate(options.ledgerBalance.asOfDate)}
190
+ </LEDGERBAL>`);
191
+ }
192
+ if (options.availableBalance) {
193
+ parts.push(`<AVAILBAL>
194
+ <BALAMT>${formatAmount(options.availableBalance.amount)}
195
+ <DTASOF>${formatOfxDate(options.availableBalance.asOfDate)}
196
+ </AVAILBAL>`);
197
+ }
198
+ parts.push(`</STMTRS>
199
+ </STMTTRNRS>
200
+ </BANKMSGSRSV1>
201
+ </OFX>`);
202
+ return parts.join(`
203
+ `);
204
+ }
205
+ function generateCreditCardStatement(options) {
206
+ const parts = [generateHeader()];
207
+ const serverDate = formatOfxDate(new Date);
208
+ const language = options.language ?? "POR";
209
+ parts.push(`<OFX>
210
+ <SIGNONMSGSRSV1>
211
+ <SONRS>
212
+ <STATUS>
213
+ <CODE>0
214
+ <SEVERITY>INFO
215
+ </STATUS>
216
+ <DTSERVER>${serverDate}
217
+ <LANGUAGE>${language}`);
218
+ if (options.financialInstitution) {
219
+ parts.push("<FI>");
220
+ if (options.financialInstitution.org) {
221
+ parts.push(`<ORG>${escapeOfxText(options.financialInstitution.org)}`);
222
+ }
223
+ if (options.financialInstitution.fid) {
224
+ parts.push(`<FID>${escapeOfxText(options.financialInstitution.fid)}`);
225
+ }
226
+ parts.push("</FI>");
227
+ }
228
+ parts.push(`</SONRS>
229
+ </SIGNONMSGSRSV1>
230
+ <CREDITCARDMSGSRSV1>
231
+ <CCSTMTTRNRS>
232
+ <TRNUID>0
233
+ <STATUS>
234
+ <CODE>0
235
+ <SEVERITY>INFO
236
+ </STATUS>
237
+ <CCSTMTRS>
238
+ <CURDEF>${options.currency}
239
+ <CCACCTFROM>
240
+ <ACCTID>${escapeOfxText(options.accountId)}
241
+ </CCACCTFROM>
242
+ <BANKTRANLIST>
243
+ <DTSTART>${formatOfxDate(options.startDate)}
244
+ <DTEND>${formatOfxDate(options.endDate)}`);
245
+ for (const trn of options.transactions) {
246
+ parts.push(generateTransaction(trn));
247
+ }
248
+ parts.push("</BANKTRANLIST>");
249
+ if (options.ledgerBalance) {
250
+ parts.push(`<LEDGERBAL>
251
+ <BALAMT>${formatAmount(options.ledgerBalance.amount)}
252
+ <DTASOF>${formatOfxDate(options.ledgerBalance.asOfDate)}
253
+ </LEDGERBAL>`);
254
+ }
255
+ if (options.availableBalance) {
256
+ parts.push(`<AVAILBAL>
257
+ <BALAMT>${formatAmount(options.availableBalance.amount)}
258
+ <DTASOF>${formatOfxDate(options.availableBalance.asOfDate)}
259
+ </AVAILBAL>`);
260
+ }
261
+ parts.push(`</CCSTMTRS>
262
+ </CCSTMTTRNRS>
263
+ </CREDITCARDMSGSRSV1>
264
+ </OFX>`);
265
+ return parts.join(`
266
+ `);
267
+ }
268
+ // src/parser.ts
269
+ import { z as z2 } from "zod";
270
+
271
+ // src/schemas.ts
2
272
  import { z } from "zod";
3
- var toInt = (val) => Number.parseInt(val, 10);
4
273
  var toFloat = (val) => Number.parseFloat(val);
5
- var toArray = (value) => Array.isArray(value) ? value : [value];
274
+ var DATE_REGEX = /^(\d{4})(\d{2})(\d{2})(\d{2})?(\d{2})?(\d{2})?/;
275
+ var TIMEZONE_REGEX = /\[([+-]?\d+):(\w+)\]/;
6
276
  function parseDateComponents(val) {
277
+ const m = DATE_REGEX.exec(val);
278
+ if (!m)
279
+ return { day: 0, hour: 0, minute: 0, month: 0, second: 0, year: 0 };
7
280
  return {
8
- day: toInt(val.substring(6, 8)),
9
- hour: toInt(val.substring(8, 10) || "0"),
10
- minute: toInt(val.substring(10, 12) || "0"),
11
- month: toInt(val.substring(4, 6)),
12
- second: toInt(val.substring(12, 14) || "0"),
13
- year: toInt(val.substring(0, 4))
281
+ day: +m[3],
282
+ hour: +(m[4] || 0),
283
+ minute: +(m[5] || 0),
284
+ month: +m[2],
285
+ second: +(m[6] || 0),
286
+ year: +m[1]
14
287
  };
15
288
  }
16
289
  function parseTimezone(val) {
17
- const match = val.match(/\[([+-]?\d+):(\w+)\]/);
290
+ const match = TIMEZONE_REGEX.exec(val);
18
291
  return {
19
292
  name: match?.[2] ?? "UTC",
20
- offset: match ? toInt(match[1] ?? "0") : 0
293
+ offset: match ? +match[1] : 0
21
294
  };
22
295
  }
23
296
  var ofxDateSchema = z.string().transform((val) => {
@@ -194,28 +467,18 @@ var schemas = {
194
467
  transactionList: transactionListSchema,
195
468
  transactionType: transactionTypeSchema
196
469
  };
197
- function parseHeader(content) {
198
- const lines = content.split(/\r?\n/);
199
- const header = {};
200
- let bodyStartIndex = 0;
201
- for (let i = 0;i < lines.length; i++) {
202
- const line = lines[i]?.trim() ?? "";
203
- if (line.startsWith("<?xml") || line.startsWith("<OFX>")) {
204
- bodyStartIndex = i;
205
- break;
206
- }
207
- const match = line.match(/^(\w+):(.*)$/);
208
- if (match?.[1] && match[2] !== undefined) {
209
- header[match[1]] = match[2];
210
- }
211
- if (line === "" && Object.keys(header).length > 0) {
212
- bodyStartIndex = i + 1;
213
- break;
214
- }
215
- }
216
- const body = lines.slice(bodyStartIndex).join(`
217
- `);
218
- return { body, header: ofxHeaderSchema.parse(header) };
470
+
471
+ // src/parser.ts
472
+ var ENTITY_MAP = {
473
+ "&amp;": "&",
474
+ "&apos;": "'",
475
+ "&gt;": ">",
476
+ "&lt;": "<",
477
+ "&quot;": '"'
478
+ };
479
+ var ENTITY_REGEX = /&(?:amp|lt|gt|quot|apos);/g;
480
+ function decodeEntities(text) {
481
+ return text.replace(ENTITY_REGEX, (match) => ENTITY_MAP[match] ?? match);
219
482
  }
220
483
  function addToContent(content, key, value) {
221
484
  const existing = content[key];
@@ -233,7 +496,8 @@ function sgmlToObject(sgml) {
233
496
  const result = {};
234
497
  const tagStack = [{ content: result, name: "root" }];
235
498
  const stackMap = new Map([["root", 0]]);
236
- const cleanSgml = sgml.replace(/<\?.*?\?>/g, "").replace(/<!--.*?-->/gs, "").trim();
499
+ const hasSpecialContent = sgml.includes("<?") || sgml.includes("<!--");
500
+ const cleanSgml = hasSpecialContent ? sgml.replace(/<\?.*?\?>|<!--.*?-->/gs, "").trim() : sgml.trim();
237
501
  const tagRegex = /<(\/?)([\w.]+)>([^<]*)/g;
238
502
  let match = tagRegex.exec(cleanSgml);
239
503
  while (match !== null) {
@@ -260,7 +524,8 @@ function sgmlToObject(sgml) {
260
524
  tagStack.length = stackIndex;
261
525
  }
262
526
  } else if (textContent) {
263
- addToContent(current.content, tagName, textContent);
527
+ const decoded = textContent.includes("&") ? decodeEntities(textContent) : textContent;
528
+ addToContent(current.content, tagName, decoded);
264
529
  } else {
265
530
  const newObj = {};
266
531
  addToContent(current.content, tagName, newObj);
@@ -271,24 +536,82 @@ function sgmlToObject(sgml) {
271
536
  }
272
537
  return result;
273
538
  }
274
- function processObject(obj) {
275
- const processed = {};
276
- for (const [key, value] of Object.entries(obj)) {
277
- if (key === "STMTTRN") {
278
- processed[key] = toArray(value).map((v) => typeof v === "object" && v !== null ? processObject(v) : v);
279
- } else if (value && typeof value === "object" && !Array.isArray(value)) {
280
- processed[key] = processObject(value);
281
- } else {
282
- processed[key] = value;
539
+ function normalizeResponseArray(msgs, responseKey, statementKey) {
540
+ const responses = msgs[responseKey];
541
+ if (!responses)
542
+ return;
543
+ for (const response of toArray(responses)) {
544
+ const stmt = response?.[statementKey];
545
+ const tranList = stmt?.BANKTRANLIST;
546
+ if (tranList?.STMTTRN !== undefined) {
547
+ tranList.STMTTRN = toArray(tranList.STMTTRN);
283
548
  }
284
549
  }
285
- return processed;
286
550
  }
287
551
  function normalizeTransactions(data) {
288
- return processObject(data);
552
+ const ofx = data.OFX;
553
+ if (!ofx)
554
+ return data;
555
+ const bankMsgs = ofx.BANKMSGSRSV1;
556
+ if (bankMsgs) {
557
+ normalizeResponseArray(bankMsgs, "STMTTRNRS", "STMTRS");
558
+ }
559
+ const ccMsgs = ofx.CREDITCARDMSGSRSV1;
560
+ if (ccMsgs) {
561
+ normalizeResponseArray(ccMsgs, "CCSTMTTRNRS", "CCSTMTRS");
562
+ }
563
+ return data;
564
+ }
565
+ function parseHeader(content) {
566
+ const lines = content.split(/\r?\n/);
567
+ const header = {};
568
+ let bodyStartIndex = 0;
569
+ for (let i = 0;i < lines.length; i++) {
570
+ const line = lines[i]?.trim() ?? "";
571
+ if (line.startsWith("<?xml") || line.startsWith("<OFX>")) {
572
+ bodyStartIndex = i;
573
+ break;
574
+ }
575
+ const match = line.match(/^(\w+):(.*)$/);
576
+ if (match?.[1] && match[2] !== undefined) {
577
+ header[match[1]] = match[2];
578
+ }
579
+ if (line === "" && Object.keys(header).length > 0) {
580
+ bodyStartIndex = i + 1;
581
+ break;
582
+ }
583
+ }
584
+ const body = lines.slice(bodyStartIndex).join(`
585
+ `);
586
+ return { body, header: ofxHeaderSchema.parse(header) };
289
587
  }
290
588
  function parse(content) {
291
589
  try {
590
+ if (typeof content !== "string") {
591
+ return {
592
+ error: new z2.ZodError([
593
+ {
594
+ code: "invalid_type",
595
+ expected: "string",
596
+ message: "Expected string, received " + typeof content,
597
+ path: []
598
+ }
599
+ ]),
600
+ success: false
601
+ };
602
+ }
603
+ if (content.trim() === "") {
604
+ return {
605
+ error: new z2.ZodError([
606
+ {
607
+ code: "custom",
608
+ message: "Content cannot be empty",
609
+ path: []
610
+ }
611
+ ]),
612
+ success: false
613
+ };
614
+ }
292
615
  const { header, body } = parseHeader(content);
293
616
  const rawData = sgmlToObject(body);
294
617
  const normalizedData = normalizeTransactions(rawData);
@@ -301,7 +624,7 @@ function parse(content) {
301
624
  success: true
302
625
  };
303
626
  } catch (err) {
304
- if (err instanceof z.ZodError) {
627
+ if (err instanceof z2.ZodError) {
305
628
  return { error: err, success: false };
306
629
  }
307
630
  throw err;
@@ -314,56 +637,244 @@ function parseOrThrow(content) {
314
637
  }
315
638
  return result.data;
316
639
  }
317
- function extractFromBankResponses(document, extractor) {
318
- const bankResponse = document.OFX.BANKMSGSRSV1?.STMTTRNRS;
319
- if (!bankResponse)
320
- return [];
321
- const results = [];
322
- for (const response of toArray(bankResponse)) {
323
- const result = extractor(response);
324
- if (result !== undefined) {
325
- results.push(result);
326
- }
327
- }
328
- return results;
640
+ // src/stream.ts
641
+ var ENTITY_MAP2 = {
642
+ "&amp;": "&",
643
+ "&apos;": "'",
644
+ "&gt;": ">",
645
+ "&lt;": "<",
646
+ "&quot;": '"'
647
+ };
648
+ var ENTITY_REGEX2 = /&(?:amp|lt|gt|quot|apos);/g;
649
+ function decodeEntities2(text) {
650
+ if (!text.includes("&"))
651
+ return text;
652
+ return text.replace(ENTITY_REGEX2, (match) => ENTITY_MAP2[match] ?? match);
329
653
  }
330
- function extractFromCreditCardResponses(document, extractor) {
331
- const ccResponse = document.OFX.CREDITCARDMSGSRSV1?.CCSTMTTRNRS;
332
- if (!ccResponse)
333
- return [];
334
- const results = [];
335
- for (const response of toArray(ccResponse)) {
336
- const result = extractor(response);
337
- if (result !== undefined) {
338
- results.push(result);
654
+ function parseHeaderFromBuffer(buffer) {
655
+ const lines = buffer.split(/\r?\n/);
656
+ const header = {};
657
+ let bodyStartIndex = 0;
658
+ for (let i = 0;i < lines.length; i++) {
659
+ const line = lines[i]?.trim() ?? "";
660
+ if (line.startsWith("<?xml") || line.startsWith("<OFX>")) {
661
+ bodyStartIndex = i;
662
+ break;
663
+ }
664
+ const match = line.match(/^(\w+):(.*)$/);
665
+ if (match?.[1] && match[2] !== undefined) {
666
+ header[match[1]] = match[2];
667
+ }
668
+ if (line === "" && Object.keys(header).length > 0) {
669
+ bodyStartIndex = i + 1;
670
+ break;
339
671
  }
340
672
  }
341
- return results;
673
+ if (Object.keys(header).length === 0)
674
+ return null;
675
+ const headerResult = ofxHeaderSchema.safeParse(header);
676
+ if (!headerResult.success)
677
+ return null;
678
+ const bodyStartChar = lines.slice(0, bodyStartIndex).join(`
679
+ `).length + 1;
680
+ return { bodyStart: bodyStartChar, header: headerResult.data };
342
681
  }
343
- function getTransactions(document) {
344
- const bankTransactions = extractFromBankResponses(document, (r) => r.STMTRS?.BANKTRANLIST?.STMTTRN);
345
- const ccTransactions = extractFromCreditCardResponses(document, (r) => r.CCSTMTRS?.BANKTRANLIST?.STMTTRN);
346
- return [...bankTransactions, ...ccTransactions].flat();
682
+ function tryParseTransaction(obj) {
683
+ const result = transactionSchema.safeParse(obj);
684
+ return result.success ? result.data : null;
347
685
  }
348
- function getAccountInfo(document) {
349
- const bankAccounts = extractFromBankResponses(document, (r) => r.STMTRS?.BANKACCTFROM);
350
- const ccAccounts = extractFromCreditCardResponses(document, (r) => r.CCSTMTRS?.CCACCTFROM);
351
- return [...bankAccounts, ...ccAccounts];
686
+ function tryParseBankAccount(obj) {
687
+ const result = bankAccountSchema.safeParse(obj);
688
+ return result.success ? result.data : null;
352
689
  }
353
- function getBalance(document) {
354
- const bankBalances = extractFromBankResponses(document, (r) => r.STMTRS ? { available: r.STMTRS.AVAILBAL, ledger: r.STMTRS.LEDGERBAL } : undefined);
355
- const ccBalances = extractFromCreditCardResponses(document, (r) => r.CCSTMTRS ? { available: r.CCSTMTRS.AVAILBAL, ledger: r.CCSTMTRS.LEDGERBAL } : undefined);
356
- return [...bankBalances, ...ccBalances];
690
+ function tryParseCreditCardAccount(obj) {
691
+ const result = creditCardAccountSchema.safeParse(obj);
692
+ return result.success ? result.data : null;
357
693
  }
358
- function getSignOnInfo(document) {
359
- return document.OFX.SIGNONMSGSRSV1.SONRS;
694
+ function tryParseBalance(obj) {
695
+ const result = balanceSchema.safeParse(obj);
696
+ return result.success ? result.data : null;
697
+ }
698
+ async function* parseStream(input) {
699
+ const state = {
700
+ buffer: "",
701
+ currentObject: {},
702
+ currentPath: [],
703
+ headerParsed: false,
704
+ inHeader: true,
705
+ objectStack: [{}],
706
+ transactionCount: 0
707
+ };
708
+ const decoder = new TextDecoder;
709
+ const tagRegex = /<(\/?)([\w.]+)>([^<]*)/g;
710
+ let pendingLedgerBalance;
711
+ let pendingAvailableBalance;
712
+ let emittedBalanceForCurrentStatement = false;
713
+ async function* processChunk(chunk, isLast = false) {
714
+ state.buffer += chunk;
715
+ if (!state.headerParsed) {
716
+ const headerResult = parseHeaderFromBuffer(state.buffer);
717
+ if (headerResult) {
718
+ state.headerParsed = true;
719
+ state.inHeader = false;
720
+ yield { data: headerResult.header, type: "header" };
721
+ state.buffer = state.buffer.slice(headerResult.bodyStart);
722
+ } else {
723
+ return;
724
+ }
725
+ }
726
+ const lastLt = state.buffer.lastIndexOf("<");
727
+ const safeEnd = isLast ? state.buffer.length : lastLt;
728
+ if (safeEnd <= 0)
729
+ return;
730
+ const safeBuffer = state.buffer.slice(0, safeEnd);
731
+ let processedUpTo = 0;
732
+ tagRegex.lastIndex = 0;
733
+ for (let match = tagRegex.exec(safeBuffer);match !== null; match = tagRegex.exec(safeBuffer)) {
734
+ const isClosing = match[1] === "/";
735
+ const tagName = match[2];
736
+ const textContent = match[3]?.trim() ?? "";
737
+ if (!tagName)
738
+ continue;
739
+ const currentObj = state.objectStack[state.objectStack.length - 1];
740
+ if (!currentObj)
741
+ continue;
742
+ if (isClosing) {
743
+ if (tagName === "STMTTRN") {
744
+ const txn = tryParseTransaction(currentObj);
745
+ if (txn) {
746
+ state.transactionCount++;
747
+ yield { data: txn, type: "transaction" };
748
+ }
749
+ } else if (tagName === "BANKACCTFROM") {
750
+ const account = tryParseBankAccount(currentObj);
751
+ if (account) {
752
+ yield { data: account, type: "account" };
753
+ }
754
+ } else if (tagName === "CCACCTFROM") {
755
+ const account = tryParseCreditCardAccount(currentObj);
756
+ if (account) {
757
+ yield { data: account, type: "account" };
758
+ }
759
+ } else if (tagName === "LEDGERBAL") {
760
+ pendingLedgerBalance = tryParseBalance(currentObj) ?? undefined;
761
+ } else if (tagName === "AVAILBAL") {
762
+ pendingAvailableBalance = tryParseBalance(currentObj) ?? undefined;
763
+ } else if ((tagName === "STMTRS" || tagName === "CCSTMTRS") && !emittedBalanceForCurrentStatement) {
764
+ if (pendingLedgerBalance || pendingAvailableBalance) {
765
+ yield {
766
+ data: {
767
+ available: pendingAvailableBalance,
768
+ ledger: pendingLedgerBalance
769
+ },
770
+ type: "balance"
771
+ };
772
+ emittedBalanceForCurrentStatement = true;
773
+ }
774
+ } else if (tagName === "STMTTRNRS" || tagName === "CCSTMTTRNRS") {
775
+ pendingLedgerBalance = undefined;
776
+ pendingAvailableBalance = undefined;
777
+ emittedBalanceForCurrentStatement = false;
778
+ }
779
+ const pathIndex = state.currentPath.lastIndexOf(tagName);
780
+ if (pathIndex !== -1) {
781
+ state.currentPath.length = pathIndex;
782
+ state.objectStack.length = Math.max(pathIndex + 1, 1);
783
+ }
784
+ } else if (textContent) {
785
+ const decoded = decodeEntities2(textContent);
786
+ const existing = currentObj[tagName];
787
+ if (existing !== undefined) {
788
+ if (Array.isArray(existing)) {
789
+ existing.push(decoded);
790
+ } else {
791
+ currentObj[tagName] = [existing, decoded];
792
+ }
793
+ } else {
794
+ currentObj[tagName] = decoded;
795
+ }
796
+ } else {
797
+ const newObj = {};
798
+ const existing = currentObj[tagName];
799
+ if (existing !== undefined) {
800
+ if (Array.isArray(existing)) {
801
+ existing.push(newObj);
802
+ } else {
803
+ currentObj[tagName] = [existing, newObj];
804
+ }
805
+ } else {
806
+ currentObj[tagName] = newObj;
807
+ }
808
+ state.currentPath.push(tagName);
809
+ state.objectStack.push(newObj);
810
+ }
811
+ processedUpTo = tagRegex.lastIndex;
812
+ }
813
+ if (processedUpTo > 0) {
814
+ state.buffer = state.buffer.slice(processedUpTo);
815
+ }
816
+ tagRegex.lastIndex = 0;
817
+ }
818
+ if (input instanceof ReadableStream) {
819
+ const reader = input.getReader();
820
+ try {
821
+ while (true) {
822
+ const { done, value } = await reader.read();
823
+ if (done)
824
+ break;
825
+ yield* processChunk(decoder.decode(value, { stream: true }));
826
+ }
827
+ yield* processChunk(decoder.decode(), true);
828
+ } finally {
829
+ reader.releaseLock();
830
+ }
831
+ } else {
832
+ const chunks = [];
833
+ for await (const chunk of input) {
834
+ chunks.push(chunk);
835
+ }
836
+ for (let i = 0;i < chunks.length; i++) {
837
+ yield* processChunk(chunks[i] ?? "", i === chunks.length - 1);
838
+ }
839
+ }
840
+ yield { transactionCount: state.transactionCount, type: "complete" };
841
+ }
842
+ async function parseStreamToArray(input) {
843
+ const result = {
844
+ accounts: [],
845
+ balances: [],
846
+ transactions: []
847
+ };
848
+ for await (const event of parseStream(input)) {
849
+ switch (event.type) {
850
+ case "header":
851
+ result.header = event.data;
852
+ break;
853
+ case "transaction":
854
+ result.transactions.push(event.data);
855
+ break;
856
+ case "account":
857
+ result.accounts.push(event.data);
858
+ break;
859
+ case "balance":
860
+ result.balances.push(event.data);
861
+ break;
862
+ }
863
+ }
864
+ return result;
360
865
  }
361
866
  export {
362
867
  schemas,
868
+ parseStreamToArray,
869
+ parseStream,
363
870
  parseOrThrow,
364
871
  parse,
365
872
  getTransactions,
366
873
  getSignOnInfo,
367
874
  getBalance,
368
- getAccountInfo
875
+ getAccountInfo,
876
+ generateHeader,
877
+ generateCreditCardStatement,
878
+ generateBankStatement,
879
+ formatOfxDate
369
880
  };
package/package.json CHANGED
@@ -7,26 +7,27 @@
7
7
  },
8
8
  "description": "Typesafe ofx handling",
9
9
  "devDependencies": {
10
- "@biomejs/biome": "^2.3.7",
10
+ "@biomejs/biome": "2.3.8",
11
11
  "@types/bun": "1.3.3",
12
12
  "bumpp": "10.3.2",
13
13
  "bunup": "0.16.10",
14
- "simple-git-hooks": "2.13.1",
15
14
  "typescript": "5.9.3"
16
15
  },
17
16
  "exports": {
18
17
  ".": {
18
+ "bun": "./src/index.ts",
19
19
  "import": {
20
20
  "default": "./dist/index.js",
21
21
  "types": "./dist/index.d.ts"
22
- }
22
+ },
23
+ "types": "./src/index.ts"
23
24
  },
24
25
  "./package.json": "./package.json"
25
26
  },
26
27
  "files": [
27
28
  "dist"
28
29
  ],
29
- "homepage": "https://github.com/F-O-T/montte-nx#readme",
30
+ "homepage": "https://github.com/F-O-T/montte-nx/blob/master/libraries/ofx",
30
31
  "license": "MIT",
31
32
  "module": "./dist/index.js",
32
33
  "name": "@f-o-t/ofx",
@@ -48,20 +49,16 @@
48
49
  },
49
50
  "scripts": {
50
51
  "build": "bunup",
52
+ "check": "biome check --write .",
51
53
  "dev": "bunup --watch",
52
- "lint": "biome check .",
53
- "lint:fix": "biome check --write .",
54
- "postinstall": "bun simple-git-hooks",
54
+ "publish": "bunx npm publish",
55
55
  "release": "bumpp --commit --push --tag",
56
56
  "test": "bun test",
57
57
  "test:coverage": "bun test --coverage",
58
58
  "test:watch": "bun test --watch",
59
59
  "type-check": "tsc --noEmit"
60
60
  },
61
- "simple-git-hooks": {
62
- "pre-commit": "bun run lint && bun run type-check"
63
- },
64
61
  "type": "module",
65
62
  "types": "./dist/index.d.ts",
66
- "version": "1.2.0"
63
+ "version": "1.3.0"
67
64
  }