From 81e4ea86f6e3a0405bfd529b8a9f196733434a4f Mon Sep 17 00:00:00 2001 From: RunasSudo Date: Sat, 23 Nov 2024 00:29:48 +1100 Subject: [PATCH] Implement OFX 1.x import --- src/importers/ofx1.ts | 85 +++++++++++++++++++++++++++++++ src/importers/ofx2.ts | 6 ++- src/pages/ImportStatementView.vue | 15 ++++-- 3 files changed, 102 insertions(+), 4 deletions(-) create mode 100644 src/importers/ofx1.ts diff --git a/src/importers/ofx1.ts b/src/importers/ofx1.ts new file mode 100644 index 0000000..749c16d --- /dev/null +++ b/src/importers/ofx1.ts @@ -0,0 +1,85 @@ +/* + DrCr: Web-based double-entry bookkeeping framework + Copyright (C) 2022–2024 Lee Yingtong Li (RunasSudo) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +import dayjs from 'dayjs'; + +import { DT_FORMAT, StatementLine, db } from '../db.ts'; + +export default function import_ofx1(sourceAccount: string, content: string): StatementLine[] { + // Import an OFX1 SGML file + + // Strip OFX header and parse + const raw_payload = content.substring(content.indexOf('= 0) { + // Ignore time zone + dateRaw = dateRaw?.substring(0, dateRaw.indexOf('[')); + } + const date = dayjs(dateRaw, 'YYYYMMDDHHmmss.SSS').hour(0).minute(0).second(0).millisecond(0).format(DT_FORMAT); + + const description = getNodeText(transaction.querySelector('memo')); + const amount = getNodeText(transaction.querySelector('trnamt')); + + const quantity = Math.round(parseFloat(amount!) * Math.pow(10, db.metadata.dps)); + if (!Number.isSafeInteger(quantity)) { throw new Error('Quantity not representable by safe integer'); } + + if (description.indexOf('PENDING') >= 0) { + // FIXME: This needs to be configurable + continue; + } + + statementLines.push({ + id: null, + source_account: sourceAccount, + dt: date, + description: description ?? '', + quantity: quantity, + balance: null, + commodity: db.metadata.reporting_commodity + }); + } + + return statementLines; +} + +function getNodeText(node: Node | null): string { + // Get text of the first text node + // HTML parser does not understand SGML/OFX nesting rules, so siblings will be incorrectly considered as children + // Therefore we use only the first text node + + if (node === null) { + throw new Error('Node not found'); + } + + for (const child of node.childNodes) { + if (child.nodeType === Node.TEXT_NODE && child.nodeValue !== null && child.nodeValue.length > 0) { + return child.nodeValue; + } + if (child.nodeType === Node.ELEMENT_NODE) { + break; + } + } + + throw new Error('No text in node'); +} diff --git a/src/importers/ofx2.ts b/src/importers/ofx2.ts index fca3926..6ef8e8e 100644 --- a/src/importers/ofx2.ts +++ b/src/importers/ofx2.ts @@ -32,7 +32,11 @@ export default function import_ofx2(sourceAccount: string, content: string): Sta const statementLines: StatementLine[] = []; for (const transaction of tree.querySelectorAll('BANKMSGSRSV1 STMTTRNRS STMTRS BANKTRANLIST STMTTRN')) { - const dateRaw = transaction.querySelector('DTPOSTED')!.textContent; + let dateRaw = transaction.querySelector('DTPOSTED')!.textContent; + if (dateRaw && dateRaw.indexOf('[') >= 0) { + // Ignore time zone + dateRaw = dateRaw?.substring(0, dateRaw.indexOf('[')); + } const date = dayjs(dateRaw, 'YYYYMMDDHHmmss').hour(0).minute(0).second(0).millisecond(0).format(DT_FORMAT); const description = transaction.querySelector('NAME')!.textContent; const amount = transaction.querySelector('TRNAMT')!.textContent; diff --git a/src/pages/ImportStatementView.vue b/src/pages/ImportStatementView.vue index 12df843..ab9b7fa 100644 --- a/src/pages/ImportStatementView.vue +++ b/src/pages/ImportStatementView.vue @@ -24,9 +24,9 @@
- +
@@ -85,10 +85,13 @@ import { StatementLine, db } from '../db.ts'; import ComboBoxAccounts from '../components/ComboBoxAccounts.vue'; import { ppWithCommodity } from '../display.ts'; + + import import_ofx1 from '../importers/ofx1.ts'; import import_ofx2 from '../importers/ofx2.ts'; const fileInput = useTemplateRef('file'); + const format = ref('ofx2'); const selectedFilename = ref(''); const sourceAccount = ref(''); @@ -112,7 +115,13 @@ const content = await file.text(); - statementLines.value = import_ofx2(sourceAccount.value, content); + if (format.value === 'ofx2') { + statementLines.value = import_ofx2(sourceAccount.value, content); + } else if (format.value === 'ofx1') { + statementLines.value = import_ofx1(sourceAccount.value, content); + } else { + throw new Error('Unexpected import format'); + } } async function doImport() {