Compare commits

...

6 Commits

12 changed files with 239 additions and 28 deletions

View File

@ -19,8 +19,8 @@
<template>
<nav class="border-b border-gray-200 bg-white print:hidden" v-if="isMainWindow">
<div class="mx-auto max-w-7xl px-6 lg:px-8">
<div class="flex h-12 justify-between ml-[-0.25rem]"><!-- Adjust margin by -0.25rem to align navbar text with body text -->
<div class="flex">
<div class="flex h-12 justify-between ml-[-0.25rem] w-full"><!-- Adjust margin by -0.25rem to align navbar text with body text -->
<div class="flex w-full">
<div class="flex flex-shrink-0">
<RouterLink to="/" class="border-transparent text-gray-900 hover:border-emerald-500 hover:text-emerald-700 inline-flex items-center border-b-2 px-1 pt-1 text-sm font-medium">
DrCr
@ -28,7 +28,7 @@
</div>
<!-- Menu items -->
<div v-if="db.filename !== null" class="hidden sm:-my-px sm:ml-6 sm:flex sm:space-x-4">
<div v-if="db.filename !== null" class="hidden sm:-my-px sm:ml-6 sm:flex sm:gap-4 w-full">
<RouterLink :to="{ name: 'journal' }" class="border-transparent text-gray-700 hover:border-emerald-500 hover:text-emerald-700 inline-flex items-center border-b-2 px-1 pt-1 text-sm">
Journal
</RouterLink>
@ -44,6 +44,10 @@
<RouterLink :to="{ name: 'income-statement'}" class="border-transparent text-gray-700 hover:border-emerald-500 hover:text-emerald-700 inline-flex items-center border-b-2 px-1 pt-1 text-sm">
Income statement
</RouterLink>
<a href="#" @click="closeFile" class="ml-auto border-transparent text-gray-700 hover:border-emerald-500 hover:text-emerald-700 inline-flex items-center border-b-2 px-1 pt-1 text-sm">
Close file
</a>
</div>
</div>
</div>
@ -54,8 +58,17 @@
<script setup lang="ts">
import { getCurrentWindow } from '@tauri-apps/api/window';
import { useRouter } from 'vue-router';
import { db } from '../db.js';
// Only display header bar in main window
const isMainWindow = getCurrentWindow().label === 'main';
const router = useRouter();
async function closeFile() {
await db.init(null);
router.push({ name: 'index' });
}
</script>

View File

@ -39,8 +39,8 @@
</tr>
<tr v-for="posting in transaction.postings">
<td></td>
<!-- TODO: Posting description -->
<td class="py-1 px-1" colspan="2">
<td class="py-1 px-1">{{ posting.description }}</td>
<td class="py-1 px-1">
<div class="relative flex">
<div class="relative flex flex-grow items-stretch shadow-sm">
<div class="absolute inset-y-0 left-0 flex items-center z-10">

View File

