Compare commits
No commits in common. "331d7d8b7c41a896d5ba832521cfdef9270df60d" and "30cb94274b957b337ef969c4158e516cb9d0964d" have entirely different histories.
331d7d8b7c
...
30cb94274b
@ -111,7 +111,7 @@
|
|||||||
|
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
|
|
||||||
import { DT_FORMAT, Transaction, db, deserialiseAmount } from '../db.ts';
|
import { 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(DT_FORMAT),
|
dayjs(transaction.dt).format('YYYY-MM-DD HH:mm:ss.SSS000'),
|
||||||
transaction.description,
|
transaction.description,
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
18
src/db.ts
18
src/db.ts
@ -25,8 +25,6 @@ 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),
|
||||||
|
|
||||||
@ -60,10 +58,10 @@ export const db = reactive({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function totalBalances(session: ExtendedDatabase): Promise<Map<string, number>> {
|
export async function totalBalances(session: ExtendedDatabase): Promise<{account: string, quantity: number}[]> {
|
||||||
await updateRunningBalances(session);
|
await updateRunningBalances(session);
|
||||||
|
|
||||||
const resultsRaw: {account: string, quantity: number}[] = await session.select(`
|
return 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,8 +72,6 @@ export async function totalBalances(session: ExtendedDatabase): Promise<Map<stri
|
|||||||
) 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) {
|
||||||
@ -247,13 +243,3 @@ 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
|
|
||||||
}
|
|
||||||
|
@ -1,60 +0,0 @@
|
|||||||
/*
|
|
||||||
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,7 +35,6 @@ 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') },
|
||||||
];
|
];
|
||||||
|
@ -66,9 +66,8 @@
|
|||||||
import { onUnmounted, ref, watch } from 'vue';
|
import { onUnmounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
import { asCost } from '../amounts.ts';
|
import { asCost } from '../amounts.ts';
|
||||||
import { Transaction, db } from '../db.ts';
|
import { JoinedTransactionPosting, Transaction, db, joinedToTransactions } 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);
|
||||||
@ -78,14 +77,15 @@
|
|||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const session = await db.load();
|
const session = await db.load();
|
||||||
const reportingWorkflow = new ReportingWorkflow();
|
|
||||||
await reportingWorkflow.generate(session);
|
|
||||||
|
|
||||||
transactions.value = reportingWorkflow.getTransactionsAtStage(ReportingStage.OrdinaryAPITransactions);
|
const joinedTransactionPostings: JoinedTransactionPosting[] = await session.select(
|
||||||
|
`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`
|
||||||
|
);
|
||||||
|
|
||||||
// Display transactions in reverse chronological order
|
transactions.value = joinedToTransactions(joinedTransactionPostings);
|
||||||
// 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() {
|
||||||
|
@ -1,138 +0,0 @@
|
|||||||
<!--
|
|
||||||
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>
|
||||||
<RouterLink :to="{ name: 'import-statement' }" class="btn-secondary">
|
<a href="#" class="btn-secondary">
|
||||||
Import statement
|
Import statement
|
||||||
</RouterLink>
|
</a>
|
||||||
<!--<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>
|
||||||
@ -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.*, p2.transaction_id, p2.account AS posting_account
|
`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
|
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
|
||||||
|
@ -41,8 +41,7 @@
|
|||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
|
|
||||||
import { Transaction, db } from '../db.ts';
|
import { JoinedTransactionPosting, Transaction, db, joinedToTransactions, updateRunningBalances } 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';
|
||||||
|
|
||||||
@ -53,17 +52,20 @@
|
|||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
const transactionsRaw = reportingWorkflow.getTransactionsAtStage(ReportingStage.OrdinaryAPITransactions);
|
// Ensure running balances are up to date because we use these
|
||||||
|
await updateRunningBalances(session);
|
||||||
|
|
||||||
// Filter only transactions affecting this account
|
const joinedTransactionPostings: JoinedTransactionPosting[] = await session.select(
|
||||||
transactions.value = transactionsRaw.filter((t) => t.postings.some((p) => p.account === route.params.account));
|
`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
|
||||||
|
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]
|
||||||
|
);
|
||||||
|
|
||||||
// Display transactions in reverse chronological order
|
transactions.value = joinedToTransactions(joinedTransactionPostings);
|
||||||
// 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>
|
||||||
|
@ -60,19 +60,6 @@
|
|||||||
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 = [];
|
||||||
|
@ -21,7 +21,7 @@
|
|||||||
Trial balance
|
Trial balance
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<table class="min-w-full" v-if="report">
|
<table class="min-w-full">
|
||||||
<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, quantity] in report.balances.entries()">
|
<tr v-for="account in accounts">
|
||||||
<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 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 px-1 text-gray-900 text-end">
|
<td class="py-0.5 px-1 text-gray-900 text-end">
|
||||||
<template v-if="quantity >= 0">{{ pp(quantity) }}</template>
|
<template v-if="account.quantity >= 0">{{ pp(account.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="quantity < 0">{{ pp(-quantity) }}</template>
|
<template v-if="account.quantity < 0">{{ pp(-account.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,28 +51,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
import { db } from '../db.ts';
|
import { db, totalBalances } from '../db.ts';
|
||||||
import { pp } from '../display.ts';
|
import { pp } from '../display.ts';
|
||||||
import { ReportingStage, ReportingWorkflow, TrialBalanceReport } from '../reporting.ts';
|
|
||||||
|
|
||||||
const report = ref(null as TrialBalanceReport | null);
|
const accounts = ref([] as {account: string, quantity: number}[]);
|
||||||
|
|
||||||
// WebKit: Iterator.reduce not supported - https://bugs.webkit.org/show_bug.cgi?id=248650
|
const total_dr = computed(() => accounts.value.reduce((acc, x) => x.quantity > 0 ? acc + x.quantity : acc, 0));
|
||||||
const total_dr = computed(() => report.value ?
|
const total_cr = computed(() => accounts.value.reduce((acc, x) => x.quantity < 0 ? acc + x.quantity : acc, 0));
|
||||||
[...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();
|
||||||
const reportingWorkflow = new ReportingWorkflow();
|
accounts.value = await totalBalances(session);
|
||||||
await reportingWorkflow.generate(session);
|
|
||||||
|
|
||||||
report.value = reportingWorkflow.getReportAtStage(ReportingStage.OrdinaryAPITransactions, TrialBalanceReport) as TrialBalanceReport;
|
|
||||||
}
|
}
|
||||||
load();
|
load();
|
||||||
</script>
|
</script>
|
||||||
|
154
src/reporting.ts
154
src/reporting.ts
@ -1,154 +0,0 @@
|
|||||||
/*
|
|
||||||
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 { 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])));
|
|
||||||
}
|
|
Loading…
Reference in New Issue
Block a user