From 7b26ef81c6ce16379b5f33819755cfd301facb7b Mon Sep 17 00:00:00 2001 From: RunasSudo Date: Mon, 18 Nov 2024 16:54:50 +1100 Subject: [PATCH] Implement OFX2.x statement import --- src/components/TransactionEditor.vue | 4 +- src/db.ts | 12 +++ src/importers/ofx2.ts | 60 ++++++++++++ src/main.ts | 1 + src/pages/ImportStatementView.vue | 138 +++++++++++++++++++++++++++ src/pages/StatementLinesView.vue | 8 +- 6 files changed, 217 insertions(+), 6 deletions(-) create mode 100644 src/importers/ofx2.ts create mode 100644 src/pages/ImportStatementView.vue diff --git a/src/components/TransactionEditor.vue b/src/components/TransactionEditor.vue index ac421fa..3279a1a 100644 --- a/src/components/TransactionEditor.vue +++ b/src/components/TransactionEditor.vue @@ -111,7 +111,7 @@ import { ref } from 'vue'; - import { Transaction, db, deserialiseAmount } from '../db.ts'; + import { DT_FORMAT, Transaction, db, deserialiseAmount } from '../db.ts'; interface EditingPosting { id: number | null, @@ -148,7 +148,7 @@ // Prepare transaction for save const newTransaction = new Transaction( transaction.id, - dayjs(transaction.dt).format('YYYY-MM-DD HH:mm:ss.SSS000'), + dayjs(transaction.dt).format(DT_FORMAT), transaction.description, [] ); diff --git a/src/db.ts b/src/db.ts index e40419a..a426f0e 100644 --- a/src/db.ts +++ b/src/db.ts @@ -25,6 +25,8 @@ import { reactive } from 'vue'; import { asCost, Balance } from './amounts.ts'; import { ExtendedDatabase } from './dbutil.ts'; +export const DT_FORMAT = 'YYYY-MM-DD HH:mm:ss.SSS000'; + export const db = reactive({ filename: null as (string | null), @@ -243,3 +245,13 @@ export interface JoinedTransactionPosting { commodity: string, running_balance?: number } + +export interface StatementLine { + id: number | null, + source_account: string, + dt: string, + description: string, + quantity: number, + balance: number | null, + commodity: string +} diff --git a/src/importers/ofx2.ts b/src/importers/ofx2.ts new file mode 100644 index 0000000..fca3926 --- /dev/null +++ b/src/importers/ofx2.ts @@ -0,0 +1,60 @@ +/* + 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_ofx2(sourceAccount: string, content: string): StatementLine[] { + // Import an OFX2 XML file + + // Convert OFX header to XML and parse + const xml_header = ''; + const raw_payload = content.substring(content.indexOf('?>') + 2).replaceAll('&', '&'); + const tree = new DOMParser().parseFromString(xml_header + raw_payload, 'application/xml'); + + // Read transactions + const statementLines: StatementLine[] = []; + + for (const transaction of tree.querySelectorAll('BANKMSGSRSV1 STMTTRNRS STMTRS BANKTRANLIST STMTTRN')) { + const dateRaw = transaction.querySelector('DTPOSTED')!.textContent; + 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; + + if (amount === '0') { + // Continuation line + statementLines.at(-1)!.description += '\n' + description; + } else { + 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'); } + + statementLines.push({ + id: null, + source_account: sourceAccount, + dt: date, + description: description ?? '', + quantity: quantity, + balance: null, + commodity: db.metadata.reporting_commodity + }); + } + } + + return statementLines; +} diff --git a/src/main.ts b/src/main.ts index 09d2319..fed8c5b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -35,6 +35,7 @@ async function initApp() { { path: '/journal/edit-transaction/:id', name: 'journal-edit-transaction', component: () => import('./pages/EditTransactionView.vue') }, { path: '/journal/new-transaction', name: 'journal-new-transaction', component: () => import('./pages/NewTransactionView.vue') }, { path: '/statement-lines', name: 'statement-lines', component: () => import('./pages/StatementLinesView.vue') }, + { path: '/statement-lines/import', name: 'import-statement', component: () => import('./pages/ImportStatementView.vue') }, { path: '/transactions/:account', name: 'transactions', component: () => import('./pages/TransactionsView.vue') }, { path: '/trial-balance', name: 'trial-balance', component: () => import('./pages/TrialBalanceView.vue') }, ]; diff --git a/src/pages/ImportStatementView.vue b/src/pages/ImportStatementView.vue new file mode 100644 index 0000000..a71835e --- /dev/null +++ b/src/pages/ImportStatementView.vue @@ -0,0 +1,138 @@ + + + + + diff --git a/src/pages/StatementLinesView.vue b/src/pages/StatementLinesView.vue index 0e852d9..e36b1af 100644 --- a/src/pages/StatementLinesView.vue +++ b/src/pages/StatementLinesView.vue @@ -25,11 +25,11 @@
+ Import statement - - + +