Add history stuff.
Build and Deploy Web App / build-and-deploy (push) Successful in 16s Details
Build and Deploy API / build-and-deploy (push) Successful in 1m17s Details

This commit is contained in:
andrewlalis 2025-08-30 20:35:52 -04:00
parent 34eb0d11fd
commit fc85170492
18 changed files with 494 additions and 263 deletions

View File

@ -13,6 +13,7 @@ import profile.service;
import profile.data;
import account.model;
import account.service;
import account.dto;
import util.money;
import util.pagination;
import util.data;
@ -20,36 +21,6 @@ import account.data;
import attachment.data;
import attachment.dto;
/// The data the API provides for an Account entity.
struct AccountResponse {
import asdf : serdeTransformOut;
ulong id;
string createdAt;
bool archived;
string type;
string numberSuffix;
string name;
Currency currency;
string description;
@serdeTransformOut!serializeOptional
Optional!long currentBalance;
static AccountResponse of(in Account account, Optional!long currentBalance) {
AccountResponse r;
r.id = account.id;
r.createdAt = account.createdAt.toISOExtString();
r.archived = account.archived;
r.type = account.type.id;
r.numberSuffix = account.numberSuffix;
r.name = account.name;
r.currency = account.currency;
r.description = account.description;
r.currentBalance = currentBalance;
return r;
}
}
void handleGetAccounts(ref ServerHttpRequest request, ref ServerHttpResponse response) {
import std.algorithm;
import std.array;
@ -67,15 +38,6 @@ void handleGetAccount(ref ServerHttpRequest request, ref ServerHttpResponse resp
writeJsonBody(response, AccountResponse.of(account, getBalance(ds, account.id)));
}
// The data provided by a user to create a new account.
struct AccountCreationPayload {
string type;
string numberSuffix;
string name;
string currency;
string description;
}
void handleCreateAccount(ref ServerHttpRequest request, ref ServerHttpResponse response) {
auto ds = getProfileDataSource(request);
AccountCreationPayload payload = readJsonBodyAs!AccountCreationPayload(request);
@ -117,34 +79,46 @@ void handleDeleteAccount(ref ServerHttpRequest request, ref ServerHttpResponse r
ds.getAccountRepository().deleteById(accountId);
}
const PageRequest VALUE_RECORD_DEFAULT_PAGE_REQUEST = PageRequest(1, 10, [Sort("timestamp", SortDir.DESC)]);
void handleGetAccountHistory(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ulong accountId = request.getPathParamOrThrow!ulong("accountId");
PageRequest pagination = PageRequest.parse(request, PageRequest(1, 10, [Sort("timestamp", SortDir.DESC)]));
auto ds = getProfileDataSource(request);
AccountRepository accountRepo = ds.getAccountRepository();
auto page = accountRepo.getHistory(accountId, pagination);
writeHistoryResponse(response, page);
}
struct AccountValueRecordResponse {
ulong id;
string timestamp;
ulong accountId;
string type;
long value;
Currency currency;
AttachmentResponse[] attachments;
static AccountValueRecordResponse of(in AccountValueRecord vr, AttachmentRepository attachmentRepo) {
import std.algorithm : map;
import std.array : array;
return AccountValueRecordResponse(
vr.id,
vr.timestamp.toISOExtString(),
vr.accountId,
vr.type,
vr.value,
vr.currency,
attachmentRepo.findAllByValueRecordId(vr.id)
.map!(AttachmentResponse.of)
.array
);
private string serializeAccountHistoryItem(in AccountHistoryItemResponse i) {
import asdf : serializeToJson;
if (i.type == AccountHistoryItemType.JournalEntry) {
return serializeToJson(cast(AccountHistoryJournalEntryItemResponse) i);
} else if (i.type == AccountHistoryItemType.ValueRecord) {
return serializeToJson(cast(AccountHistoryValueRecordItemResponse) i);
} else {
return serializeToJson(i);
}
}
private void writeHistoryResponse(ref ServerHttpResponse response, in Page!AccountHistoryItemResponse page) {
// Manual serialization of response due to inheritance structure.
import asdf;
import std.json;
string initialJsonObj = serializeToJson(page);
JSONValue obj = parseJSON(initialJsonObj);
obj.object["items"] = JSONValue.emptyArray;
foreach (item; page.items) {
string initialItemJson = serializeAccountHistoryItem(item);
obj.object["items"].array ~= parseJSON(initialItemJson);
}
string jsonStr = obj.toJSON();
response.writeBodyString(jsonStr, ContentTypes.APPLICATION_JSON);
}
// Value records:
const PageRequest VALUE_RECORD_DEFAULT_PAGE_REQUEST = PageRequest(1, 10, [Sort("timestamp", SortDir.DESC)]);
void handleGetValueRecords(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ulong accountId = request.getPathParamAs!ulong("accountId");
auto ds = getProfileDataSource(request);
@ -165,12 +139,6 @@ void handleGetValueRecord(ref ServerHttpRequest request, ref ServerHttpResponse
writeJsonBody(response, AccountValueRecordResponse.of(record, attachmentRepo));
}
struct ValueRecordCreationPayload {
string timestamp;
string type;
long value;
}
void handleCreateValueRecord(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ulong accountId = request.getPathParamAs!ulong("accountId");
ProfileDataSource ds = getProfileDataSource(request);

View File

@ -3,9 +3,9 @@ module account.data;
import handy_http_primitives : Optional;
import account.model;
import account.dto;
import util.money;
import util.pagination;
import history.model;
import std.datetime : SysTime;
@ -19,7 +19,8 @@ interface AccountRepository {
Account[] findAll();
AccountCreditCardProperties getCreditCardProperties(ulong id);
void setCreditCardProperties(ulong id, in AccountCreditCardProperties props);
History getHistory(ulong id);
Page!AccountHistoryItemResponse getHistory(ulong accountId, in PageRequest pagination);
}
interface AccountJournalEntryRepository {
@ -34,7 +35,7 @@ interface AccountJournalEntryRepository {
);
void deleteById(ulong id);
void deleteByAccountIdAndTransactionId(ulong accountId, ulong transactionId);
AccountJournalEntry[] findAllBetween(SysTime startIncl, SysTime endIncl);
AccountJournalEntry[] findAllBetween(SysTime startIncl, SysTime endIncl, ulong accountId);
}
interface AccountValueRecordRepository {

View File

@ -7,7 +7,7 @@ import handy_http_primitives : Optional;
import account.data;
import account.model;
import history.model;
import account.dto;
import util.sqlite;
import util.money;
import util.pagination;
@ -87,9 +87,9 @@ SQL",
// Delete associated history.
util.sqlite.update(
db,
"DELETE FROM history
"DELETE FROM history_item
WHERE id IN (
SELECT history_id FROM account_history
SELECT history_item_id FROM account_history_item
WHERE account_id = ?
)",
id
@ -150,31 +150,94 @@ SQL",
}
}
History getHistory(ulong id) {
if (!exists(db, "SELECT id FROM account WHERE id = ?", id)) {
throw new Exception("Account doesn't exist.");
}
Optional!History history = findOne(
private static struct BaseHistoryItem {
ulong id;
string timestamp;
string type;
}
Page!AccountHistoryItemResponse getHistory(ulong accountId, in PageRequest pagination) {
ulong count = util.sqlite.count(
db,
q"SQL
SELECT * FROM history
LEFT JOIN account_history ah ON ah.history_id = history.id
WHERE ah.account_id = ?
SQL",
r => History(r.peek!ulong(0)),
id
"SELECT COUNT(history_item_id) FROM account_history_item WHERE account_id = ?",
accountId
);
if (!history.empty) {
return history.value;
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 = ?";
string query = baseQuery ~ " " ~ pagination.toSql();
// First fetch the basic information about each item.
BaseHistoryItem[] items = util.sqlite.findAll(
db,
query,
(row) {
return BaseHistoryItem(
row.peek!ulong(0),
row.peek!string(1),
row.peek!string(2)
);
},
accountId
);
// Then we'll do another query based on the type of history item.
// This is fine for paginated responses.
AccountHistoryItemResponse[] results;
foreach (BaseHistoryItem item; items) {
if (item.type == AccountHistoryItemType.ValueRecord) {
results ~= fetchValueRecordHistoryItem(item);
} else if (item.type == AccountHistoryItemType.JournalEntry) {
results ~= fetchJournalEntryHistoryItem(item);
} else {
AccountHistoryItemResponse base = new AccountHistoryItemResponse();
base.timestamp = item.timestamp;
base.type = item.type;
results ~= base;
}
}
// No history exists yet, so add it.
ulong historyId = doTransaction(db, () {
util.sqlite.update(db, "INSERT INTO history DEFAULT VALUES");
ulong historyId = db.lastInsertRowid();
util.sqlite.update(db, "INSERT INTO account_history (account_id, history_id) VALUES (?, ?)", id, historyId);
return historyId;
});
return History(historyId);
return Page!AccountHistoryItemResponse.of(results, pagination, count);
}
private AccountHistoryValueRecordItemResponse fetchValueRecordHistoryItem(in BaseHistoryItem item) {
return util.sqlite.findOne(
db,
"SELECT vr.id, vr.type, vr.value, vr.currency FROM history_item_linked_value_record h " ~
"LEFT JOIN account_value_record vr ON vr.id = h.value_record_id " ~
"WHERE h.item_id = ?",
(row) {
auto obj = new AccountHistoryValueRecordItemResponse();
obj.timestamp = item.timestamp;
obj.type = item.type;
obj.valueRecordId = row.peek!ulong(0);
obj.valueRecordType = row.peek!string(1);
obj.value = row.peek!long(2);
obj.currency = Currency.ofCode(row.peek!(string, PeekMode.slice)(3));
return obj;
},
item.id
).orElseThrow();
}
private AccountHistoryJournalEntryItemResponse fetchJournalEntryHistoryItem(in BaseHistoryItem item) {
return util.sqlite.findOne(
db,
"SELECT je.type, je.amount, je.currency, tx.id, tx.description FROM history_item_linked_journal_entry h " ~
"LEFT JOIN account_journal_entry je ON je.id = h.journal_entry_id " ~
"LEFT JOIN \"transaction\" tx ON tx.id = je.transaction_id " ~
"WHERE h.item_id = ?",
(row) {
auto obj = new AccountHistoryJournalEntryItemResponse();
obj.timestamp = item.timestamp;
obj.type = item.type;
obj.journalEntryType = row.peek!string(0);
obj.amount = row.peek!ulong(1);
obj.currency = Currency.ofCode(row.peek!(string, PeekMode.slice)(2));
obj.transactionId = row.peek!ulong(3);
obj.transactionDescription = row.peek!string(4);
return obj;
},
item.id
).orElseThrow();
}
static Account parseAccount(Row row) {
@ -231,15 +294,38 @@ class SqliteAccountJournalEntryRepository : AccountJournalEntryRepository {
type,
currency.code
);
ulong id = db.lastInsertRowid();
return findById(id).orElseThrow();
ulong journalEntryId = db.lastInsertRowid();
// Insert a history item that links to the journal entry.
ulong historyItemId = insertNewAccountHistoryItem(
db, timestamp, accountId, AccountHistoryItemType.JournalEntry);
util.sqlite.update(
db,
"INSERT INTO history_item_linked_journal_entry (item_id, journal_entry_id) VALUES (?, ?)",
historyItemId,
journalEntryId
);
return findById(journalEntryId).orElseThrow();
}
void deleteById(ulong id) {
util.sqlite.update(
db,
"DELETE FROM history_item WHERE id IN " ~
"(SELECT item_id FROM history_item_linked_journal_entry WHERE journal_entry_id = ?)",
id
);
util.sqlite.deleteById(db, "account_journal_entry", id);
}
void deleteByAccountIdAndTransactionId(ulong accountId, ulong transactionId) {
util.sqlite.update(
db,
"DELETE FROM 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 = ?)",
accountId, transactionId
);
util.sqlite.update(
db,
"DELETE FROM account_journal_entry WHERE account_id = ? AND transaction_id = ?",
@ -247,15 +333,15 @@ class SqliteAccountJournalEntryRepository : AccountJournalEntryRepository {
);
}
AccountJournalEntry[] findAllBetween(SysTime startIncl, SysTime endIncl) {
AccountJournalEntry[] findAllBetween(SysTime startIncl, SysTime endIncl, ulong accountId) {
const query = "SELECT * FROM account_journal_entry " ~
"WHERE timestamp >= ? AND timestamp <= ? " ~
"WHERE timestamp >= ? AND timestamp <= ? AND account_id = ? " ~
"ORDER BY timestamp ASC";
return util.sqlite.findAll(
db,
query,
&parseEntry,
startIncl.toISOExtString(), endIncl.toISOExtString()
startIncl.toISOExtString(), endIncl.toISOExtString(), accountId
);
}
@ -331,8 +417,17 @@ class SqliteAccountValueRecordRepository : AccountValueRecordRepository {
value,
currency.code
);
ulong id = db.lastInsertRowid();
return findById(accountId, id).orElseThrow();
ulong valueRecordId = db.lastInsertRowid();
// Insert a history item that links to this value record.
ulong historyItemId = insertNewAccountHistoryItem(
db, timestamp, accountId, AccountHistoryItemType.ValueRecord);
util.sqlite.update(
db,
"INSERT INTO history_item_linked_value_record (item_id, value_record_id) VALUES (?, ?)",
historyItemId,
valueRecordId
);
return findById(accountId, valueRecordId).orElseThrow();
}
void linkAttachment(ulong valueRecordId, ulong attachmentId) {
@ -345,6 +440,13 @@ class SqliteAccountValueRecordRepository : AccountValueRecordRepository {
}
void deleteById(ulong accountId, ulong id) {
// First delete any associated history items:
util.sqlite.update(
db,
"DELETE FROM history_item WHERE id IN " ~
"(SELECT item_id FROM history_item_linked_value_record WHERE value_record_id = ?)",
id
);
util.sqlite.update(
db,
"DELETE FROM account_value_record WHERE account_id = ? AND id = ?",
@ -394,3 +496,25 @@ class SqliteAccountValueRecordRepository : AccountValueRecordRepository {
);
}
}
private ulong insertNewAccountHistoryItem(
ref Database db,
SysTime timestamp,
ulong accountId,
AccountHistoryItemType type
) {
util.sqlite.update(
db,
"INSERT INTO history_item (timestamp, type) VALUES (?, ?)",
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;
}

View File

@ -0,0 +1,101 @@
module account.dto;
import handy_http_primitives : Optional;
import account.model;
import attachment.data;
import attachment.dto;
import util.money;
import util.data : serializeOptional;
/// The data the API provides for an Account entity.
struct AccountResponse {
import asdf : serdeTransformOut;
ulong id;
string createdAt;
bool archived;
string type;
string numberSuffix;
string name;
Currency currency;
string description;
@serdeTransformOut!serializeOptional
Optional!long currentBalance;
static AccountResponse of(in Account account, Optional!long currentBalance) {
AccountResponse r;
r.id = account.id;
r.createdAt = account.createdAt.toISOExtString();
r.archived = account.archived;
r.type = account.type.id;
r.numberSuffix = account.numberSuffix;
r.name = account.name;
r.currency = account.currency;
r.description = account.description;
r.currentBalance = currentBalance;
return r;
}
}
// The data provided by a user to create a new account.
struct AccountCreationPayload {
string type;
string numberSuffix;
string name;
string currency;
string description;
}
struct AccountValueRecordResponse {
ulong id;
string timestamp;
ulong accountId;
string type;
long value;
Currency currency;
AttachmentResponse[] attachments;
static AccountValueRecordResponse of(in AccountValueRecord vr, AttachmentRepository attachmentRepo) {
import std.algorithm : map;
import std.array : array;
return AccountValueRecordResponse(
vr.id,
vr.timestamp.toISOExtString(),
vr.accountId,
vr.type,
vr.value,
vr.currency,
attachmentRepo.findAllByValueRecordId(vr.id)
.map!(AttachmentResponse.of)
.array
);
}
}
struct ValueRecordCreationPayload {
string timestamp;
string type;
long value;
}
// Class-based inheritance structure for history item response format.
class AccountHistoryItemResponse {
string timestamp;
string type;
}
class AccountHistoryValueRecordItemResponse : AccountHistoryItemResponse {
ulong valueRecordId;
string valueRecordType;
long value;
Currency currency;
}
class AccountHistoryJournalEntryItemResponse : AccountHistoryItemResponse {
string journalEntryType;
ulong amount;
Currency currency;
ulong transactionId;
string transactionDescription;
}

View File

@ -70,3 +70,10 @@ struct AccountValueRecord {
long value;
Currency currency;
}
enum AccountHistoryItemType : string {
Text = "TEXT",
PropertyChange = "PROPERTY_CHANGE",
ValueRecord = "VALUE_RECORD",
JournalEntry = "JOURNAL_ENTRY"
}

View File

@ -84,7 +84,7 @@ private long deriveBalance(
}
long balance = valueRecord.value;
AccountJournalEntry[] journalEntries = journalEntryRepo.findAllBetween(startTimestamp, endTimestamp);
AccountJournalEntry[] journalEntries = journalEntryRepo.findAllBetween(startTimestamp, endTimestamp, account.id);
foreach (entry; journalEntries) {
long entryValue = entry.amount;
if (entry.type == AccountJournalEntryType.CREDIT) {

View File

@ -55,6 +55,7 @@ HttpRequestHandler mapApiHandlers(string webOrigin) {
a.map(HttpMethod.GET, ACCOUNT_PATH, &handleGetAccount);
a.map(HttpMethod.PUT, ACCOUNT_PATH, &handleUpdateAccount);
a.map(HttpMethod.DELETE, ACCOUNT_PATH, &handleDeleteAccount);
a.map(HttpMethod.GET, ACCOUNT_PATH ~ "/history", &handleGetAccountHistory);
a.map(HttpMethod.GET, ACCOUNT_PATH ~ "/value-records", &handleGetValueRecords);
a.map(HttpMethod.GET, ACCOUNT_PATH ~ "/value-records/:valueRecordId:ulong", &handleGetValueRecord);
a.map(HttpMethod.POST, ACCOUNT_PATH ~ "/value-records", &handleCreateValueRecord);

View File

@ -1,14 +0,0 @@
module history.data;
import std.datetime;
import handy_http_primitives : Optional;
import history.model;
interface HistoryRepository {
Optional!History findById(ulong id);
HistoryItem[] findItemsBefore(ulong historyId, SysTime timestamp, uint limit);
HistoryItemText getTextItem(ulong itemId);
void addTextItem(ulong historyId, SysTime timestamp, string text);
void deleteById(ulong id);
}

View File

@ -1,90 +0,0 @@
module history.data_impl_sqlite;
import std.datetime;
import handy_http_primitives : Optional;
import d2sqlite3;
import history.data;
import history.model;
import util.sqlite;
class SqliteHistoryRepository : HistoryRepository {
private Database db;
this(Database db) {
this.db = db;
}
Optional!History findById(ulong id) {
Statement stmt = db.prepare("SELECT * FROM history WHERE id = ?");
stmt.bind(1, id);
ResultRange result = stmt.execute();
if (result.empty) return Optional!History.empty;
return Optional!History.of(parseHistory(result.front));
}
HistoryItem[] findItemsBefore(ulong historyId, SysTime timestamp, uint limit) {
import std.conv;
import std.algorithm;
import std.array;
const query = q"SQL
SELECT * FROM history_item
WHERE history_id = ? AND timestamp <= ?
ORDER BY timestamp DESC
LIMIT
SQL";
Statement stmt = db.prepare(query ~ " " ~ to!string(limit));
stmt.bind(1, historyId);
stmt.bind(2, timestamp.toISOExtString());
ResultRange result = stmt.execute();
return result.map!(r => parseItem(r)).array;
}
HistoryItemText getTextItem(ulong itemId) {
Statement stmt = db.prepare("SELECT * FROM history_item_text WHERE item_id = ?");
stmt.bind(1, itemId);
ResultRange result = stmt.execute();
if (result.empty) throw new Exception("No history item exists.");
return parseTextItem(result.front);
}
void addTextItem(ulong historyId, SysTime timestamp, string text) {
ulong itemId = addItem(historyId, timestamp, HistoryItemType.TEXT);
update(
db,
"INSERT INTO history_item_text (item_id, content) VALUES (?, ?)",
itemId, text
);
}
void deleteById(ulong id) {
Statement stmt = db.prepare("DELETE FROM history WHERE id = ?");
stmt.bind(1, id);
stmt.execute();
}
static History parseHistory(Row row) {
return History(row.peek!ulong(0));
}
static HistoryItem parseItem(Row row) {
return HistoryItem(
row.peek!ulong(0),
row.peek!ulong(1),
parseISOTimestamp(row, 2),
getHistoryItemType(row.peek!(string, PeekMode.slice)(3))
);
}
static HistoryItemText parseTextItem(Row row) {
return HistoryItemText(row.peek!ulong(0), row.peek!string(1));
}
private ulong addItem(ulong historyId, SysTime timestamp, HistoryItemType type) {
update(
db,
"INSERT INTO history_item (history_id, timestamp, type) VALUES (?, ?, ?)",
historyId, timestamp, type
);
return db.lastInsertRowid();
}
}

View File

@ -1,46 +0,0 @@
module history.model;
import std.datetime.systime;
/**
* A history containing a series of items, which all usually pertain to a
* certain target entity.
*/
struct History {
immutable ulong id;
}
/**
* The type of history item. This can be used as a discriminator value to treat
* different history types separately.
*/
enum HistoryItemType : string {
TEXT = "TEXT"
}
HistoryItemType getHistoryItemType(string text) {
import std.traits;
static foreach (t; EnumMembers!HistoryItemType) {
if (text == t) return t;
}
throw new Exception("Unknown history item type: " ~ text);
}
/**
* A single item in a history. It has a UTC timestamp and a type. From the type,
* one can get more specific information.
*/
struct HistoryItem {
immutable ulong id;
immutable ulong historyId;
immutable SysTime timestamp;
immutable HistoryItemType type;
}
/**
* Additional data for history items with the TEXT type.
*/
struct HistoryItemText {
immutable ulong itemId;
immutable string content;
}

View File

@ -15,6 +15,14 @@ import handy_http_primitives : Optional;
* args = Arguments for the query.
* Returns: An optional result.
*/
Optional!T findOne(T, Args...)(Database db, string query, T delegate(Row) resultMapper, Args args) {
Statement stmt = db.prepare(query);
stmt.bindAll(args);
ResultRange result = stmt.execute();
if (result.empty) return Optional!T.empty;
return Optional!T.of(resultMapper(result.front));
}
/// Overload that accepts a function.
Optional!T findOne(T, Args...)(Database db, string query, T function(Row) resultMapper, Args args) {
Statement stmt = db.prepare(query);
stmt.bindAll(args);

View File

@ -163,7 +163,8 @@ CREATE TABLE account_value_record_attachment (
CREATE TABLE history_item (
id INTEGER PRIMARY KEY,
timestamp TEXT NOT NULL
timestamp TEXT NOT NULL,
type TEXT NOT NULL
);
CREATE TABLE account_history_item (
@ -178,7 +179,7 @@ CREATE TABLE account_history_item (
ON UPDATE CASCADE ON DELETE CASCADE
);
-- Zero or more plain text messages may be logged for any history item.
-- A plain text history item.
CREATE TABLE history_item_text (
item_id INTEGER PRIMARY KEY,
content TEXT NOT NULL,
@ -187,7 +188,7 @@ CREATE TABLE history_item_text (
ON UPDATE CASCADE ON DELETE CASCADE
);
-- Zero or more property changes may be logged for any history item.
-- Zero or more property changes may be logged for a history item.
CREATE TABLE history_item_property_change (
item_id INTEGER NOT NULL,
property_name TEXT NOT NULL,

View File

@ -94,6 +94,34 @@ export interface AccountValueRecordCreationPayload {
value: number
}
// History:
export enum AccountHistoryItemType {
TEXT = 'TEXT',
PROPERTY_CHANGE = 'PROPERTY_CHANGE',
VALUE_RECORD = 'VALUE_RECORD',
JOURNAL_ENTRY = 'JOURNAL_ENTRY',
}
export interface AccountHistoryItem {
timestamp: string
type: AccountHistoryItemType
}
export interface AccountHistoryValueRecordItem extends AccountHistoryItem {
valueRecordId: number
valueRecordType: AccountValueRecordType
value: number
currency: Currency
}
export interface AccountHistoryJournalEntryItem extends AccountHistoryItem {
journalEntryType: AccountJournalEntryType
amount: number
currency: Currency
transactionId: number
transactionDescription: string
}
export class AccountApiClient extends ApiClient {
readonly path: string
@ -122,6 +150,10 @@ export class AccountApiClient extends ApiClient {
return super.delete(this.path + '/' + id)
}
getHistory(id: number, pageRequest: PageRequest): Promise<Page<AccountHistoryItem>> {
return super.getJsonPage(`${this.path}/${id}/history`, pageRequest)
}
getValueRecords(accountId: number, pageRequest: PageRequest): Promise<Page<AccountValueRecord>> {
return super.getJsonPage(this.path + '/' + accountId + '/value-records', pageRequest)
}

View File

@ -0,0 +1,76 @@
<script setup lang="ts">
import { AccountApiClient, AccountHistoryItemType, type AccountHistoryItem, type AccountHistoryJournalEntryItem, type AccountHistoryValueRecordItem } from '@/api/account';
import type { PageRequest } from '@/api/pagination';
import { useProfileStore } from '@/stores/profile-store';
import { onMounted, ref, type Ref } from 'vue';
import ValueRecordHistoryItem from './ValueRecordHistoryItem.vue';
import JournalEntryHistoryItem from './JournalEntryHistoryItem.vue';
const props = defineProps<{ accountId: number }>()
const historyItems: Ref<AccountHistoryItem[]> = ref([])
onMounted(async () => {
const profileStore = useProfileStore()
if (!profileStore.state) return
const pageRequest: PageRequest = { page: 1, size: 10, sorts: [{ attribute: 'timestamp', dir: 'DESC' }] }
const api = new AccountApiClient(profileStore.state)
while (true) {
try {
const page = await api.getHistory(props.accountId, pageRequest)
historyItems.value.push(...page.items)
if (page.isLast) return
pageRequest.page++
} catch (err) {
console.error(err)
historyItems.value = []
return
}
}
})
function asVR(i: AccountHistoryItem): AccountHistoryValueRecordItem {
return i as AccountHistoryValueRecordItem
}
function asJE(i: AccountHistoryItem): AccountHistoryJournalEntryItem {
return i as AccountHistoryJournalEntryItem
}
</script>
<template>
<div>
<div v-for="item in historyItems" :key="item.timestamp" class="history-item">
<div class="history-item-header">
<div class="history-item-header-timestamp">{{ new Date(item.timestamp).toLocaleString() }}</div>
<div>{{ item.type }}</div>
</div>
<ValueRecordHistoryItem v-if="item.type === AccountHistoryItemType.VALUE_RECORD" :item="asVR(item)"
:account-id="accountId" />
<JournalEntryHistoryItem v-if="item.type === AccountHistoryItemType.JOURNAL_ENTRY" :item="asJE(item)" />
</div>
</div>
</template>
<style lang="css">
.history-item {
margin-top: 1rem;
margin-bottom: 1rem;
padding: 0.25rem 1rem;
background-color: var(--bg-secondary);
border-radius: 1rem;
}
.history-item-header {
float: right;
text-align: right;
}
.history-item-header-timestamp {
font-family: monospace;
font-size: 12px;
}
.history-item-content {
padding: 0.5rem 0;
}
</style>

View File

@ -0,0 +1,23 @@
<script setup lang="ts">
import type { AccountHistoryJournalEntryItem } from '@/api/account'
import { formatMoney } from '@/api/data';
import { useProfileStore } from '@/stores/profile-store';
defineProps<{ item: AccountHistoryJournalEntryItem }>()
const profileStore = useProfileStore()
</script>
<template>
<div class="history-item-content">
<div>
<RouterLink :to="`/profiles/${profileStore.state?.name}/transactions/${item.transactionId}`">
Transaction #{{ item.transactionId }}
</RouterLink>
entered as a
<strong>{{ item.journalEntryType.toLowerCase() }}</strong>
for this account with a value of
{{ formatMoney(item.amount, item.currency) }}.
<br />
{{ item.transactionDescription }}
</div>
</div>
</template>

View File

@ -0,0 +1,32 @@
<script setup lang="ts">
import { AccountApiClient, type AccountHistoryValueRecordItem } from '@/api/account';
import { formatMoney } from '@/api/data';
import AppButton from '../AppButton.vue';
import { useProfileStore } from '@/stores/profile-store';
import { showConfirm } from '@/util/alert';
const props = defineProps<{ item: AccountHistoryValueRecordItem, accountId: number }>()
const profileStore = useProfileStore()
async function deleteValueRecord(id: number) {
if (!profileStore.state) return
const confirm = await showConfirm('Are you sure you want to delete this value record?')
if (!confirm) return
const api = new AccountApiClient(profileStore.state)
try {
await api.deleteValueRecord(props.accountId, id)
} catch (err) {
console.error(err)
}
}
</script>
<template>
<div class="history-item-content">
<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>
</div>
</template>

View File

@ -4,6 +4,7 @@ import { formatMoney } from '@/api/data';
import AddValueRecordModal from '@/components/AddValueRecordModal.vue';
import AppButton from '@/components/AppButton.vue';
import AppPage from '@/components/AppPage.vue';
import AccountHistory from '@/components/history/AccountHistory.vue';
import PropertiesTable from '@/components/PropertiesTable.vue';
import { useProfileStore } from '@/stores/profile-store';
import { showConfirm } from '@/util/alert';
@ -100,6 +101,8 @@ async function addValueRecord() {
<AppButton icon="trash" @click="deleteAccount()">Delete</AppButton>
</div>
<AccountHistory :account-id="account.id" v-if="account" />
<AddValueRecordModal v-if="account" :account="account" ref="addValueRecordModal" />
</AppPage>
</template>

View File

@ -15,6 +15,7 @@ const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const apiClient = new AuthApiClient()
const isDev = import.meta.env.DEV
const username = ref('')
const password = ref('')
@ -48,11 +49,11 @@ function isDataValid() {
return username.value.length > 0 && password.value.length >= 8
}
// function generateSampleData() {
// fetch(import.meta.env.VITE_API_BASE_URL + '/sample-data', {
// method: 'POST'
// })
// }
function generateSampleData() {
fetch(import.meta.env.VITE_API_BASE_URL + '/sample-data', {
method: 'POST'
})
}
</script>
<template>
<div class="app-login-panel">
@ -71,6 +72,9 @@ function isDataValid() {
</AppButton>
<AppButton button-type="button" button-style="secondary" :disabled="true">Register</AppButton>
</div>
<div v-if="isDev">
<AppButton button-type="button" @click="generateSampleData()">Generate Sample Data</AppButton>
</div>
</AppForm>
</div>
</template>