Implement OFX2.x statement import
This commit is contained in:
parent
30cb94274b
commit
7b26ef81c6
@ -111,7 +111,7 @@
|
|||||||
|
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
|
|
||||||
import { Transaction, db, deserialiseAmount } from '../db.ts';
|
import { DT_FORMAT, Transaction, db, deserialiseAmount } from '../db.ts';
|
||||||
|
|
||||||
interface EditingPosting {
|
interface EditingPosting {
|
||||||
id: number | null,
|
id: number | null,
|
||||||
@ -148,7 +148,7 @@
|
|||||||
// Prepare transaction for save
|
// Prepare transaction for save
|
||||||
const newTransaction = new Transaction(
|
const newTransaction = new Transaction(
|
||||||
transaction.id,
|
transaction.id,
|
||||||
dayjs(transaction.dt).format('YYYY-MM-DD HH:mm:ss.SSS000'),
|
dayjs(transaction.dt).format(DT_FORMAT),
|
||||||
transaction.description,
|
transaction.description,
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
12
src/db.ts
12
src/db.ts
@ -25,6 +25,8 @@ import { reactive } from 'vue';
|
|||||||
import { asCost, Balance } from './amounts.ts';
|
import { asCost, Balance } from './amounts.ts';
|
||||||
import { ExtendedDatabase } from './dbutil.ts';
|
import { ExtendedDatabase } from './dbutil.ts';
|
||||||
|
|
||||||
|
export const DT_FORMAT = 'YYYY-MM-DD HH:mm:ss.SSS000';
|
||||||
|
|
||||||
export const db = reactive({
|
export const db = reactive({
|
||||||
filename: null as (string | null),
|
filename: null as (string | null),
|
||||||
|
|
||||||
@ -243,3 +245,13 @@ export interface JoinedTransactionPosting {
|
|||||||
commodity: string,
|
commodity: string,
|
||||||
running_balance?: number
|
running_balance?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StatementLine {
|
||||||
|
id: number | null,
|
||||||
|
source_account: string,
|
||||||
|
dt: string,
|
||||||
|
description: string,
|
||||||
|
quantity: number,
|
||||||
|
balance: number | null,
|
||||||
|
commodity: string
|
||||||
|
}
|
||||||
|
60
src/importers/ofx2.ts
Normal file
60
src/importers/ofx2.ts
Normal file
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 = '<?xml version="1.0" encoding="UTF-8" standalone="no"?>';
|
||||||
|
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;
|
||||||
|
}
|
@ -35,6 +35,7 @@ async function initApp() {
|
|||||||
{ path: '/journal/edit-transaction/:id', name: 'journal-edit-transaction', component: () => import('./pages/EditTransactionView.vue') },
|
{ 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: '/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', 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: '/transactions/:account', name: 'transactions', component: () => import('./pages/TransactionsView.vue') },
|
||||||
{ path: '/trial-balance', name: 'trial-balance', component: () => import('./pages/TrialBalanceView.vue') },
|
{ path: '/trial-balance', name: 'trial-balance', component: () => import('./pages/TrialBalanceView.vue') },
|
||||||
];
|
];
|
||||||
|
138
src/pages/ImportStatementView.vue
Normal file
138
src/pages/ImportStatementView.vue
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
<!--
|
||||||
|
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 <https://www.gnu.org/licenses/>.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<h1 class="page-heading mb-4">
|
||||||
|
Import statement
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-[max-content_1fr] space-y-2 mb-4 items-baseline">
|
||||||
|
<label for="format" class="block text-gray-900 pr-4">File type</label>
|
||||||
|
<div>
|
||||||
|
<select class="bordered-field" id="format">
|
||||||
|
<!--<option value="ofx1">OFX 1.x</option>-->
|
||||||
|
<option value="ofx2">OFX 2.x</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<label for="account" class="block text-gray-900 pr-4">Source account</label>
|
||||||
|
<div class="relative combobox">
|
||||||
|
<!-- WebKit bug: Does not align baseline correctly unless some text or placeholder is present -->
|
||||||
|
<input type="text" class="bordered-field peer" id="account" placeholder=" " autocomplete="off" v-model="sourceAccount">
|
||||||
|
<!--{% include 'components/accounts_combobox_inner.html' %}-->
|
||||||
|
</div>
|
||||||
|
<label for="file" class="block text-gray-900 pr-4">File</label>
|
||||||
|
<div class="flex grow">
|
||||||
|
<!-- WebKit: file:hidden hides the filename as well so we have a dummy text input -->
|
||||||
|
<input type="text" class="bordered-field" :value="selectedFilename" @click="openFileDialog" placeholder=" " readonly>
|
||||||
|
<input type="file" class="hidden" id="file" accept=".ofx" ref="file" @change="fileInputChanged">
|
||||||
|
<label for="file" class="btn-primary bg-gray-600 hover:bg-gray-700">Browse</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end mt-4 space-x-2">
|
||||||
|
<button class="btn-secondary" @click="previewImport">Preview</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="statementLines.length > 0">
|
||||||
|
<h2 class="page-heading my-4">
|
||||||
|
Import statement preview
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<table class="min-w-full">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-gray-300">
|
||||||
|
<th class="py-0.5 pr-1 text-gray-900 font-semibold text-start">Date</th>
|
||||||
|
<th class="py-0.5 px-1 text-gray-900 font-semibold text-start">Description</th>
|
||||||
|
<th class="py-0.5 px-1 text-gray-900 font-semibold text-end">Dr</th>
|
||||||
|
<th class="py-0.5 px-1 text-gray-900 font-semibold text-end">Cr</th>
|
||||||
|
<th class="py-0.5 pl-1 text-gray-900 font-semibold text-end">Balance</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="line in statementLines">
|
||||||
|
<td class="py-0.5 pr-1 text-gray-900">{{ dayjs(line.dt).format('YYYY-MM-DD') }}</td>
|
||||||
|
<td class="py-0.5 px-1 text-gray-900">{{ line.description }}</td>
|
||||||
|
<td class="py-0.5 px-1 text-gray-900 text-end">{{ line.quantity >= 0 ? ppWithCommodity(line.quantity, line.commodity) : '' }}</td>
|
||||||
|
<td class="py-0.5 px-1 text-gray-900 text-end">{{ line.quantity < 0 ? ppWithCommodity(-line.quantity, line.commodity) : '' }}</td>
|
||||||
|
<td class="py-0.5 pl-1 text-gray-900 text-end">{{ line.balance ?? '' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="flex justify-end mt-4 space-x-2">
|
||||||
|
<button class="btn-primary" @click="doImport">Import</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
import { defineModel, ref, useTemplateRef } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
import { StatementLine, db } from '../db.ts';
|
||||||
|
import { ppWithCommodity } from '../display.ts';
|
||||||
|
import import_ofx2 from '../importers/ofx2.ts';
|
||||||
|
|
||||||
|
const fileInput = useTemplateRef('file');
|
||||||
|
|
||||||
|
const selectedFilename = defineModel('selectedFilename', { default: '' });
|
||||||
|
const sourceAccount = defineModel('sourceAccount', { default: '' });
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const statementLines = ref([] as StatementLine[]);
|
||||||
|
|
||||||
|
function openFileDialog() {
|
||||||
|
fileInput.value?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileInputChanged() {
|
||||||
|
selectedFilename.value = fileInput.value!.files?.item(0)?.name ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function previewImport() {
|
||||||
|
const file = fileInput.value!.files?.item(0);
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await file.text();
|
||||||
|
|
||||||
|
statementLines.value = import_ofx2(sourceAccount.value, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doImport() {
|
||||||
|
// Import statement lines to database atomically
|
||||||
|
const session = await db.load();
|
||||||
|
const dbTransaction = await session.begin();
|
||||||
|
|
||||||
|
for (const line of statementLines.value) {
|
||||||
|
await dbTransaction.execute(
|
||||||
|
`INSERT INTO statement_lines (source_account, dt, description, quantity, balance, commodity)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
[line.source_account, line.dt, line.description, line.quantity, line.balance, line.commodity]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
dbTransaction.commit();
|
||||||
|
|
||||||
|
router.push({ name: 'statement-lines' });
|
||||||
|
}
|
||||||
|
</script>
|
@ -25,11 +25,11 @@
|
|||||||
<div class="grow flex gap-x-2 items-baseline">
|
<div class="grow flex gap-x-2 items-baseline">
|
||||||
<!--<button class="btn-secondary text-emerald-700 ring-emerald-600">
|
<!--<button class="btn-secondary text-emerald-700 ring-emerald-600">
|
||||||
Reconcile selected as transfer
|
Reconcile selected as transfer
|
||||||
</button>
|
</button>-->
|
||||||
<a href="#" class="btn-secondary">
|
<RouterLink :to="{ name: 'import-statement' }" class="btn-secondary">
|
||||||
Import statement
|
Import statement
|
||||||
</a>
|
</RouterLink>
|
||||||
<a href="{{ url_for('statement_lines', **dict(request.args, unclassified=1)) }}" class="btn-secondary">
|
<!--<a href="{{ url_for('statement_lines', **dict(request.args, unclassified=1)) }}" class="btn-secondary">
|
||||||
Show only unclassified lines
|
Show only unclassified lines
|
||||||
</a>-->
|
</a>-->
|
||||||
</div>
|
</div>
|
||||||
|
Loading…
Reference in New Issue
Block a user