Compare commits

..

No commits in common. "40945def3ee762fea4f3d892a830f0ebe42c1b69" and "331d7d8b7c41a896d5ba832521cfdef9270df60d" have entirely different histories.

21 changed files with 64 additions and 806 deletions

View File

@ -1,117 +0,0 @@
<!--
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>
<div class="grid grid-cols-[max-content_1fr] space-y-2 mb-4 items-baseline">
<label for="dt" class="block text-gray-900 pr-4">Date</label>
<div>
<input type="date" class="bordered-field" id="dt" v-model="assertion.dt">
</div>
<label for="description" class="block text-gray-900 pr-4">Description</label>
<div>
<input type="text" class="bordered-field" id="description" v-model="assertion.description" placeholder=" ">
</div>
<label for="account" class="block text-gray-900 pr-4">Account</label>
<div class="relative combobox">
<input type="text" class="bordered-field peer" id="account" v-model="assertion.account" placeholder=" " autocomplete="off">
<!--{% include 'components/accounts_combobox_inner.html' %}-->
</div>
<label for="amount" class="block text-gray-900 pr-4">Balance</label>
<div class="relative shadow-sm">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<span class="text-gray-500">{{ db.metadata.reporting_commodity }}</span>
</div>
<!-- TODO: Display existing credit assertion as credit, not as negative debit -->
<input type="number" class="bordered-field pl-7 pr-16" step="0.01" v-model="assertion.amount_abs" placeholder="0.00">
<div class="absolute inset-y-0 right-0 flex items-center">
<select class="h-full border-0 bg-transparent py-0 pl-2 pr-8 text-gray-900 focus:ring-2 focus:ring-inset focus:ring-indigo-600" v-model="assertion.sign">
<option value="dr">Dr</option>
<option value="cr">Cr</option>
</select>
</div>
</div>
</div>
<div class="flex justify-end mt-4 space-x-2">
<button class="btn-secondary text-red-600 ring-red-500" @click="deleteAssertion" v-if="assertion.id !== null">Delete</button>
<button class="btn-primary" @click="saveAssertion">Save</button>
</div>
</template>
<script setup lang="ts">
import dayjs from 'dayjs';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { defineProps } from 'vue';
import { DT_FORMAT, db, deserialiseAmount } from '../db.ts';
export interface EditingAssertion {
id: number | null,
dt: string,
description: string,
account: string,
sign: string,
amount_abs: string,
}
const { assertion } = defineProps<{ assertion: EditingAssertion }>();
async function saveAssertion() {
// Save changes to the assertion
const amount_abs = deserialiseAmount('' + assertion.amount_abs);
const quantity = assertion.sign === 'dr' ? amount_abs.quantity : -amount_abs.quantity;
const session = await db.load();
if (assertion.id === null) {
await session.execute(
`INSERT INTO balance_assertions (dt, description, account, quantity, commodity)
VALUES ($1, $2, $3, $4, $5)`,
[dayjs(assertion.dt).format(DT_FORMAT), assertion.description, assertion.account, quantity, amount_abs.commodity]
);
} else {
await session.execute(
`UPDATE balance_assertions
SET dt = $1, description = $2, account = $3, quantity = $4, commodity = $5
WHERE id = $6`,
[dayjs(assertion.dt).format(DT_FORMAT), assertion.description, assertion.account, quantity, amount_abs.commodity, assertion.id]
);
}
await getCurrentWindow().close();
}
async function deleteAssertion() {
// Delete the current assertion
if (!await confirm('Are you sure you want to delete this balance assertion? This operation is irreversible.')) {
return;
}
const session = await db.load();
await session.execute(
`DELETE FROM balance_assertions
WHERE id = $1`,
[assertion.id]
);
await getCurrentWindow().close();
}
</script>

View File