@ -38,21 +38,28 @@ export const db = reactive({
dps: null! as number,
},
init: async function(filename: string): Promise<void> {
init: async function(filename: string | null): Promise<void> {
// Set the DB filename and initialise cached data
this.filename = filename;
await invoke('set_open_filename', { 'filename': filename });
await getCurrentWindow().setTitle('DrCr – ' + filename.replaceAll('\\', '/').split('/').at(-1));
// Initialise cached data
const session = await this.load();
const metadataRaw: {key: string, value: string}[] = await session.select("SELECT * FROM metadata");
const metadataObject = Object.fromEntries(metadataRaw.map((x) => [x.key, x.value]));
this.metadata.version = parseInt(metadataObject.version);
this.metadata.eofy_date = metadataObject.eofy_date;
this.metadata.reporting_commodity = metadataObject.reporting_commodity;
this.metadata.dps = parseInt(metadataObject.amount_dps);
if (filename !== null) {
await getCurrentWindow().setTitle('DrCr – ' + filename?.replaceAll('\\', '/').split('/').at(-1));
} else {
await getCurrentWindow().setTitle('DrCr');
}
if (filename !== null) {
// Initialise cached data
const session = await this.load();
const metadataRaw: {key: string, value: string}[] = await session.select("SELECT * FROM metadata");
const metadataObject = Object.fromEntries(metadataRaw.map((x) => [x.key, x.value]));
this.metadata.version = parseInt(metadataObject.version);
this.metadata.eofy_date = metadataObject.eofy_date;
this.metadata.reporting_commodity = metadataObject.reporting_commodity;
this.metadata.dps = parseInt(metadataObject.amount_dps);
}
},
load: async function(): Promise<ExtendedDatabase> {

85
src/importers/ofx1.ts Normal file
View File

@ -0,0 +1,85 @@
/*
DrCr: Web-based double-entry bookkeeping framework
Copyright (C) 20222024 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_ofx1(sourceAccount: string, content: string): StatementLine[] {
// Import an OFX1 SGML file
// Strip OFX header and parse
const raw_payload = content.substring(content.indexOf('<OFX')).replaceAll('&', '&amp;');
const tree = new DOMParser().parseFromString(raw_payload, 'text/html'); // HTML was originally based on SGML so use this parser
// Read transactions
const statementLines: StatementLine[] = [];
for (const transaction of tree.querySelectorAll('banktranlist stmttrn')) {
let dateRaw = getNodeText(transaction.querySelector('dtposted'));
if (dateRaw && dateRaw.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.trim().length > 0) {
return child.nodeValue.trim();
}
if (child.nodeType === Node.ELEMENT_NODE) {
break;
}
}
throw new Error('No text in node');
}

View File

@ -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;

View File

@ -22,7 +22,7 @@
</h1>
<div class="my-4 flex gap-x-2">
<a href="/balance-assertions/new" class="btn-primary pl-2" onclick="return openLinkInNewWindow(this);">
<a :href="$router.resolve({name: 'balance-assertions-new'}).fullPath" class="btn-primary pl-2" onclick="return openLinkInNewWindow(this);">
<PlusIcon class="w-4 h-4" />
New assertion
</a>

View File

@ -23,7 +23,7 @@
<div class="my-4 flex gap-x-2 items-center">
<!-- Use a rather than RouterLink because RouterLink adds its own event handler -->
<a href="/journal/new-transaction" class="btn-primary pl-2" onclick="return openLinkInNewWindow(this);">
<a :href="$router.resolve({name: 'journal-new-transaction'}).fullPath" class="btn-primary pl-2" onclick="return openLinkInNewWindow(this);">
<PlusIcon class="w-4 h-4" />
New transaction
</a>

View File

@ -24,9 +24,9 @@
<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>-->
<select class="bordered-field" id="format" v-model="format">
<option value="ofx2">OFX 2.x</option>
<option value="ofx1">OFX 1.x</option>
</select>
</div>
<label for="account" class="block text-gray-900 pr-4">Source account</label>
@ -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() {

View File

@ -23,7 +23,7 @@
<div class="my-4 flex gap-x-2 items-center">
<!-- Use a rather than RouterLink because RouterLink adds its own event handler -->
<a href="/journal/new-transaction" class="btn-primary pl-2" onclick="return openLinkInNewWindow(this);">
<a :href="$router.resolve({name: 'journal-new-transaction'}).fullPath" class="btn-primary pl-2" onclick="return openLinkInNewWindow(this);">
<PlusIcon class="w-4 h-4" />
New transaction
</a>

View File

@ -36,7 +36,7 @@
});
if (file !== null) {
db.init(file);
await db.init(file);
}
}
</script>

View File

@ -23,9 +23,9 @@
<div class="my-2 py-2 flex bg-white sticky top-0">
<div class="grow flex gap-x-2 items-baseline">
<!--<button class="btn-secondary text-emerald-700 ring-emerald-600">
<button @click="reconcileAsTransfer" class="btn-secondary text-emerald-700 ring-emerald-600">
Reconcile selected as transfer
</button>-->
</button>
<RouterLink :to="{ name: 'import-statement' }" class="btn-secondary">
Import statement
</RouterLink>
@ -172,6 +172,11 @@
const statementLine = statementLines.value.find((l) => l.id === lineId)!;
if (statementLine.posting_accounts.length !== 0) {
await alert('Cannot reconcile already reconciled statement line');
return;
}
// Insert transaction and statement line reconciliation atomically
const session = await db.load();
const dbTransaction = await session.begin();
@ -226,22 +231,109 @@
await load();
}
async function reconcileAsTransfer() {
const selectedCheckboxes = document.querySelectorAll('.statement-line-checkbox:checked');
if (selectedCheckboxes.length !== 2) {
await alert('Must select exactly 2 statement lines');
return;
}
const selectedLineIds = [...selectedCheckboxes].map((el) => parseInt(el.closest('tr')?.dataset.lineId!));
const line1 = statementLines.value.find((l) => l.id === selectedLineIds[0])!;
const line2 = statementLines.value.find((l) => l.id === selectedLineIds[1])!;
// Sanity checks
if (line1.quantity + line2.quantity !== 0 || line1.commodity !== line2.commodity) {
await alert('Selected statement line debits/credits must equal');
return;
}
if (line1.posting_accounts.length !== 0 || line2.posting_accounts.length !== 0) {
await alert('Cannot reconcile already reconciled statement lines');
return;
}
// Insert transaction and statement line reconciliation atomically
const session = await db.load();
const dbTransaction = await session.begin();
// Insert transaction
const transactionResult = await dbTransaction.execute(
`INSERT INTO transactions (dt, description)
VALUES ($1, $2)`,
[line1.dt, line1.description]
);
const transactionId = transactionResult.lastInsertId;
// Insert posting for line1
const postingResult1 = await dbTransaction.execute(
`INSERT INTO postings (transaction_id, description, account, quantity, commodity, running_balance)
VALUES ($1, $2, $3, $4, $5, NULL)`,
[transactionId, line1.description, line1.source_account, line1.quantity, line1.commodity]
);
const postingId1 = postingResult1.lastInsertId;
// Insert statement line reconciliation
await dbTransaction.execute(
`INSERT INTO statement_line_reconciliations (statement_line_id, posting_id)
VALUES ($1, $2)`,
[line1.id, postingId1]
);
// Insert posting for line2
const postingResult2 = await dbTransaction.execute(
`INSERT INTO postings (transaction_id, description, account, quantity, commodity, running_balance)
VALUES ($1, $2, $3, $4, $5, NULL)`,
[transactionId, line2.description, line2.source_account, line2.quantity, line2.commodity]
);
const postingId2 = postingResult2.lastInsertId;
// Insert statement line reconciliation
await dbTransaction.execute(
`INSERT INTO statement_line_reconciliations (statement_line_id, posting_id)
VALUES ($1, $2)`,
[line2.id, postingId2]
);
// Invalidate running balances
await dbTransaction.execute(
`UPDATE postings
SET running_balance = NULL
FROM (
SELECT postings.id
FROM transactions
JOIN postings ON transactions.id = postings.transaction_id
WHERE DATE(dt) >= DATE($1) AND account IN ($2, $3)
) p
WHERE postings.id = p.id`,
[line1.dt, line1.source_account, line2.source_account]
);
dbTransaction.commit();
// Reload transactions and re-render the table
await load();
}
function renderTable() {
const PencilIconHTML = renderComponent(PencilIcon, { 'class': 'w-4 h-4 inline align-middle -mt-0.5' }); // Pre-render the pencil icon
const rows = [];
for (const line of statementLines.value) {
let reconciliationCell;
let reconciliationCell, checkboxCell;
if (line.posting_accounts.length === 0) {
// Unreconciled
reconciliationCell =
`<a href="#" class="classify-link text-red-500 hover:text-red-600 hover:underline" onclick="return showClassifyLinePanel(this);">Unclassified</a>`;
checkboxCell = `<input class="checkbox-primary statement-line-checkbox" type="checkbox">`; // Only show checkbox for unreconciled lines
} else if (line.posting_accounts.length === 2) {
// Simple reconciliation
const otherAccount = line.posting_accounts.find((a) => a !== line.source_account);
reconciliationCell =
`<span>${ otherAccount }</span>
<a href="/journal/edit/${ line.transaction_id }" class="text-gray-500 hover:text-gray-700" onclick="return openLinkInNewWindow(this);">${ PencilIconHTML }</a>`;
checkboxCell = '';
if (showOnlyUnclassified.value) { continue; }
} else {
@ -249,13 +341,14 @@
reconciliationCell =
`<i>(Complex)</i>
<a href="/journal/edit/${ line.transaction_id }" class="text-gray-500 hover:text-gray-700" onclick="return openLinkInNewWindow(this);">${ PencilIconHTML }</a>`;
checkboxCell = '';
if (showOnlyUnclassified.value) { continue; }
}
rows.push(
`<tr data-line-id="${ line.id }">
<td class="py-0.5 pr-1 align-baseline"><input class="checkbox-primary" type="checkbox" name="sel-line-id" value="${ line.id }"></td>
<td class="py-0.5 pr-1 align-baseline">${ checkboxCell }</td>
<td class="py-0.5 px-1 align-baseline text-gray-900"><a href="#" class="hover:text-blue-700 hover:underline">${ line.source_account }</a></td>
<td class="py-0.5 px-1 align-baseline text-gray-900 lg:w-[12ex]">${ dayjs(line.dt).format('YYYY-MM-DD') }</td>
<td class="py-0.5 px-1 align-baseline text-gray-900">${ line.description }</td>

View File

@ -23,7 +23,7 @@
<div class="my-4 flex gap-x-2 items-center">
<!-- Use a rather than RouterLink because RouterLink adds its own event handler -->
<a href="/journal/new-transaction" class="btn-primary pl-2" onclick="return openLinkInNewWindow(this);">
<a :href="$router.resolve({name: 'journal-new-transaction'}).fullPath" class="btn-primary pl-2" onclick="return openLinkInNewWindow(this);">
<PlusIcon class="w-4 h-4" />
New transaction
</a>