Implement statement line view

This commit is contained in:
RunasSudo 2024-11-17 18:43:28 +11:00
parent 86c007c28a
commit a688ea7c22
Signed by: RunasSudo
GPG Key ID: 7234E476BF21C61A
4 changed files with 231 additions and 4 deletions

View File

@ -165,16 +165,45 @@
}); });
} }
// Validate transaction
if (!newTransaction.doesBalance()) { if (!newTransaction.doesBalance()) {
error.value = 'Debits and credits do not balance.'; error.value = 'Debits and credits do not balance.';
return; return;
} }
const session = await db.load();
// Validate statement line reconciliations
// Keep track of mapping, so we can fix up the reconciliation posting_id if renumbering occurs
const postingsToReconciliations = new Map();
if (newTransaction.id !== null) {
// Get statement line reconciliations affected by this transaction
const joinedReconciliations: any[] = await session.select(
`SELECT statement_line_reconciliations.id, postings.id AS posting_id, source_account, statement_lines.quantity, statement_lines.commodity
FROM statement_line_reconciliations
JOIN postings ON statement_line_reconciliations.posting_id = postings.id
JOIN statement_lines ON statement_line_reconciliations.statement_line_id = statement_lines.id
WHERE postings.transaction_id = $1`,
[newTransaction.id]
);
for (const joinedReconciliation of joinedReconciliations) {
for (const posting of newTransaction.postings) {
if (posting.id === joinedReconciliation.posting_id) {
if (posting.account !== joinedReconciliation.source_account || posting.quantity !== joinedReconciliation.quantity || posting.commodity !== joinedReconciliation.commodity) {
error.value = 'Edit would break reconciled statement line.';
return;
}
postingsToReconciliations.set(posting, joinedReconciliation);
}
}
}
}
// Save changes to database // Save changes to database
// FIXME: Use transactions // FIXME: Use transactions
const session = await db.load();
if (newTransaction.id === null) { if (newTransaction.id === null) {
// Insert new transaction // Insert new transaction
const result = await session.execute( const result = await session.execute(
@ -212,11 +241,22 @@
} }
// Insert new posting // Insert new posting
await session.execute( const result = await session.execute(
`INSERT INTO postings (transaction_id, description, account, quantity, commodity, running_balance) `INSERT INTO postings (transaction_id, description, account, quantity, commodity, running_balance)
VALUES ($1, $2, $3, $4, $5, NULL)`, VALUES ($1, $2, $3, $4, $5, NULL)`,
[newTransaction.id, posting.description, posting.account, posting.quantity, posting.commodity] [newTransaction.id, posting.description, posting.account, posting.quantity, posting.commodity]
); );
// Fixup reconciliation if required
const joinedReconciliation = postingsToReconciliations.get(posting);
if (joinedReconciliation) {
await session.execute(
`UPDATE statement_line_reconciliations
SET posting_id = $1
WHERE id = $2`,
[result.lastInsertId, joinedReconciliation.id]
);
}
} else { } else {
// Update existing posting // Update existing posting
await session.execute( await session.execute(

View File

@ -34,6 +34,7 @@ async function initApp() {
{ path: '/journal', name: 'journal', component: () => import('./pages/JournalView.vue') }, { path: '/journal', name: 'journal', component: () => import('./pages/JournalView.vue') },
{ 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: '/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') },
]; ];

View File

@ -22,7 +22,7 @@
<h2 class="font-medium text-gray-700 mb-2">Data sources</h2> <h2 class="font-medium text-gray-700 mb-2">Data sources</h2>
<ul class="list-disc ml-6"> <ul class="list-disc ml-6">
<li><RouterLink :to="{ name: 'journal' }" class="text-gray-900 hover:text-blue-700 hover:underline">Journal</RouterLink></li> <li><RouterLink :to="{ name: 'journal' }" class="text-gray-900 hover:text-blue-700 hover:underline">Journal</RouterLink></li>
<!--<li><a href="#" class="text-gray-900 hover:text-blue-700 hover:underline">Statement lines</a></li>--> <li><RouterLink :to="{ name: 'statement-lines' }" class="text-gray-900 hover:text-blue-700 hover:underline">Statement lines</RouterLink></li>
<!--<li><a href="#" class="text-gray-900 hover:text-blue-700 hover:underline">Balance assertions</a></li>--> <!--<li><a href="#" class="text-gray-900 hover:text-blue-700 hover:underline">Balance assertions</a></li>-->
<!--<li><a href="#" class="text-gray-900 hover:text-blue-700 hover:underline">Chart of accounts</a></li>--> <!--<li><a href="#" class="text-gray-900 hover:text-blue-700 hover:underline">Chart of accounts</a></li>-->
<!-- TODO: Plugin reports --> <!-- TODO: Plugin reports -->

View File

@ -0,0 +1,186 @@
<!--
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/>.
-->
<template>
<h1 class="page-heading">
Statement lines
</h1>
<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">
Reconcile selected as transfer
</button>
<a href="#" class="btn-secondary">
Import statement
</a>
<a href="{{ url_for('statement_lines', **dict(request.args, unclassified=1)) }}" class="btn-secondary">
Show only unclassified lines
</a>-->
</div>
</div>
<div id="statement-line-list" class="max-h-[100vh] overflow-y-scroll wk-aa">
<table class="min-w-full">
<thead>
<tr class="border-b border-gray-300">
<th></th>
<th class="py-0.5 px-1 align-bottom text-gray-900 font-semibold text-start">Source account</th>
<th class="py-0.5 px-1 align-bottom text-gray-900 font-semibold lg:w-[12ex] text-start">Date</th>
<th class="py-0.5 px-1 align-bottom text-gray-900 font-semibold text-start">Description</th>
<th class="py-0.5 px-1 align-bottom text-gray-900 font-semibold text-start">Charged to</th>
<th class="py-0.5 px-1 align-bottom text-gray-900 font-semibold lg:w-[12ex] text-end">Dr</th>
<th class="py-0.5 px-1 align-bottom text-gray-900 font-semibold lg:w-[12ex] text-end">Cr</th>
<th class="py-0.5 pl-1 align-bottom text-gray-900 font-semibold text-end">Balance</th>
</tr>
</thead>
<tbody>
<tr>
<td></td>
<td class="py-0.5 px-1" colspan="7">Loading data</td>
</tr>
</tbody>
</table>
</div>
</template>
<script setup lang="ts">
import Clusterize from 'clusterize.js';
import dayjs from 'dayjs';
import { PencilIcon } from '@heroicons/vue/24/outline';
import { onUnmounted, ref, watch } from 'vue';
import { db } from '../db.ts';
import { renderComponent } from '../webutil.ts';
import { ppWithCommodity } from '../display.ts';
interface StatementLine {
id: number,
source_account: string,
dt: string,
description: string,
quantity: number,
balance: number | null,
commodity: string,
transaction_id: number,
posting_accounts: string[]
}
const statementLines = ref([] as StatementLine[]);
let clusterize: Clusterize | null = null;
async function load() {
const session = await db.load();
const joinedStatementLines: any[] = await session.select(
`SELECT statement_lines.id, source_account, statement_lines.dt, statement_lines.description, statement_lines.quantity, statement_lines.balance, statement_lines.commodity, p2.transaction_id, p2.account AS posting_account
FROM statement_lines
LEFT JOIN statement_line_reconciliations ON statement_lines.id = statement_line_reconciliations.statement_line_id
LEFT JOIN postings ON statement_line_reconciliations.posting_id = postings.id
LEFT JOIN transactions ON postings.transaction_id = transactions.id
LEFT JOIN postings p2 ON transactions.id = p2.transaction_id
ORDER BY statement_lines.dt DESC, statement_lines.id DESC, p2.id`
);
// Unflatten statement lines
const newStatementLines: StatementLine[] = [];
for (const joinedStatementLine of joinedStatementLines) {
if (newStatementLines.length === 0 || newStatementLines.at(-1)!.id !== joinedStatementLine.id) {
newStatementLines.push({
id: joinedStatementLine.id,
source_account: joinedStatementLine.source_account,
dt: joinedStatementLine.dt,
description: joinedStatementLine.description,
quantity: joinedStatementLine.quantity,
balance: joinedStatementLine.balance,
commodity: joinedStatementLine.commodity,
transaction_id: joinedStatementLine.transaction_id,
posting_accounts: []
});
}
if (joinedStatementLine.posting_account !== null) {
newStatementLines.at(-1)!.posting_accounts.push(joinedStatementLine.posting_account);
}
}
statementLines.value = newStatementLines;
}
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;
if (line.posting_accounts.length === 0) {
// Unreconciled
reconciliationCell =
`<a href="#" class="text-red-500 hover:text-red-600 hover:underline" onclick="return classifyLine(this);">Unclassified</a>
<a href="/journal/edit-transaction/${ line.transaction_id }" class="text-gray-500 hover:text-gray-700" onclick="return openLinkInNewWindow(this);">${ PencilIconHTML }</a>`;
} else if (line.posting_accounts.length === 2) {
// Simple reconciliation
const otherAccount = line.posting_accounts.find((a) => a !== line.source_account);
reconciliationCell =
`<a href="#" class="hover:text-blue-700 hover:underline" onclick="return classifyLine(this);">${ otherAccount }</a>
<a href="/journal/edit-transaction/${ line.transaction_id }" class="text-gray-500 hover:text-gray-700" onclick="return openLinkInNewWindow(this);">${ PencilIconHTML }</a>`;
} else {
// Complex reconciliation
reconciliationCell =
`<i>(Complex)</i>
<a href="/journal/edit-transaction/${ line.transaction_id }" class="text-gray-500 hover:text-gray-700" onclick="return openLinkInNewWindow(this);">${ PencilIconHTML }</a>`;
}
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 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>
<td class="charge-account py-0.5 px-1 align-baseline text-gray-900">${ reconciliationCell }</td>
<td class="py-0.5 px-1 align-baseline text-gray-900 lg:w-[12ex] text-end">${ line.quantity >= 0 ? ppWithCommodity(line.quantity, line.commodity) : '' }</td>
<td class="py-0.5 px-1 align-baseline text-gray-900 lg:w-[12ex] text-end">${ line.quantity < 0 ? ppWithCommodity(-line.quantity, line.commodity) : '' }</td>
<td class="py-0.5 pl-1 align-baseline text-gray-900 text-end">${ line.balance ?? '' }</td>
</tr>`
)
}
if (clusterize === null) {
clusterize = new Clusterize({
'rows': rows,
scrollElem: document.getElementById('statement-line-list')!,
contentElem: document.querySelector('#statement-line-list tbody')!
});
} else {
clusterize.update(rows);
}
}
watch(statementLines, renderTable);
load();
onUnmounted(() => {
if (clusterize !== null) {
clusterize.destroy();
}
});
</script>