Fixed cascading history item deletion, added login disclaimer.
Build and Deploy Web App / build-and-deploy (push) Successful in 19s Details
Build and Deploy API / build-and-deploy (push) Successful in 1m18s Details

This commit is contained in:
andrewlalis 2025-08-31 18:20:32 -04:00
parent 71e99d1c94
commit aba86b6979
9 changed files with 138 additions and 97 deletions

View File

@ -4,6 +4,7 @@ import std.datetime;
import d2sqlite3;
import handy_http_primitives : Optional;
import slf4d;
import account.data;
import account.model;
@ -84,16 +85,6 @@ SQL",
void deleteById(ulong id) {
doTransaction(db, () {
// Delete associated history.
util.sqlite.update(
db,
"DELETE FROM history_item
WHERE id IN (
SELECT history_item_id FROM account_history_item
WHERE account_id = ?
)",
id
);
// Delete all associated transactions.
util.sqlite.update(
db,
@ -101,8 +92,19 @@ SQL",
"(SELECT transaction_id FROM account_journal_entry WHERE account_id = ?)",
id
);
// Finally delete the account itself (and all cascaded entities, like journal entries).
// Delete the account itself (and all cascaded entities, like journal entries).
util.sqlite.update(db, "DELETE FROM account WHERE id = ?", id);
/* Delete all orphaned history entries for journal entries referencing deleted transactions.
This is needed because even though the above `DELETE` cascades to
delete all history items for this account, there are some history
items linked to other accounts which aren't deleted automatically.
*/
util.sqlite.update(
db,
"DELETE FROM account_history_item WHERE type LIKE 'JOURNAL_ENTRY' AND id NOT IN (
SELECT item_id FROM history_item_linked_journal_entry
)"
);
});
}
@ -159,13 +161,12 @@ SQL",
Page!AccountHistoryItemResponse getHistory(ulong accountId, in PageRequest pagination) {
ulong count = util.sqlite.count(
db,
"SELECT COUNT(history_item_id) FROM account_history_item WHERE account_id = ?",
"SELECT COUNT(id) FROM account_history_item WHERE account_id = ?",
accountId
);
const baseQuery = "SELECT hi.id, hi.timestamp, hi.type " ~
"FROM account_history_item ahi " ~
"LEFT JOIN history_item hi ON ahi.history_item_id = hi.id " ~
"WHERE ahi.account_id = ?";
const baseQuery = "SELECT id, timestamp, type " ~
"FROM account_history_item " ~
"WHERE account_id = ?";
string query = baseQuery ~ " " ~ pagination.toSql();
// First fetch the basic information about each item.
BaseHistoryItem[] items = util.sqlite.findAll(
@ -310,7 +311,7 @@ class SqliteAccountJournalEntryRepository : AccountJournalEntryRepository {
void deleteById(ulong id) {
util.sqlite.update(
db,
"DELETE FROM history_item WHERE id IN " ~
"DELETE FROM account_history_item WHERE id IN " ~
"(SELECT item_id FROM history_item_linked_journal_entry WHERE journal_entry_id = ?)",
id
);
@ -320,7 +321,7 @@ class SqliteAccountJournalEntryRepository : AccountJournalEntryRepository {
void deleteByAccountIdAndTransactionId(ulong accountId, ulong transactionId) {
util.sqlite.update(
db,
"DELETE FROM history_item WHERE id IN " ~
"DELETE FROM account_history_item WHERE id IN " ~
"(SELECT j.item_id FROM history_item_linked_journal_entry j " ~
" LEFT JOIN account_journal_entry je ON je.id = j.journal_entry_id " ~
"WHERE je.account_id = ? AND je.transaction_id = ?)",
@ -443,7 +444,7 @@ class SqliteAccountValueRecordRepository : AccountValueRecordRepository {
// First delete any associated history items:
util.sqlite.update(
db,
"DELETE FROM history_item WHERE id IN " ~
"DELETE FROM account_history_item WHERE id IN " ~
"(SELECT item_id FROM history_item_linked_value_record WHERE value_record_id = ?)",
id
);
@ -505,16 +506,10 @@ private ulong insertNewAccountHistoryItem(
) {
util.sqlite.update(
db,
"INSERT INTO history_item (timestamp, type) VALUES (?, ?)",
"INSERT INTO account_history_item (account_id, timestamp, type) VALUES (?, ?, ?)",
accountId,
timestamp.toISOExtString(),
type
);
ulong historyItemId = db.lastInsertRowid();
util.sqlite.update(
db,
"INSERT INTO account_history_item (account_id, history_item_id) VALUES (?, ?)",
accountId,
historyItemId
);
return historyItemId;
return db.lastInsertRowid();
}

View File

@ -403,6 +403,13 @@ class SqliteTransactionRepository : TransactionRepository {
void deleteById(ulong id) {
util.sqlite.deleteById(db, TABLE_NAME, id);
// Delete all history items for journal entries that have been removed by the deletion.
util.sqlite.update(
db,
"DELETE FROM account_history_item WHERE type LIKE 'JOURNAL_ENTRY' AND id NOT IN (
SELECT item_id FROM history_item_linked_journal_entry
)"
);
}
static Transaction parseTransaction(Row row) {

View File

@ -161,21 +161,13 @@ CREATE TABLE account_value_record_attachment (
-- History Entities
CREATE TABLE history_item (
id INTEGER PRIMARY KEY,
timestamp TEXT NOT NULL,
type TEXT NOT NULL
);
CREATE TABLE account_history_item (
id INTEGER PRIMARY KEY,
account_id INTEGER NOT NULL,
history_item_id INTEGER NOT NULL,
PRIMARY KEY (account_id, history_item_id),
timestamp TEXT NOT NULL,
type TEXT NOT NULL,
CONSTRAINT fk_account_history_item_account
FOREIGN KEY (account_id) REFERENCES account(id)
ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT fk_account_history_item_history_item
FOREIGN KEY (history_item_id) REFERENCES history_item(id)
ON UPDATE CASCADE ON DELETE CASCADE
);
@ -184,7 +176,7 @@ CREATE TABLE history_item_text (
item_id INTEGER PRIMARY KEY,
content TEXT NOT NULL,
CONSTRAINT fk_history_item_text_item
FOREIGN KEY (item_id) REFERENCES history_item(id)
FOREIGN KEY (item_id) REFERENCES account_history_item(id)
ON UPDATE CASCADE ON DELETE CASCADE
);
@ -196,7 +188,7 @@ CREATE TABLE history_item_property_change (
new_value TEXT,
PRIMARY KEY (item_id, property_name),
CONSTRAINT fk_history_item_property_change_item
FOREIGN KEY (item_id) REFERENCES history_item(id)
FOREIGN KEY (item_id) REFERENCES account_history_item(id)
ON UPDATE CASCADE ON DELETE CASCADE
);
@ -206,7 +198,7 @@ CREATE TABLE history_item_linked_value_record (
value_record_id INTEGER NOT NULL,
PRIMARY KEY (item_id, value_record_id),
CONSTRAINT fk_history_item_linked_value_record_item
FOREIGN KEY (item_id) REFERENCES history_item(id)
FOREIGN KEY (item_id) REFERENCES account_history_item(id)
ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT fk_history_item_linked_value_record_value_record
FOREIGN KEY (value_record_id) REFERENCES account_value_record(id)
@ -219,7 +211,7 @@ CREATE TABLE history_item_linked_journal_entry (
journal_entry_id INTEGER NOT NULL,
PRIMARY KEY (item_id, journal_entry_id),
CONSTRAINT fk_history_item_linked_journal_entry_item
FOREIGN KEY (item_id) REFERENCES history_item(id)
FOREIGN KEY (item_id) REFERENCES account_history_item(id)
ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT fk_history_item_linked_journal_entry_journal_entry
FOREIGN KEY (journal_entry_id) REFERENCES account_journal_entry(id)

View File

@ -4,13 +4,14 @@ import { formatMoney } from '@/api/data';
import AppButton from '../AppButton.vue';
import { showConfirm } from '@/util/alert';
import { useRoute } from 'vue-router';
import ButtonBar from '../ButtonBar.vue';
const route = useRoute()
const props = defineProps<{ item: AccountHistoryValueRecordItem, accountId: number }>()
async function deleteValueRecord(id: number) {
const confirm = await showConfirm('Are you sure you want to delete this value record?')
const confirm = await showConfirm("Are you sure you want to delete this value record? This may affect how your account's balance is calculated.")
if (!confirm) return
const api = new AccountApiClient(route)
try {
@ -25,8 +26,10 @@ async function deleteValueRecord(id: number) {
<div>
Value recorded for this account at {{ formatMoney(item.value, item.currency) }}
</div>
<div>
<AppButton button-type="button" icon="trash" @click="deleteValueRecord(item.valueRecordId)" />
</div>
<ButtonBar>
<AppButton type="button" icon="trash" size="sm" @click="deleteValueRecord(item.valueRecordId)">
Delete this record
</AppButton>
</ButtonBar>
</div>
</template>

View File

@ -84,7 +84,7 @@ async function addValueRecord() {
<th>Description</th>
<td>{{ account.description }}</td>
</tr>
<tr v-if="account.currentBalance">
<tr v-if="account.currentBalance !== null">
<th>Current Balance</th>
<td>{{ formatMoney(account.currentBalance, account.currency) }}</td>
</tr>

View File

@ -75,24 +75,43 @@ function generateSampleData() {
</script>
<template>
<div class="app-login-panel">
<h1 style="text-align: center;">Login to <span style="font-family: 'PlaywriteNL';">Finnow</span></h1>
<h1 style="text-align: center; margin-bottom: 0.5rem; font-family: 'PlaywriteNL';">Finnow</h1>
<p style="text-align: center; font-weight: 600; margin-top: 0;">
<em>Personal finance for the modern era.</em>
</p>
<AppForm @submit="doLogin()">
<FormGroup>
<FormControl label="Username">
<input type="text" v-model="username" :disabled="disableForm" />
<FormControl label="Username" class="login-control">
<input class="login-input" type="text" v-model="username" :disabled="disableForm" />
</FormControl>
<FormControl label="Password">
<input id="password-input" type="password" v-model="password" :disabled="disableForm" />
<FormControl label="Password" class="login-control">
<input class="login-input" id="password-input" type="password" v-model="password" :disabled="disableForm" />
</FormControl>
</FormGroup>
<div style="display: flex; margin-left: 1rem; margin-right: 1rem;">
<AppButton type="submit" :disabled="disableForm || !isDataValid()" style="flex-grow: 1;">Login
</AppButton>
<AppButton button-type="button" button-style="secondary" :disabled="disableForm || !isDataValid()"
@click="doRegister()">Register</AppButton>
<AppButton type="button" theme="secondary" :disabled="disableForm || !isDataValid()" @click="doRegister()">
Register</AppButton>
</div>
<div v-if="isDev">
<AppButton button-type="button" @click="generateSampleData()">Generate Sample Data</AppButton>
<AppButton type="button" @click="generateSampleData()">Generate Sample Data</AppButton>
</div>
<!-- Disclaimer note that this project is still a work-in-progress! -->
<div style="font-size: smaller;">
<p>
<strong>Note:</strong> Finnow is still under development, and may be
prone to bugs or unexpected data loss. Proceed to register and use
this service at your own risk.
</p>
<p>
Data is stored securely in protected cloud storage, but may be
accessed by a system administrator in case of errors or debugging.
Please <strong>DO NOT</strong> store any sensitive financial
credentials that you aren't okay with losing or potentially being
leaked. Finnow is not responsible for any lost or compromised data.
</p>
</div>
</AppForm>
</div>
@ -102,4 +121,14 @@ function generateSampleData() {
max-width: 40ch;
margin: 0 auto;
}
.login-input {
max-width: 200px;
margin-top: 0.25rem;
}
.login-control {
flex-grow: 0;
margin: 0.5rem auto;
}
</style>

View File

@ -60,9 +60,13 @@ async function doChangePassword() {
<p>
You are logged in as <code style="font-size: 14px;">{{ authStore.state?.username }}</code>.
</p>
<p>
There's not really that much to do with your user account specifically,
all important settings are profile-specific.
</p>
<div style="text-align: right;">
<AppButton @click="showChangePasswordModal()">Change Password</AppButton>
<AppButton @click="doDeleteUser()">Delete My User Account</AppButton>
<AppButton icon="trash" theme="secondary" @click="doDeleteUser()">Delete My User</AppButton>
</div>
<!-- Modal for changing the user's password. -->
@ -70,6 +74,9 @@ async function doChangePassword() {
<template v-slot:default>
<AppForm>
<h2>Change Password</h2>
<p>
Change the password used to log into your user account.
</p>
<FormGroup>
<FormControl label="Current Password">
<input type="password" v-model="currentPassword" minlength="8" />

View File

@ -21,7 +21,7 @@ onMounted(async () => {
<template>
<HomeModule title="Accounts">
<template v-slot:default>
<table class="app-table">
<table class="app-table" v-if="accounts.length > 0">
<thead>
<tr>
<th>Name</th>
@ -47,6 +47,9 @@ onMounted(async () => {
</tr>
</tbody>
</table>
<p v-if="accounts.length === 0">
You haven't added any accounts. Add one to start tracking your finances.
</p>
</template>
<template v-slot:actions>
<AppButton icon="plus" @click="router.push(`/profiles/${getSelectedProfile(route)}/add-account`)">Add Account

View File

@ -29,45 +29,50 @@ async function fetchPage(pageRequest: PageRequest) {
<template>
<HomeModule title="Transactions">
<template v-slot:default>
<table class="app-table">
<thead>
<tr>
<th>Date</th>
<th>Amount</th>
<th>Currency</th>
<th>Description</th>
<th>Credited Account</th>
<th>Debited Account</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="tx in transactions.items" :key="tx.id">
<td>
{{ new Date(tx.timestamp).toLocaleDateString() }}
</td>
<td style="text-align: right;">{{ formatMoney(tx.amount, tx.currency) }}</td>
<td>{{ tx.currency.code }}</td>
<td>{{ tx.description }}</td>
<td>
<RouterLink v-if="tx.creditedAccount"
:to="`/profiles/${getSelectedProfile(route)}/accounts/${tx.creditedAccount.id}`">
{{ tx.creditedAccount?.name }}
</RouterLink>
</td>
<td>
<RouterLink v-if="tx.debitedAccount"
:to="`/profiles/${getSelectedProfile(route)}/accounts/${tx.debitedAccount.id}`">
{{ tx.debitedAccount?.name }}
</RouterLink>
</td>
<td>
<RouterLink :to="`/profiles/${getSelectedProfile(route)}/transactions/${tx.id}`">View</RouterLink>
</td>
</tr>
</tbody>
</table>
<PaginationControls :page="transactions" @update="pr => fetchPage(pr)"></PaginationControls>
<div v-if="transactions.totalElements > 0">
<table class="app-table">
<thead>
<tr>
<th>Date</th>
<th>Amount</th>
<th>Currency</th>
<th>Description</th>
<th>Credited Account</th>
<th>Debited Account</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="tx in transactions.items" :key="tx.id">
<td>
{{ new Date(tx.timestamp).toLocaleDateString() }}
</td>
<td style="text-align: right;">{{ formatMoney(tx.amount, tx.currency) }}</td>
<td>{{ tx.currency.code }}</td>
<td>{{ tx.description }}</td>
<td>
<RouterLink v-if="tx.creditedAccount"
:to="`/profiles/${getSelectedProfile(route)}/accounts/${tx.creditedAccount.id}`">
{{ tx.creditedAccount?.name }}
</RouterLink>
</td>
<td>
<RouterLink v-if="tx.debitedAccount"
:to="`/profiles/${getSelectedProfile(route)}/accounts/${tx.debitedAccount.id}`">
{{ tx.debitedAccount?.name }}
</RouterLink>
</td>
<td>
<RouterLink :to="`/profiles/${getSelectedProfile(route)}/transactions/${tx.id}`">View</RouterLink>
</td>
</tr>
</tbody>
</table>
<PaginationControls :page="transactions" @update="pr => fetchPage(pr)"></PaginationControls>
</div>
<p v-if="transactions.totalElements === 0">
You haven't added any transactions.
</p>
</template>
<template v-slot:actions>
<AppButton icon="plus" @click="router.push(`/profiles/${getSelectedProfile(route)}/add-transaction`)">