Add history stuff.
This commit is contained in:
parent
34eb0d11fd
commit
fc85170492
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
private static struct BaseHistoryItem {
|
||||
ulong id;
|
||||
string timestamp;
|
||||
string type;
|
||||
}
|
||||
Optional!History history = findOne(
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -70,3 +70,10 @@ struct AccountValueRecord {
|
|||
long value;
|
||||
Currency currency;
|
||||
}
|
||||
|
||||
enum AccountHistoryItemType : string {
|
||||
Text = "TEXT",
|
||||
PropertyChange = "PROPERTY_CHANGE",
|
||||
ValueRecord = "VALUE_RECORD",
|
||||
JournalEntry = "JOURNAL_ENTRY"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue