ofx-data-extractor
Version:
A module written in TypeScript that provides a utility to extract data from an OFX file in Node.js and Browser
9 lines (8 loc) • 16.2 kB
JavaScript
const L=/<\/[\w.]+>/g,B=/<[\w.]+>/g,g="<BANKMSGSRSV1>",m="</BANKMSGSRSV1>",S="<CREDITCARDMSGSRSV1>",C="</CREDITCARDMSGSRSV1>",D="<STMTTRN>",h="</STMTTRN>";const j=new RegExp("_#_","g"),Q=[g,D,S],Z=[m,C,h];function q(n){const t=n.slice(0,4),e=n.slice(4,6),r=n.slice(6,8),a=n.slice(8,10),s=n.slice(10,12),i=n.slice(12,14);return{yyyy:t,yy:t.slice(2),y:t,MM:e,M:e,dd:r,d:r,hh:a,h:a,mm:s,m:s,ss:i,s:i}}function _(n,t){const e=["yyyy","yy","y","MM","M","dd","d","hh","h","mm","m","ss","s"];let r=n;for(const a of e)r=r.replace(a,t[a]);return r}function tt(n,t){const e=q(n);return _(t,e)}function nt(n,t){const e=String(n.getUTCFullYear()),r=String(n.getUTCMonth()+1).padStart(2,"0"),a=String(n.getUTCDate()).padStart(2,"0"),s=String(n.getUTCHours()).padStart(2,"0"),i=String(n.getUTCMinutes()).padStart(2,"0"),c=String(n.getUTCSeconds()).padStart(2,"0");return _(t,{yyyy:e,yy:e.slice(2),y:e,MM:r,M:r,dd:a,d:a,hh:s,h:s,mm:i,m:i,ss:c,s:c})}function et(n){return n%400===0?!0:n%100===0?!1:n%4===0}function rt(n,t){return n===2?et(t)?29:28:[4,6,9,11].includes(n)?30:31}function at(n){if(!/^\d{4}-\d{2}-\d{2}$/.test(n))return null;const[t,e,r]=n.split("-").map(Number);return{year:t,month:e,day:r,hour:0,minutes:0,seconds:0}}function st(n){if(!/^\d{4}-\d{2}-\d{2}T/.test(n))return null;const t=n.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):?(\d{2})(?::?(\d{2}))?(?:\.\d+)?(?:([+-]\d{2}):?(\d{2})|Z)?$/);if(!t)return null;const e=Number(t[1]),r=Number(t[2]),a=Number(t[3]),s=Number(t[4]),i=Number(t[5]),c=Number(t[6]||"0");if(!v({year:e,month:r,day:a,hour:s,minutes:i,seconds:c}))return null;const l=Number(t[7]||"0"),f=Number(t[8]||"0"),T=l<0?l*60-f:l*60+f,u=new Date(Date.UTC(e,r-1,a,s,i,c)-T*6e4);return Number.isNaN(u.getTime())?null:{year:u.getUTCFullYear(),month:u.getUTCMonth()+1,day:u.getUTCDate(),hour:u.getUTCHours(),minutes:u.getUTCMinutes(),seconds:u.getUTCSeconds()}}function it(n){const e=n.split("[")[0].split(".")[0];return/^\d{8}(\d{6})?$/.test(e)?{year:Number(e.slice(0,4)),month:Number(e.slice(4,6)),day:Number(e.slice(6,8)),hour:Number(e.slice(8,10)||"00"),minutes:Number(e.slice(10,12)||"00"),seconds:Number(e.slice(12,14)||"00")}:null}function ot(n){const t=n.match(/^(\d{1,4})[/-](\d{1,2})[/-](\d{1,4})$/);if(!t)return null;const e=Number(t[1]),r=Number(t[2]),a=Number(t[3]),s=String(t[1]).length===4,i=String(t[3]).length===4;return!s&&!i?null:{year:s?e:a,month:r,day:s?a:e,hour:0,minutes:0,seconds:0}}function ct(n){return at(n)||st(n)||ot(n)||it(n)}function v(n){return!(!Number.isFinite(n.year)||!Number.isFinite(n.month)||!Number.isFinite(n.day)||!Number.isFinite(n.hour)||!Number.isFinite(n.minutes)||!Number.isFinite(n.seconds)||n.month<1||n.month>12||n.day<1||n.day>rt(n.month,n.year)||n.hour<0||n.hour>23||n.minutes<0||n.minutes>59||n.seconds<0||n.seconds>59)}function N(n){const t=ct(n);return!t||!v(t)?null:new Date(Date.UTC(t.year,t.month-1,t.day,t.hour,t.minutes,t.seconds))}var p;(function(n){n.BANK="BANK",n.CREDIT_CARD="CREDIT_CARD"})(p||(p={}));const ut=["debit","fee","srvchg","atm","pos","check","payment","directdebit","cash","repeatpmt"],lt=n=>n.includes(g)?p.BANK:p.CREDIT_CARD;function k(n){if(String(n.TRNAMT).startsWith("-"))return!0;const t=String(n.TRNTYPE).toLocaleLowerCase();return t==="1"||ut.includes(t)}function I(n){const t=n.replace(/(\\)/g,"\\\\").replace(L,e=>$(e,!0)).replace(B,e=>P(e,!0)).replace(/(},})/g,"}}").replace(/(}")/g,'},"').replace(/(]")/g,'],"').replace(/(},])/g,"}]").replace(/(,})/g,"}").replace(/,\s*}/g,"}").replace(/(,",)/,'",').replace(j,'\\"').slice(0,-1);return t.endsWith(",")?t.slice(0,-1):t}const dt=n=>JSON.stringify({date:n.slice(0,12),transactionCode:n.slice(12,19),protocol:n.slice(19)}),Tt=n=>n.startsWith("DT"),U=n=>n.trim();function P(n,t=!1){return Q.includes(n)&&!t?n:n.replace(/[<]/g,`
"`).replace(/[>]/g,'":{')}function $(n,t=!1){return Z.includes(n)&&!t?`
${n}`:`},
`}function ft({fitId:n,fitValue:t}){return n==="separated"?dt(t):`"${t}",`}function gt(n){return n.search(",")>n.search(".")?n.replace(/[.]/g,"").replace(/[,]/g,"."):n.replace(/[,]/g,"")}function E({dateString:n,formatDate:t="y-M-d"}){return!/^\d{8}(\d{6})?(\.\d+)?(\[[^\]]+\])?$/.test(n)||!N(n)?n:tt(n,t)}function ht(n){let t=n.value.replace(/[{]/g,"").replace(/(},)/g,"").replace(/["]/g,"_#_");const e=n.field.replace(/['"]/g,"");return e.endsWith("AMT")&&(t=gt(t)),Tt(e)&&(t=E({dateString:t,formatDate:n.formatDate})),e==="FITID"?ft({fitId:n.fitId,fitValue:t}):n.nativeTypes&&G(e,t)?`${Number(t)},`:`"${t}",`}function mt(n,t){let e=n;return n.match(/{(\w|\W)+/)&&(e=e.replace(/({(\w|\W)+)$/,r=>ht({field:e.slice(0,e.indexOf(":")),value:r,...t}))),e}function G(n,t){return n.endsWith("ID")||n.endsWith("NUM")?!1:!isNaN(Number(t))}function K(n){const t=n.indexOf(D),e=n.lastIndexOf(h)+h.length;if(t<0||e<0||t>=e)return{oldListText:n,newListText:n};const r=n.substring(t,e),a=new RegExp(D,"g"),s=new RegExp(h,"g"),i=`"STRTTRN":[${r.replace(a,"{").replace(s,"},")}]`;return{oldListText:n,newListText:n.replace(r,i)}}function O(n){if(!(n.indexOf(g)>0))return{newBankStatementTransactions:null,oldBankStatementTransactions:null};const e=K(n.substring(n.indexOf(g),n.indexOf(m)+m.length));return{newBankStatementTransactions:e.newListText,oldBankStatementTransactions:e.oldListText}}function w(n){if(!(n.indexOf(S)>0))return{newCreditCardStatementTransactions:null,oldCreditCardStatementTransactions:null};const e=n.substring(n.indexOf(S),n.indexOf(C)+C.length),r=K(e);return{newCreditCardStatementTransactions:r.newListText,oldCreditCardStatementTransactions:r.oldListText}}function St(n){const t=[];for(const e of n)t.push(...e.replace("<?xml"," ").replace("<?OFX"," ").replace("?>"," ").split(" ").filter(Boolean).map(U).map(r=>r.replace("=",":").replace(/["]/g,"")));return t}function Ct(n,t){const e={};n.join("").search("<?")>-1&&(n=St(n));for(const r of n){const[a,s]=r.split(":"),i=a.replace(`
`,"").toUpperCase();e[i]=t&&G(a,s)?Number(s):String(s).replace(/\?>/,"")}return e}function Nt(n){return n.reduce((t,e)=>{const r=Math.abs(+e.TRNAMT);return k(e)?(t.amountOfDebits++,t.debit+=r,t):(t.amountOfCredits++,t.credit+=r,t)},{credit:0,debit:0,amountOfCredits:0,amountOfDebits:0})}class pt{config;internConfig={};constructor(t){this.config=t,this.internConfig=t}getConfig(){return this.internConfig}sanitizeRow(t){return mt(t,this.internConfig)}getPartialJsonData(t){const[e,r]=t.split("<OFX>"),a="<OFX>"+r,{sanitizeRow:s}=this;return a.replace(/<(?=[^\/])/g,`
<`).replace(L,i=>$(i)).replace(B,i=>P(i)).split(`
`).map(U).filter(Boolean).map(s,this).join("")}}function x(n){return n.toString()}async function F(n){return await new Promise((e,r)=>{if(typeof window<"u"&&window.FileReader){const a=new window.FileReader;a.onload=s=>e(s.target.result),a.onerror=s=>r(s.target.error),a.readAsText(n)}else r(new Error("FileReader is not available in this environment."))})}class d{data;dataRead="";constructor(t){this.data=t,t&&(this.dataRead=t)}getData(){return this.dataRead}fromString(t){return new d(t)}fromBuffer(t){return new d(x(t))}async fromBlob(t){return new d(await F(t))}static fromString(t){return new d(t)}static fromBuffer(t){return new d(x(t))}static async fromBlob(t){return new d(await F(t))}}class Rt{configInstance={}}class X extends Rt{setConfig(t){this.configInstance=t}getBankTransferList(t){const{newBankStatementTransactions:e}=O(this.configInstance.getPartialJsonData(t));return e?JSON.parse(`{${I(e)}}`)?.BANKMSGSRSV1?.STMTTRNRS?.STMTRS?.BANKTRANLIST?.STRTTRN:[]}getCreditCardTransferList(t){const{newCreditCardStatementTransactions:e}=w(this.configInstance.getPartialJsonData(t));return e?JSON.parse(`{${I(e)}}`)?.CREDITCARDMSGSRSV1?.CCSTMTTRNRS?.CCSTMTRS?.BANKTRANLIST?.STRTTRN:[]}getTransactionsSummary(t){let e=[],r=[];const a=this.configInstance?.getConfig?.().parserMode||"strict";let s=null,i=null;try{e=this.getBankTransferList(t)||[]}catch(R){s=R,e=[]}try{r=this.getCreditCardTransferList(t)||[]}catch(R){i=R,r=[]}const c=t.includes("<STMTTRN>");if(a==="strict"&&s&&i&&c)throw s;const o=e.length?e:r,l=e.length?g:S,f=e.length?m:C,T=t.indexOf(l),u=t.indexOf(f),b=T>=0&&u>T?t.slice(T,u+f.length):"",A=b.match(/<DTSTART>([^\n<]+)/),M=b.match(/<DTEND>([^\n<]+)/),V=Nt(o||[]),H=A?.[1]?E({dateString:A[1],formatDate:this.configInstance.getConfig().formatDate}):"",Y=M?.[1]?E({dateString:M[1],formatDate:this.configInstance.getConfig().formatDate}):"";return{dateStart:H,dateEnd:Y,...V}}getContent(t){const e=this.configInstance.getPartialJsonData(t),{newBankStatementTransactions:r,oldBankStatementTransactions:a}=O(e),{newCreditCardStatementTransactions:s,oldCreditCardStatementTransactions:i}=w(e);let c=e;return r&&(c=c.replace(a,r)),s&&(c=c.replace(i,s)),JSON.parse(`{${I(c)}}`)}}function z(n){if(n==null)return"";if(typeof n=="string"||typeof n=="number")return String(n);if(typeof n=="object"){const t=n;if(t.date!==void 0&&t.transactionCode!==void 0&&t.protocol!==void 0)return`${t.date}${t.transactionCode}${t.protocol}`;try{return JSON.stringify(n)}catch{return String(n)}}return String(n)}function W(n){if(n==null)return null;const t=Number(String(n).replace(/,/g,"."));return Number.isNaN(t)?null:t}function It(n){return n.normalize("NFD").replace(/[\u0300-\u036f]/g,"").replace(/\s+/g," ").trim().toLowerCase()}function Dt(n,t){const e=t.dateMode||"formatted";if(!n)return{value:null,warning:{code:"MISSING_DATE",message:"Transaction does not include DTPOSTED.",severity:"warning"}};if(e==="raw"||e==="formatted"){const a=N(n);return a?e==="formatted"&&t.formatDate?{value:nt(a,t.formatDate)}:{value:n}:{value:n,warning:{code:"INVALID_DATE",message:`Unable to parse date '${n}'.`,severity:"warning"}}}const r=N(n);return r?e==="date"?{value:r}:e==="timestamp"?{value:r.getTime()}:{value:r.toISOString()}:{value:null,warning:{code:"INVALID_DATE",message:`Unable to parse date '${n}'.`,severity:"warning"}}}function Et(n,t){const e=W(n);return e===null?{amount:null,amountAbs:null,warning:{code:"INVALID_AMOUNT",message:`Unable to parse amount '${String(n)}'.`,severity:"warning"}}:t==="string"?{amount:String(n),amountAbs:String(Math.abs(e))}:t==="cents"?{amount:Math.round(e*100),amountAbs:Math.round(Math.abs(e)*100)}:{amount:e,amountAbs:Math.abs(e)}}function J(n){const t=[],e=n?.OFX?.SIGNONMSGSRSV1?.SONRS?.FI||null,r=n?.OFX?.BANKMSGSRSV1?.STMTTRNRS?.STMTRS;(r?.BANKTRANLIST?.STRTTRN||r?.BANKTRANLIST?.STMTTRN||[]).forEach((c,o)=>{t.push({source:"bank",transaction:c,currency:r?.CURDEF||null,account:r?.BANKACCTFROM||null,institution:e,path:`OFX.BANKMSGSRSV1.STMTTRNRS.STMTRS.BANKTRANLIST.${o}`})});const s=n?.OFX?.CREDITCARDMSGSRSV1?.CCSTMTTRNRS?.CCSTMTRS;return(s?.BANKTRANLIST?.STRTTRN||s?.BANKTRANLIST?.STMTTRN||[]).forEach((c,o)=>{t.push({source:"credit_card",transaction:c,currency:s?.CURDEF||null,account:s?.CCACCTFROM||null,institution:e,path:`OFX.CREDITCARDMSGSRSV1.CCSTMTTRNRS.CCSTMTRS.BANKTRANLIST.${o}`})}),t}function yt(n,t={}){const e=t.amountMode||"number",r=[],a=[];return J(n).forEach(i=>{const c=[],o=String(i.transaction.MEMO||i.transaction.NAME||i.transaction.TRNTYPE||"").trim(),{amount:l,amountAbs:f,warning:T}=Et(i.transaction.TRNAMT,e);T&&(T.path=i.path,c.push(T),r.push(T));const u=Dt(i.transaction.DTPOSTED,t);u.warning&&(u.warning.path=i.path,c.push(u.warning),r.push(u.warning)),a.push({source:i.source,direction:k(i.transaction)?"debit":"credit",amount:l,amountAbs:f,postedAt:u.value,description:o,descriptionNormalized:It(o),fitId:z(i.transaction.FITID),currency:i.currency,account:i.account,institution:i.institution,raw:i.transaction,warnings:c})}),{transactions:a,warnings:r}}function bt(n){const t=[],e=[],r=J(n);n?.OFX||e.push({code:"MISSING_OFX_BLOCK",message:"Missing OFX root block.",severity:"error",path:"OFX"}),r.length||t.push({code:"NO_TRANSACTIONS_FOUND",message:"No transactions were found in bank or credit card statements.",severity:"warning",path:"OFX"});const a=new Map;r.forEach(o=>{const l=z(o.transaction.FITID);l?a.set(l,(a.get(l)||0)+1):t.push({code:"MISSING_FITID",message:"Transaction does not include FITID.",severity:"warning",path:o.path}),W(o.transaction.TRNAMT)===null&&e.push({code:"INVALID_AMOUNT",message:`Invalid amount '${String(o.transaction.TRNAMT)}'.`,severity:"error",path:o.path}),N(String(o.transaction.DTPOSTED||""))||t.push({code:"INVALID_DATE",message:`Invalid DTPOSTED '${String(o.transaction.DTPOSTED||"")}'.`,severity:"warning",path:o.path})});const s=Array.from(a.values()).filter(o=>o>1).length;s>0&&t.push({code:"DUPLICATED_FITID",message:`Found ${s} duplicated FITID values.`,severity:"warning",path:"OFX"});const i=r.filter(o=>o.source==="bank").length,c=r.filter(o=>o.source==="credit_card").length;return{isValid:e.length===0,warnings:t,errors:e,stats:{totalTransactions:r.length,bankTransactions:i,creditCardTransactions:c,duplicatedFitIds:s}}}class At{customExtractor;customExtractorInstance;dataReaderInstance=new d("");diagnostics=[];constructor(t){this.customExtractor=t,this.customExtractorInstance=t||new X,this.config({})}data(t){return this.dataReaderInstance=t,this}config(t){return this.customExtractorInstance.setConfig(new pt(t)),this}getType(){return lt(this.dataReaderInstance.getData())}beginOperation(){this.diagnostics=[]}getParserMode(){return this.customExtractorInstance.configInstance.getConfig().parserMode}pushWarning(t){this.diagnostics.push(t)}safelyExecute(t,e,r){try{return r()}catch(a){if(this.getParserMode()!=="lenient")throw a;return this.pushWarning({code:"PARSE_ERROR",message:`Failed to parse OFX while running '${t}'.`,severity:"warning",context:a instanceof Error?a.message:String(a),path:t}),e}}getHeadersInternal(){return this.safelyExecute("getHeaders",{},()=>{const[t]=this.dataReaderInstance.getData().split("<OFX>");return Ct(String(t).split(`
`).filter(e=>!!e.trim().length),!!this.customExtractorInstance.configInstance.getConfig().nativeTypes)})}getContentInternal(){return this.safelyExecute("getContent",{OFX:{}},()=>this.customExtractorInstance.getContent(this.dataReaderInstance.getData()))}getResponseInternal(){const t=this.getHeadersInternal(),e=this.getContentInternal();return{...t,...e}}getHeaders(){return this.beginOperation(),this.getHeadersInternal()}getBankTransferList(){return this.beginOperation(),this.safelyExecute("getBankTransferList",[],()=>this.customExtractorInstance.getBankTransferList(this.dataReaderInstance.getData()))}getCreditCardTransferList(){return this.beginOperation(),this.safelyExecute("getCreditCardTransferList",[],()=>this.customExtractorInstance.getCreditCardTransferList(this.dataReaderInstance.getData()))}getTransactionsSummary(){return this.beginOperation(),this.safelyExecute("getTransactionsSummary",{credit:0,debit:0,amountOfCredits:0,amountOfDebits:0,dateStart:"",dateEnd:""},()=>this.customExtractorInstance.getTransactionsSummary(this.dataReaderInstance.getData()))}getContent(){return this.beginOperation(),this.getContentInternal()}toJson(){return this.beginOperation(),this.getResponseInternal()}toNormalized(t={}){this.beginOperation();const e=this.getResponseInternal();return yt(e,{...t,formatDate:t.formatDate||this.customExtractorInstance.configInstance.getConfig().formatDate})}validate(){this.beginOperation();const t=this.getResponseInternal();return bt(t)}getWarnings(){return[...this.diagnostics]}}class y{extractor;constructor(t,e){this.extractor=new At(new X),this.extractor.data(new d(t)),this.extractor.config(e||{})}getType(){return this.extractor.getType()}static fromBuffer(t){return new y(d.fromBuffer(t).getData())}static async fromBlob(t){const e=await d.fromBlob(t);return new y(e.getData())}config(t){return this.extractor.config(t),this}getHeaders(){return this.extractor.getHeaders()}getBankTransferList(){return this.extractor.getBankTransferList()}getCreditCardTransferList(){return this.extractor.getCreditCardTransferList()}getTransactionsSummary(){return this.extractor.getTransactionsSummary()}getContent(){return this.extractor.getContent()}toJson(){return this.extractor.toJson()}toNormalized(t){return this.extractor.toNormalized(t)}validate(){return this.extractor.validate()}getWarnings(){return this.extractor.getWarnings()}}export{At as Extractor,y as Ofx,X as OfxExtractor,d as Reader,F as blobToString,x as bufferToString,I as fixJsonProblems,tt as formatDate};