Compare commits
6 Commits
be6af2fd27
...
7b9c8ebd55
Author | SHA1 | Date | |
---|---|---|---|
7b9c8ebd55 | |||
82ed8d99b0 | |||
2959b2bbaa | |||
b21eb0b04d | |||
81e4ea86f6 | |||
3596a78b06 |
@ -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>
|
||||
|
@ -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">
|
||||
|
27
src/db.ts
27
src/db.ts
@ -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
85
src/importers/ofx1.ts
Normal file
@ -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 <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('&', '&');
|
||||
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');
|
||||
}
|
@ -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;
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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() {
|
||||
|
@ -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>
|
||||
|
@ -36,7 +36,7 @@
|
||||
});
|
||||
|
||||
if (file !== null) {
|
||||
db.init(file);
|
||||
await db.init(file);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
Loading…
x
Reference in New Issue
Block a user