@f-o-t/ofx 2.0.0 → 2.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/dist/index.js CHANGED
@@ -343,7 +343,7 @@ var transactionSchema = z.object({
343
343
  DTAVAIL: ofxDateSchema.optional(),
344
344
  DTPOSTED: ofxDateSchema,
345
345
  DTUSER: ofxDateSchema.optional(),
346
- FITID: z.string(),
346
+ FITID: z.string().optional(),
347
347
  MEMO: z.string().optional(),
348
348
  NAME: z.string().optional(),
349
349
  PAYEEID: z.string().optional(),
@@ -360,6 +360,14 @@ var accountTypeSchema = z.enum([
360
360
  "CREDITLINE",
361
361
  "CD"
362
362
  ]);
363
+ var extendedAccountTypeSchema = z.enum([
364
+ "CHECKING",
365
+ "SAVINGS",
366
+ "MONEYMRKT",
367
+ "CREDITLINE",
368
+ "CD",
369
+ "CREDITCARD"
370
+ ]);
363
371
  var bankAccountSchema = z.object({
364
372
  ACCTID: z.string(),
365
373
  ACCTKEY: z.string().optional(),
@@ -367,6 +375,13 @@ var bankAccountSchema = z.object({
367
375
  BANKID: z.string(),
368
376
  BRANCHID: z.string().optional()
369
377
  });
378
+ var flexibleBankAccountSchema = z.object({
379
+ ACCTID: z.string(),
380
+ ACCTKEY: z.string().optional(),
381
+ ACCTTYPE: extendedAccountTypeSchema.optional(),
382
+ BANKID: z.string().optional(),
383
+ BRANCHID: z.string().optional()
384
+ });
370
385
  var creditCardAccountSchema = z.object({
371
386
  ACCTID: z.string(),
372
387
  ACCTKEY: z.string().optional()
@@ -390,11 +405,14 @@ var bankStatementResponseSchema = z.object({
390
405
  });
391
406
  var creditCardStatementResponseSchema = z.object({
392
407
  AVAILBAL: balanceSchema.optional(),
408
+ BANKACCTFROM: flexibleBankAccountSchema.optional(),
393
409
  BANKTRANLIST: transactionListSchema.optional(),
394
- CCACCTFROM: creditCardAccountSchema,
410
+ CCACCTFROM: creditCardAccountSchema.optional(),
395
411
  CURDEF: z.string().default("USD"),
396
412
  LEDGERBAL: balanceSchema.optional(),
397
413
  MKTGINFO: z.string().optional()
414
+ }).refine((data) => data.CCACCTFROM || data.BANKACCTFROM, {
415
+ message: "Either CCACCTFROM or BANKACCTFROM is required"
398
416
  });
399
417
  var signOnResponseSchema = z.object({
400
418
  ACCESSKEY: z.string().optional(),
@@ -405,14 +423,14 @@ var signOnResponseSchema = z.object({
405
423
  STATUS: statusSchema
406
424
  });
407
425
  var bankStatementTransactionResponseSchema = z.object({
408
- STATUS: statusSchema,
426
+ STATUS: statusSchema.optional(),
409
427
  STMTRS: bankStatementResponseSchema.optional(),
410
- TRNUID: z.string()
428
+ TRNUID: z.string().optional()
411
429
  });
412
430
  var creditCardStatementTransactionResponseSchema = z.object({
413
431
  CCSTMTRS: creditCardStatementResponseSchema.optional(),
414
- STATUS: statusSchema,
415
- TRNUID: z.string()
432
+ STATUS: statusSchema.optional(),
433
+ TRNUID: z.string().optional()
416
434
  });
417
435
  var singleOrArray = (schema) => z.union([schema, z.array(schema)]).optional();
418
436
  var bankMessageSetResponseSchema = z.object({
@@ -446,6 +464,25 @@ var ofxDocumentSchema = z.object({
446
464
  });
447
465
 
448
466
  // src/parser.ts
467
+ var CHARSET_MAP = {
468
+ "1252": "windows-1252",
469
+ "WINDOWS-1252": "windows-1252",
470
+ CP1252: "windows-1252",
471
+ "8859-1": "iso-8859-1",
472
+ "ISO-8859-1": "iso-8859-1",
473
+ LATIN1: "iso-8859-1",
474
+ "LATIN-1": "iso-8859-1",
475
+ "UTF-8": "utf-8",
476
+ UTF8: "utf-8",
477
+ NONE: "utf-8",
478
+ "": "utf-8"
479
+ };
480
+ function getEncodingFromCharset(charset) {
481
+ if (!charset)
482
+ return "utf-8";
483
+ const normalized = charset.toUpperCase().trim();
484
+ return CHARSET_MAP[normalized] ?? "windows-1252";
485
+ }
449
486
  var ENTITY_MAP = {
450
487
  "&": "&",
451
488
  "'": "'",
@@ -513,6 +550,18 @@ function sgmlToObject(sgml) {
513
550
  }
514
551
  return result;
515
552
  }
553
+ function generateFitId(txn, index) {
554
+ const date = String(txn.DTPOSTED ?? "");
555
+ const amount = String(txn.TRNAMT ?? "0");
556
+ const name = String(txn.NAME ?? txn.MEMO ?? "");
557
+ const input = `${date}:${amount}:${name}:${index}`;
558
+ let hash = 0;
559
+ for (let i = 0;i < input.length; i++) {
560
+ hash = (hash << 5) - hash + input.charCodeAt(i);
561
+ hash = hash & hash;
562
+ }
563
+ return `AUTO${Math.abs(hash).toString(16).toUpperCase().padStart(8, "0")}`;
564
+ }
516
565
  function normalizeResponseArray(msgs, responseKey, statementKey) {
517
566
  const responses = msgs[responseKey];
518
567
  if (!responses)
@@ -522,13 +571,40 @@ function normalizeResponseArray(msgs, responseKey, statementKey) {
522
571
  const tranList = stmt?.BANKTRANLIST;
523
572
  if (tranList?.STMTTRN !== undefined) {
524
573
  tranList.STMTTRN = toArray(tranList.STMTTRN);
574
+ const transactions = tranList.STMTTRN;
575
+ transactions.forEach((txn, idx) => {
576
+ if (!txn.FITID) {
577
+ txn.FITID = generateFitId(txn, idx);
578
+ }
579
+ });
525
580
  }
526
581
  }
527
582
  }
583
+ function normalizeSignOn(data) {
584
+ const ofx = data.OFX;
585
+ if (!ofx)
586
+ return;
587
+ const signonMsgs = ofx.SIGNONMSGSRSV1;
588
+ const sonrs = signonMsgs?.SONRS;
589
+ if (!sonrs)
590
+ return;
591
+ const status = sonrs.STATUS;
592
+ if (!status)
593
+ return;
594
+ if (!sonrs.DTSERVER && status.DTSERVER) {
595
+ sonrs.DTSERVER = status.DTSERVER;
596
+ delete status.DTSERVER;
597
+ }
598
+ if (!sonrs.LANGUAGE && status.LANGUAGE) {
599
+ sonrs.LANGUAGE = status.LANGUAGE;
600
+ delete status.LANGUAGE;
601
+ }
602
+ }
528
603
  function normalizeTransactions(data) {
529
604
  const ofx = data.OFX;
530
605
  if (!ofx)
531
606
  return data;
607
+ normalizeSignOn(data);
532
608
  const bankMsgs = ofx.BANKMSGSRSV1;
533
609
  if (bankMsgs) {
534
610
  normalizeResponseArray(bankMsgs, "STMTTRNRS", "STMTRS");
@@ -614,6 +690,132 @@ function parseOrThrow(content) {
614
690
  }
615
691
  return result.data;
616
692
  }
693
+ function isValidUtf8(buffer) {
694
+ let i = 0;
695
+ while (i < buffer.length) {
696
+ const byte = buffer[i];
697
+ if (byte === undefined)
698
+ break;
699
+ if (byte <= 127) {
700
+ i++;
701
+ } else if ((byte & 224) === 192) {
702
+ const b1 = buffer[i + 1];
703
+ if (i + 1 >= buffer.length || b1 === undefined || (b1 & 192) !== 128)
704
+ return false;
705
+ i += 2;
706
+ } else if ((byte & 240) === 224) {
707
+ const b1 = buffer[i + 1];
708
+ const b2 = buffer[i + 2];
709
+ if (i + 2 >= buffer.length || b1 === undefined || b2 === undefined || (b1 & 192) !== 128 || (b2 & 192) !== 128)
710
+ return false;
711
+ i += 3;
712
+ } else if ((byte & 248) === 240) {
713
+ const b1 = buffer[i + 1];
714
+ const b2 = buffer[i + 2];
715
+ const b3 = buffer[i + 3];
716
+ if (i + 3 >= buffer.length || b1 === undefined || b2 === undefined || b3 === undefined || (b1 & 192) !== 128 || (b2 & 192) !== 128 || (b3 & 192) !== 128)
717
+ return false;
718
+ i += 4;
719
+ } else {
720
+ return false;
721
+ }
722
+ }
723
+ return true;
724
+ }
725
+ function hasUtf8MultiByte(buffer) {
726
+ for (let i = 0;i < buffer.length; i++) {
727
+ const byte = buffer[i];
728
+ if (byte !== undefined && byte > 127) {
729
+ return true;
730
+ }
731
+ }
732
+ return false;
733
+ }
734
+ function parseHeaderFromBuffer(buffer) {
735
+ const maxHeaderSize = Math.min(buffer.length, 1000);
736
+ const headerSection = new TextDecoder("iso-8859-1").decode(buffer.slice(0, maxHeaderSize));
737
+ const header = {};
738
+ const singleLineMatch = headerSection.match(/^(OFXHEADER:\d+.*?)(?=<OFX|<\?xml)/is);
739
+ if (singleLineMatch?.[1]) {
740
+ const headerPart = singleLineMatch[1];
741
+ const fieldRegex = /(\w+):([^\s<]+)/g;
742
+ let fieldMatch = fieldRegex.exec(headerPart);
743
+ while (fieldMatch !== null) {
744
+ const key = fieldMatch[1];
745
+ const value = fieldMatch[2];
746
+ if (key && value !== undefined) {
747
+ header[key] = value;
748
+ }
749
+ fieldMatch = fieldRegex.exec(headerPart);
750
+ }
751
+ } else {
752
+ const lines = headerSection.split(/\r?\n/);
753
+ for (const line of lines) {
754
+ const trimmed = line.trim();
755
+ if (trimmed.startsWith("<?xml") || trimmed.startsWith("<OFX")) {
756
+ break;
757
+ }
758
+ const match = trimmed.match(/^(\w+):(.*)$/);
759
+ if (match?.[1] && match[2] !== undefined) {
760
+ header[match[1]] = match[2];
761
+ }
762
+ if (trimmed === "" && Object.keys(header).length > 0) {
763
+ break;
764
+ }
765
+ }
766
+ }
767
+ const parsedHeader = ofxHeaderSchema.parse(header);
768
+ let encoding = getEncodingFromCharset(parsedHeader.CHARSET);
769
+ if (encoding !== "utf-8" && hasUtf8MultiByte(buffer) && isValidUtf8(buffer)) {
770
+ encoding = "utf-8";
771
+ }
772
+ return { encoding, header: parsedHeader };
773
+ }
774
+ function parseBuffer(buffer) {
775
+ try {
776
+ if (!(buffer instanceof Uint8Array)) {
777
+ return {
778
+ error: new z2.ZodError([
779
+ {
780
+ code: "invalid_type",
781
+ expected: "object",
782
+ message: "Expected Uint8Array",
783
+ path: []
784
+ }
785
+ ]),
786
+ success: false
787
+ };
788
+ }
789
+ if (buffer.length === 0) {
790
+ return {
791
+ error: new z2.ZodError([
792
+ {
793
+ code: "custom",
794
+ message: "Buffer cannot be empty",
795
+ path: []
796
+ }
797
+ ]),
798
+ success: false
799
+ };
800
+ }
801
+ const { encoding } = parseHeaderFromBuffer(buffer);
802
+ const decoder = new TextDecoder(encoding);
803
+ const content = decoder.decode(buffer);
804
+ return parse(content);
805
+ } catch (err) {
806
+ if (err instanceof z2.ZodError) {
807
+ return { error: err, success: false };
808
+ }
809
+ throw err;
810
+ }
811
+ }
812
+ function parseBufferOrThrow(buffer) {
813
+ const result = parseBuffer(buffer);
814
+ if (!result.success) {
815
+ throw result.error;
816
+ }
817
+ return result.data;
818
+ }
617
819
  // src/stream.ts
618
820
  var ENTITY_MAP2 = {
619
821
  "&amp;": "&",
@@ -628,7 +830,7 @@ function decodeEntities2(text) {
628
830
  return text;
629
831
  return text.replace(ENTITY_REGEX2, (match) => ENTITY_MAP2[match] ?? match);
630
832
  }
631
- function parseHeaderFromBuffer(buffer) {
833
+ function parseHeaderFromBuffer2(buffer) {
632
834
  const lines = buffer.split(/\r?\n/);
633
835
  const header = {};
634
836
  let bodyStartIndex = 0;
@@ -672,7 +874,7 @@ function tryParseBalance(obj) {
672
874
  const result = balanceSchema.safeParse(obj);
673
875
  return result.success ? result.data : null;
674
876
  }
675
- async function* parseStream(input) {
877
+ async function* parseStream(input, options) {
676
878
  const state = {
677
879
  buffer: "",
678
880
  currentObject: {},
@@ -682,7 +884,8 @@ async function* parseStream(input) {
682
884
  objectStack: [{}],
683
885
  transactionCount: 0
684
886
  };
685
- const decoder = new TextDecoder;
887
+ let detectedEncoding = options?.encoding;
888
+ let decoder = new TextDecoder(detectedEncoding ?? "utf-8");
686
889
  const tagRegex = /<(\/?)([\w.]+)>([^<]*)/g;
687
890
  let pendingLedgerBalance;
688
891
  let pendingAvailableBalance;
@@ -690,10 +893,14 @@ async function* parseStream(input) {
690
893
  async function* processChunk(chunk, isLast = false) {
691
894
  state.buffer += chunk;
692
895
  if (!state.headerParsed) {
693
- const headerResult = parseHeaderFromBuffer(state.buffer);
896
+ const headerResult = parseHeaderFromBuffer2(state.buffer);
694
897
  if (headerResult) {
695
898
  state.headerParsed = true;
696
899
  state.inHeader = false;
900
+ if (!detectedEncoding && headerResult.header.CHARSET) {
901
+ detectedEncoding = getEncodingFromCharset(headerResult.header.CHARSET);
902
+ decoder = new TextDecoder(detectedEncoding);
903
+ }
697
904
  yield { data: headerResult.header, type: "header" };
698
905
  state.buffer = state.buffer.slice(headerResult.bodyStart);
699
906
  } else {
@@ -794,7 +1001,32 @@ async function* parseStream(input) {
794
1001
  }
795
1002
  if (input instanceof ReadableStream) {
796
1003
  const reader = input.getReader();
1004
+ const initialChunks = [];
1005
+ let headerFound = false;
797
1006
  try {
1007
+ while (!headerFound) {
1008
+ const { done, value } = await reader.read();
1009
+ if (done)
1010
+ break;
1011
+ initialChunks.push(value);
1012
+ const combined = new Uint8Array(initialChunks.reduce((sum, chunk) => sum + chunk.length, 0));
1013
+ let offset = 0;
1014
+ for (const chunk of initialChunks) {
1015
+ combined.set(chunk, offset);
1016
+ offset += chunk.length;
1017
+ }
1018
+ const headerSection = new TextDecoder("iso-8859-1").decode(combined.slice(0, Math.min(combined.length, 1000)));
1019
+ if (headerSection.includes("<OFX") || headerSection.includes("<?xml")) {
1020
+ const charsetMatch = headerSection.match(/CHARSET:(\S+)/i);
1021
+ if (charsetMatch && !detectedEncoding) {
1022
+ detectedEncoding = getEncodingFromCharset(charsetMatch[1]);
1023
+ decoder = new TextDecoder(detectedEncoding);
1024
+ }
1025
+ headerFound = true;
1026
+ const content = decoder.decode(combined);
1027
+ yield* processChunk(content);
1028
+ }
1029
+ }
798
1030
  while (true) {
799
1031
  const { done, value } = await reader.read();
800
1032
  if (done)
@@ -816,13 +1048,13 @@ async function* parseStream(input) {
816
1048
  }
817
1049
  yield { transactionCount: state.transactionCount, type: "complete" };
818
1050
  }
819
- async function parseStreamToArray(input) {
1051
+ async function parseStreamToArray(input, options) {
820
1052
  const result = {
821
1053
  accounts: [],
822
1054
  balances: [],
823
1055
  transactions: []
824
1056
  };
825
- for await (const event of parseStream(input)) {
1057
+ for await (const event of parseStream(input, options)) {
826
1058
  switch (event.type) {
827
1059
  case "header":
828
1060
  result.header = event.data;
@@ -840,13 +1072,128 @@ async function parseStreamToArray(input) {
840
1072
  }
841
1073
  return result;
842
1074
  }
1075
+ async function* createChunkIterable(content, chunkSize = 65536) {
1076
+ for (let i = 0;i < content.length; i += chunkSize) {
1077
+ yield content.slice(i, i + chunkSize);
1078
+ await new Promise((resolve) => setTimeout(resolve, 0));
1079
+ }
1080
+ }
1081
+ async function* parseBatchStream(files, options) {
1082
+ let totalTransactions = 0;
1083
+ let errorCount = 0;
1084
+ for (let i = 0;i < files.length; i++) {
1085
+ const file = files[i];
1086
+ if (!file)
1087
+ continue;
1088
+ yield { type: "file_start", fileIndex: i, filename: file.filename };
1089
+ try {
1090
+ let fileTransactionCount = 0;
1091
+ const headerSection = new TextDecoder("iso-8859-1").decode(file.buffer.slice(0, Math.min(file.buffer.length, 1000)));
1092
+ const charsetMatch = headerSection.match(/CHARSET:(\S+)/i);
1093
+ const encoding = charsetMatch ? getEncodingFromCharset(charsetMatch[1]) : options?.encoding ?? "utf-8";
1094
+ const decoder = new TextDecoder(encoding);
1095
+ const content = decoder.decode(file.buffer);
1096
+ const chunkIterable = createChunkIterable(content);
1097
+ for await (const event of parseStream(chunkIterable, options)) {
1098
+ switch (event.type) {
1099
+ case "header":
1100
+ yield { type: "header", fileIndex: i, data: event.data };
1101
+ break;
1102
+ case "transaction":
1103
+ yield { type: "transaction", fileIndex: i, data: event.data };
1104
+ fileTransactionCount++;
1105
+ break;
1106
+ case "account":
1107
+ yield { type: "account", fileIndex: i, data: event.data };
1108
+ break;
1109
+ case "balance":
1110
+ yield { type: "balance", fileIndex: i, data: event.data };
1111
+ break;
1112
+ case "complete":
1113
+ break;
1114
+ }
1115
+ }
1116
+ totalTransactions += fileTransactionCount;
1117
+ yield {
1118
+ type: "file_complete",
1119
+ fileIndex: i,
1120
+ filename: file.filename,
1121
+ transactionCount: fileTransactionCount
1122
+ };
1123
+ } catch (err) {
1124
+ errorCount++;
1125
+ yield {
1126
+ type: "file_error",
1127
+ fileIndex: i,
1128
+ filename: file.filename,
1129
+ error: err instanceof Error ? err.message : String(err)
1130
+ };
1131
+ }
1132
+ await new Promise((resolve) => setTimeout(resolve, 0));
1133
+ }
1134
+ yield {
1135
+ type: "batch_complete",
1136
+ totalFiles: files.length,
1137
+ totalTransactions,
1138
+ errorCount
1139
+ };
1140
+ }
1141
+ async function parseBatchStreamToArray(files, options) {
1142
+ const results = files.map((file, index) => ({
1143
+ fileIndex: index,
1144
+ filename: file.filename,
1145
+ transactions: [],
1146
+ accounts: [],
1147
+ balances: []
1148
+ }));
1149
+ for await (const event of parseBatchStream(files, options)) {
1150
+ switch (event.type) {
1151
+ case "header": {
1152
+ const result = results[event.fileIndex];
1153
+ if (result)
1154
+ result.header = event.data;
1155
+ break;
1156
+ }
1157
+ case "transaction": {
1158
+ const result = results[event.fileIndex];
1159
+ if (result)
1160
+ result.transactions.push(event.data);
1161
+ break;
1162
+ }
1163
+ case "account": {
1164
+ const result = results[event.fileIndex];
1165
+ if (result)
1166
+ result.accounts.push(event.data);
1167
+ break;
1168
+ }
1169
+ case "balance": {
1170
+ const result = results[event.fileIndex];
1171
+ if (result)
1172
+ result.balances.push(event.data);
1173
+ break;
1174
+ }
1175
+ case "file_error": {
1176
+ const result = results[event.fileIndex];
1177
+ if (result)
1178
+ result.error = event.error;
1179
+ break;
1180
+ }
1181
+ }
1182
+ }
1183
+ return results;
1184
+ }
843
1185
  export {
844
1186
  parseStreamToArray,
845
1187
  parseStream,
846
1188
  parseOrThrow,
1189
+ parseBufferOrThrow,
1190
+ parseBuffer,
1191
+ parseBatchStreamToArray,
1192
+ parseBatchStream,
847
1193
  parse,
848
1194
  getTransactions,
849
1195
  getSignOnInfo,
1196
+ getEncodingFromCharset,
850
1197
  getBalance,
851
1198
  getAccountInfo,
852
1199
  generateHeader,
package/package.json CHANGED
@@ -51,14 +51,13 @@
51
51
  "build": "bunup",
52
52
  "check": "biome check --write .",
53
53
  "dev": "bunup --watch",
54
- "publish": "bunx npm publish",
55
54
  "release": "bumpp --commit --push --tag",
56
55
  "test": "bun test",
57
56
  "test:coverage": "bun test --coverage",
58
57
  "test:watch": "bun test --watch",
59
- "typecheck": "tsc --noEmit"
58
+ "typecheck": "tsc "
60
59
  },
61
60
  "type": "module",
62
61
  "types": "./dist/index.d.ts",
63
- "version": "2.0.0"
62
+ "version": "2.2.0"
64
63
  }