@ -1,56 +0,0 @@
<!--
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>
<div class="relative">
<button type="button" class="relative w-full cursor-default bg-white bordered-field pl-3 pr-10 text-left" @click="isOpen = !isOpen">
<span class="block truncate">{{ selectedValue[1] }}</span>
<span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon class="h-5 w-5 text-gray-400" />
</span>
</button>
<ul class="absolute z-20 mt-1 max-h-60 w-full overflow-auto bg-white py-1 text-sm shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none" :class="isOpen ? 'block' : 'hidden'">
<template v-for="([categoryName, categoryItems], index) in values">
<li class="relative cursor-default select-none py-1 pl-3 pr-9 text-gray-500 border-b border-gray-300" :class="{ 'pt-4': index > 0 }">
<span class="block truncate text-xs font-bold uppercase">{{ categoryName }}</span>
</li>
<li v-for="item in categoryItems" class="group relative cursor-default select-none py-1 pl-3 pr-9 text-gray-900 hover:text-white hover:bg-emerald-600" :data-selected="item[0] === selectedValue[0] ? 'selected' : null" @click="selectedValue = item; isOpen = false">
<span class="block truncate group-data-[selected=selected]:font-semibold">{{ item[1] }}</span>
<span class="hidden group-data-[selected=selected]:flex absolute inset-y-0 right-0 items-center pr-4 text-emerald-600 group-hover:text-white">
<CheckIcon class="h-5 w-5" />
</span>
</li>
</template>
</ul>
</div>
</template>
<script setup lang="ts">
import { CheckIcon, ChevronUpDownIcon } from '@heroicons/vue/24/outline';
import { defineModel, defineProps, ref } from 'vue';
const { values } = defineProps<{ values: [string, [string, string][]][] }>(); // Array of [category name, [internal identifier, pretty name]]
const selectedValue = defineModel({ default: null! as [string, string] }); // Vue bug: Compiler produces broken code if setting default directly here
if (selectedValue.value === null) {
selectedValue.value = values[0][1][0];
}
const isOpen = ref(false);
</script>

View File

@ -288,7 +288,7 @@
}
async function deleteTransaction() {
if (!await confirm('Are you sure you want to delete this transaction? This operation is irreversible.')) {
if (!confirm('Are you sure you want to delete this transaction? This operation is irreversible.')) {
return;
}
@ -310,14 +310,14 @@
`DELETE FROM postings
WHERE transaction_id = $1`,
[transaction.id]
);
)
// Delete transaction
await dbTransaction.execute(
`DELETE FROM transactions
WHERE id = $1`,
[transaction.id]
);
)
await dbTransaction.commit();

View File

