Compare commits

...

6 Commits

12 changed files with 329 additions and 72 deletions

View File

@ -446,33 +446,35 @@ function reporting.CalculateIncomeTax.execute(args, context, kinds_for_account,
end
-- Charge monthly tax
table.insert(transactions, {
id = nil,
dt = libdrcr.date_to_dt(libdrcr.format_date(this_year, month, this_day)),
description = 'Estimated income tax',
postings = {
{
id = nil,
transaction_id = nil,
description = nil,
account = INCOME_TAX,
quantity = this_month_tax,
commodity = context.reporting_commodity,
quantity_ascost = this_month_tax,
if this_month_tax ~= 0 then
table.insert(transactions, {
id = nil,
dt = libdrcr.date_to_dt(libdrcr.format_date(this_year, month, this_day)),
description = 'Estimated income tax',
postings = {
{
id = nil,
transaction_id = nil,
description = nil,
account = INCOME_TAX,
quantity = this_month_tax,
commodity = context.reporting_commodity,
quantity_ascost = this_month_tax,
},
{
id = nil,
transaction_id = nil,
description = nil,
account = INCOME_TAX_CONTROL,
quantity = -this_month_tax,
commodity = context.reporting_commodity,
quantity_ascost = -this_month_tax,
},
},
{
id = nil,
transaction_id = nil,
description = nil,
account = INCOME_TAX_CONTROL,
quantity = -this_month_tax,
commodity = context.reporting_commodity,
quantity_ascost = -this_month_tax,
},
},
})
})
end
end
else
elseif (tax_total - total_offset) ~= 0 then
-- Charge income tax expense in one transaction at EOFY
table.insert(transactions, {
id = nil,
@ -502,31 +504,33 @@ function reporting.CalculateIncomeTax.execute(args, context, kinds_for_account,
end
-- Mandatory study loan repayment
table.insert(transactions, {
id = nil,
dt = libdrcr.date_to_dt(context.eofy_date),
description = 'Mandatory study loan repayment payable',
postings = {
{
id = nil,
transaction_id = nil,
description = nil,
account = HELP,
quantity = study_loan_repayment,
commodity = context.reporting_commodity,
quantity_ascost = study_loan_repayment,
if study_loan_repayment ~= 0 then
table.insert(transactions, {
id = nil,
dt = libdrcr.date_to_dt(context.eofy_date),
description = 'Mandatory study loan repayment payable',
postings = {
{
id = nil,
transaction_id = nil,
description = nil,
account = HELP,
quantity = study_loan_repayment,
commodity = context.reporting_commodity,
quantity_ascost = study_loan_repayment,
},
{
id = nil,
transaction_id = nil,
description = nil,
account = INCOME_TAX_CONTROL,
quantity = -study_loan_repayment,
commodity = context.reporting_commodity,
quantity_ascost = -study_loan_repayment,
},
},
{
id = nil,
transaction_id = nil,
description = nil,
account = INCOME_TAX_CONTROL,
quantity = -study_loan_repayment,
commodity = context.reporting_commodity,
quantity_ascost = -study_loan_repayment,
},
},
})
})
end
-- Transfer PAYGW balances to Income Tax Control
for account, kinds in pairs(kinds_for_account) do

View File

@ -22,8 +22,13 @@
<div class="py-8">
<main>
<div class="mx-auto max-w-7xl px-6 lg:px-8">
<NoFileView v-if="!(db.filename !== null || route.name === 'new-file')" />
<RouterView v-if="db.filename !== null || route.name === 'new-file'" />
<template v-if="error === null">
<NoFileView v-if="!(db.filename !== null || route.name === 'new-file')" />
<RouterView v-if="db.filename !== null || route.name === 'new-file'" />
</template>
<template v-if="error !== null">
<CriticalErrorView />
</template>
</div>
</main>
</div>
@ -31,11 +36,16 @@
</template>
<script setup lang="ts">
import { onErrorCaptured } from 'vue';
import { useRoute } from 'vue-router';
import HeaderBar from './components/HeaderBar.vue';
import { db } from './db.js';
import { error, handleCriticalError } from './error.js';
import CriticalErrorView from './pages/CriticalErrorView.vue';
import NoFileView from './pages/NoFileView.vue';
const route = useRoute();
onErrorCaptured((err) => handleCriticalError(err));
</script>

View File

@ -47,14 +47,27 @@
<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>
<div class="rounded-md bg-red-50 mt-4 p-4 col-span-2" v-if="error !== null">
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon class="h-5 w-5 text-red-400" />
</div>
<div class="ml-3 flex-1">
<p class="text-sm text-red-700">{{ error }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import dayjs from 'dayjs';
import { XCircleIcon } from '@heroicons/vue/24/solid';
import { emit } from '@tauri-apps/api/event';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { ref } from 'vue';
import { DT_FORMAT, db, deserialiseAmount } from '../db.ts';
import { DeserialiseAmountError, DT_FORMAT, db, deserialiseAmount } from '../db.ts';
import ComboBoxAccounts from './ComboBoxAccounts.vue';
export interface EditingAssertion {
@ -68,19 +81,34 @@
const { assertion } = defineProps<{ assertion: EditingAssertion }>();
const error = ref(null as string | null);
async function saveAssertion() {
// Save changes to the assertion
const amount_abs = deserialiseAmount('' + assertion.amount_abs);
error.value = null;
let amount_abs;
try {
amount_abs = deserialiseAmount('' + assertion.amount_abs);
} catch (err) {
if (err instanceof DeserialiseAmountError) {
error.value = err.message;
return;
} else {
throw err;
}
}
const quantity = assertion.sign === 'dr' ? amount_abs.quantity : -amount_abs.quantity;
const session = await db.load();
if (assertion.id === null) {
await session.execute(
const result = 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]
);
assertion.id = result.lastInsertId!;
} else {
await session.execute(
`UPDATE balance_assertions
@ -90,6 +118,7 @@
);
}
await emit('balance-assertion-updated', {id: assertion.id});
await getCurrentWindow().close();
}
@ -107,6 +136,7 @@
[assertion.id]
);
await emit('balance-assertion-updated', {id: assertion.id});
await getCurrentWindow().close();
}
</script>

View File

@ -109,7 +109,7 @@
import { ref } from 'vue';
import { DT_FORMAT, Posting, Transaction, db, deserialiseAmount } from '../db.ts';
import { DT_FORMAT, DeserialiseAmountError, Posting, Transaction, db, deserialiseAmount } from '../db.ts';
import ComboBoxAccounts from './ComboBoxAccounts.vue';
interface EditingPosting {
@ -155,7 +155,17 @@
);
for (const posting of transaction.postings) {
const amount_abs = deserialiseAmount(posting.amount_abs);
let amount_abs;
try {
amount_abs = deserialiseAmount(posting.amount_abs);
} catch (err) {
if (err instanceof DeserialiseAmountError) {
error.value = err.message;
return;
} else {
throw err;
}
}
newTransaction.postings.push({
id: posting.id,
@ -310,6 +320,7 @@
await dbTransaction.commit();
await emit('transaction-updated', {id: transaction.id});
await getCurrentWindow().close();
}

View File

@ -25,6 +25,7 @@ import { reactive } from 'vue';
import { Balance } from './amounts.ts';
import { ExtendedDatabase } from './dbutil.ts';
import { CriticalError } from './error.ts';
export const DB_VERSION = 3; // Should match schema.sql
export const DT_FORMAT = 'YYYY-MM-DD HH:mm:ss.SSS000';
@ -53,8 +54,23 @@ export const db = reactive({
}
if (filename !== null) {
// Initialise cached data
const session = await this.load();
// Validate database version
let dbVersion: {value: string}[];
try {
dbVersion = await session.select("SELECT value FROM metadata WHERE key = 'version'");
} catch (err) {
throw new CriticalError('Unable to parse database (SQL error getting metadata.version)', err);
}
if (dbVersion.length === 0) {
throw new CriticalError('Unable to parse database (no metadata.version)');
}
if (dbVersion[0].value !== DB_VERSION.toString()) {
throw new CriticalError('Unsupported database version ' + dbVersion[0].value + ' (expected ' + DB_VERSION + ')');
}
// Initialise cached data
const metadataRaw: {key: string, value: string}[] = await session.select("SELECT * FROM metadata");
const metadataObject = Object.fromEntries(metadataRaw.map((x) => [x.key, x.value]));
this.metadata.version = parseInt(metadataObject.version);
@ -62,8 +78,6 @@ export const db = reactive({
this.metadata.reporting_commodity = metadataObject.reporting_commodity;
this.metadata.dps = parseInt(metadataObject.amount_dps);
}
// TODO: Validate database version
},
load: async function(): Promise<ExtendedDatabase> {
@ -162,7 +176,8 @@ export function deserialiseAmount(amount: string): { quantity: number, commodity
// Default commodity
const quantity = Math.round(parseFloat(amount) * factor)
if (!Number.isSafeInteger(quantity)) { throw new Error('Quantity not representable by safe integer'); }
if (Number.isNaN(quantity)) { throw new DeserialiseAmountError('Invalid quantity: ' + amount); }
if (!Number.isSafeInteger(quantity)) { throw new DeserialiseAmountError('Quantity not representable by safe integer: ' + amount); }
return {
'quantity': quantity,
@ -175,7 +190,8 @@ export function deserialiseAmount(amount: string): { quantity: number, commodity
const quantityStr = amount.substring(0, amount.indexOf(' '));
const quantity = Math.round(parseFloat(quantityStr) * factor)
if (!Number.isSafeInteger(quantity)) { throw new Error('Quantity not representable by safe integer'); }
if (Number.isNaN(quantity)) { throw new DeserialiseAmountError('Invalid quantity: ' + amount); }
if (!Number.isSafeInteger(quantity)) { throw new DeserialiseAmountError('Quantity not representable by safe integer: ' + amount); }
const commodity = amount.substring(amount.indexOf(' ') + 1);
@ -185,6 +201,8 @@ export function deserialiseAmount(amount: string): { quantity: number, commodity
};
}
export class DeserialiseAmountError extends Error {}
// Type definitions
export class Transaction {

39
src/error.ts Normal file
View File

@ -0,0 +1,39 @@
/*
DrCr: Web-based double-entry bookkeeping framework
Copyright (C) 2022-2025 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 { ref } from 'vue';
// Global error state
export const error = ref(null as CriticalError | null);
export class CriticalError extends Error {
public error?: any;
constructor(message: string, error?: any) {
super(message);
this.error = error;
}
}
export function handleCriticalError(err: any) {
if (err instanceof CriticalError) {
error.value = err;
} else {
error.value = new CriticalError('An unexpected error occurred', err);
}
}

View File

@ -25,6 +25,7 @@ import { createRouter, createWebHistory } from 'vue-router';
import App from './App.vue';
import { db } from './db.ts';
import { handleCriticalError } from './error.ts';
async function initApp() {
// Init router
@ -61,7 +62,11 @@ async function initApp() {
// Init state
const dbFilename: string = await invoke('get_open_filename');
if (dbFilename !== null) {
await db.init(dbFilename); // Ensure all metadata cached before loading Vue
try {
await db.init(dbFilename); // Ensure all metadata cached before loading Vue
} catch (err) {
handleCriticalError(err);
}
}
// Create Vue app

View File

@ -66,7 +66,8 @@
import { CheckIcon, PencilIcon, XMarkIcon } from '@heroicons/vue/24/outline';
import { PlusIcon } from '@heroicons/vue/16/solid';
import { invoke } from '@tauri-apps/api/core';
import { ref } from 'vue';
import { UnlistenFn, listen } from '@tauri-apps/api/event';
import { onUnmounted, ref } from 'vue';
import { db } from '../db.ts';
import { pp } from '../display.ts';
@ -97,4 +98,17 @@
}
load();
// Refresh balance assertions list when assertion updated
let unlistenAssertionUpdated: UnlistenFn | null = null;
(async () => {
// Cannot await at top level without <Suspense> therefore do this in an async function
unlistenAssertionUpdated = await listen('balance-assertion-updated', async (_event) => { await load(); });
})();
onUnmounted(() => {
if (unlistenAssertionUpdated !== null) {
unlistenAssertionUpdated();
}
});
</script>

View File

@ -0,0 +1,39 @@
<!--
DrCr: Web-based double-entry bookkeeping framework
Copyright (C) 2022-2025 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="rounded-md bg-red-50 mt-4 p-4 col-span-2">
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon class="h-5 w-5 text-red-400" />
</div>
<div class="ml-3 flex-1">
<p class="text-sm text-red-700">{{ error.message }}</p>
<p class="text-sm text-red-700 mt-1" v-if="error.error">{{ error.error }}</p>
</div>
</div>
</div>
</template>
<script setup type="ts">
import { XCircleIcon } from '@heroicons/vue/24/solid';
import { error } from '../error.ts';
console.error(error.value);
</script>

View File

@ -60,14 +60,28 @@
<button class="btn-secondary text-red-600 ring-red-500" @click="deleteAdjustment" v-if="adjustment.id !== null">Delete</button>
<button class="btn-primary" @click="saveAdjustment">Save</button>
</div>
<div class="rounded-md bg-red-50 mt-4 p-4 col-span-2" v-if="error !== null">
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon class="h-5 w-5 text-red-400" />
</div>
<div class="ml-3 flex-1">
<p class="text-sm text-red-700">{{ error }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import dayjs from 'dayjs';
import { XCircleIcon } from '@heroicons/vue/24/solid';
import { emit } from '@tauri-apps/api/event';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { ref } from 'vue';
import ComboBoxAccounts from '../../components/ComboBoxAccounts.vue';
import { DT_FORMAT, db, deserialiseAmount } from '../../db.ts';
import { DT_FORMAT, DeserialiseAmountError, db, deserialiseAmount } from '../../db.ts';
export interface EditingCGTAdjustment {
id: number | null,
@ -82,10 +96,36 @@
const { adjustment } = defineProps<{ adjustment: EditingCGTAdjustment }>();
const error = ref(null as string | null);
async function saveAdjustment() {
// Save changes to the CGT adjustment
const asset = deserialiseAmount('' + adjustment.asset);
const cost_adjustment_abs = deserialiseAmount('' + adjustment.cost_adjustment_abs);
error.value = null;
let asset;
try {
asset = deserialiseAmount('' + adjustment.asset);
} catch (err) {
if (err instanceof DeserialiseAmountError) {
error.value = err.message;
return;
} else {
throw err;
}
}
let cost_adjustment_abs;
try {
cost_adjustment_abs = deserialiseAmount('' + adjustment.cost_adjustment_abs);
} catch (err) {
if (err instanceof DeserialiseAmountError) {
error.value = err.message;
return;
} else {
throw err;
}
}
const cost_adjustment = adjustment.sign === 'dr' ? cost_adjustment_abs.quantity : -cost_adjustment_abs.quantity;
const session = await db.load();
@ -105,6 +145,7 @@
);
}
await emit('cgt-adjustment-updated');
await getCurrentWindow().close();
}
@ -122,6 +163,7 @@
[adjustment.id]
);
await emit('cgt-adjustment-updated');
await getCurrentWindow().close();
}
</script>

View File

@ -70,7 +70,8 @@
import dayjs from 'dayjs';
import { PencilIcon } from '@heroicons/vue/24/outline';
import { PlusIcon } from '@heroicons/vue/16/solid';
import { ref } from 'vue';
import { UnlistenFn, listen } from '@tauri-apps/api/event';
import { onUnmounted, ref } from 'vue';
import { CGTAdjustment, cgtAssetCommodityName } from './cgt.ts';
import { asCost } from '../../amounts.ts';
@ -90,4 +91,17 @@
}
load();
// Refresh CGT adjustments list when CGT adjustment updated
let unlistenAdjustmentUpdated: UnlistenFn | null = null;
(async () => {
// Cannot await at top level without <Suspense> therefore do this in an async function
unlistenAdjustmentUpdated = await listen('cgt-adjustment-updated', async (_event) => { await load(); });
})();
onUnmounted(() => {
if (unlistenAdjustmentUpdated !== null) {
unlistenAdjustmentUpdated();
}
});
</script>

View File

@ -70,18 +70,32 @@
<div class="flex justify-end mt-4 space-x-2">
<button class="btn-primary" @click="saveAdjustment">Save</button>
</div>
<div class="rounded-md bg-red-50 mt-4 p-4 col-span-2" v-if="error !== null">
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon class="h-5 w-5 text-red-400" />
</div>
<div class="ml-3 flex-1">
<p class="text-sm text-red-700">{{ error }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import dayjs from 'dayjs';
import { XCircleIcon } from '@heroicons/vue/24/solid';
import { InformationCircleIcon } from '@heroicons/vue/20/solid';
import { emit } from '@tauri-apps/api/event';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { ref } from 'vue';
import { CGTAsset } from './cgt.ts';
import ComboBoxAccounts from '../../components/ComboBoxAccounts.vue';
import { DT_FORMAT, JoinedTransactionPosting, db, deserialiseAmount } from '../../db.ts';
import { DT_FORMAT, DeserialiseAmountError, JoinedTransactionPosting, db, deserialiseAmount } from '../../db.ts';
import { ppWithCommodity } from '../../display.ts';
import { CriticalError } from '../../error.ts';
const account = ref('');
const commodity = ref('');
@ -90,10 +104,25 @@
const cost_adjustment_abs = ref(null! as number);
const sign = ref('dr');
const error = ref(null as string | null);
async function saveAdjustment() {
// TODO: Preview mode?
const totalAdjustmentAbs = deserialiseAmount('' + cost_adjustment_abs.value);
error.value = null;
let totalAdjustmentAbs;
try {
totalAdjustmentAbs = deserialiseAmount('' + cost_adjustment_abs.value);
} catch (err) {
if (err instanceof DeserialiseAmountError) {
error.value = err.message;
return;
} else {
throw err;
}
}
const totalAdjustment = sign.value === 'dr' ? totalAdjustmentAbs.quantity : -totalAdjustmentAbs.quantity;
// Get all postings to the CGT asset account
@ -149,7 +178,8 @@
}
if (assets.length === 0) {
throw new Error('No matching CGT assets');
error.value = 'No matching CGT assets';
return;
}
// Distribute total adjustment across matching assets
@ -192,7 +222,7 @@
// Sanity check
const totalRoundedAdjustment = cgtAdjustments.reduce((acc, adj) => acc + adj, 0);
if (totalRoundedAdjustment !== totalAdjustment) {
throw new Error('Rounding unexpectedly changed total CGT adjustment amount');
throw new CriticalError('Rounding unexpectedly changed total CGT adjustment amount');
}
// Add adjustments to database atomically
@ -209,6 +239,7 @@
}
await dbTransaction.commit();
await emit('cgt-adjustment-updated');
await getCurrentWindow().close();
}
</script>