diff --git a/packages/desktop-client/src/components/accounts/Account.jsx b/packages/desktop-client/src/components/accounts/Account.jsx index 5a4025793ba5c2136967bb9855bb32bb4660e620..c37aad5148d49b9842294575fa04ca2b76637afd 100644 --- a/packages/desktop-client/src/components/accounts/Account.jsx +++ b/packages/desktop-client/src/components/accounts/Account.jsx @@ -448,7 +448,7 @@ class AccountInternal extends PureComponent { filters: [ { name: 'Financial Files', - extensions: ['qif', 'ofx', 'qfx', 'csv', 'tsv'], + extensions: ['qif', 'ofx', 'qfx', 'csv', 'tsv', 'xml'], }, ], }); diff --git a/packages/desktop-client/src/components/modals/ImportTransactions.jsx b/packages/desktop-client/src/components/modals/ImportTransactions.jsx index 176a06e71823c6eb952b93fe6302c925d6701cf4..bffda77e0fb3bc76bf91e3ec1afc94066bc5c945 100644 --- a/packages/desktop-client/src/components/modals/ImportTransactions.jsx +++ b/packages/desktop-client/src/components/modals/ImportTransactions.jsx @@ -888,7 +888,7 @@ export function ImportTransactions({ modalProps, options }) { filters: [ { name: 'Financial Files', - extensions: ['qif', 'ofx', 'qfx', 'csv', 'tsv'], + extensions: ['qif', 'ofx', 'qfx', 'csv', 'tsv', 'xml'], }, ], }); @@ -916,9 +916,10 @@ export function ImportTransactions({ modalProps, options }) { for (let trans of transactions) { trans = fieldMappings ? applyFieldMappings(trans, fieldMappings) : trans; - const date = isOfxFile(filetype) - ? trans.date - : parseDate(trans.date, parseDateFormat); + const date = + isOfxFile(filetype) || isCamtFile(filetype) + ? trans.date + : parseDate(trans.date, parseDateFormat); if (date == null) { errorMessage = `Unable to parse date ${ trans.date || '(empty)' @@ -959,7 +960,7 @@ export function ImportTransactions({ modalProps, options }) { return; } - if (!isOfxFile(filetype)) { + if (!isOfxFile(filetype) && !isCamtFile(filetype)) { const key = `parse-date-${accountId}-${filetype}`; savePrefs({ [key]: parseDateFormat }); } @@ -1110,32 +1111,32 @@ export function ImportTransactions({ modalProps, options }) { )} {isOfxFile(filetype) && ( - <> - <CheckboxOption - id="form_fallback_missing_payee" - checked={fallbackMissingPayeeToMemo} - onChange={() => { - setFallbackMissingPayeeToMemo(state => !state); - parse( - filename, - getParseOptions('ofx', { - fallbackMissingPayeeToMemo: !fallbackMissingPayeeToMemo, - }), - ); - }} - > - Use Memo as a fallback for empty Payees - </CheckboxOption> - <CheckboxOption - id="form_dont_reconcile" - checked={reconcile} - onChange={() => { - setReconcile(state => !state); - }} - > - Reconcile transactions - </CheckboxOption> - </> + <CheckboxOption + id="form_fallback_missing_payee" + checked={fallbackMissingPayeeToMemo} + onChange={() => { + setFallbackMissingPayeeToMemo(state => !state); + parse( + filename, + getParseOptions('ofx', { + fallbackMissingPayeeToMemo: !fallbackMissingPayeeToMemo, + }), + ); + }} + > + Use Memo as a fallback for empty Payees + </CheckboxOption> + )} + {(isOfxFile(filetype) || isCamtFile(filetype)) && ( + <CheckboxOption + id="form_dont_reconcile" + checked={reconcile} + onChange={() => { + setReconcile(state => !state); + }} + > + Reconcile transactions + </CheckboxOption> )} {/*Import Options */} @@ -1312,3 +1313,7 @@ function getParseOptions(fileType, options = {}) { function isOfxFile(fileType) { return fileType === 'ofx' || fileType === 'qfx'; } + +function isCamtFile(fileType) { + return fileType === 'xml'; +} diff --git a/packages/loot-core/src/mocks/files/camt/camt.053.xml b/packages/loot-core/src/mocks/files/camt/camt.053.xml new file mode 100644 index 0000000000000000000000000000000000000000..9f22aa5e59830db6ec82fcc5e62eb2f7b4a6efcb --- /dev/null +++ b/packages/loot-core/src/mocks/files/camt/camt.053.xml @@ -0,0 +1,465 @@ +<?xml version="1.0" encoding="UTF-8"?> +<Document + xsi:schemaLocation="urn:iso:std:iso:20022:tech:xsd:camt.053.001.04 camt.053.001.04.xsd" + xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.04" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> + + <BkToCstmrStmt> + <GrpHdr> + <MsgId>053D2014-01-03T22:01:42.0N140000001</MsgId> + <CreDtTm>2014-01-03T22:01:36.0+01:00</CreDtTm> + <MsgPgntn> + <PgNb>1</PgNb> + <LastPgInd>true</LastPgInd> + </MsgPgntn> + </GrpHdr> + <Stmt> + <Id>0352C5320140103220142</Id> + <ElctrncSeqNb>140000001</ElctrncSeqNb> + <CreDtTm>2014-01-03T22:01:36.0+01:00</CreDtTm> + <Acct> + <Id> + <IBAN>DE14740618130000033626</IBAN> + </Id> + <Ccy>EUR</Ccy> + <Ownr> + <Nm>Testkonto Nummer 1</Nm> + </Ownr> + <Svcr> + <FinInstnId> + <BIC>GENODEF1PFK</BIC> + <Nm>VR-Bank Rottal-Inn eG</Nm> + <Othr> + <Id>DE 129267947</Id> + <Issr>UmsStId</Issr> + </Othr> + </FinInstnId> + </Svcr> + </Acct> + <Bal> + <Tp> + <CdOrPrtry> + <Cd>PRCD</Cd> + </CdOrPrtry> + </Tp> + <Amt Ccy="EUR">24.16</Amt> + <CdtDbtInd>CRDT</CdtDbtInd> + <Dt> + <Dt>2014-01-03</Dt> + </Dt> + </Bal> + <Bal> + <Tp> + <CdOrPrtry> + <Cd>CLBD</Cd> + </CdOrPrtry> + </Tp> + <Amt Ccy="EUR">27.61</Amt> + <CdtDbtInd>CRDT</CdtDbtInd> + <Dt> + <Dt>2014-01-03</Dt> + </Dt> + </Bal> + <Ntry> + <Amt Ccy="EUR">0.60</Amt> + <CdtDbtInd>CRDT</CdtDbtInd> + <Sts>BOOK</Sts> + <BookgDt> + <Dt>2013-12-30</Dt> + </BookgDt> + <ValDt> + <Dt>2013-12-30</Dt> + </ValDt> + <AcctSvcrRef>2013123001153870000</AcctSvcrRef> + <BkTxCd /> + <NtryDtls> + <TxDtls> + <Refs> + <EndToEndId>STZV-EtE28122013-11:29-1</EndToEndId> + </Refs> + <BkTxCd> + <Prtry> + <Cd>NMSC+051</Cd> + <Issr>ZKA</Issr> + </Prtry> + </BkTxCd> + <RltdPties> + <Dbtr> + <Nm>Testkonto Nummer 2</Nm> + </Dbtr> + <DbtrAcct> + <Id> + <IBAN>DE58740618130100033626</IBAN> + </Id> + </DbtrAcct> + <UltmtDbtr> + <Nm>keine Information vorhanden</Nm> + </UltmtDbtr> + <Cdtr> + <Nm>Testkonto Nummer 1</Nm> + </Cdtr> + <CdtrAcct> + <Id> + <IBAN>DE14740618130000033626</IBAN> + </Id> + </CdtrAcct> + <UltmtCdtr> + <Nm>Testkonto 1</Nm> + </UltmtCdtr> + </RltdPties> + <RltdAgts> + <DbtrAgt> + <FinInstnId> + <BIC>GENODEF1PFK</BIC> + </FinInstnId> + </DbtrAgt> + </RltdAgts> + <RmtInf> + <Ustrd>Sammler aus Testknto 2 Zweite Ueberweisung EREF: + STZV-EtE28122013-11:29-1 IBAN: DE58740618130100033626 BIC: + GENODEF1PFK ABWE: Testkonto 1</Ustrd> + </RmtInf> + </TxDtls> + </NtryDtls> + </Ntry> + <Ntry> + <Amt Ccy="EUR">0.50</Amt> + <CdtDbtInd>CRDT</CdtDbtInd> + <Sts>BOOK</Sts> + <BookgDt> + <Dt>2013-12-30</Dt> + </BookgDt> + <ValDt> + <Dt>2013-12-30</Dt> + </ValDt> + <AcctSvcrRef>2013123001153870001</AcctSvcrRef> + <BkTxCd /> + <NtryDtls> + <TxDtls> + <Refs> + <EndToEndId>STZV-EtE28122013-11:29-2</EndToEndId> + </Refs> + <BkTxCd> + <Prtry> + <Cd>NMSC+051</Cd> + <Issr>ZKA</Issr> + </Prtry> + </BkTxCd> + <RltdPties> + <Dbtr> + <Nm>Testkonto Nummer 2</Nm> + </Dbtr> + <DbtrAcct> + <Id> + <IBAN>DE58740618130100033626</IBAN> + </Id> + </DbtrAcct> + <UltmtDbtr> + <Nm>keine Information vorhanden</Nm> + </UltmtDbtr> + <Cdtr> + <Nm>Testkonto Nummer 1</Nm> + </Cdtr> + <CdtrAcct> + <Id> + <IBAN>DE14740618130000033626</IBAN> + </Id> + </CdtrAcct> + <UltmtCdtr> + <Nm>Testkonto 1</Nm> + </UltmtCdtr> + </RltdPties> + <RltdAgts> + <DbtrAgt> + <FinInstnId> + <BIC>GENODEF1PFK</BIC> + </FinInstnId> + </DbtrAgt> + </RltdAgts> + <RmtInf> + <Ustrd>Sammler aus Testkonto 2 Erste Ueberweisung EREF: + STZV-EtE28122013-11:29-2 IBAN: DE58740618130100033626 BIC: + GENODEF1PFK ABWE: Testkonto 1</Ustrd> + </RmtInf> + </TxDtls> + </NtryDtls> + </Ntry> + <Ntry> + <Amt Ccy="EUR">89.85</Amt> + <CdtDbtInd>DBIT</CdtDbtInd> + <Sts>BOOK</Sts> + <BookgDt> + <Dt>2024-03-12</Dt> + </BookgDt> + <ValDt> + <Dt>2024-03-11</Dt> + </ValDt> + <AcctSvcrRef>640387810124072/2</AcctSvcrRef> + <BkTxCd> + <Domn> + <Cd>PMNT</Cd> + <Fmly> + <Cd>CCRD</Cd> + <SubFmlyCd>POSD</SubFmlyCd> + </Fmly> + </Domn> + </BkTxCd> + <NtryDtls> + <TxDtls> + <Refs> + <MsgId>494.844</MsgId> + <EndToEndId>VSDB 4395 9061</EndToEndId> + </Refs> + <Amt Ccy="EUR">89.85</Amt> + <CdtDbtInd>DBIT</CdtDbtInd> + </TxDtls> + </NtryDtls> + <AddtlNtryInf>Supermarkt A</AddtlNtryInf> + </Ntry> + <Ntry> + <Amt Ccy="EUR">1.20</Amt> + <CdtDbtInd>CRDT</CdtDbtInd> + <Sts>BOOK</Sts> + <BookgDt> + <Dt>2014-01-07</Dt> + </BookgDt> + <ValDt> + <Dt>2014-01-05</Dt> + </ValDt> + <AcctSvcrRef>2014010509572410000</AcctSvcrRef> + <BkTxCd /> + <NtryDtls> + <TxDtls> + <Refs> + <MsgId>20140107135635060183</MsgId> + <EndToEndId>STZV-EtE27122013-11:05-2</EndToEndId> + <MndtId>Mandat20131227</MndtId> + </Refs> + <BkTxCd> + <Prtry> + <Cd>NMSC+835</Cd> + <Issr>ZKA</Issr> + </Prtry> + </BkTxCd> + <RmtInf> + <Ustrd>Retoure SEPA Lastschrift vom 03.01.2014, Rueckgabegrund: MD06 + Lastschriftwiderspruch durch den Zahlungspflichtigen EREF: + STZV-EtE27122013-11</Ustrd> + <Ustrd>:05-2 CRED: DE79ZZZ00000000584 IBAN: DE14740618130000033626 BIC: + GENODEF1PFK ABWE: Testkonto</Ustrd> + </RmtInf> + <RtrInf> + <OrgnlBkTxCd> + <Prtry> + <Cd>105</Cd> + <Issr>ZKA</Issr> + </Prtry> + </OrgnlBkTxCd> + <Orgtr> + <Id> + <OrgId> + <BICOrBEI>GENODEF1PFK</BICOrBEI> + </OrgId> + </Id> + </Orgtr> + <Rsn> + <Cd>MD06</Cd> + </Rsn> + </RtrInf> + </TxDtls> + </NtryDtls> + </Ntry> + <Ntry> + <Amt Ccy="EUR">80</Amt> + <CdtDbtInd>DBIT</CdtDbtInd> + <Sts>BOOK</Sts> + <BookgDt> + <Dt>2024-03-12</Dt> + </BookgDt> + <ValDt> + <Dt>2024-03-12</Dt> + </ValDt> + <AcctSvcrRef>367406715024072/1</AcctSvcrRef> + <BkTxCd> + <Domn> + <Cd>PMNT</Cd> + <Fmly> + <Cd>ICDT</Cd> + <SubFmlyCd>AUTT</SubFmlyCd> + </Fmly> + </Domn> + </BkTxCd> + <NtryDtls> + <TxDtls> + <Refs> + <MsgId>448.106.</MsgId> + <InstrId>2024031109</InstrId> + <EndToEndId>2024031</EndToEndId> + </Refs> + <Amt Ccy="EUR">80</Amt> + <CdtDbtInd>DBIT</CdtDbtInd> + <AmtDtls> + <InstdAmt> + <Amt Ccy="EUR">80</Amt> + </InstdAmt> + </AmtDtls> + <RltdPties> + <Cdtr> + <Nm>Rega</Nm> + <PstlAdr> + <StrtNm>Postfach</StrtNm> + <BldgNb>122</BldgNb> + <PstCd>5555</PstCd> + <TwnNm>Flughafen</TwnNm> + <Ctry>CH</Ctry> + </PstlAdr> + </Cdtr> + <CdtrAcct> + <Id> + <IBAN>CH803000000</IBAN> + </Id> + </CdtrAcct> + </RltdPties> + <RltdAgts> + <CdtrAgt> + <FinInstnId> + <ClrSysMmbId> + <ClrSysId> + <Cd>CHSIC</Cd> + </ClrSysId> + <MmbId>1</MmbId> + </ClrSysMmbId> + </FinInstnId> + </CdtrAgt> + </RltdAgts> + <RmtInf> + <Strd> + <CdtrRefInf> + <Tp> + <CdOrPrtry> + <Prtry>QRR</Prtry> + </CdOrPrtry> + </Tp> + <Ref>002462</Ref> + </CdtrRefInf> + </Strd> + </RmtInf> + <RltdDts> + <IntrBkSttlmDt>2024-03-12</IntrBkSttlmDt> + </RltdDts> + </TxDtls> + </NtryDtls> + <AddtlNtryInf>Rega</AddtlNtryInf> + </Ntry> + <Ntry> + <Amt Ccy="EUR">3.45</Amt> + <CdtDbtInd>CRDT</CdtDbtInd> + <Sts>BOOK</Sts> + <BookgDt> + <Dt>2014-01-03</Dt> + </BookgDt> + <ValDt> + <Dt>2014-01-03</Dt> + </ValDt> + <AcctSvcrRef>2014010303263400000</AcctSvcrRef> + <BkTxCd /> + <NtryDtls> + <Btch> + <PmtInfId>STZV-PmInf27122013-11:05-2</PmtInfId> + <NbOfTxs>2</NbOfTxs> + </Btch> + <TxDtls> + <Refs> + <MsgId>STZV-Msg27122013-11:05</MsgId> + <EndToEndId>STZV-EtE27122013-11:05-1</EndToEndId> + <MndtId>Mandat20131227</MndtId> + </Refs> + <AmtDtls> + <TxAmt> + <Amt Ccy="EUR">2.25</Amt> + </TxAmt> + </AmtDtls> + <BkTxCd> + <Prtry> + <Cd>NDDT+071</Cd> + <Issr>ZKA</Issr> + </Prtry> + </BkTxCd> + <RltdPties> + <Dbtr> + <Nm>Testkonto Nummer 2</Nm> + </Dbtr> + <DbtrAcct> + <Id> + <IBAN>DE58740618130100033626</IBAN> + </Id> + </DbtrAcct> + <UltmtDbtr> + <Nm>Testkonto</Nm> + </UltmtDbtr> + <Cdtr> + <Nm>Testkonto Nummer 1</Nm> + </Cdtr> + <CdtrAcct> + <Id> + <IBAN>DE14740618130000033626</IBAN> + </Id> + </CdtrAcct> + <UltmtCdtr> + <Nm>keine Information vorhanden</Nm> + </UltmtCdtr> + </RltdPties> + <RmtInf> + <Ustrd>Lastschrift 2. Zahlung TAN:747216 </Ustrd> + </RmtInf> + </TxDtls> + <TxDtls> + <Refs> + <MsgId>STZV-Msg27122013-11:05</MsgId> + <EndToEndId>STZV-EtE27122013-11:05-2</EndToEndId> + <MndtId>Mandat20131227</MndtId> + </Refs> + <AmtDtls> + <TxAmt> + <Amt Ccy="EUR">1.20</Amt> + </TxAmt> + </AmtDtls> + <BkTxCd> + <Prtry> + <Cd>NDDT+071</Cd> + <Issr>ZKA</Issr> + </Prtry> + </BkTxCd> + <RltdPties> + <Dbtr> + <Nm>Testkonto Nummer 2</Nm> + </Dbtr> + <DbtrAcct> + <Id> + <IBAN>DE58740618130100033626</IBAN> + </Id> + </DbtrAcct> + <UltmtDbtr> + <Nm>Testkonto</Nm> + </UltmtDbtr> + <Cdtr> + <Nm>Testkonto Nummer 1</Nm> + </Cdtr> + <CdtrAcct> + <Id> + <IBAN>DE14740618130000033626</IBAN> + </Id> + </CdtrAcct> + <UltmtCdtr> + <Nm>keine Information vorhanden</Nm> + </UltmtCdtr> + </RltdPties> + <RmtInf> + <Ustrd>Lastschrift 1. Zahlung TAN:747216 </Ustrd> + </RmtInf> + </TxDtls> + </NtryDtls> + </Ntry> + </Stmt> + </BkToCstmrStmt> +</Document> \ No newline at end of file diff --git a/packages/loot-core/src/server/accounts/__snapshots__/parse-file.test.ts.snap b/packages/loot-core/src/server/accounts/__snapshots__/parse-file.test.ts.snap index 5e325d19b8e547c564e236d804b4534754d898f9..ac23fd9bab6c097f83b39da1127a440b12311022 100644 --- a/packages/loot-core/src/server/accounts/__snapshots__/parse-file.test.ts.snap +++ b/packages/loot-core/src/server/accounts/__snapshots__/parse-file.test.ts.snap @@ -1,5 +1,192 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`File import CAMT.053 import works 1`] = ` +Array [ + Object { + "acct": "one", + "amount": 60, + "category": null, + "cleared": 1, + "date": 20131230, + "description": "id2", + "error": null, + "financial_id": "2013123001153870000", + "id": "id5", + "imported_description": "Testkonto Nummer 2", + "isChild": 0, + "isParent": 0, + "location": null, + "notes": "Sammler aus Testknto 2 Zweite Ueberweisung EREF: + STZV-EtE28122013-11:29-1 IBAN: DE58740618130100033626 BIC: + GENODEF1PFK ABWE: Testkonto 1", + "parent_id": null, + "pending": 0, + "reconciled": 0, + "schedule": null, + "sort_order": 123456789, + "starting_balance_flag": 0, + "tombstone": 0, + "transferred_id": null, + "type": null, + }, + Object { + "acct": "one", + "amount": 50, + "category": null, + "cleared": 1, + "date": 20131230, + "description": "id2", + "error": null, + "financial_id": "2013123001153870001", + "id": "id6", + "imported_description": "Testkonto Nummer 2", + "isChild": 0, + "isParent": 0, + "location": null, + "notes": "Sammler aus Testkonto 2 Erste Ueberweisung EREF: + STZV-EtE28122013-11:29-2 IBAN: DE58740618130100033626 BIC: + GENODEF1PFK ABWE: Testkonto 1", + "parent_id": null, + "pending": 0, + "reconciled": 0, + "schedule": null, + "sort_order": 123456789, + "starting_balance_flag": 0, + "tombstone": 0, + "transferred_id": null, + "type": null, + }, + Object { + "acct": "one", + "amount": -8985, + "category": null, + "cleared": 1, + "date": 20240311, + "description": "id3", + "error": null, + "financial_id": "640387810124072/2", + "id": "id7", + "imported_description": "Supermarkt A", + "isChild": 0, + "isParent": 0, + "location": null, + "notes": null, + "parent_id": null, + "pending": 0, + "reconciled": 0, + "schedule": null, + "sort_order": 123456789, + "starting_balance_flag": 0, + "tombstone": 0, + "transferred_id": null, + "type": null, + }, + Object { + "acct": "one", + "amount": 120, + "category": null, + "cleared": 1, + "date": 20140105, + "description": null, + "error": null, + "financial_id": "2014010509572410000", + "id": "id8", + "imported_description": null, + "isChild": 0, + "isParent": 0, + "location": null, + "notes": "Retoure SEPA Lastschrift vom 03.01.2014, Rueckgabegrund: MD06 + Lastschriftwiderspruch durch den Zahlungspflichtigen EREF: + STZV-EtE27122013-11 :05-2 CRED: DE79ZZZ00000000584 IBAN: DE14740618130000033626 BIC: + GENODEF1PFK ABWE: Testkonto", + "parent_id": null, + "pending": 0, + "reconciled": 0, + "schedule": null, + "sort_order": 123456789, + "starting_balance_flag": 0, + "tombstone": 0, + "transferred_id": null, + "type": null, + }, + Object { + "acct": "one", + "amount": -8000, + "category": null, + "cleared": 1, + "date": 20240312, + "description": "id4", + "error": null, + "financial_id": "367406715024072/1", + "id": "id9", + "imported_description": "Rega", + "isChild": 0, + "isParent": 0, + "location": null, + "notes": null, + "parent_id": null, + "pending": 0, + "reconciled": 0, + "schedule": null, + "sort_order": 123456789, + "starting_balance_flag": 0, + "tombstone": 0, + "transferred_id": null, + "type": null, + }, + Object { + "acct": "one", + "amount": 225, + "category": null, + "cleared": 1, + "date": 20140103, + "description": "id2", + "error": null, + "financial_id": null, + "id": "id10", + "imported_description": "Testkonto Nummer 2", + "isChild": 0, + "isParent": 0, + "location": null, + "notes": "Lastschrift 2. Zahlung TAN:747216 ", + "parent_id": null, + "pending": 0, + "reconciled": 0, + "schedule": null, + "sort_order": 123456789, + "starting_balance_flag": 0, + "tombstone": 0, + "transferred_id": null, + "type": null, + }, + Object { + "acct": "one", + "amount": 120, + "category": null, + "cleared": 1, + "date": 20140103, + "description": "id2", + "error": null, + "financial_id": null, + "id": "id11", + "imported_description": "Testkonto Nummer 2", + "isChild": 0, + "isParent": 0, + "location": null, + "notes": "Lastschrift 1. Zahlung TAN:747216 ", + "parent_id": null, + "pending": 0, + "reconciled": 0, + "schedule": null, + "sort_order": 123456789, + "starting_balance_flag": 0, + "tombstone": 0, + "transferred_id": null, + "type": null, + }, +] +`; + exports[`File import handles html escaped plaintext 1`] = ` Array [ Object { diff --git a/packages/loot-core/src/server/accounts/parse-file.test.ts b/packages/loot-core/src/server/accounts/parse-file.test.ts index 345d098027e68e2fc92a61c354d165c8c3a384b4..c84e30e4aa131f8b65f66ac342c8dbdd598cfe4a 100644 --- a/packages/loot-core/src/server/accounts/parse-file.test.ts +++ b/packages/loot-core/src/server/accounts/parse-file.test.ts @@ -167,4 +167,16 @@ describe('File import', () => { expect(errors.length).toBe(0); expect(await getTransactions('one')).toMatchSnapshot(); }); + + test('CAMT.053 import works', async () => { + prefs.loadPrefs(); + await db.insertAccount({ id: 'one', name: 'one' }); + + const { errors } = await importFileWithRealTime( + 'one', + __dirname + '/../../mocks/files/camt/camt.053.xml', + ); + expect(errors.length).toBe(0); + expect(await getTransactions('one')).toMatchSnapshot(); + }); }); diff --git a/packages/loot-core/src/server/accounts/parse-file.ts b/packages/loot-core/src/server/accounts/parse-file.ts index 4bd47d35faf5756ed2936f9f3dcd89812c9f6336..ffbc1d28aea5be919b199f62823a99e977303eec 100644 --- a/packages/loot-core/src/server/accounts/parse-file.ts +++ b/packages/loot-core/src/server/accounts/parse-file.ts @@ -6,6 +6,7 @@ import { looselyParseAmount } from '../../shared/util'; import { ofx2json } from './ofx2json'; import { qif2json } from './qif2json'; +import { xmlCAMT2json } from './xmlcamt2json'; type ParseError = { message: string; internal: string }; export type ParseFileResult = { @@ -38,6 +39,8 @@ export async function parseFile( case '.ofx': case '.qfx': return parseOFX(filepath, options); + case '.xml': + return parseCAMT(filepath); default: } } @@ -144,3 +147,22 @@ async function parseOFX( }), }; } + +async function parseCAMT(filepath: string): Promise<ParseFileResult> { + const errors = Array<ParseError>(); + const contents = await fs.readFile(filepath); + + let data; + try { + data = await xmlCAMT2json(contents); + } catch (err) { + console.error(err); + errors.push({ + message: 'Failed importing file', + internal: err.stack, + }); + return { errors }; + } + + return { errors, transactions: data }; +} diff --git a/packages/loot-core/src/server/accounts/xmlcamt2json.ts b/packages/loot-core/src/server/accounts/xmlcamt2json.ts new file mode 100644 index 0000000000000000000000000000000000000000..3ffbf12bbcf9d3d4b2a5f4885f5a6b2195dc2f2f --- /dev/null +++ b/packages/loot-core/src/server/accounts/xmlcamt2json.ts @@ -0,0 +1,165 @@ +// @ts-strict-ignore +import { parseStringPromise } from 'xml2js'; + +type DateRef = { DtTm: string } | { Dt: string }; +type Amt = { _: string }; + +interface Ntry { + AcctSvcrRef?: string; + Amt?: Amt; + CdtDbtInd: 'CRDT' | 'DBIT'; + ValDt?: DateRef; + BookgDt?: DateRef; + NtryDtls?: NtryDtls; + AddtlNtryInf?: string; + NtryRef?: string; +} + +interface NtryDtls { + TxDtls: TxDtls | TxDtls[]; +} + +interface TxDtls { + RltdPties?: { + Cdtr: { + Nm: string; + }; + Dbtr: { + Nm: string; + }; + }; + RmtInf?: { + Ustrd: string | string[]; + }; +} + +interface TransactionCAMT { + amount: number; + date: string; + payee_name: string | null; + imported_payee: string | null; + notes: string | null; + imported_id?: string; +} + +function findKeys(obj: object, key: string): unknown[] { + let result = []; + for (const i in obj) { + if (!obj.hasOwnProperty(i)) continue; + if (i === key) { + if (Array.isArray(obj[i])) { + result = result.concat(obj[i]); + } else { + result.push(obj[i]); + } + } + if (typeof obj[i] === 'object') { + result = result.concat(findKeys(obj[i], key)); + } + } + return result; +} + +function getPayeeNameFromTxDtls( + TxDtls: TxDtls, + isDebit: boolean, +): string | null { + if (TxDtls?.RltdPties) { + const key = isDebit ? TxDtls.RltdPties.Cdtr : TxDtls.RltdPties.Dbtr; + const Nm = findKeys(key, 'Nm'); + return Nm.length > 0 ? (Nm[0] as string) : null; + } + return null; +} + +function getNotesFromTxDtls(TxDtls: TxDtls): string | null { + if (TxDtls?.RmtInf) { + const Ustrd = TxDtls.RmtInf.Ustrd; + return Array.isArray(Ustrd) ? Ustrd.join(' ') : Ustrd; + } + return null; +} + +function convertToNumberOrNull(value: string): number | null { + const number = Number(value); + return isNaN(number) ? null : number; +} + +function getDtOrDtTm(Date: DateRef | null): string | null { + if ('DtTm' in Date) { + return Date.DtTm.slice(0, 10); + } + return Date?.Dt; +} + +export async function xmlCAMT2json( + content: string, +): Promise<TransactionCAMT[]> { + const data = await parseStringPromise(content, { explicitArray: false }); + const entries = findKeys(data, 'Ntry') as Ntry[]; + + const transactions: TransactionCAMT[] = []; + + for (const entry of entries) { + /* + For (camt.052/054) could filter on entry.Sts= BOOK or PDNG, currently importing all entries + */ + + const id = entry.AcctSvcrRef; + + const amount = convertToNumberOrNull(entry.Amt?._); + const isDebit = entry.CdtDbtInd === 'DBIT'; + + const date = getDtOrDtTm(entry.ValDt) || getDtOrDtTm(entry.BookgDt); + + if (Array.isArray(entry.NtryDtls?.TxDtls)) { + // we add subtransactions as normal transactions as importing split with subtransactions is not supported + // amount, and payee_name are not processed correctly for subtransaction. + entry.NtryDtls.TxDtls.forEach((TxDtls: TxDtls) => { + const subPayee = getPayeeNameFromTxDtls(TxDtls, isDebit); + const subNotes = getNotesFromTxDtls(TxDtls); + const Amt = findKeys(TxDtls, 'Amt') as Amt[]; + const amount = Amt.length > 0 ? convertToNumberOrNull(Amt[0]._) : null; + transactions.push({ + amount: isDebit ? -amount : amount, + date, + payee_name: subPayee, + imported_payee: subPayee, + notes: subNotes, + }); + }); + } else { + let payee_name: string | null; + let notes: string | null; + payee_name = getPayeeNameFromTxDtls(entry.NtryDtls?.TxDtls, isDebit); + if (!payee_name && entry.AddtlNtryInf) { + payee_name = entry.AddtlNtryInf; + } + notes = getNotesFromTxDtls(entry.NtryDtls?.TxDtls); + if (!notes && entry.AddtlNtryInf && entry.AddtlNtryInf !== payee_name) { + notes = entry.AddtlNtryInf; + } + if (!payee_name && !notes && entry.NtryRef) { + notes = entry.NtryRef; + } + if (payee_name && notes && payee_name.includes(notes)) { + notes = null; + } + + const transaction: TransactionCAMT = { + amount: isDebit ? -amount : amount, + date, + payee_name, + imported_payee: payee_name, + notes, + }; + if (id) { + transaction.imported_id = id; + } + transactions.push(transaction); + } + } + return transactions.filter( + trans => trans.date != null && trans.amount != null, + ); +} diff --git a/upcoming-release-notes/2706.md b/upcoming-release-notes/2706.md new file mode 100644 index 0000000000000000000000000000000000000000..868b3568f5e1555cf73e22456c80f08e709d1564 --- /dev/null +++ b/upcoming-release-notes/2706.md @@ -0,0 +1,6 @@ +--- +category: Features +authors: [bfritscher] +--- + +Add option to import CAMT.053 based XML files