Add history stuff.
This commit is contained in:
parent
34eb0d11fd
commit
fc85170492
|
|
@ -13,6 +13,7 @@ import profile.service;
|
||||||
import profile.data;
|
import profile.data;
|
||||||
import account.model;
|
import account.model;
|
||||||
import account.service;
|
import account.service;
|
||||||
|
import account.dto;
|
||||||
import util.money;
|
import util.money;
|
||||||
import util.pagination;
|
import util.pagination;
|
||||||
import util.data;
|
import util.data;
|
||||||
|
|
@ -20,36 +21,6 @@ import account.data;
|
||||||
import attachment.data;
|
import attachment.data;
|
||||||
import attachment.dto;
|
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) {
|
void handleGetAccounts(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
import std.algorithm;
|
import std.algorithm;
|
||||||
import std.array;
|
import std.array;
|
||||||
|
|
@ -67,15 +38,6 @@ void handleGetAccount(ref ServerHttpRequest request, ref ServerHttpResponse resp
|
||||||
writeJsonBody(response, AccountResponse.of(account, getBalance(ds, account.id)));
|
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) {
|
void handleCreateAccount(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
auto ds = getProfileDataSource(request);
|
auto ds = getProfileDataSource(request);
|
||||||
AccountCreationPayload payload = readJsonBodyAs!AccountCreationPayload(request);
|
AccountCreationPayload payload = readJsonBodyAs!AccountCreationPayload(request);
|
||||||
|
|
@ -117,34 +79,46 @@ void handleDeleteAccount(ref ServerHttpRequest request, ref ServerHttpResponse r
|
||||||
ds.getAccountRepository().deleteById(accountId);
|
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 {
|
private string serializeAccountHistoryItem(in AccountHistoryItemResponse i) {
|
||||||
ulong id;
|
import asdf : serializeToJson;
|
||||||
string timestamp;
|
if (i.type == AccountHistoryItemType.JournalEntry) {
|
||||||
ulong accountId;
|
return serializeToJson(cast(AccountHistoryJournalEntryItemResponse) i);
|
||||||
string type;
|
} else if (i.type == AccountHistoryItemType.ValueRecord) {
|
||||||
long value;
|
return serializeToJson(cast(AccountHistoryValueRecordItemResponse) i);
|
||||||
Currency currency;
|
} else {
|
||||||
AttachmentResponse[] attachments;
|
return serializeToJson(i);
|
||||||
|
|
||||||
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 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) {
|
void handleGetValueRecords(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
ulong accountId = request.getPathParamAs!ulong("accountId");
|
ulong accountId = request.getPathParamAs!ulong("accountId");
|
||||||
auto ds = getProfileDataSource(request);
|
auto ds = getProfileDataSource(request);
|
||||||
|
|
@ -165,12 +139,6 @@ void handleGetValueRecord(ref ServerHttpRequest request, ref ServerHttpResponse
|
||||||
writeJsonBody(response, AccountValueRecordResponse.of(record, attachmentRepo));
|
writeJsonBody(response, AccountValueRecordResponse.of(record, attachmentRepo));
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ValueRecordCreationPayload {
|
|
||||||
string timestamp;
|
|
||||||
string type;
|
|
||||||
long value;
|
|
||||||
}
|
|
||||||
|
|
||||||
void handleCreateValueRecord(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
void handleCreateValueRecord(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
ulong accountId = request.getPathParamAs!ulong("accountId");
|
ulong accountId = request.getPathParamAs!ulong("accountId");
|
||||||
ProfileDataSource ds = getProfileDataSource(request);
|
ProfileDataSource ds = getProfileDataSource(request);
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@ module account.data;
|
||||||
import handy_http_primitives : Optional;
|
import handy_http_primitives : Optional;
|
||||||
|
|
||||||
import account.model;
|
import account.model;
|
||||||
|
import account.dto;
|
||||||
import util.money;
|
import util.money;
|
||||||
import util.pagination;
|
import util.pagination;
|
||||||
import history.model;
|
|
||||||
|
|
||||||
import std.datetime : SysTime;
|
import std.datetime : SysTime;
|
||||||
|
|
||||||
|
|
@ -19,7 +19,8 @@ interface AccountRepository {
|
||||||
Account[] findAll();
|
Account[] findAll();
|
||||||
AccountCreditCardProperties getCreditCardProperties(ulong id);
|
AccountCreditCardProperties getCreditCardProperties(ulong id);
|
||||||
void setCreditCardProperties(ulong id, in AccountCreditCardProperties props);
|
void setCreditCardProperties(ulong id, in AccountCreditCardProperties props);
|
||||||
History getHistory(ulong id);
|
|
||||||
|
Page!AccountHistoryItemResponse getHistory(ulong accountId, in PageRequest pagination);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AccountJournalEntryRepository {
|
interface AccountJournalEntryRepository {
|
||||||
|
|
@ -34,7 +35,7 @@ interface AccountJournalEntryRepository {
|
||||||
);
|
);
|
||||||
void deleteById(ulong id);
|
void deleteById(ulong id);
|
||||||
void deleteByAccountIdAndTransactionId(ulong accountId, ulong transactionId);
|
void deleteByAccountIdAndTransactionId(ulong accountId, ulong transactionId);
|
||||||
AccountJournalEntry[] findAllBetween(SysTime startIncl, SysTime endIncl);
|
AccountJournalEntry[] findAllBetween(SysTime startIncl, SysTime endIncl, ulong accountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AccountValueRecordRepository {
|
interface AccountValueRecordRepository {
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import handy_http_primitives : Optional;
|
||||||
|
|
||||||
import account.data;
|
import account.data;
|
||||||
import account.model;
|
import account.model;
|
||||||
import history.model;
|
import account.dto;
|
||||||
import util.sqlite;
|
import util.sqlite;
|
||||||
import util.money;
|
import util.money;
|
||||||
import util.pagination;
|
import util.pagination;
|
||||||
|
|
@ -87,9 +87,9 @@ SQL",
|
||||||
// Delete associated history.
|
// Delete associated history.
|
||||||
util.sqlite.update(
|
util.sqlite.update(
|
||||||
db,
|
db,
|
||||||
"DELETE FROM history
|
"DELETE FROM history_item
|
||||||
WHERE id IN (
|
WHERE id IN (
|
||||||
SELECT history_id FROM account_history
|
SELECT history_item_id FROM account_history_item
|
||||||
WHERE account_id = ?
|
WHERE account_id = ?
|
||||||
)",
|
)",
|
||||||
id
|
id
|
||||||
|
|
@ -150,31 +150,94 @@ SQL",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
History getHistory(ulong id) {
|
private static struct BaseHistoryItem {
|
||||||
if (!exists(db, "SELECT id FROM account WHERE id = ?", id)) {
|
ulong id;
|
||||||
throw new Exception("Account doesn't exist.");
|
string timestamp;
|
||||||
}
|
string type;
|
||||||
Optional!History history = findOne(
|
}
|
||||||
|
|
||||||
|
Page!AccountHistoryItemResponse getHistory(ulong accountId, in PageRequest pagination) {
|
||||||
|
ulong count = util.sqlite.count(
|
||||||
db,
|
db,
|
||||||
q"SQL
|
"SELECT COUNT(history_item_id) FROM account_history_item WHERE account_id = ?",
|
||||||
SELECT * FROM history
|
accountId
|
||||||
LEFT JOIN account_history ah ON ah.history_id = history.id
|
|
||||||
WHERE ah.account_id = ?
|
|
||||||
SQL",
|
|
||||||
r => History(r.peek!ulong(0)),
|
|
||||||
id
|
|
||||||
);
|
);
|
||||||
if (!history.empty) {
|
const baseQuery = "SELECT hi.id, hi.timestamp, hi.type " ~
|
||||||
return history.value;
|
"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.
|
return Page!AccountHistoryItemResponse.of(results, pagination, count);
|
||||||
ulong historyId = doTransaction(db, () {
|
}
|
||||||
util.sqlite.update(db, "INSERT INTO history DEFAULT VALUES");
|
|
||||||
ulong historyId = db.lastInsertRowid();
|
private AccountHistoryValueRecordItemResponse fetchValueRecordHistoryItem(in BaseHistoryItem item) {
|
||||||
util.sqlite.update(db, "INSERT INTO account_history (account_id, history_id) VALUES (?, ?)", id, historyId);
|
return util.sqlite.findOne(
|
||||||
return historyId;
|
db,
|
||||||
});
|
"SELECT vr.id, vr.type, vr.value, vr.currency FROM history_item_linked_value_record h " ~
|
||||||
return History(historyId);
|
"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) {
|
static Account parseAccount(Row row) {
|
||||||
|
|
@ -231,15 +294,38 @@ class SqliteAccountJournalEntryRepository : AccountJournalEntryRepository {
|
||||||
type,
|
type,
|
||||||
currency.code
|
currency.code
|
||||||
);
|
);
|
||||||
ulong id = db.lastInsertRowid();
|
ulong journalEntryId = db.lastInsertRowid();
|
||||||
return findById(id).orElseThrow();
|
// 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) {
|
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);
|
util.sqlite.deleteById(db, "account_journal_entry", id);
|
||||||
}
|
}
|
||||||
|
|
||||||
void deleteByAccountIdAndTransactionId(ulong accountId, ulong transactionId) {
|
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(
|
util.sqlite.update(
|
||||||
db,
|
db,
|
||||||
"DELETE FROM account_journal_entry WHERE account_id = ? AND transaction_id = ?",
|
"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 " ~
|
const query = "SELECT * FROM account_journal_entry " ~
|
||||||
"WHERE timestamp >= ? AND timestamp <= ? " ~
|
"WHERE timestamp >= ? AND timestamp <= ? AND account_id = ? " ~
|
||||||
"ORDER BY timestamp ASC";
|
"ORDER BY timestamp ASC";
|
||||||
return util.sqlite.findAll(
|
return util.sqlite.findAll(
|
||||||
db,
|
db,
|
||||||
query,
|
query,
|
||||||
&parseEntry,
|
&parseEntry,
|
||||||
startIncl.toISOExtString(), endIncl.toISOExtString()
|
startIncl.toISOExtString(), endIncl.toISOExtString(), accountId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -331,8 +417,17 @@ class SqliteAccountValueRecordRepository : AccountValueRecordRepository {
|
||||||
value,
|
value,
|
||||||
currency.code
|
currency.code
|
||||||
);
|
);
|
||||||
ulong id = db.lastInsertRowid();
|
ulong valueRecordId = db.lastInsertRowid();
|
||||||
return findById(accountId, id).orElseThrow();
|
// 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) {
|
void linkAttachment(ulong valueRecordId, ulong attachmentId) {
|
||||||
|
|
@ -345,6 +440,13 @@ class SqliteAccountValueRecordRepository : AccountValueRecordRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
void deleteById(ulong accountId, ulong id) {
|
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(
|
util.sqlite.update(
|
||||||
db,
|
db,
|
||||||
"DELETE FROM account_value_record WHERE account_id = ? AND id = ?",
|
"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;
|
long value;
|
||||||
Currency currency;
|
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;
|
long balance = valueRecord.value;
|
||||||
AccountJournalEntry[] journalEntries = journalEntryRepo.findAllBetween(startTimestamp, endTimestamp);
|
AccountJournalEntry[] journalEntries = journalEntryRepo.findAllBetween(startTimestamp, endTimestamp, account.id);
|
||||||
foreach (entry; journalEntries) {
|
foreach (entry; journalEntries) {
|
||||||
long entryValue = entry.amount;
|
long entryValue = entry.amount;
|
||||||
if (entry.type == AccountJournalEntryType.CREDIT) {
|
if (entry.type == AccountJournalEntryType.CREDIT) {
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ HttpRequestHandler mapApiHandlers(string webOrigin) {
|
||||||
a.map(HttpMethod.GET, ACCOUNT_PATH, &handleGetAccount);
|
a.map(HttpMethod.GET, ACCOUNT_PATH, &handleGetAccount);
|
||||||
a.map(HttpMethod.PUT, ACCOUNT_PATH, &handleUpdateAccount);
|
a.map(HttpMethod.PUT, ACCOUNT_PATH, &handleUpdateAccount);
|
||||||
a.map(HttpMethod.DELETE, ACCOUNT_PATH, &handleDeleteAccount);
|
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", &handleGetValueRecords);
|
||||||
a.map(HttpMethod.GET, ACCOUNT_PATH ~ "/value-records/:valueRecordId:ulong", &handleGetValueRecord);
|
a.map(HttpMethod.GET, ACCOUNT_PATH ~ "/value-records/:valueRecordId:ulong", &handleGetValueRecord);
|
||||||
a.map(HttpMethod.POST, ACCOUNT_PATH ~ "/value-records", &handleCreateValueRecord);
|
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.
|
* args = Arguments for the query.
|
||||||
* Returns: An optional result.
|
* 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) {
|
Optional!T findOne(T, Args...)(Database db, string query, T function(Row) resultMapper, Args args) {
|
||||||
Statement stmt = db.prepare(query);
|
Statement stmt = db.prepare(query);
|
||||||
stmt.bindAll(args);
|
stmt.bindAll(args);
|
||||||
|
|
|
||||||
|
|
@ -163,7 +163,8 @@ CREATE TABLE account_value_record_attachment (
|
||||||
|
|
||||||
CREATE TABLE history_item (
|
CREATE TABLE history_item (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
timestamp TEXT NOT NULL
|
timestamp TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE account_history_item (
|
CREATE TABLE account_history_item (
|
||||||
|
|
@ -178,7 +179,7 @@ CREATE TABLE account_history_item (
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE
|
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 (
|
CREATE TABLE history_item_text (
|
||||||
item_id INTEGER PRIMARY KEY,
|
item_id INTEGER PRIMARY KEY,
|
||||||
content TEXT NOT NULL,
|
content TEXT NOT NULL,
|
||||||
|
|
@ -187,7 +188,7 @@ CREATE TABLE history_item_text (
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE
|
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 (
|
CREATE TABLE history_item_property_change (
|
||||||
item_id INTEGER NOT NULL,
|
item_id INTEGER NOT NULL,
|
||||||
property_name TEXT NOT NULL,
|
property_name TEXT NOT NULL,
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,34 @@ export interface AccountValueRecordCreationPayload {
|
||||||
value: number
|
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 {
|
export class AccountApiClient extends ApiClient {
|
||||||
readonly path: string
|
readonly path: string
|
||||||
|
|
||||||
|
|
@ -122,6 +150,10 @@ export class AccountApiClient extends ApiClient {
|
||||||
return super.delete(this.path + '/' + id)
|
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>> {
|
getValueRecords(accountId: number, pageRequest: PageRequest): Promise<Page<AccountValueRecord>> {
|
||||||
return super.getJsonPage(this.path + '/' + accountId + '/value-records', pageRequest)
|
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 AddValueRecordModal from '@/components/AddValueRecordModal.vue';
|
||||||
import AppButton from '@/components/AppButton.vue';
|
import AppButton from '@/components/AppButton.vue';
|
||||||
import AppPage from '@/components/AppPage.vue';
|
import AppPage from '@/components/AppPage.vue';
|
||||||
|
import AccountHistory from '@/components/history/AccountHistory.vue';
|
||||||
import PropertiesTable from '@/components/PropertiesTable.vue';
|
import PropertiesTable from '@/components/PropertiesTable.vue';
|
||||||
import { useProfileStore } from '@/stores/profile-store';
|
import { useProfileStore } from '@/stores/profile-store';
|
||||||
import { showConfirm } from '@/util/alert';
|
import { showConfirm } from '@/util/alert';
|
||||||
|
|
@ -100,6 +101,8 @@ async function addValueRecord() {
|
||||||
<AppButton icon="trash" @click="deleteAccount()">Delete</AppButton>
|
<AppButton icon="trash" @click="deleteAccount()">Delete</AppButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AccountHistory :account-id="account.id" v-if="account" />
|
||||||
|
|
||||||
<AddValueRecordModal v-if="account" :account="account" ref="addValueRecordModal" />
|
<AddValueRecordModal v-if="account" :account="account" ref="addValueRecordModal" />
|
||||||
</AppPage>
|
</AppPage>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const apiClient = new AuthApiClient()
|
const apiClient = new AuthApiClient()
|
||||||
|
const isDev = import.meta.env.DEV
|
||||||
|
|
||||||
const username = ref('')
|
const username = ref('')
|
||||||
const password = ref('')
|
const password = ref('')
|
||||||
|
|
@ -48,11 +49,11 @@ function isDataValid() {
|
||||||
return username.value.length > 0 && password.value.length >= 8
|
return username.value.length > 0 && password.value.length >= 8
|
||||||
}
|
}
|
||||||
|
|
||||||
// function generateSampleData() {
|
function generateSampleData() {
|
||||||
// fetch(import.meta.env.VITE_API_BASE_URL + '/sample-data', {
|
fetch(import.meta.env.VITE_API_BASE_URL + '/sample-data', {
|
||||||
// method: 'POST'
|
method: 'POST'
|
||||||
// })
|
})
|
||||||
// }
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="app-login-panel">
|
<div class="app-login-panel">
|
||||||
|
|
@ -71,6 +72,9 @@ function isDataValid() {
|
||||||
</AppButton>
|
</AppButton>
|
||||||
<AppButton button-type="button" button-style="secondary" :disabled="true">Register</AppButton>
|
<AppButton button-type="button" button-style="secondary" :disabled="true">Register</AppButton>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="isDev">
|
||||||
|
<AppButton button-type="button" @click="generateSampleData()">Generate Sample Data</AppButton>
|
||||||
|
</div>
|
||||||
</AppForm>
|
</AppForm>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue