Fixed cascading history item deletion, added login disclaimer.
This commit is contained in:
parent
71e99d1c94
commit
aba86b6979
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`)">
|
||||
|
|
|
|||
Loading…
Reference in New Issue