Show unclassified statement lines in reports

This commit is contained in:
RunasSudo 2024-11-18 20:02:05 +11:00
parent 7b26ef81c6
commit 331d7d8b7c
Signed by: RunasSudo
GPG Key ID: 7234E476BF21C61A
7 changed files with 213 additions and 35 deletions

View File

@ -60,10 +60,10 @@ export const db = reactive({
}, },
}); });
export async function totalBalances(session: ExtendedDatabase): Promise<{account: string, quantity: number}[]> { export async function totalBalances(session: ExtendedDatabase): Promise<Map<string, number>> {
await updateRunningBalances(session); await updateRunningBalances(session);
return await session.select(` const resultsRaw: {account: string, quantity: number}[] = await session.select(`
SELECT p3.account AS account, running_balance AS quantity FROM SELECT p3.account AS account, running_balance AS quantity FROM
( (
SELECT p1.account, max(p2.transaction_id) AS max_tid FROM SELECT p1.account, max(p2.transaction_id) AS max_tid FROM
@ -74,6 +74,8 @@ export async function totalBalances(session: ExtendedDatabase): Promise<{account
) p3 ) p3
JOIN postings p4 ON p3.account = p4.account AND p3.max_tid = p4.transaction_id ORDER BY account JOIN postings p4 ON p3.account = p4.account AND p3.max_tid = p4.transaction_id ORDER BY account
`); `);
return new Map(resultsRaw.map((x) => [x.account, x.quantity]));
} }
export async function updateRunningBalances(session: ExtendedDatabase) { export async function updateRunningBalances(session: ExtendedDatabase) {

View File

@ -66,8 +66,9 @@
import { onUnmounted, ref, watch } from 'vue'; import { onUnmounted, ref, watch } from 'vue';
import { asCost } from '../amounts.ts'; import { asCost } from '../amounts.ts';
import { JoinedTransactionPosting, Transaction, db, joinedToTransactions } from '../db.ts'; import { Transaction, db } from '../db.ts';
import { pp, ppWithCommodity } from '../display.ts'; import { pp, ppWithCommodity } from '../display.ts';
import { ReportingStage, ReportingWorkflow } from '../reporting.ts';
import { renderComponent } from '../webutil.ts'; import { renderComponent } from '../webutil.ts';
const commodityDetail = ref(false); const commodityDetail = ref(false);
@ -77,15 +78,14 @@
async function load() { async function load() {
const session = await db.load(); const session = await db.load();
const reportingWorkflow = new ReportingWorkflow();
await reportingWorkflow.generate(session);
const joinedTransactionPostings: JoinedTransactionPosting[] = await session.select( transactions.value = reportingWorkflow.getTransactionsAtStage(ReportingStage.OrdinaryAPITransactions);
`SELECT transaction_id, dt, transactions.description AS transaction_description, postings.id, postings.description, account, quantity, commodity
FROM transactions
JOIN postings ON transactions.id = postings.transaction_id
ORDER BY dt DESC, transaction_id DESC, postings.id`
);
transactions.value = joinedToTransactions(joinedTransactionPostings); // Display transactions in reverse chronological order
// We must sort here because they are returned by reportingWorkflow in order of ReportingStage
transactions.value.sort((a, b) => (b.dt.localeCompare(a.dt)) || ((b.id ?? 0) - (a.id ?? 0)));
} }
function renderTable() { function renderTable() {

View File

@ -91,7 +91,7 @@ import { ppWithCommodity } from '../display.ts';
const session = await db.load(); const session = await db.load();
const joinedStatementLines: any[] = await session.select( 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 `SELECT statement_lines.*, p2.transaction_id, p2.account AS posting_account
FROM statement_lines FROM statement_lines
LEFT JOIN statement_line_reconciliations ON statement_lines.id = statement_line_reconciliations.statement_line_id 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 postings ON statement_line_reconciliations.posting_id = postings.id

View File

@ -41,7 +41,8 @@
import { ref } from 'vue'; import { ref } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { JoinedTransactionPosting, Transaction, db, joinedToTransactions, updateRunningBalances } from '../db.ts'; import { Transaction, db } from '../db.ts';
import { ReportingStage, ReportingWorkflow } from '../reporting.ts';
import TransactionsWithCommodityView from './TransactionsWithCommodityView.vue'; import TransactionsWithCommodityView from './TransactionsWithCommodityView.vue';
import TransactionsWithoutCommodityView from './TransactionsWithoutCommodityView.vue'; import TransactionsWithoutCommodityView from './TransactionsWithoutCommodityView.vue';
@ -52,20 +53,17 @@
async function load() { async function load() {
const session = await db.load(); const session = await db.load();
const reportingWorkflow = new ReportingWorkflow();
await reportingWorkflow.generate(session); // This also ensures running balances are up to date
// Ensure running balances are up to date because we use these const transactionsRaw = reportingWorkflow.getTransactionsAtStage(ReportingStage.OrdinaryAPITransactions);
await updateRunningBalances(session);
const joinedTransactionPostings: JoinedTransactionPosting[] = await session.select( // Filter only transactions affecting this account
`SELECT transaction_id, dt, transactions.description AS transaction_description, postings.id, postings.description, account, quantity, commodity, running_balance transactions.value = transactionsRaw.filter((t) => t.postings.some((p) => p.account === route.params.account));
FROM transactions
JOIN postings ON transactions.id = postings.transaction_id
WHERE transactions.id IN (SELECT transaction_id FROM postings WHERE postings.account = $1)
ORDER by dt DESC, transaction_id DESC, postings.id`,
[route.params.account]
);
transactions.value = joinedToTransactions(joinedTransactionPostings); // Display transactions in reverse chronological order
// We must sort here because they are returned by reportingWorkflow in order of ReportingStage
transactions.value.sort((a, b) => (b.dt.localeCompare(a.dt)) || ((b.id ?? 0) - (a.id ?? 0)));
} }
load(); load();
</script> </script>

View File

@ -60,6 +60,19 @@
let clusterize: Clusterize | null = null; let clusterize: Clusterize | null = null;
function renderTable() { function renderTable() {
// Recompute running balances
// This is necessary because running_balance is cached only considering database transactions
let balance = 0;
for (let i = transactions.length - 1; i >= 0; i--) {
const transaction = transactions[i];
for (const posting of transaction.postings) {
if (posting.account === route.params.account) {
balance += asCost(posting.quantity, posting.commodity);
posting.running_balance = balance; // We should absolutely not commit this to the database!
}
}
}
// Render table // Render table
const PencilIconHTML = renderComponent(PencilIcon, { 'class': 'w-4 h-4 inline align-middle -mt-0.5' }); // Pre-render the pencil icon const PencilIconHTML = renderComponent(PencilIcon, { 'class': 'w-4 h-4 inline align-middle -mt-0.5' }); // Pre-render the pencil icon
const rows = []; const rows = [];

View File

@ -21,7 +21,7 @@
Trial balance Trial balance
</h1> </h1>
<table class="min-w-full"> <table class="min-w-full" v-if="report">
<thead> <thead>
<tr class="border-b border-gray-300"> <tr class="border-b border-gray-300">
<th class="py-0.5 pr-1 text-gray-900 font-semibold text-start">Account</th> <th class="py-0.5 pr-1 text-gray-900 font-semibold text-start">Account</th>
@ -30,19 +30,19 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="account in accounts"> <tr v-for="[account, quantity] in report.balances.entries()">
<td class="py-0.5 pr-1 text-gray-900"><RouterLink :to="{ name: 'transactions', params: { 'account': account.account } }" class="hover:text-blue-700 hover:underline">{{ account.account }}</RouterLink></td> <td class="py-0.5 pr-1 text-gray-900"><RouterLink :to="{ name: 'transactions', params: { account: account } }" class="hover:text-blue-700 hover:underline">{{ account }}</RouterLink></td>
<td class="py-0.5 px-1 text-gray-900 text-end"> <td class="py-0.5 px-1 text-gray-900 text-end">
<template v-if="account.quantity >= 0">{{ pp(account.quantity) }}</template> <template v-if="quantity >= 0">{{ pp(quantity) }}</template>
</td> </td>
<td class="py-0.5 pl-1 text-gray-900 text-end"> <td class="py-0.5 pl-1 text-gray-900 text-end">
<template v-if="account.quantity < 0">{{ pp(-account.quantity) }}</template> <template v-if="quantity < 0">{{ pp(-quantity) }}</template>
</td> </td>
</tr> </tr>
<tr> <tr>
<th class="py-0.5 pr-1 text-gray-900 font-semibold text-start">Total</th> <th class="py-0.5 pr-1 text-gray-900 font-semibold text-start">Total</th>
<th class="py-0.5 px-1 text-gray-900 text-end">{{ pp(total_dr) }}</th> <th class="py-0.5 px-1 text-gray-900 text-end">{{ pp(total_dr!) }}</th>
<th class="py-0.5 pl-1 text-gray-900 text-end">{{ pp(-total_cr) }}</th> <th class="py-0.5 pl-1 text-gray-900 text-end">{{ pp(-total_cr!) }}</th>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@ -51,17 +51,28 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { db, totalBalances } from '../db.ts'; import { db } from '../db.ts';
import { pp } from '../display.ts'; import { pp } from '../display.ts';
import { ReportingStage, ReportingWorkflow, TrialBalanceReport } from '../reporting.ts';
const accounts = ref([] as {account: string, quantity: number}[]); const report = ref(null as TrialBalanceReport | null);
const total_dr = computed(() => accounts.value.reduce((acc, x) => x.quantity > 0 ? acc + x.quantity : acc, 0)); // WebKit: Iterator.reduce not supported - https://bugs.webkit.org/show_bug.cgi?id=248650
const total_cr = computed(() => accounts.value.reduce((acc, x) => x.quantity < 0 ? acc + x.quantity : acc, 0)); const total_dr = computed(() => report.value ?
[...report.value.balances.values()].reduce((acc, x) => x > 0 ? acc + x : acc, 0)
: 0
);
const total_cr = computed(() => report.value ?
[...report.value.balances.values()].reduce((acc, x) => x < 0 ? acc + x : acc, 0)
: 0
);
async function load() { async function load() {
const session = await db.load(); const session = await db.load();
accounts.value = await totalBalances(session); const reportingWorkflow = new ReportingWorkflow();
await reportingWorkflow.generate(session);
report.value = reportingWorkflow.getReportAtStage(ReportingStage.OrdinaryAPITransactions, TrialBalanceReport) as TrialBalanceReport;
} }
load(); load();
</script> </script>

154
src/reporting.ts Normal file
View File

@ -0,0 +1,154 @@
/*
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 { asCost } from './amounts.ts';
import { JoinedTransactionPosting, StatementLine, Transaction, joinedToTransactions, totalBalances } from './db.ts';
import { ExtendedDatabase } from './dbutil.ts';
export enum ReportingStage {
// Load transactions from database
TransactionsFromDatabase = 100,
// Load unreconciled statement lines and other ordinary API transactions
OrdinaryAPITransactions = 200,
}
export class ReportingWorkflow {
transactionsForStage: Map<ReportingStage, Transaction[]> = new Map();
reportsForStage: Map<ReportingStage, Report[]> = new Map();
async generate(session: ExtendedDatabase) {
// ------------------------
// TransactionsFromDatabase
let balances: Map<string, number>;
{
// Load balances from database
balances = await totalBalances(session);
this.reportsForStage.set(ReportingStage.TransactionsFromDatabase, [new TrialBalanceReport(balances)]);
// Load transactions from database
const joinedTransactionPostings: JoinedTransactionPosting[] = await session.select(
`SELECT transaction_id, dt, transactions.description AS transaction_description, postings.id, postings.description, account, quantity, commodity, running_balance
FROM transactions
JOIN postings ON transactions.id = postings.transaction_id
ORDER BY dt, transaction_id, postings.id`
);
const transactions = joinedToTransactions(joinedTransactionPostings);
this.transactionsForStage.set(ReportingStage.TransactionsFromDatabase, transactions);
}
// -----------------------
// OrdinaryAPITransactions
{
// Get unreconciled statement lines
const unreconciledStatementLines: StatementLine[] = await session.select(
// On testing, JOIN is much faster than WHERE NOT EXISTS
`SELECT statement_lines.* FROM statement_lines
LEFT JOIN statement_line_reconciliations ON statement_lines.id = statement_line_reconciliations.statement_line_id
WHERE statement_line_reconciliations.id IS NULL`
);
const transactions = [];
for (const line of unreconciledStatementLines) {
const unclassifiedAccount = line.quantity >= 0 ? 'Unclassified Statement Line Debits' : 'Unclassified Statement Line Credits';
transactions.push(new Transaction(
null,
line.dt,
line.description,
[
{
id: null,
description: null,
account: line.source_account,
quantity: line.quantity,
commodity: line.commodity
},
{
id: null,
description: null,
account: unclassifiedAccount,
quantity: -line.quantity,
commodity: line.commodity
}
]
));
}
this.transactionsForStage.set(ReportingStage.OrdinaryAPITransactions, transactions);
// Recompute balances
balances = applyTransactionsToBalances(balances, transactions);
this.reportsForStage.set(ReportingStage.OrdinaryAPITransactions, [new TrialBalanceReport(balances)]);
}
}
getReportAtStage(stage: ReportingStage, reportType: any): Report {
// TODO: This function needs generics
const reportsForTheStage = this.reportsForStage.get(stage);
if (!reportsForTheStage) {
throw new Error('Attempt to get report for unavailable stage');
}
const report = reportsForTheStage.find((r) => r instanceof reportType);
if (!report) {
throw new Error('Report does not exist');
}
return report;
}
getTransactionsAtStage(stage: ReportingStage): Transaction[] {
const transactions: Transaction[] = [];
for (const [curStage, curTransactions] of this.transactionsForStage.entries()) {
if (curStage <= stage) {
transactions.push(...curTransactions);
}
}
return transactions;
}
}
interface Report {
}
export class TrialBalanceReport implements Report {
constructor(
public balances: Map<string, number>
) {}
}
function applyTransactionsToBalances(balances: Map<string, number>, transactions: Transaction[]): Map<string, number> {
// Initialise new balances
const newBalances: Map<string, number> = new Map([...balances.entries()]);
// Apply transactions
for (const transaction of transactions) {
for (const posting of transaction.postings) {
const openingBalance = newBalances.get(posting.account) ?? 0;
const quantityCost = asCost(posting.quantity, posting.commodity);
const runningBalance = openingBalance + quantityCost;
newBalances.set(posting.account, runningBalance);
}
}
// Sort accounts
return new Map([...newBalances.entries()].sort((a, b) => a[0].localeCompare(b[0])));
}