@ -68,10 +68,7 @@ export async function totalBalances(session: ExtendedDatabase): Promise<Map<stri
(
SELECT p1.account, max(p2.transaction_id) AS max_tid FROM
(
SELECT account, max(dt) AS max_dt
FROM postings
JOIN transactions ON postings.transaction_id = transactions.id
GROUP BY account
SELECT account, max(dt) AS max_dt FROM postings JOIN transactions ON postings.transaction_id = transactions.id GROUP BY account
) p1
JOIN postings p2 ON p1.account = p2.account AND p1.max_dt = transactions.dt JOIN transactions ON p2.transaction_id = transactions.id GROUP BY p2.account
) p3
@ -81,29 +78,6 @@ export async function totalBalances(session: ExtendedDatabase): Promise<Map<stri
return new Map(resultsRaw.map((x) => [x.account, x.quantity]));
}
export async function totalBalancesAtDate(session: ExtendedDatabase, dt: string): Promise<Map<string, number>> {
await updateRunningBalances(session);
const resultsRaw: {account: string, quantity: number}[] = await session.select(
`SELECT p3.account AS account, running_balance AS quantity FROM
(
SELECT p1.account, max(p2.transaction_id) AS max_tid FROM
(
SELECT account, max(dt) AS max_dt
FROM postings
JOIN transactions ON postings.transaction_id = transactions.id
WHERE DATE(dt) <= DATE($1)
GROUP BY account
) p1
JOIN postings p2 ON p1.account = p2.account AND p1.max_dt = transactions.dt JOIN transactions ON p2.transaction_id = transactions.id GROUP BY p2.account
) p3
JOIN postings p4 ON p3.account = p4.account AND p3.max_tid = p4.transaction_id ORDER BY account`,
[dt]
);
return new Map(resultsRaw.map((x) => [x.account, x.quantity]));
}
export async function updateRunningBalances(session: ExtendedDatabase) {
// TODO: This is very slow - it would be faster to do this in Rust

View File

@ -30,14 +30,10 @@ async function initApp() {
// Init router
const routes = [
{ path: '/', name: 'index', component: () => import('./pages/HomeView.vue') },
{ path: '/balance-assertions', name: 'balance-assertions', component: () => import('./pages/BalanceAssertionsView.vue') },
{ path: '/balance-assertions/edit/:id', name: 'balance-assertions-edit', component: () => import('./pages/EditBalanceAssertionView.vue') },
{ path: '/balance-assertions/new', name: 'balance-assertions-new', component: () => import('./pages/NewBalanceAssertionView.vue') },
{ path: '/chart-of-accounts', name: 'chart-of-accounts', component: () => import('./pages/ChartOfAccountsView.vue') },
{ path: '/general-ledger', name: 'general-ledger', component: () => import('./pages/GeneralLedgerView.vue') },
{ path: '/journal', name: 'journal', component: () => import('./pages/JournalView.vue') },
{ path: '/journal/edit/:id', name: 'journal-edit-transaction', component: () => import('./pages/EditTransactionView.vue') },
{ path: '/journal/new', name: 'journal-new-transaction', component: () => import('./pages/NewTransactionView.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: '/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') },

View File

@ -1,129 +0,0 @@
<!--
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">
Balance assertions
</h1>
<div class="my-4 flex gap-x-2">
<a href="/balance-assertions/new" class="btn-primary pl-2" onclick="return openLinkInNewWindow(this);">
<PlusIcon class="w-4 h-4" />
New assertion
</a>
</div>
<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-start">Account</th>
<th class="py-0.5 px-1 text-gray-900 font-semibold text-end">Balance</th>
<th></th>
<th class="py-0.5 px-1 text-gray-900 font-semibold text-start">Status</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="assertion of balanceAssertions">
<td class="py-0.5 pr-1 text-gray-900">{{ dayjs(assertion.dt).format('YYYY-MM-DD') }}</td>
<td class="py-0.5 px-1 text-gray-900">{{ assertion.description }}</td>
<td class="py-0.5 px-1 text-gray-900"><RouterLink :to="{ name: 'transactions', params: { account: assertion.account } }" class="text-gray-900 hover:text-blue-700 hover:underline">{{ assertion.account }}</RouterLink></td>
<td class="py-0.5 px-1 text-gray-900 text-end">{{ pp(Math.abs(assertion.quantity)) }}</td>
<td class="py-0.5 pr-1 text-gray-900">{{ assertion.quantity >= 0 ? 'Dr' : 'Cr' }}</td>
<td class="py-0.5 px-1 text-gray-900">
<CheckIcon class="w-4 h-4" v-if="assertion.isValid" />
<XMarkIcon class="w-4 h-4 text-red-500" v-if="!assertion.isValid" />
</td>
<td class="py-0.5 pl-1 text-gray-900 text-end">
<a :href="'/balance-assertions/edit/' + assertion.id" class="text-gray-500 hover:text-gray-700" onclick="return openLinkInNewWindow(this);">
<PencilIcon class="w-4 h-4" />
</a>
</td>
</tr>
</tbody>
</table>
</template>
<script setup lang="ts">
import dayjs from 'dayjs';
import { ref } from 'vue';
import { db, totalBalancesAtDate } from '../db.ts';
import { pp } from '../display.ts';
import { CheckIcon, PencilIcon, XMarkIcon } from '@heroicons/vue/24/outline';
import { PlusIcon } from '@heroicons/vue/16/solid';
const balanceAssertions = ref([] as ValidatedBalanceAssertion[]);
interface ValidatedBalanceAssertion {
id: number,
dt: string,
description: string,
account: string,
quantity: number,
commodity: string,
isValid: boolean,
}
async function load() {
const session = await db.load();
const rawBalanceAssertions: any[] = await session.select(
`SELECT *
FROM balance_assertions
ORDER BY dt DESC, id DESC`
);
/*
// Cache trial balances in case there are multiple assertions per date
const trialBalanceForDate = new Map<string, TrialBalanceReport>();
for (const balanceAssertion of rawBalanceAssertions) {
// Check assertion status
// TODO: This is very inefficient because API transactions are generated multiple times
if (!trialBalanceForDate.has(balanceAssertion.dt)) {
const reportingWorkflow = new ReportingWorkflow();
await reportingWorkflow.generate(session, balanceAssertion.dt);
const trialBalance = reportingWorkflow.getReportAtStage(ReportingStage.OrdinaryAPITransactions, TrialBalanceReport) as TrialBalanceReport;
trialBalanceForDate.set(balanceAssertion.dt, trialBalance);
}
const trialBalance = trialBalanceForDate.get(balanceAssertion.dt)!;
balanceAssertion.isValid = balanceAssertion.quantity === trialBalance.balances.get(balanceAssertion.account) && balanceAssertion.commodity === db.metadata.reporting_commodity;
}
*/
// Check assertion status
const balancesForDate = new Map<string, Map<string, number>>();
for (const balanceAssertion of rawBalanceAssertions) {
if (!balancesForDate.has(balanceAssertion.dt)) {
// FIXME: This is quite slow
balancesForDate.set(balanceAssertion.dt, await totalBalancesAtDate(session, balanceAssertion.dt));
}
balanceAssertion.isValid = balanceAssertion.quantity === balancesForDate.get(balanceAssertion.dt)!.get(balanceAssertion.account) && balanceAssertion.commodity === db.metadata.reporting_commodity;
}
balanceAssertions.value = rawBalanceAssertions as ValidatedBalanceAssertion[];
}
load();
</script>

View File

@ -1,132 +0,0 @@
<!--
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">
Chart of accounts
</h1>
<div class="my-2 py-2 flex gap-x-2 items-baseline bg-white sticky top-0">
<DropdownBox class="w-[450px]" :values="accountKindsByModule" v-model="selectedAccountKind" />
<button class="btn-primary" @click="addAccountType">Add type</button>
<button class="btn-secondary text-red-600 ring-red-500" @click="removeAccountType">Remove type</button>
</div>
<table class="min-w-full">
<thead>
<tr>
<th></th>
<th class="py-0.5 px-1 text-gray-900 font-semibold text-start">Account</th>
<th class="py-0.5 pl-1 text-gray-900 font-semibold text-start">Associated types</th>
</tr>
</thead>
<tbody>
<tr class="border-t border-gray-300" v-for="[account, thisAccountKinds] in accounts.entries()">
<td class="py-0.5 pr-1 text-gray-900 align-baseline"><input class="checkbox-primary" type="checkbox" v-model="selectedAccounts" :value="account"></td>
<td class="py-0.5 px-1 text-gray-900 align-baseline">{{ account }}</td>
<td class="py-0.5 pl-1 text-gray-900 align-baseline">
<ul class="list-disc ml-5" v-if="thisAccountKinds">
<!-- First display known account kinds -->
<template v-for="[accountKind, accountKindPrettyName] in accountKindsMap.entries()">
<li v-if="thisAccountKinds.indexOf(accountKind) >= 0">{{ accountKindPrettyName }}</li>
</template>
<!-- Then display unknown account kinds -->
<template v-for="accountKind in thisAccountKinds">
<li v-if="!accountKindsMap.has(accountKind)" class="italic">{{ accountKind }}</li>
</template>
</ul>
</td>
</tr>
</tbody>
</table>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { accountKinds } from '../registry.ts';
import { db } from '../db.ts';
import DropdownBox from '../components/DropdownBox.vue';
const accountKindsMap = new Map(accountKinds);
const accountKindsByModule = [...Map.groupBy(accountKinds, (k) => k[0].split('.')[0]).entries()];
const accounts = ref(new Map<string, string[]>());
const selectedAccounts = ref([]);
const selectedAccountKind = ref(accountKinds[0]);
async function load() {
const session = await db.load();
const accountKindsRaw: {account: string, kind: string}[] = await session.select(
`SELECT account, kind FROM account_configurations ORDER BY account, kind`
);
for (const accountKindRaw of accountKindsRaw) {
const kinds = accounts.value.get(accountKindRaw.account) ?? [];
kinds.push(accountKindRaw.kind);
accounts.value.set(accountKindRaw.account, kinds);
}
}
load();
async function addAccountType() {
// Associate selected accounts with the selected account kind
const session = await db.load();
const dbTransaction = await session.begin();
for (const account of selectedAccounts.value) {
await dbTransaction.execute(
`INSERT INTO account_configurations (account, kind)
VALUES ($1, $2)`,
[account, selectedAccountKind.value[0]]
);
}
await dbTransaction.commit();
selectedAccounts.value = [];
// Reload data
accounts.value.clear();
await load();
}
async function removeAccountType() {
// De-associate selected accounts with the selected account kind
const session = await db.load();
const dbTransaction = await session.begin();
for (const account of selectedAccounts.value) {
await dbTransaction.execute(
`DELETE FROM account_configurations
WHERE account = $1 AND kind = $2`,
[account, selectedAccountKind.value[0]]
);
}
await dbTransaction.commit();
selectedAccounts.value = [];
// Reload data
accounts.value.clear();
await load();
}
</script>

View File

@ -1,67 +0,0 @@
<!--
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 mb-4">
Edit balance assertion
</h1>
<BalanceAssertionEditor :assertion="assertion" />
</template>
<script setup lang="ts">
import dayjs from 'dayjs';
import { ref } from 'vue';
import { useRoute } from 'vue-router';
import { db, serialiseAmount } from '../db.ts';
import BalanceAssertionEditor, { EditingAssertion } from '../components/BalanceAssertionEditor.vue';
const route = useRoute();
const assertion = ref({
id: null,
dt: null!,
description: null!,
account: null!,
sign: null!,
amount_abs: null!,
} as EditingAssertion);
async function load() {
const session = await db.load();
const rawAssertions: any[] = await session.select(
`SELECT *
FROM balance_assertions
WHERE id = $1`,
[route.params.id]
);
const rawAssertion = rawAssertions[0];
// Format parameters for display
rawAssertion.dt = dayjs(rawAssertion.dt).format('YYYY-MM-DD');
rawAssertion.sign = rawAssertion.quantity >= 0 ? 'dr' : 'cr';
rawAssertion.amount_abs = serialiseAmount(Math.abs(rawAssertion.quantity), rawAssertion.commodity);
assertion.value = rawAssertion as EditingAssertion;
}
load();
</script>

View File

@ -30,7 +30,7 @@
import { ref } from 'vue';
import { useRoute } from 'vue-router';
import { JoinedTransactionPosting, db, joinedToTransactions, serialiseAmount } from '../db.ts';
import { JoinedTransactionPosting, Posting, db, joinedToTransactions, serialiseAmount } from '../db.ts';
import TransactionEditor, { EditingTransaction } from '../components/TransactionEditor.vue';
const route = useRoute();
@ -56,18 +56,16 @@
const transactions = joinedToTransactions(joinedTransactionPostings);
if (transactions.length !== 1) { throw new Error('Unexpected number of transactions returned from SQL'); }
const rawTransaction = transactions[0] as any;
transaction.value = transactions[0] as unknown as EditingTransaction;
// Format dt
rawTransaction.dt = dayjs(rawTransaction.dt).format('YYYY-MM-DD');
transaction.value.dt = dayjs(transaction.value.dt).format('YYYY-MM-DD')
// Initialise sign and amount_abs
for (const posting of rawTransaction.postings) {
posting.sign = posting.quantity >= 0 ? 'dr' : 'cr';
posting.amount_abs = serialiseAmount(Math.abs(posting.quantity), posting.commodity);
for (const posting of transaction.value.postings) {
posting.sign = (posting as unknown as Posting).quantity! >= 0 ? 'dr' : 'cr';
posting.amount_abs = serialiseAmount(Math.abs((posting as unknown as Posting).quantity), (posting as unknown as Posting).commodity);
}
transaction.value = rawTransaction as EditingTransaction;
}
load();
</script>

View File

@ -21,16 +21,14 @@
General ledger
</h1>
<div class="my-4 flex gap-x-2 items-center">
<div class="my-4 flex gap-x-2">
<!-- 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);">
<PlusIcon class="w-4 h-4" />
New transaction
</a>
<div class="flex items-baseline">
<input id="only-unclassified" class="ml-3 mr-1 self-center checkbox-primary" type="checkbox" v-model="commodityDetail">
<label for="only-unclassified" class="text-gray-900">Show commodity detail</label>
</div>
<button v-if="commodityDetail" class="btn-secondary" @click="commodityDetail = false">Hide commodity detail</button>
<button v-if="!commodityDetail" class="btn-secondary" @click="commodityDetail = true">Show commodity detail</button>
</div>
<div id="transaction-list" class="max-h-[100vh] overflow-y-scroll wk-aa">
@ -97,7 +95,7 @@
for (const transaction of transactions.value) {
let editLink = '';
if (transaction.id !== null) {
editLink = `<a href="/journal/edit/${ transaction.id }" class="text-gray-500 hover:text-gray-700" onclick="return openLinkInNewWindow(this);">${ PencilIconHTML }</a>`;
editLink = `<a href="/journal/edit-transaction/${ transaction.id }" class="text-gray-500 hover:text-gray-700" onclick="return openLinkInNewWindow(this);">${ PencilIconHTML }</a>`;
}
rows.push(
`<tr class="border-t border-gray-300">
@ -147,8 +145,7 @@
clusterize = new Clusterize({
'rows': rows,
scrollElem: document.getElementById('transaction-list')!,
contentElem: document.querySelector('#transaction-list tbody')!,
show_no_data_row: false,
contentElem: document.querySelector('#transaction-list tbody')!
});
} else {
clusterize.update(rows);

View File

@ -23,8 +23,8 @@
<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: 'statement-lines' }" class="text-gray-900 hover:text-blue-700 hover:underline">Statement lines</RouterLink></li>
<li><RouterLink :to="{ name: 'balance-assertions' }" class="text-gray-900 hover:text-blue-700 hover:underline">Balance assertions</RouterLink></li>
<li><RouterLink :to="{ name: 'chart-of-accounts' }" class="text-gray-900 hover:text-blue-700 hover:underline">Chart of accounts</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">Chart of accounts</a></li>-->
<!-- TODO: Plugin reports -->
</ul>
</div>

View File

@ -83,7 +83,7 @@
<script setup lang="ts">
import dayjs from 'dayjs';
import { ref, useTemplateRef } from 'vue';
import { defineModel, ref, useTemplateRef } from 'vue';
import { useRouter } from 'vue-router';
import { StatementLine, db } from '../db.ts';
@ -92,8 +92,8 @@
const fileInput = useTemplateRef('file');
const selectedFilename = ref('');
const sourceAccount = ref('');
const selectedFilename = defineModel('selectedFilename', { default: '' });
const sourceAccount = defineModel('sourceAccount', { default: '' });
const router = useRouter();

View File

@ -21,16 +21,14 @@
Journal
</h1>
<div class="my-4 flex gap-x-2 items-center">
<div class="my-4 flex gap-x-2">
<!-- 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);">
<PlusIcon class="w-4 h-4" />
New transaction
</a>
<div class="flex items-baseline">
<input id="only-unclassified" class="ml-3 mr-1 self-center checkbox-primary" type="checkbox" v-model="commodityDetail">
<label for="only-unclassified" class="text-gray-900">Show commodity detail</label>
</div>
<button v-if="commodityDetail" class="btn-secondary" @click="commodityDetail = false">Hide commodity detail</button>
<button v-if="!commodityDetail" class="btn-secondary" @click="commodityDetail = true">Show commodity detail</button>
</div>
<div id="transaction-list" class="max-h-[100vh] overflow-y-scroll wk-aa">
@ -63,8 +61,7 @@
import dayjs from 'dayjs';
import { PencilIcon } from '@heroicons/vue/24/outline';
import { PlusIcon } from '@heroicons/vue/16/solid';
import { PencilIcon, PlusIcon } from '@heroicons/vue/24/outline';
import { onUnmounted, ref, watch } from 'vue';
@ -101,7 +98,7 @@
<td class="py-0.5 pr-1 text-gray-900 lg:w-[12ex]">${ dayjs(transaction.dt).format('YYYY-MM-DD') }</td>
<td class="py-0.5 px-1 text-gray-900" colspan="3">
${ transaction.description }
<a href="/journal/edit/${ transaction.id }" class="text-gray-500 hover:text-gray-700" onclick="return openLinkInNewWindow(this);">${ PencilIconHTML }</a>
<a href="/journal/edit-transaction/${ transaction.id }" class="text-gray-500 hover:text-gray-700" onclick="return openLinkInNewWindow(this);">${ PencilIconHTML }</a>
</td>
<td></td>
<td></td>
@ -146,8 +143,7 @@
clusterize = new Clusterize({
'rows': rows,
scrollElem: document.getElementById('transaction-list')!,
contentElem: document.querySelector('#transaction-list tbody')!,
show_no_data_row: false,
contentElem: document.querySelector('#transaction-list tbody')!
});
} else {
clusterize.update(rows);

View File

@ -1,41 +0,0 @@
<!--
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 mb-4">
New balance assertion
</h1>
<BalanceAssertionEditor :assertion="assertion" />
</template>
<script setup lang="ts">
import dayjs from 'dayjs';
import { ref } from 'vue';
import BalanceAssertionEditor, { EditingAssertion } from '../components/BalanceAssertionEditor.vue';
const assertion = ref({
id: null,
dt: dayjs().format('YYYY-MM-DD'),
description: '',
account: '',
sign: 'dr',
amount_abs: '',
} as EditingAssertion);
</script>

View File

@ -29,10 +29,9 @@
<RouterLink :to="{ name: 'import-statement' }" class="btn-secondary">
Import statement
</RouterLink>
<div class="flex items-baseline">
<input id="only-unclassified" class="ml-3 mr-1 self-center checkbox-primary" type="checkbox" v-model="showOnlyUnclassified">
<label for="only-unclassified" class="text-gray-900">Show only unclassified lines</label>
</div>
<!--<a href="{{ url_for('statement_lines', **dict(request.args, unclassified=1)) }}" class="btn-secondary">
Show only unclassified lines
</a>-->
</div>
</div>
@ -65,7 +64,7 @@
import dayjs from 'dayjs';
import { CheckIcon, PencilIcon } from '@heroicons/vue/24/outline';
import { PencilIcon } from '@heroicons/vue/24/outline';
import { onUnmounted, ref, watch } from 'vue';
@ -85,7 +84,6 @@
posting_accounts: string[]
}
const showOnlyUnclassified = ref(false);
const statementLines = ref([] as StatementLine[]);
let clusterize: Clusterize | null = null;
@ -127,105 +125,6 @@
statementLines.value = newStatementLines;
}
// TODO: Could probably avoid polluting global scope by using clusterize clusterChanged callback
(window as any).showClassifyLinePanel = function(el: HTMLAnchorElement) {
const CheckIconHTML = renderComponent(CheckIcon, { 'class': 'w-5 h-5' });
const td = el.closest('td')!;
td.className = 'relative'; // CSS trickery so as to not expand the height of the tr
td.innerHTML =
`<div class="flex items-stretch absolute top-[-4px]">
<input type="text" class="bordered-field">
<button type="button" class="relative -ml-px inline-flex items-center gap-x-1.5 px-3 py-1 text-gray-800 shadow-sm ring-1 ring-inset ring-gray-400 bg-white hover:bg-gray-50">${ CheckIconHTML }</button>
</div>`;
td.querySelector('input')!.addEventListener('keydown', async function(event: KeyboardEvent) {
if (event.key === 'Enter') {
await onLineClassified(event);
}
})
td.querySelector('button')!.addEventListener('click', onLineClassified);
td.querySelector('input')!.focus();
return false;
};
async function onLineClassified(event: Event) {
// Callback when clicking OK or pressing enter to classify a statement line
if ((event.target as HTMLInputElement).disabled) {
return;
}
const td = (event.target as Element).closest('td')!;
const tr = td.closest('tr')!;
const lineId = parseInt(tr.dataset.lineId!);
const chargeAccount = (td.querySelector('input')! as HTMLInputElement).value;
if (!chargeAccount) {
return;
}
// Disable further submissions
td.querySelector('input')!.disabled = true;
td.querySelector('button')!.disabled = true;
const statementLine = statementLines.value.find((l) => l.id === lineId)!;
// 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)`,
[statementLine.dt, statementLine.description]
);
const transactionId = transactionResult.lastInsertId;
// Insert posting for this account
const accountPostingResult = await dbTransaction.execute(
`INSERT INTO postings (transaction_id, description, account, quantity, commodity, running_balance)
VALUES ($1, NULL, $2, $3, $4, NULL)`,
[transactionId, statementLine.source_account, statementLine.quantity, statementLine.commodity]
);
const accountPostingId = accountPostingResult.lastInsertId;
// Insert posting for the charge account - no need to remember this ID
await dbTransaction.execute(
`INSERT INTO postings (transaction_id, description, account, quantity, commodity, running_balance)
VALUES ($1, NULL, $2, $3, $4, NULL)`,
[transactionId, chargeAccount, -statementLine.quantity, statementLine.commodity]
);
// Insert statement line reconciliation
await dbTransaction.execute(
`INSERT INTO statement_line_reconciliations (statement_line_id, posting_id)
VALUES ($1, $2)`,
[statementLine.id, accountPostingId]
);
// 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`,
[statementLine.dt, statementLine.source_account, chargeAccount]
);
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 = [];
@ -235,26 +134,23 @@
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>`;
`<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 =
`<span>${ otherAccount }</span>
<a href="/journal/edit/${ line.transaction_id }" class="text-gray-500 hover:text-gray-700" onclick="return openLinkInNewWindow(this);">${ PencilIconHTML }</a>`;
if (showOnlyUnclassified.value) { continue; }
`<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/${ line.transaction_id }" class="text-gray-500 hover:text-gray-700" onclick="return openLinkInNewWindow(this);">${ PencilIconHTML }</a>`;
if (showOnlyUnclassified.value) { continue; }
<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 }">
`<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>
@ -271,15 +167,13 @@
clusterize = new Clusterize({
'rows': rows,
scrollElem: document.getElementById('statement-line-list')!,
contentElem: document.querySelector('#statement-line-list tbody')!,
show_no_data_row: false,
contentElem: document.querySelector('#statement-line-list tbody')!
});
} else {
clusterize.update(rows);
}
}
watch(showOnlyUnclassified, renderTable);
watch(statementLines, renderTable);
load();

View File

@ -21,16 +21,14 @@
{{ route.params.account }}
</h1>
<div class="my-4 flex gap-x-2 items-center">
<div class="my-4 flex gap-x-2">
<!-- 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);">
<PlusIcon class="w-4 h-4" />
New transaction
</a>
<div class="flex items-baseline">
<input id="only-unclassified" class="ml-3 mr-1 self-center checkbox-primary" type="checkbox" v-model="commodityDetail">
<label for="only-unclassified" class="text-gray-900">Show commodity detail</label>
</div>
<button v-if="commodityDetail" class="btn-secondary" @click="commodityDetail = false">Hide commodity detail</button>
<button v-if="!commodityDetail" class="btn-secondary" @click="commodityDetail = true">Show commodity detail</button>
</div>
<TransactionsWithCommodityView v-if="commodityDetail" :transactions="transactions"/>

View File

@ -84,7 +84,7 @@
for (const transaction of transactions) {
let editLink = '';
if (transaction.id !== null) {
editLink = `<a href="/journal/edit/${ transaction.id }" class="text-gray-500 hover:text-gray-700" onclick="return openLinkInNewWindow(this);">${ PencilIconHTML }</a>`;
editLink = `<a href="/journal/edit-transaction/${ transaction.id }" class="text-gray-500 hover:text-gray-700" onclick="return openLinkInNewWindow(this);">${ PencilIconHTML }</a>`;
}
rows.push(
`<tr class="border-t border-gray-300">
@ -140,8 +140,7 @@
clusterize = new Clusterize({
'rows': rows,
scrollElem: document.getElementById('transaction-list')!,
contentElem: document.querySelector('#transaction-list tbody')!,
show_no_data_row: false,
contentElem: document.querySelector('#transaction-list tbody')!
});
} else {
clusterize.update(rows);

View File

@ -80,7 +80,7 @@
for (const transaction of transactions) {
let editLink = '';
if (transaction.id !== null) {
editLink = `<a href="/journal/edit/${ transaction.id }" class="text-gray-500 hover:text-gray-700" onclick="return openLinkInNewWindow(this);">${ PencilIconHTML }</a>`;
editLink = `<a href="/journal/edit-transaction/${ transaction.id }" class="text-gray-500 hover:text-gray-700" onclick="return openLinkInNewWindow(this);">${ PencilIconHTML }</a>`;
}
if (transaction.postings.length == 2) {
@ -139,8 +139,7 @@
clusterize = new Clusterize({
'rows': rows,
scrollElem: document.getElementById('transaction-list')!,
contentElem: document.querySelector('#transaction-list tbody')!,
show_no_data_row: false,
contentElem: document.querySelector('#transaction-list tbody')!
});
} else {
clusterize.update(rows);

View File

@ -1,25 +0,0 @@
/*
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/>.
*/
export const accountKinds: [string, string][] = [
['drcr.asset', 'Asset'],
['drcr.liability', 'Liability'],
['drcr.income', 'Income'],
['drcr.expense', 'Expense'],
['drcr.equity', 'Equity']
];

View File

@ -17,7 +17,7 @@
*/
import { asCost } from './amounts.ts';
import { JoinedTransactionPosting, StatementLine, Transaction, joinedToTransactions, totalBalances, totalBalancesAtDate } from './db.ts';
import { JoinedTransactionPosting, StatementLine, Transaction, joinedToTransactions, totalBalances } from './db.ts';
import { ExtendedDatabase } from './dbutil.ts';
export enum ReportingStage {
@ -32,7 +32,7 @@ export class ReportingWorkflow {
transactionsForStage: Map<ReportingStage, Transaction[]> = new Map();
reportsForStage: Map<ReportingStage, Report[]> = new Map();
async generate(session: ExtendedDatabase, dt?: string) {
async generate(session: ExtendedDatabase) {
// ------------------------
// TransactionsFromDatabase
@ -40,32 +40,16 @@ export class ReportingWorkflow {
{
// Load balances from database
if (dt) {
balances = await totalBalancesAtDate(session, dt);
} else {
balances = await totalBalances(session);
}
this.reportsForStage.set(ReportingStage.TransactionsFromDatabase, [new TrialBalanceReport(balances)]);
// Load transactions from database
let joinedTransactionPostings: JoinedTransactionPosting[];
if (dt) {
joinedTransactionPostings = 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
WHERE DATE(dt) <= DATE($1)
ORDER BY dt, transaction_id, postings.id`,
[dt]
);
} else {
joinedTransactionPostings = await session.select(
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);
}
@ -75,22 +59,12 @@ export class ReportingWorkflow {
{
// Get unreconciled statement lines
let unreconciledStatementLines: StatementLine[];
if (dt) {
unreconciledStatementLines = await session.select(
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 AND DATE(dt) <= DATE($1)`,
[dt]
);
} else {
unreconciledStatementLines = await session.select(
`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) {

View File

@ -1,9 +1,9 @@
{
"compilerOptions": {
"target": "ESNext",
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */