Added value record API and modal, and balance computations.
This commit is contained in:
parent
e29d4e1c0f
commit
844f17c80d
|
|
@ -7,14 +7,21 @@ module account.api;
|
||||||
import handy_http_primitives;
|
import handy_http_primitives;
|
||||||
import handy_http_data.json;
|
import handy_http_data.json;
|
||||||
import handy_http_handlers.path_handler;
|
import handy_http_handlers.path_handler;
|
||||||
|
import std.datetime;
|
||||||
|
|
||||||
import profile.service;
|
import profile.service;
|
||||||
|
import profile.data;
|
||||||
import account.model;
|
import account.model;
|
||||||
|
import account.service;
|
||||||
import util.money;
|
import util.money;
|
||||||
|
import util.pagination;
|
||||||
import account.data;
|
import account.data;
|
||||||
|
|
||||||
/// The data the API provides for an Account entity.
|
/// The data the API provides for an Account entity.
|
||||||
struct AccountResponse {
|
struct AccountResponse {
|
||||||
|
import asdf : serdeTransformOut;
|
||||||
|
import util.data;
|
||||||
|
|
||||||
ulong id;
|
ulong id;
|
||||||
string createdAt;
|
string createdAt;
|
||||||
bool archived;
|
bool archived;
|
||||||
|
|
@ -23,8 +30,10 @@ struct AccountResponse {
|
||||||
string name;
|
string name;
|
||||||
string currency;
|
string currency;
|
||||||
string description;
|
string description;
|
||||||
|
@serdeTransformOut!serializeOptional
|
||||||
|
Optional!long currentBalance;
|
||||||
|
|
||||||
static AccountResponse of(in Account account) {
|
static AccountResponse of(in Account account, Optional!long currentBalance) {
|
||||||
AccountResponse r;
|
AccountResponse r;
|
||||||
r.id = account.id;
|
r.id = account.id;
|
||||||
r.createdAt = account.createdAt.toISOExtString();
|
r.createdAt = account.createdAt.toISOExtString();
|
||||||
|
|
@ -34,6 +43,7 @@ struct AccountResponse {
|
||||||
r.name = account.name;
|
r.name = account.name;
|
||||||
r.currency = account.currency.code.dup;
|
r.currency = account.currency.code.dup;
|
||||||
r.description = account.description;
|
r.description = account.description;
|
||||||
|
r.currentBalance = currentBalance;
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -43,7 +53,7 @@ void handleGetAccounts(ref ServerHttpRequest request, ref ServerHttpResponse res
|
||||||
import std.array;
|
import std.array;
|
||||||
auto ds = getProfileDataSource(request);
|
auto ds = getProfileDataSource(request);
|
||||||
auto accounts = ds.getAccountRepository().findAll()
|
auto accounts = ds.getAccountRepository().findAll()
|
||||||
.map!(a => AccountResponse.of(a)).array;
|
.map!(a => AccountResponse.of(a, getBalance(ds, a.id))).array;
|
||||||
writeJsonBody(response, accounts);
|
writeJsonBody(response, accounts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -52,7 +62,7 @@ void handleGetAccount(ref ServerHttpRequest request, ref ServerHttpResponse resp
|
||||||
auto ds = getProfileDataSource(request);
|
auto ds = getProfileDataSource(request);
|
||||||
auto account = ds.getAccountRepository().findById(accountId)
|
auto account = ds.getAccountRepository().findById(accountId)
|
||||||
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
|
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
|
||||||
writeJsonBody(response, AccountResponse.of(account));
|
writeJsonBody(response, AccountResponse.of(account, getBalance(ds, account.id)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// The data provided by a user to create a new account.
|
// The data provided by a user to create a new account.
|
||||||
|
|
@ -77,7 +87,7 @@ void handleCreateAccount(ref ServerHttpRequest request, ref ServerHttpResponse r
|
||||||
currency,
|
currency,
|
||||||
payload.description
|
payload.description
|
||||||
);
|
);
|
||||||
writeJsonBody(response, AccountResponse.of(account));
|
writeJsonBody(response, AccountResponse.of(account, getBalance(ds, account.id)));
|
||||||
}
|
}
|
||||||
|
|
||||||
void handleUpdateAccount(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
void handleUpdateAccount(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
|
|
@ -96,7 +106,7 @@ void handleUpdateAccount(ref ServerHttpRequest request, ref ServerHttpResponse r
|
||||||
Currency.ofCode(payload.currency),
|
Currency.ofCode(payload.currency),
|
||||||
payload.description
|
payload.description
|
||||||
));
|
));
|
||||||
writeJsonBody(response, AccountResponse.of(updated));
|
writeJsonBody(response, AccountResponse.of(updated, getBalance(ds, updated.id)));
|
||||||
}
|
}
|
||||||
|
|
||||||
void handleDeleteAccount(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
void handleDeleteAccount(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
|
|
@ -104,3 +114,75 @@ void handleDeleteAccount(ref ServerHttpRequest request, ref ServerHttpResponse r
|
||||||
auto ds = getProfileDataSource(request);
|
auto ds = getProfileDataSource(request);
|
||||||
ds.getAccountRepository().deleteById(accountId);
|
ds.getAccountRepository().deleteById(accountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PageRequest VALUE_RECORD_DEFAULT_PAGE_REQUEST = PageRequest(1, 10, [Sort("timestamp", SortDir.DESC)]);
|
||||||
|
|
||||||
|
struct AccountValueRecordResponse {
|
||||||
|
ulong id;
|
||||||
|
string timestamp;
|
||||||
|
ulong accountId;
|
||||||
|
string type;
|
||||||
|
long value;
|
||||||
|
Currency currency;
|
||||||
|
|
||||||
|
static AccountValueRecordResponse of(in AccountValueRecord vr) {
|
||||||
|
return AccountValueRecordResponse(
|
||||||
|
vr.id,
|
||||||
|
vr.timestamp.toISOExtString(),
|
||||||
|
vr.accountId,
|
||||||
|
vr.type,
|
||||||
|
vr.value,
|
||||||
|
vr.currency
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void handleGetValueRecords(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
|
ulong accountId = request.getPathParamAs!ulong("accountId");
|
||||||
|
auto ds = getProfileDataSource(request);
|
||||||
|
auto page = ds.getAccountValueRecordRepository()
|
||||||
|
.findAllByAccountId(accountId, PageRequest.parse(request, VALUE_RECORD_DEFAULT_PAGE_REQUEST))
|
||||||
|
.mapTo!()(&AccountValueRecordResponse.of);
|
||||||
|
writeJsonBody(response, page);
|
||||||
|
}
|
||||||
|
|
||||||
|
void handleGetValueRecord(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
|
ulong accountId = request.getPathParamAs!ulong("accountId");
|
||||||
|
ulong valueRecordId = request.getPathParamAs!ulong("valueRecordId");
|
||||||
|
auto ds = getProfileDataSource(request);
|
||||||
|
auto record = ds.getAccountValueRecordRepository().findById(accountId, valueRecordId)
|
||||||
|
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
|
||||||
|
writeJsonBody(response, AccountValueRecordResponse.of(record));
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
Account account = ds.getAccountRepository().findById(accountId)
|
||||||
|
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
|
||||||
|
ValueRecordCreationPayload payload = readJsonBodyAs!ValueRecordCreationPayload(request);
|
||||||
|
SysTime timestamp = SysTime.fromISOExtString(payload.timestamp);
|
||||||
|
AccountValueRecordType type = AccountValueRecordType.BALANCE; // TODO: Support more types.
|
||||||
|
AccountValueRecord record = ds.getAccountValueRecordRepository().insert(
|
||||||
|
timestamp,
|
||||||
|
account.id,
|
||||||
|
type,
|
||||||
|
payload.value,
|
||||||
|
account.currency
|
||||||
|
);
|
||||||
|
writeJsonBody(response, AccountValueRecordResponse.of(record));
|
||||||
|
}
|
||||||
|
|
||||||
|
void handleDeleteValueRecord(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
|
ulong accountId = request.getPathParamAs!ulong("accountId");
|
||||||
|
ulong valueRecordId = request.getPathParamAs!ulong("valueRecordId");
|
||||||
|
ProfileDataSource ds = getProfileDataSource(request);
|
||||||
|
ds.getAccountValueRecordRepository()
|
||||||
|
.deleteById(accountId, valueRecordId);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import handy_http_primitives : Optional;
|
||||||
|
|
||||||
import account.model;
|
import account.model;
|
||||||
import util.money;
|
import util.money;
|
||||||
|
import util.pagination;
|
||||||
import history.model;
|
import history.model;
|
||||||
|
|
||||||
import std.datetime : SysTime;
|
import std.datetime : SysTime;
|
||||||
|
|
@ -33,4 +34,104 @@ 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AccountValueRecordRepository {
|
||||||
|
Optional!AccountValueRecord findById(ulong accountId, ulong id);
|
||||||
|
AccountValueRecord insert(
|
||||||
|
SysTime timestamp,
|
||||||
|
ulong accountId,
|
||||||
|
AccountValueRecordType type,
|
||||||
|
long value,
|
||||||
|
Currency currency
|
||||||
|
);
|
||||||
|
Page!AccountValueRecord findAllByAccountId(ulong accountId, in PageRequest pr);
|
||||||
|
void deleteById(ulong accountId, ulong id);
|
||||||
|
Optional!AccountValueRecord findNearestByAccountIdBefore(ulong accountId, SysTime timestamp);
|
||||||
|
Optional!AccountValueRecord findNearestByAccountIdAfter(ulong accountId, SysTime timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
version(unittest) {
|
||||||
|
class TestAccountRepositoryStub : AccountRepository {
|
||||||
|
Optional!Account findById(ulong id) {
|
||||||
|
throw new Exception("Not implemented");
|
||||||
|
}
|
||||||
|
bool existsById(ulong id) {
|
||||||
|
throw new Exception("Not implemented");
|
||||||
|
}
|
||||||
|
Account insert(AccountType type, string numberSuffix, string name, Currency currency, string description) {
|
||||||
|
throw new Exception("Not implemented");
|
||||||
|
}
|
||||||
|
void setArchived(ulong id, bool archived) {
|
||||||
|
throw new Exception("Not implemented");
|
||||||
|
}
|
||||||
|
Account update(ulong id, in Account newData) {
|
||||||
|
throw new Exception("Not implemented");
|
||||||
|
}
|
||||||
|
void deleteById(ulong id) {
|
||||||
|
throw new Exception("Not implemented");
|
||||||
|
}
|
||||||
|
Account[] findAll() {
|
||||||
|
throw new Exception("Not implemented");
|
||||||
|
}
|
||||||
|
AccountCreditCardProperties getCreditCardProperties(ulong id) {
|
||||||
|
throw new Exception("Not implemented");
|
||||||
|
}
|
||||||
|
void setCreditCardProperties(ulong id, in AccountCreditCardProperties props) {
|
||||||
|
throw new Exception("Not implemented");
|
||||||
|
}
|
||||||
|
History getHistory(ulong id) {
|
||||||
|
throw new Exception("Not implemented");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TestAccountJournalEntryRepositoryStub : AccountJournalEntryRepository {
|
||||||
|
Optional!AccountJournalEntry findById(ulong id) {
|
||||||
|
throw new Exception("Not implemented");
|
||||||
|
}
|
||||||
|
AccountJournalEntry insert(
|
||||||
|
SysTime timestamp,
|
||||||
|
ulong accountId,
|
||||||
|
ulong transactionId,
|
||||||
|
ulong amount,
|
||||||
|
AccountJournalEntryType type,
|
||||||
|
Currency currency
|
||||||
|
) {
|
||||||
|
throw new Exception("Not implemented");
|
||||||
|
}
|
||||||
|
void deleteById(ulong id) {
|
||||||
|
|
||||||
|
}
|
||||||
|
void deleteByAccountIdAndTransactionId(ulong accountId, ulong transactionId) {
|
||||||
|
throw new Exception("Not implemented");
|
||||||
|
}
|
||||||
|
AccountJournalEntry[] findAllBetween(SysTime startIncl, SysTime endIncl) {
|
||||||
|
throw new Exception("Not implemented");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TestAccountValueRecordRepositoryStub : AccountValueRecordRepository {
|
||||||
|
Optional!AccountValueRecord findById(ulong id) {
|
||||||
|
throw new Exception("Not implemented");
|
||||||
|
}
|
||||||
|
AccountValueRecord insert(
|
||||||
|
SysTime timestamp,
|
||||||
|
ulong accountId,
|
||||||
|
AccountValueRecordType type,
|
||||||
|
long value,
|
||||||
|
Currency currency
|
||||||
|
) {
|
||||||
|
throw new Exception("Not implemented");
|
||||||
|
}
|
||||||
|
void deleteById(ulong id) {
|
||||||
|
throw new Exception("Not implemented");
|
||||||
|
}
|
||||||
|
Optional!AccountValueRecord findNearestByAccountIdBefore(ulong accountId, SysTime timestamp) {
|
||||||
|
throw new Exception("Not implemented");
|
||||||
|
}
|
||||||
|
Optional!AccountValueRecord findNearestByAccountIdAfter(ulong accountId, SysTime timestamp) {
|
||||||
|
throw new Exception("Not implemented");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import account.model;
|
||||||
import history.model;
|
import history.model;
|
||||||
import util.sqlite;
|
import util.sqlite;
|
||||||
import util.money;
|
import util.money;
|
||||||
|
import util.pagination;
|
||||||
|
|
||||||
class SqliteAccountRepository : AccountRepository {
|
class SqliteAccountRepository : AccountRepository {
|
||||||
private Database db;
|
private Database db;
|
||||||
|
|
@ -246,6 +247,18 @@ class SqliteAccountJournalEntryRepository : AccountJournalEntryRepository {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AccountJournalEntry[] findAllBetween(SysTime startIncl, SysTime endIncl) {
|
||||||
|
const query = "SELECT * FROM account_journal_entry " ~
|
||||||
|
"WHERE timestamp >= ? AND timestamp <= ? " ~
|
||||||
|
"ORDER BY timestamp ASC";
|
||||||
|
return util.sqlite.findAll(
|
||||||
|
db,
|
||||||
|
query,
|
||||||
|
&parseEntry,
|
||||||
|
startIncl.toISOExtString(), endIncl.toISOExtString()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
static AccountJournalEntry parseEntry(Row row) {
|
static AccountJournalEntry parseEntry(Row row) {
|
||||||
string typeStr = row.peek!(string, PeekMode.slice)(5);
|
string typeStr = row.peek!(string, PeekMode.slice)(5);
|
||||||
AccountJournalEntryType type;
|
AccountJournalEntryType type;
|
||||||
|
|
@ -267,3 +280,108 @@ class SqliteAccountJournalEntryRepository : AccountJournalEntryRepository {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class SqliteAccountValueRecordRepository : AccountValueRecordRepository {
|
||||||
|
private Database db;
|
||||||
|
this(Database db) {
|
||||||
|
this.db = db;
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional!AccountValueRecord findById(ulong accountId, ulong id) {
|
||||||
|
return util.sqlite.findOne(
|
||||||
|
db,
|
||||||
|
"SELECT * FROM account_value_record WHERE account_id = ? AND id = ?",
|
||||||
|
&parseValueRecord,
|
||||||
|
accountId, id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Page!AccountValueRecord findAllByAccountId(ulong accountId, in PageRequest pr) {
|
||||||
|
const baseQuery = "SELECT * FROM account_value_record WHERE account_id = ?";
|
||||||
|
string query = baseQuery ~ " " ~ pr.toSql();
|
||||||
|
AccountValueRecord[] records = util.sqlite.findAll(
|
||||||
|
db,
|
||||||
|
query,
|
||||||
|
&parseValueRecord,
|
||||||
|
accountId
|
||||||
|
);
|
||||||
|
ulong count = util.sqlite.count(
|
||||||
|
db,
|
||||||
|
"SELECT COUNT(id) FROM account_value_record WHERE account_id = ?",
|
||||||
|
accountId
|
||||||
|
);
|
||||||
|
return Page!AccountValueRecord.of(records, pr, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
AccountValueRecord insert(
|
||||||
|
SysTime timestamp,
|
||||||
|
ulong accountId,
|
||||||
|
AccountValueRecordType type,
|
||||||
|
long value,
|
||||||
|
Currency currency
|
||||||
|
) {
|
||||||
|
util.sqlite.update(
|
||||||
|
db,
|
||||||
|
"INSERT INTO account_value_record " ~
|
||||||
|
"(timestamp, account_id, type, value, currency) " ~
|
||||||
|
"VALUES (?, ?, ?, ?, ?)",
|
||||||
|
timestamp.toISOExtString(),
|
||||||
|
accountId,
|
||||||
|
type,
|
||||||
|
value,
|
||||||
|
currency.code
|
||||||
|
);
|
||||||
|
ulong id = db.lastInsertRowid();
|
||||||
|
return findById(accountId, id).orElseThrow();
|
||||||
|
}
|
||||||
|
|
||||||
|
void deleteById(ulong accountId, ulong id) {
|
||||||
|
util.sqlite.update(
|
||||||
|
db,
|
||||||
|
"DELETE FROM account_value_record WHERE account_id = ? AND id = ?",
|
||||||
|
accountId, id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional!AccountValueRecord findNearestByAccountIdBefore(ulong accountId, SysTime timestamp) {
|
||||||
|
const query = "SELECT * FROM account_value_record " ~
|
||||||
|
"WHERE account_id = ? AND timestamp < ? " ~
|
||||||
|
"ORDER BY timestamp DESC LIMIT 1";
|
||||||
|
return util.sqlite.findOne(
|
||||||
|
db,
|
||||||
|
query,
|
||||||
|
&parseValueRecord,
|
||||||
|
accountId, timestamp.toISOExtString()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional!AccountValueRecord findNearestByAccountIdAfter(ulong accountId, SysTime timestamp) {
|
||||||
|
const query = "SELECT * FROM account_value_record " ~
|
||||||
|
"WHERE account_id = ? AND timestamp > ? " ~
|
||||||
|
"ORDER BY timestamp ASC LIMIT 1";
|
||||||
|
return util.sqlite.findOne(
|
||||||
|
db,
|
||||||
|
query,
|
||||||
|
&parseValueRecord,
|
||||||
|
accountId, timestamp.toISOExtString()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static AccountValueRecord parseValueRecord(Row row) {
|
||||||
|
string typeStr = row.peek!(string, PeekMode.slice)(3);
|
||||||
|
AccountValueRecordType type;
|
||||||
|
if (typeStr == "BALANCE") {
|
||||||
|
type = AccountValueRecordType.BALANCE;
|
||||||
|
} else {
|
||||||
|
throw new Exception("Invalid account value record type: " ~ typeStr);
|
||||||
|
}
|
||||||
|
return AccountValueRecord(
|
||||||
|
row.peek!ulong(0),
|
||||||
|
parseISOTimestamp(row, 1),
|
||||||
|
row.peek!ulong(2),
|
||||||
|
type,
|
||||||
|
row.peek!long(4),
|
||||||
|
Currency.ofCode(row.peek!(string, PeekMode.slice)(5))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,14 +28,14 @@ enum AccountTypes : AccountType {
|
||||||
immutable(AccountType[]) ALL_ACCOUNT_TYPES = cast(AccountType[]) [ EnumMembers!AccountTypes ];
|
immutable(AccountType[]) ALL_ACCOUNT_TYPES = cast(AccountType[]) [ EnumMembers!AccountTypes ];
|
||||||
|
|
||||||
struct Account {
|
struct Account {
|
||||||
immutable ulong id;
|
ulong id;
|
||||||
immutable SysTime createdAt;
|
SysTime createdAt;
|
||||||
immutable bool archived;
|
bool archived;
|
||||||
immutable AccountType type;
|
AccountType type;
|
||||||
immutable string numberSuffix;
|
string numberSuffix;
|
||||||
immutable string name;
|
string name;
|
||||||
immutable Currency currency;
|
Currency currency;
|
||||||
immutable string description;
|
string description;
|
||||||
}
|
}
|
||||||
|
|
||||||
struct AccountCreditCardProperties {
|
struct AccountCreditCardProperties {
|
||||||
|
|
@ -49,13 +49,13 @@ enum AccountJournalEntryType : string {
|
||||||
}
|
}
|
||||||
|
|
||||||
struct AccountJournalEntry {
|
struct AccountJournalEntry {
|
||||||
immutable ulong id;
|
ulong id;
|
||||||
immutable SysTime timestamp;
|
SysTime timestamp;
|
||||||
immutable ulong accountId;
|
ulong accountId;
|
||||||
immutable ulong transactionId;
|
ulong transactionId;
|
||||||
immutable ulong amount;
|
ulong amount;
|
||||||
immutable AccountJournalEntryType type;
|
AccountJournalEntryType type;
|
||||||
immutable Currency currency;
|
Currency currency;
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AccountValueRecordType : string {
|
enum AccountValueRecordType : string {
|
||||||
|
|
@ -63,10 +63,10 @@ enum AccountValueRecordType : string {
|
||||||
}
|
}
|
||||||
|
|
||||||
struct AccountValueRecord {
|
struct AccountValueRecord {
|
||||||
immutable ulong id;
|
ulong id;
|
||||||
immutable SysTime timestamp;
|
SysTime timestamp;
|
||||||
immutable ulong accountId;
|
ulong accountId;
|
||||||
immutable AccountValueRecordType type;
|
AccountValueRecordType type;
|
||||||
immutable long value;
|
long value;
|
||||||
immutable Currency currency;
|
Currency currency;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,298 @@
|
||||||
|
module account.service;
|
||||||
|
|
||||||
|
import handy_http_primitives;
|
||||||
|
import std.datetime;
|
||||||
|
|
||||||
|
import account.model;
|
||||||
|
import account.data;
|
||||||
|
import profile.data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the balance for an account, at a given point in time.
|
||||||
|
* Params:
|
||||||
|
* ds = The data source for fetching account data.
|
||||||
|
* accountId = The account's id.
|
||||||
|
* timestamp = The timestamp at which to determine the account's balance.
|
||||||
|
* Returns: An optional that resolves to the account's balance if it can be
|
||||||
|
* determined from value records and journal entries.
|
||||||
|
*/
|
||||||
|
Optional!long getBalance(ProfileDataSource ds, ulong accountId, SysTime timestamp = Clock.currTime(UTC())) {
|
||||||
|
auto accountRepo = ds.getAccountRepository();
|
||||||
|
auto valueRecordRepo = ds.getAccountValueRecordRepository();
|
||||||
|
auto journalEntryRepo = ds.getAccountJournalEntryRepository();
|
||||||
|
|
||||||
|
Account account = accountRepo.findById(accountId).orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
|
||||||
|
|
||||||
|
Optional!AccountValueRecord nearestBefore = valueRecordRepo.findNearestByAccountIdBefore(accountId, timestamp);
|
||||||
|
Optional!AccountValueRecord nearestAfter = valueRecordRepo.findNearestByAccountIdAfter(accountId, timestamp);
|
||||||
|
if (nearestBefore.isNull && nearestAfter.isNull) return Optional!long.empty;
|
||||||
|
|
||||||
|
if (!nearestBefore.isNull && !nearestAfter.isNull) {
|
||||||
|
Duration timeFromBefore = timestamp - nearestBefore.value.timestamp;
|
||||||
|
Duration timeToAfter = nearestAfter.value.timestamp - timestamp;
|
||||||
|
if (timeFromBefore <= timeToAfter) {
|
||||||
|
return Optional!long.of(deriveBalance(account, nearestBefore.value, timestamp, journalEntryRepo));
|
||||||
|
} else {
|
||||||
|
return Optional!long.of(deriveBalance(account, nearestAfter.value, timestamp, journalEntryRepo));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!nearestBefore.isNull) {
|
||||||
|
return Optional!long.of(deriveBalance(account, nearestBefore.value, timestamp, journalEntryRepo));
|
||||||
|
}
|
||||||
|
if (!nearestAfter.isNull) {
|
||||||
|
return Optional!long.of(deriveBalance(account, nearestAfter.value, timestamp, journalEntryRepo));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Optional!long.empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper method that derives a balance for an account, by using the nearest
|
||||||
|
* value record, and all journal entries between that record and the desired
|
||||||
|
* timestamp. We start at the value record's balance, then apply each journal
|
||||||
|
* entry's amount to that balance (either credit or debit) to arrive at the
|
||||||
|
* final balance.
|
||||||
|
* Params:
|
||||||
|
* account = The account to derive a balance for.
|
||||||
|
* valueRecord = The nearest value record to use.
|
||||||
|
* timestamp = The target timestamp at which to determine the balance.
|
||||||
|
* journalEntryRepo = A repository for fetching journal entries.
|
||||||
|
* Returns: The derived balance.
|
||||||
|
*/
|
||||||
|
private long deriveBalance(
|
||||||
|
Account account,
|
||||||
|
AccountValueRecord valueRecord,
|
||||||
|
SysTime timestamp,
|
||||||
|
AccountJournalEntryRepository journalEntryRepo
|
||||||
|
) {
|
||||||
|
enum TimeDirection {
|
||||||
|
Forward,
|
||||||
|
Backward
|
||||||
|
}
|
||||||
|
|
||||||
|
SysTime startTimestamp;
|
||||||
|
SysTime endTimestamp;
|
||||||
|
TimeDirection timeDirection;
|
||||||
|
if (valueRecord.timestamp < timestamp) {
|
||||||
|
startTimestamp = valueRecord.timestamp;
|
||||||
|
endTimestamp = timestamp;
|
||||||
|
timeDirection = TimeDirection.Forward;
|
||||||
|
} else {
|
||||||
|
startTimestamp = timestamp;
|
||||||
|
endTimestamp = valueRecord.timestamp;
|
||||||
|
timeDirection = TimeDirection.Backward;
|
||||||
|
}
|
||||||
|
|
||||||
|
long balance = valueRecord.value;
|
||||||
|
AccountJournalEntry[] journalEntries = journalEntryRepo.findAllBetween(startTimestamp, endTimestamp);
|
||||||
|
foreach (entry; journalEntries) {
|
||||||
|
long entryValue = entry.amount;
|
||||||
|
if (entry.type == AccountJournalEntryType.CREDIT) {
|
||||||
|
entryValue *= -1;
|
||||||
|
}
|
||||||
|
if (!account.type.debitsPositive) {
|
||||||
|
entryValue *= -1;
|
||||||
|
}
|
||||||
|
if (timeDirection == TimeDirection.Backward) {
|
||||||
|
entryValue *= -1;
|
||||||
|
}
|
||||||
|
balance += entryValue;
|
||||||
|
}
|
||||||
|
return balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
unittest {
|
||||||
|
import std.algorithm;
|
||||||
|
import std.array;
|
||||||
|
import util.money;
|
||||||
|
|
||||||
|
class MockAccountRepository : TestAccountRepositoryStub {
|
||||||
|
private Account account;
|
||||||
|
|
||||||
|
this(Account account) {
|
||||||
|
this.account = account;
|
||||||
|
}
|
||||||
|
|
||||||
|
override Optional!Account findById(ulong id) {
|
||||||
|
if (id == this.account.id) return Optional!Account.of(account);
|
||||||
|
return Optional!Account.empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockValueRecordRepository : TestAccountValueRecordRepositoryStub {
|
||||||
|
private AccountValueRecord[] valueRecords;
|
||||||
|
|
||||||
|
this(AccountValueRecord[] valueRecords) {
|
||||||
|
this.valueRecords = valueRecords;
|
||||||
|
}
|
||||||
|
|
||||||
|
override Optional!AccountValueRecord findNearestByAccountIdBefore(ulong accountId, SysTime timestamp) {
|
||||||
|
auto matches = valueRecords
|
||||||
|
.filter!(v => v.accountId == accountId)
|
||||||
|
.filter!(v => v.timestamp < timestamp)
|
||||||
|
.array;
|
||||||
|
matches.sort!((a, b) => a.timestamp < b.timestamp);
|
||||||
|
if (matches.length == 0) return Optional!AccountValueRecord.empty;
|
||||||
|
return Optional!AccountValueRecord.of(matches[$-1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
override Optional!AccountValueRecord findNearestByAccountIdAfter(ulong accountId, SysTime timestamp) {
|
||||||
|
auto matches = valueRecords
|
||||||
|
.filter!(v => v.accountId == accountId)
|
||||||
|
.filter!(v => v.timestamp > timestamp)
|
||||||
|
.array;
|
||||||
|
matches.sort!((a, b) => a.timestamp < b.timestamp);
|
||||||
|
if (matches.length == 0) return Optional!AccountValueRecord.empty;
|
||||||
|
return Optional!AccountValueRecord.of(matches[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockJournalEntryRepository : TestAccountJournalEntryRepositoryStub {
|
||||||
|
private AccountJournalEntry[] journalEntries;
|
||||||
|
|
||||||
|
this(AccountJournalEntry[] journalEntries) {
|
||||||
|
this.journalEntries = journalEntries;
|
||||||
|
}
|
||||||
|
|
||||||
|
override AccountJournalEntry[] findAllBetween(SysTime startIncl, SysTime endIncl) {
|
||||||
|
auto matches = journalEntries.filter!(je => je.timestamp >= startIncl && je.timestamp <= endIncl)
|
||||||
|
.array;
|
||||||
|
matches.sort!((a, b) => a.timestamp < b.timestamp);
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockDataSource : TestProfileDataSourceStub {
|
||||||
|
private Account account;
|
||||||
|
private AccountValueRecord[] valueRecords;
|
||||||
|
private AccountJournalEntry[] journalEntries;
|
||||||
|
|
||||||
|
this(Account account, AccountValueRecord[] valueRecords, AccountJournalEntry[] journalEntries) {
|
||||||
|
this.account = account;
|
||||||
|
this.valueRecords = valueRecords;
|
||||||
|
this.journalEntries = journalEntries;
|
||||||
|
}
|
||||||
|
|
||||||
|
override AccountRepository getAccountRepository() {
|
||||||
|
return new MockAccountRepository(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
override AccountValueRecordRepository getAccountValueRecordRepository() {
|
||||||
|
return new MockValueRecordRepository(valueRecords);
|
||||||
|
}
|
||||||
|
|
||||||
|
override AccountJournalEntryRepository getAccountJournalEntryRepository() {
|
||||||
|
return new MockJournalEntryRepository(journalEntries);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SysTime NOW = SysTime(DateTime(2025, 8, 18, 15, 30, 59), UTC());
|
||||||
|
Account sampleAccount = Account(
|
||||||
|
1,
|
||||||
|
NOW - days(30),
|
||||||
|
false,
|
||||||
|
AccountTypes.CHECKING,
|
||||||
|
"1234",
|
||||||
|
"Sample Checking Account",
|
||||||
|
Currencies.USD,
|
||||||
|
"This is a sample testing account."
|
||||||
|
);
|
||||||
|
|
||||||
|
AccountValueRecord makeSampleValueRecord(SysTime timestamp, long value) {
|
||||||
|
return AccountValueRecord(
|
||||||
|
1,
|
||||||
|
timestamp,
|
||||||
|
sampleAccount.id,
|
||||||
|
AccountValueRecordType.BALANCE,
|
||||||
|
value,
|
||||||
|
sampleAccount.currency
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
AccountJournalEntry makeSampleCreditEntry(SysTime timestamp, ulong amount) {
|
||||||
|
return AccountJournalEntry(
|
||||||
|
1,
|
||||||
|
timestamp,
|
||||||
|
sampleAccount.id,
|
||||||
|
1,
|
||||||
|
amount,
|
||||||
|
AccountJournalEntryType.CREDIT,
|
||||||
|
sampleAccount.currency
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
AccountJournalEntry makeSampleDebitEntry(SysTime timestamp, ulong amount) {
|
||||||
|
return AccountJournalEntry(
|
||||||
|
1,
|
||||||
|
timestamp,
|
||||||
|
sampleAccount.id,
|
||||||
|
1,
|
||||||
|
amount,
|
||||||
|
AccountJournalEntryType.DEBIT,
|
||||||
|
sampleAccount.currency
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Account exists, but no value records or journal entries.
|
||||||
|
ProfileDataSource ds1 = new MockDataSource(sampleAccount, [], []);
|
||||||
|
assert(getBalance(ds1, sampleAccount.id).isNull);
|
||||||
|
|
||||||
|
// 2a. Single value record exists, so that's the value of the account.
|
||||||
|
ProfileDataSource ds2a = new MockDataSource(
|
||||||
|
sampleAccount,
|
||||||
|
[makeSampleValueRecord(NOW - days(5), 123)],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
assert(getBalance(ds2a, sampleAccount.id, NOW).value == 123);
|
||||||
|
|
||||||
|
// 2b. Same as the previous, but a value record exists after the timestamp.
|
||||||
|
ProfileDataSource ds2b = new MockDataSource(
|
||||||
|
sampleAccount,
|
||||||
|
[makeSampleValueRecord(NOW + days(5), 123)],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
assert(getBalance(ds2b, sampleAccount.id, NOW).value == 123);
|
||||||
|
|
||||||
|
// 3. If there are balance records both before and after the timestamp, the closest one is used.
|
||||||
|
ProfileDataSource ds3 = new MockDataSource(
|
||||||
|
sampleAccount,
|
||||||
|
[
|
||||||
|
makeSampleValueRecord(NOW - days(5), 123),
|
||||||
|
makeSampleValueRecord(NOW + days(3), -25)
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
assert(getBalance(ds3, sampleAccount.id, NOW).value == -25);
|
||||||
|
assert(getBalance(ds3, sampleAccount.id, NOW - days(2)).value == 123);
|
||||||
|
|
||||||
|
// 4. Balance record followed by some entries, as a basic balance derivation.
|
||||||
|
ProfileDataSource ds4 = new MockDataSource(
|
||||||
|
sampleAccount,
|
||||||
|
[
|
||||||
|
makeSampleValueRecord(NOW - days(10), 100)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
makeSampleDebitEntry(NOW - days(9), 25), // 100 + 25 = 125
|
||||||
|
makeSampleCreditEntry(NOW - days(8), 5), // 125 - 5 = 120
|
||||||
|
makeSampleDebitEntry(NOW - days(5), 80), // 120 + 80 = 200
|
||||||
|
makeSampleCreditEntry(NOW - days(1), 150) // 200 - 150 = 50
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert(getBalance(ds4, sampleAccount.id, NOW).value == 50);
|
||||||
|
assert(getBalance(ds4, sampleAccount.id, NOW - days(2)).value == 200);
|
||||||
|
|
||||||
|
// 5. Balance record in the future, with some entries between it and now, for backwards derivation.
|
||||||
|
ProfileDataSource ds5 = new MockDataSource(
|
||||||
|
sampleAccount,
|
||||||
|
[
|
||||||
|
makeSampleValueRecord(NOW + days(10), 100)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
makeSampleCreditEntry(NOW + days(9), 200), // 100 - (-200) = 300
|
||||||
|
makeSampleDebitEntry(NOW + days(8), 50), // 300 - 50 = 250
|
||||||
|
makeSampleDebitEntry(NOW + days(2), 25) // 250 - 25 = 225
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert(getBalance(ds5, sampleAccount.id, NOW).value == 225);
|
||||||
|
assert(getBalance(ds5, sampleAccount.id, NOW + days(4)).value == 250);
|
||||||
|
}
|
||||||
|
|
@ -51,9 +51,14 @@ HttpRequestHandler mapApiHandlers(string webOrigin) {
|
||||||
import account.api;
|
import account.api;
|
||||||
a.map(HttpMethod.GET, PROFILE_PATH ~ "/accounts", &handleGetAccounts);
|
a.map(HttpMethod.GET, PROFILE_PATH ~ "/accounts", &handleGetAccounts);
|
||||||
a.map(HttpMethod.POST, PROFILE_PATH ~ "/accounts", &handleCreateAccount);
|
a.map(HttpMethod.POST, PROFILE_PATH ~ "/accounts", &handleCreateAccount);
|
||||||
a.map(HttpMethod.GET, PROFILE_PATH ~ "/accounts/:accountId:ulong", &handleGetAccount);
|
const ACCOUNT_PATH = PROFILE_PATH ~ "/accounts/:accountId:ulong";
|
||||||
a.map(HttpMethod.PUT, PROFILE_PATH ~ "/accounts/:accountId:ulong", &handleUpdateAccount);
|
a.map(HttpMethod.GET, ACCOUNT_PATH, &handleGetAccount);
|
||||||
a.map(HttpMethod.DELETE, PROFILE_PATH ~ "/accounts/:accountId:ulong", &handleDeleteAccount);
|
a.map(HttpMethod.PUT, ACCOUNT_PATH, &handleUpdateAccount);
|
||||||
|
a.map(HttpMethod.DELETE, ACCOUNT_PATH, &handleDeleteAccount);
|
||||||
|
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);
|
||||||
|
a.map(HttpMethod.DELETE, ACCOUNT_PATH ~ "/value-records/:valueRecordId:ulong", &handleDeleteValueRecord);
|
||||||
|
|
||||||
import transaction.api;
|
import transaction.api;
|
||||||
// Transaction vendor endpoints:
|
// Transaction vendor endpoints:
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ interface ProfileDataSource {
|
||||||
|
|
||||||
AccountRepository getAccountRepository();
|
AccountRepository getAccountRepository();
|
||||||
AccountJournalEntryRepository getAccountJournalEntryRepository();
|
AccountJournalEntryRepository getAccountJournalEntryRepository();
|
||||||
|
AccountValueRecordRepository getAccountValueRecordRepository();
|
||||||
|
|
||||||
TransactionVendorRepository getTransactionVendorRepository();
|
TransactionVendorRepository getTransactionVendorRepository();
|
||||||
TransactionCategoryRepository getTransactionCategoryRepository();
|
TransactionCategoryRepository getTransactionCategoryRepository();
|
||||||
|
|
@ -41,3 +42,38 @@ interface ProfileDataSource {
|
||||||
|
|
||||||
void doTransaction(void delegate () dg);
|
void doTransaction(void delegate () dg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
version(unittest) {
|
||||||
|
class TestProfileDataSourceStub : ProfileDataSource {
|
||||||
|
import account.data;
|
||||||
|
import transaction.data;
|
||||||
|
|
||||||
|
PropertiesRepository getPropertiesRepository() {
|
||||||
|
throw new Exception("Not implemented");
|
||||||
|
}
|
||||||
|
AccountRepository getAccountRepository() {
|
||||||
|
throw new Exception("Not implemented");
|
||||||
|
}
|
||||||
|
AccountJournalEntryRepository getAccountJournalEntryRepository() {
|
||||||
|
throw new Exception("Not implemented");
|
||||||
|
}
|
||||||
|
AccountValueRecordRepository getAccountValueRecordRepository() {
|
||||||
|
throw new Exception("Not implemented");
|
||||||
|
}
|
||||||
|
TransactionVendorRepository getTransactionVendorRepository() {
|
||||||
|
throw new Exception("Not implemented");
|
||||||
|
}
|
||||||
|
TransactionCategoryRepository getTransactionCategoryRepository() {
|
||||||
|
throw new Exception("Not implemented");
|
||||||
|
}
|
||||||
|
TransactionTagRepository getTransactionTagRepository() {
|
||||||
|
throw new Exception("Not implemented");
|
||||||
|
}
|
||||||
|
TransactionRepository getTransactionRepository() {
|
||||||
|
throw new Exception("Not implemented");
|
||||||
|
}
|
||||||
|
void doTransaction(void delegate () dg) {
|
||||||
|
throw new Exception("Not implemented");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -170,6 +170,10 @@ class SqliteProfileDataSource : ProfileDataSource {
|
||||||
return new SqliteAccountJournalEntryRepository(db);
|
return new SqliteAccountJournalEntryRepository(db);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AccountValueRecordRepository getAccountValueRecordRepository() {
|
||||||
|
return new SqliteAccountValueRecordRepository(db);
|
||||||
|
}
|
||||||
|
|
||||||
TransactionVendorRepository getTransactionVendorRepository() {
|
TransactionVendorRepository getTransactionVendorRepository() {
|
||||||
return new SqliteTransactionVendorRepository(db);
|
return new SqliteTransactionVendorRepository(db);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -121,17 +121,38 @@ void handleGetCategories(ref ServerHttpRequest request, ref ServerHttpResponse r
|
||||||
}
|
}
|
||||||
|
|
||||||
void handleGetCategory(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
void handleGetCategory(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
// TODO
|
auto category = getCategory(getProfileDataSource(request), getCategoryId(request));
|
||||||
|
writeJsonBody(response, category);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CategoryPayload {
|
||||||
|
string name;
|
||||||
|
string description;
|
||||||
|
string color;
|
||||||
|
Nullable!ulong parentId;
|
||||||
}
|
}
|
||||||
|
|
||||||
void handleCreateCategory(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
void handleCreateCategory(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
// TODO
|
CategoryPayload payload = readJsonBodyAs!CategoryPayload(request);
|
||||||
|
ProfileDataSource ds = getProfileDataSource(request);
|
||||||
|
auto category = createCategory(ds, payload);
|
||||||
|
writeJsonBody(response, category);
|
||||||
}
|
}
|
||||||
|
|
||||||
void handleUpdateCategory(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
void handleUpdateCategory(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
// TODO
|
CategoryPayload payload = readJsonBodyAs!CategoryPayload(request);
|
||||||
|
ProfileDataSource ds = getProfileDataSource(request);
|
||||||
|
ulong categoryId = getCategoryId(request);
|
||||||
|
auto category = updateCategory(ds, categoryId, payload);
|
||||||
|
writeJsonBody(response, category);
|
||||||
}
|
}
|
||||||
|
|
||||||
void handleDeleteCategory(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
void handleDeleteCategory(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||||
// TODO
|
ProfileDataSource ds = getProfileDataSource(request);
|
||||||
|
ulong categoryId = getCategoryId(request);
|
||||||
|
deleteCategory(ds, categoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ulong getCategoryId(in ServerHttpRequest request) {
|
||||||
|
return getPathParamOrThrow(request, "categoryId");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ interface TransactionVendorRepository {
|
||||||
interface TransactionCategoryRepository {
|
interface TransactionCategoryRepository {
|
||||||
Optional!TransactionCategory findById(ulong id);
|
Optional!TransactionCategory findById(ulong id);
|
||||||
bool existsById(ulong id);
|
bool existsById(ulong id);
|
||||||
|
bool existsByName(string name);
|
||||||
TransactionCategory[] findAll();
|
TransactionCategory[] findAll();
|
||||||
TransactionCategory[] findAllByParentId(Optional!ulong parentId);
|
TransactionCategory[] findAllByParentId(Optional!ulong parentId);
|
||||||
TransactionCategory insert(Optional!ulong parentId, string name, string description, string color);
|
TransactionCategory insert(Optional!ulong parentId, string name, string description, string color);
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,10 @@ class SqliteTransactionCategoryRepository : TransactionCategoryRepository {
|
||||||
return util.sqlite.exists(db, "SELECT id FROM transaction_category WHERE id = ?", id);
|
return util.sqlite.exists(db, "SELECT id FROM transaction_category WHERE id = ?", id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool existsByName(string name) {
|
||||||
|
return util.sqlite.exists(db, "SELECT id FROM transaction_category WHERE name = ?", name);
|
||||||
|
}
|
||||||
|
|
||||||
TransactionCategory[] findAll() {
|
TransactionCategory[] findAll() {
|
||||||
return util.sqlite.findAll(
|
return util.sqlite.findAll(
|
||||||
db,
|
db,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import handy_http_primitives : Optional;
|
||||||
import asdf : serdeTransformOut;
|
import asdf : serdeTransformOut;
|
||||||
import std.typecons;
|
import std.typecons;
|
||||||
|
|
||||||
|
import transaction.model : TransactionCategory;
|
||||||
import util.data;
|
import util.data;
|
||||||
import util.money;
|
import util.money;
|
||||||
|
|
||||||
|
|
@ -121,3 +122,22 @@ struct TransactionCategoryTree {
|
||||||
TransactionCategoryTree[] children;
|
TransactionCategoryTree[] children;
|
||||||
uint depth;
|
uint depth;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct TransactionCategoryResponse {
|
||||||
|
ulong id;
|
||||||
|
@serdeTransformOut!serializeOptional
|
||||||
|
Optional!ulong parentId;
|
||||||
|
string name;
|
||||||
|
string description;
|
||||||
|
string color;
|
||||||
|
|
||||||
|
static TransactionCategoryResponse of(in TransactionCategory category) {
|
||||||
|
return TransactionCategoryResponse(
|
||||||
|
category.id,
|
||||||
|
category.parentId,
|
||||||
|
category.name,
|
||||||
|
category.description,
|
||||||
|
category.color
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import account.model;
|
||||||
import account.data;
|
import account.data;
|
||||||
import util.money;
|
import util.money;
|
||||||
import util.pagination;
|
import util.pagination;
|
||||||
|
import util.data;
|
||||||
import core.internal.container.common;
|
import core.internal.container.common;
|
||||||
|
|
||||||
// Transactions Services
|
// Transactions Services
|
||||||
|
|
@ -267,3 +268,55 @@ private TransactionCategoryTree[] getCategoriesRecursive(
|
||||||
}
|
}
|
||||||
return nodes;
|
return nodes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TransactionCategoryResponse getCategory(ProfileDataSource ds, ulong categoryId) {
|
||||||
|
auto category = ds.getTransactionCategoryRepository().findById(categoryId)
|
||||||
|
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
|
||||||
|
return TransactionCategoryResponse.of(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
TransactionCategoryResponse createCategory(ProfileDataSource ds, in CategoryPayload payload) {
|
||||||
|
TransactionCategoryRepository repo = ds.getTransactionCategoryRepository();
|
||||||
|
if (payload.name is null || payload.name.length == 0) {
|
||||||
|
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Missing name.");
|
||||||
|
}
|
||||||
|
if (repo.existsByName(payload.name)) {
|
||||||
|
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Name already in use.");
|
||||||
|
}
|
||||||
|
if (!payload.parentId.isNull && !repo.existsById(payload.parentId.get)) {
|
||||||
|
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid parent id.");
|
||||||
|
}
|
||||||
|
auto category = repo.insert(
|
||||||
|
toOptional(payload.parentId),
|
||||||
|
payload.name,
|
||||||
|
payload.description,
|
||||||
|
payload.color
|
||||||
|
);
|
||||||
|
return TransactionCategoryResponse.of(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
TransactionCategoryResponse updateCategory(ProfileDataSource ds, ulong categoryId, in CategoryPayload payload) {
|
||||||
|
TransactionCategoryRepository repo = ds.getTransactionCategoryRepository();
|
||||||
|
if (payload.name is null || payload.name.length == 0) {
|
||||||
|
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Missing name.");
|
||||||
|
}
|
||||||
|
TransactionCategory prev = repo.findById(categoryId)
|
||||||
|
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
|
||||||
|
if (payload.name != prev.name && repo.existsByName(payload.name)) {
|
||||||
|
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Name already in use.");
|
||||||
|
}
|
||||||
|
if (!payload.parentId.isNull && !repo.existsById(payload.parentId.get)) {
|
||||||
|
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid parent id.");
|
||||||
|
}
|
||||||
|
TransactionCategory curr = repo.updateById(
|
||||||
|
categoryId,
|
||||||
|
payload.name,
|
||||||
|
payload.description,
|
||||||
|
payload.color
|
||||||
|
);
|
||||||
|
return TransactionCategoryResponse.of(curr);
|
||||||
|
}
|
||||||
|
|
||||||
|
void deleteCategory(ProfileDataSource ds, ulong categoryId) {
|
||||||
|
ds.getTransactionCategoryRepository().deleteById(categoryId);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -145,7 +145,7 @@ void generateRandomTransactions(ProfileDataSource ds) {
|
||||||
|
|
||||||
// Randomly choose an account to credit / debit the transaction to.
|
// Randomly choose an account to credit / debit the transaction to.
|
||||||
Account primaryAccount = choice(accounts);
|
Account primaryAccount = choice(accounts);
|
||||||
data.currencyCode = primaryAccount.currency.code;
|
data.currencyCode = primaryAccount.currency.code.idup;
|
||||||
Optional!ulong secondaryAccountId;
|
Optional!ulong secondaryAccountId;
|
||||||
if (uniform01() < 0.25) {
|
if (uniform01() < 0.25) {
|
||||||
foreach (acc; accounts) {
|
foreach (acc; accounts) {
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,9 @@ CREATE TABLE "transaction" (
|
||||||
ON UPDATE CASCADE ON DELETE SET NULL,
|
ON UPDATE CASCADE ON DELETE SET NULL,
|
||||||
CONSTRAINT fk_transaction_category
|
CONSTRAINT fk_transaction_category
|
||||||
FOREIGN KEY (category_id) REFERENCES transaction_category(id)
|
FOREIGN KEY (category_id) REFERENCES transaction_category(id)
|
||||||
ON UPDATE CASCADE ON DELETE SET NULL
|
ON UPDATE CASCADE ON DELETE SET NULL,
|
||||||
|
CONSTRAINT ck_transaction_amount_positive
|
||||||
|
CHECK (amount > 0)
|
||||||
);
|
);
|
||||||
CREATE INDEX idx_transaction_by_timestamp ON "transaction"(timestamp);
|
CREATE INDEX idx_transaction_by_timestamp ON "transaction"(timestamp);
|
||||||
|
|
||||||
|
|
@ -126,7 +128,9 @@ CREATE TABLE account_journal_entry (
|
||||||
FOREIGN KEY (transaction_id) REFERENCES "transaction"(id)
|
FOREIGN KEY (transaction_id) REFERENCES "transaction"(id)
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||||
CONSTRAINT uq_account_journal_entry_ids
|
CONSTRAINT uq_account_journal_entry_ids
|
||||||
UNIQUE (account_id, transaction_id)
|
UNIQUE (account_id, transaction_id),
|
||||||
|
CONSTRAINT ck_account_journal_entry_amount_positive
|
||||||
|
CHECK (amount > 0)
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Value records
|
-- Value records
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import { ApiClient } from './base'
|
import { ApiClient } from './base'
|
||||||
|
import type { Currency } from './data'
|
||||||
|
import type { Page, PageRequest } from './pagination'
|
||||||
import type { Profile } from './profile'
|
import type { Profile } from './profile'
|
||||||
|
|
||||||
export interface AccountType {
|
export interface AccountType {
|
||||||
|
|
@ -47,6 +49,7 @@ export interface Account {
|
||||||
name: string
|
name: string
|
||||||
currency: string
|
currency: string
|
||||||
description: string
|
description: string
|
||||||
|
currentBalance: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AccountCreationPayload {
|
export interface AccountCreationPayload {
|
||||||
|
|
@ -57,6 +60,40 @@ export interface AccountCreationPayload {
|
||||||
description: string
|
description: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum AccountJournalEntryType {
|
||||||
|
CREDIT = 'CREDIT',
|
||||||
|
DEBIT = 'DEBIT',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountJournalEntry {
|
||||||
|
id: number
|
||||||
|
timestamp: string
|
||||||
|
accountId: number
|
||||||
|
transactionId: number
|
||||||
|
amount: number
|
||||||
|
type: AccountJournalEntryType
|
||||||
|
currency: Currency
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum AccountValueRecordType {
|
||||||
|
BALANCE = 'BALANCE',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountValueRecord {
|
||||||
|
id: number
|
||||||
|
timestamp: string
|
||||||
|
accountId: number
|
||||||
|
type: AccountValueRecordType
|
||||||
|
value: number
|
||||||
|
currency: Currency
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountValueRecordCreationPayload {
|
||||||
|
timestamp: string
|
||||||
|
type: AccountValueRecordType
|
||||||
|
value: number
|
||||||
|
}
|
||||||
|
|
||||||
export class AccountApiClient extends ApiClient {
|
export class AccountApiClient extends ApiClient {
|
||||||
readonly path: string
|
readonly path: string
|
||||||
|
|
||||||
|
|
@ -65,23 +102,42 @@ export class AccountApiClient extends ApiClient {
|
||||||
this.path = `/profiles/${profile.name}/accounts`
|
this.path = `/profiles/${profile.name}/accounts`
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAccounts(): Promise<Account[]> {
|
getAccounts(): Promise<Account[]> {
|
||||||
return super.getJson(this.path)
|
return super.getJson(this.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAccount(id: number): Promise<Account> {
|
getAccount(id: number): Promise<Account> {
|
||||||
return super.getJson(this.path + '/' + id)
|
return super.getJson(this.path + '/' + id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async createAccount(data: AccountCreationPayload): Promise<Account> {
|
createAccount(data: AccountCreationPayload): Promise<Account> {
|
||||||
return super.postJson(this.path, data)
|
return super.postJson(this.path, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateAccount(id: number, data: AccountCreationPayload): Promise<Account> {
|
updateAccount(id: number, data: AccountCreationPayload): Promise<Account> {
|
||||||
return super.putJson(this.path + '/' + id, data)
|
return super.putJson(this.path + '/' + id, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteAccount(id: number): Promise<void> {
|
deleteAccount(id: number): Promise<void> {
|
||||||
return super.delete(this.path + '/' + id)
|
return super.delete(this.path + '/' + id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getValueRecords(accountId: number, pageRequest: PageRequest): Promise<Page<AccountValueRecord>> {
|
||||||
|
return super.getJsonPage(this.path + '/' + accountId + '/value-records', pageRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
getValueRecord(accountId: number, valueRecordId: number): Promise<AccountValueRecord> {
|
||||||
|
return super.getJson(this.path + '/' + accountId + '/value-records/' + valueRecordId)
|
||||||
|
}
|
||||||
|
|
||||||
|
createValueRecord(
|
||||||
|
accountId: number,
|
||||||
|
payload: AccountValueRecordCreationPayload,
|
||||||
|
): Promise<AccountValueRecord> {
|
||||||
|
return super.postJson(this.path + '/' + accountId + '/value-records', payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteValueRecord(accountId: number, valueRecordId: number): Promise<void> {
|
||||||
|
return super.delete(this.path + '/' + accountId + '/value-records/' + valueRecordId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,19 +20,19 @@ export class AuthApiClient extends ApiClient {
|
||||||
return r.available
|
return r.available
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMyUser(): Promise<string> {
|
getMyUser(): Promise<string> {
|
||||||
return await super.getText('/me')
|
return super.getText('/me')
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteMyUser(): Promise<void> {
|
deleteMyUser(): Promise<void> {
|
||||||
return await super.delete('/me')
|
return super.delete('/me')
|
||||||
}
|
}
|
||||||
|
|
||||||
async getNewToken(): Promise<string> {
|
getNewToken(): Promise<string> {
|
||||||
return await super.getText('/me/token')
|
return super.getText('/me/token')
|
||||||
}
|
}
|
||||||
|
|
||||||
async changeMyPassword(currentPassword: string, newPassword: string): Promise<void> {
|
changeMyPassword(currentPassword: string, newPassword: string): Promise<void> {
|
||||||
return await super.postNoResponse('/me/password', { currentPassword, newPassword })
|
return super.postNoResponse('/me/password', { currentPassword, newPassword })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ export interface Currency {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DataApiClient extends ApiClient {
|
export class DataApiClient extends ApiClient {
|
||||||
async getCurrencies(): Promise<Currency[]> {
|
getCurrencies(): Promise<Currency[]> {
|
||||||
return await super.getJson('/currencies')
|
return super.getJson('/currencies')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,23 +10,23 @@ export interface ProfileProperty {
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ProfileApiClient extends ApiClient {
|
export class ProfileApiClient extends ApiClient {
|
||||||
async getProfiles(): Promise<Profile[]> {
|
getProfiles(): Promise<Profile[]> {
|
||||||
return await super.getJson('/profiles')
|
return super.getJson('/profiles')
|
||||||
}
|
}
|
||||||
|
|
||||||
async getProfile(name: string): Promise<Profile> {
|
getProfile(name: string): Promise<Profile> {
|
||||||
return await super.getJson('/profiles/' + name)
|
return super.getJson('/profiles/' + name)
|
||||||
}
|
}
|
||||||
|
|
||||||
async createProfile(name: string): Promise<Profile> {
|
createProfile(name: string): Promise<Profile> {
|
||||||
return await super.postJson('/profiles', { name })
|
return super.postJson('/profiles', { name })
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteProfile(name: string): Promise<void> {
|
deleteProfile(name: string): Promise<void> {
|
||||||
return await super.delete(`/profiles/${name}`)
|
return super.delete(`/profiles/${name}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
async getProperties(profileName: string): Promise<ProfileProperty[]> {
|
getProperties(profileName: string): Promise<ProfileProperty[]> {
|
||||||
return await super.getJson(`/profiles/${profileName}/properties`)
|
return super.getJson(`/profiles/${profileName}/properties`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,13 @@ export interface AddTransactionPayloadLineItem {
|
||||||
categoryId: number | null
|
categoryId: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreateCategoryPayload {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
color: string
|
||||||
|
parentId: number | null
|
||||||
|
}
|
||||||
|
|
||||||
export class TransactionApiClient extends ApiClient {
|
export class TransactionApiClient extends ApiClient {
|
||||||
readonly path: string
|
readonly path: string
|
||||||
|
|
||||||
|
|
@ -132,53 +139,69 @@ export class TransactionApiClient extends ApiClient {
|
||||||
this.path = `/profiles/${profile.name}`
|
this.path = `/profiles/${profile.name}`
|
||||||
}
|
}
|
||||||
|
|
||||||
async getVendors(): Promise<TransactionVendor[]> {
|
getVendors(): Promise<TransactionVendor[]> {
|
||||||
return await super.getJson(this.path + '/vendors')
|
return super.getJson(this.path + '/vendors')
|
||||||
}
|
}
|
||||||
|
|
||||||
async getVendor(id: number): Promise<TransactionVendor> {
|
getVendor(id: number): Promise<TransactionVendor> {
|
||||||
return await super.getJson(this.path + '/vendors/' + id)
|
return super.getJson(this.path + '/vendors/' + id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async createVendor(data: TransactionVendorPayload): Promise<TransactionVendor> {
|
createVendor(data: TransactionVendorPayload): Promise<TransactionVendor> {
|
||||||
return await super.postJson(this.path + '/vendors', data)
|
return super.postJson(this.path + '/vendors', data)
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateVendor(id: number, data: TransactionVendorPayload): Promise<TransactionVendor> {
|
updateVendor(id: number, data: TransactionVendorPayload): Promise<TransactionVendor> {
|
||||||
return await super.putJson(this.path + '/vendors/' + id, data)
|
return super.putJson(this.path + '/vendors/' + id, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteVendor(id: number): Promise<void> {
|
deleteVendor(id: number): Promise<void> {
|
||||||
return await super.delete(this.path + '/vendors/' + id)
|
return super.delete(this.path + '/vendors/' + id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCategories(): Promise<TransactionCategoryTree[]> {
|
getCategories(): Promise<TransactionCategoryTree[]> {
|
||||||
return await super.getJson(this.path + '/categories')
|
return super.getJson(this.path + '/categories')
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTransactions(
|
getCategory(id: number): Promise<TransactionCategory> {
|
||||||
|
return super.getJson(this.path + '/categories/' + id)
|
||||||
|
}
|
||||||
|
|
||||||
|
createCategory(data: CreateCategoryPayload): Promise<TransactionCategory> {
|
||||||
|
return super.postJson(this.path + '/categories', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCategory(id: number, data: CreateCategoryPayload): Promise<TransactionCategory> {
|
||||||
|
return super.postJson(this.path + '/categories/' + id, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteCategory(id: number): Promise<void> {
|
||||||
|
return super.delete(this.path + '/categories/' + id)
|
||||||
|
}
|
||||||
|
|
||||||
|
getTransactions(
|
||||||
paginationOptions: PageRequest | undefined = undefined,
|
paginationOptions: PageRequest | undefined = undefined,
|
||||||
): Promise<Page<TransactionsListItem>> {
|
): Promise<Page<TransactionsListItem>> {
|
||||||
return await super.getJsonPage(this.path + '/transactions', paginationOptions)
|
return super.getJsonPage(this.path + '/transactions', paginationOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTransaction(id: number): Promise<TransactionDetail> {
|
getTransaction(id: number): Promise<TransactionDetail> {
|
||||||
return await super.getJson(this.path + '/transactions/' + id)
|
return super.getJson(this.path + '/transactions/' + id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async addTransaction(data: AddTransactionPayload): Promise<TransactionDetail> {
|
addTransaction(data: AddTransactionPayload): Promise<TransactionDetail> {
|
||||||
return await super.postJson(this.path + '/transactions', data)
|
return super.postJson(this.path + '/transactions', data)
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateTransaction(id: number, data: AddTransactionPayload): Promise<TransactionDetail> {
|
updateTransaction(id: number, data: AddTransactionPayload): Promise<TransactionDetail> {
|
||||||
return await super.putJson(this.path + '/transactions/' + id, data)
|
return super.putJson(this.path + '/transactions/' + id, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteTransaction(id: number): Promise<void> {
|
deleteTransaction(id: number): Promise<void> {
|
||||||
return await super.delete(this.path + '/transactions/' + id)
|
return super.delete(this.path + '/transactions/' + id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAllTags(): Promise<string[]> {
|
getAllTags(): Promise<string[]> {
|
||||||
return await super.getJson(this.path + '/transaction-tags')
|
return super.getJson(this.path + '/transaction-tags')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -48,3 +48,16 @@ a:hover {
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A generic table styling for most default tables. */
|
||||||
|
.app-table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-table td,
|
||||||
|
.app-table th {
|
||||||
|
border: 2px solid var(--theme-secondary);
|
||||||
|
padding: 0.1rem 0.25rem;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, useTemplateRef, type Ref } from 'vue';
|
||||||
|
import AppForm from './form/AppForm.vue';
|
||||||
|
import FormControl from './form/FormControl.vue';
|
||||||
|
import FormGroup from './form/FormGroup.vue';
|
||||||
|
import ModalWrapper from './ModalWrapper.vue';
|
||||||
|
import AppButton from './AppButton.vue';
|
||||||
|
import { AccountApiClient, AccountValueRecordType, type Account, type AccountValueRecord, type AccountValueRecordCreationPayload } from '@/api/account';
|
||||||
|
import { useProfileStore } from '@/stores/profile-store';
|
||||||
|
|
||||||
|
const props = defineProps<{ account: Account }>()
|
||||||
|
const profileStore = useProfileStore()
|
||||||
|
const modal = useTemplateRef('modal')
|
||||||
|
const savedValueRecord: Ref<AccountValueRecord | undefined> = ref(undefined)
|
||||||
|
|
||||||
|
// Form data:
|
||||||
|
const timestamp = ref('')
|
||||||
|
const amount = ref(0)
|
||||||
|
|
||||||
|
async function show(): Promise<AccountValueRecord | undefined> {
|
||||||
|
if (!modal.value) return Promise.resolve(undefined)
|
||||||
|
const now = new Date()
|
||||||
|
const localDate = new Date(now.getTime() - now.getTimezoneOffset() * 60_000)
|
||||||
|
localDate.setMilliseconds(0)
|
||||||
|
timestamp.value = localDate.toISOString().slice(0, -1)
|
||||||
|
amount.value = props.account.currentBalance ?? 0
|
||||||
|
savedValueRecord.value = undefined
|
||||||
|
const result = await modal.value.show()
|
||||||
|
if (result === 'saved') {
|
||||||
|
return savedValueRecord.value
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addValueRecord() {
|
||||||
|
if (!profileStore.state) return
|
||||||
|
const payload: AccountValueRecordCreationPayload = {
|
||||||
|
timestamp: new Date(timestamp.value).toISOString(),
|
||||||
|
type: AccountValueRecordType.BALANCE,
|
||||||
|
value: amount.value
|
||||||
|
}
|
||||||
|
const api = new AccountApiClient(profileStore.state)
|
||||||
|
try {
|
||||||
|
savedValueRecord.value = await api.createValueRecord(props.account.id, payload)
|
||||||
|
modal.value?.close('saved')
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
savedValueRecord.value = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ show })
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<ModalWrapper ref="modal">
|
||||||
|
<template v-slot:default>
|
||||||
|
<h2>Add Value Record</h2>
|
||||||
|
<p>
|
||||||
|
Record the current value of this account, to act as a keyframe from
|
||||||
|
which the account's balance can be derived.
|
||||||
|
</p>
|
||||||
|
<AppForm>
|
||||||
|
<FormGroup>
|
||||||
|
<FormControl label="Timestamp">
|
||||||
|
<input type="datetime-local" v-model="timestamp" step="1" style="min-width: 250px;" />
|
||||||
|
</FormControl>
|
||||||
|
<FormControl label="Value">
|
||||||
|
<input type="number" v-model="amount" step="0.01" />
|
||||||
|
</FormControl>
|
||||||
|
</FormGroup>
|
||||||
|
</AppForm>
|
||||||
|
</template>
|
||||||
|
<template v-slot:buttons>
|
||||||
|
<AppButton @click="addValueRecord()">Add</AppButton>
|
||||||
|
<AppButton button-style="secondary" @click="modal?.close()">Cancel</AppButton>
|
||||||
|
</template>
|
||||||
|
</ModalWrapper>
|
||||||
|
</template>
|
||||||
|
|
@ -13,7 +13,8 @@ defineEmits(['click'])
|
||||||
:class="{ 'app-button-secondary': buttonStyle === 'secondary', 'app-button-disabled': disabled ?? false }"
|
:class="{ 'app-button-secondary': buttonStyle === 'secondary', 'app-button-disabled': disabled ?? false }"
|
||||||
@click="$emit('click')" :type="buttonType" :disabled="disabled ?? false">
|
@click="$emit('click')" :type="buttonType" :disabled="disabled ?? false">
|
||||||
<span v-if="icon">
|
<span v-if="icon">
|
||||||
<font-awesome-icon :icon="'fa-' + icon" style="margin-right: 0.5rem; margin-left: -0.5rem;"></font-awesome-icon>
|
<font-awesome-icon :icon="'fa-' + icon"
|
||||||
|
:class="{ 'app-button-icon-with-text': $slots.default !== undefined, 'app-button-icon-without-text': $slots.default === undefined }"></font-awesome-icon>
|
||||||
</span>
|
</span>
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -73,4 +74,14 @@ defineEmits(['click'])
|
||||||
.app-button-secondary {
|
.app-button-secondary {
|
||||||
background-color: #1d2330;
|
background-color: #1d2330;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-button-icon-with-text {
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
margin-left: -0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-button-icon-without-text {
|
||||||
|
margin-left: -0.5rem;
|
||||||
|
margin-right: -0.5rem;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, useTemplateRef } from 'vue';
|
||||||
|
import AppForm from './form/AppForm.vue';
|
||||||
|
import FormControl from './form/FormControl.vue';
|
||||||
|
import FormGroup from './form/FormGroup.vue';
|
||||||
|
import ModalWrapper from './ModalWrapper.vue';
|
||||||
|
import { TransactionApiClient, type TransactionVendor } from '@/api/transaction';
|
||||||
|
import { useProfileStore } from '@/stores/profile-store';
|
||||||
|
import AppButton from './AppButton.vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
vendor?: TransactionVendor
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{ saved: [TransactionVendor] }>()
|
||||||
|
const profileStore = useProfileStore()
|
||||||
|
const modal = useTemplateRef('modal')
|
||||||
|
|
||||||
|
// Form data:
|
||||||
|
const name = ref('')
|
||||||
|
const description = ref('')
|
||||||
|
|
||||||
|
function show(): Promise<string | undefined> {
|
||||||
|
if (!modal.value) return Promise.resolve(undefined)
|
||||||
|
name.value = props.vendor?.name ?? ''
|
||||||
|
description.value = props.vendor?.description ?? ''
|
||||||
|
return modal.value?.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
function canSave() {
|
||||||
|
const inputValid = name.value.trim().length > 0
|
||||||
|
if (!inputValid) return false
|
||||||
|
if (props.vendor) {
|
||||||
|
return props.vendor.name.trim() !== name.value.trim() ||
|
||||||
|
props.vendor.description.trim() !== description.value.trim()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doSave() {
|
||||||
|
if (!profileStore.state) return
|
||||||
|
const api = new TransactionApiClient(profileStore.state)
|
||||||
|
const payload = {
|
||||||
|
name: name.value.trim(),
|
||||||
|
description: description.value.trim()
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
let savedVendor = null
|
||||||
|
if (props.vendor) {
|
||||||
|
savedVendor = await api.updateVendor(props.vendor.id, payload)
|
||||||
|
} else {
|
||||||
|
savedVendor = await api.createVendor(payload)
|
||||||
|
}
|
||||||
|
emit('saved', savedVendor)
|
||||||
|
modal.value?.close('saved')
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
modal.value?.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ show })
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<ModalWrapper ref="modal">
|
||||||
|
<template v-slot:default>
|
||||||
|
<h2>Add Vendor</h2>
|
||||||
|
<AppForm>
|
||||||
|
<FormGroup>
|
||||||
|
<FormControl label="Name">
|
||||||
|
<input type="text" v-model="name" />
|
||||||
|
</FormControl>
|
||||||
|
<FormControl label="Description" style="min-width: 300px;">
|
||||||
|
<textarea v-model="description"></textarea>
|
||||||
|
</FormControl>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
</AppForm>
|
||||||
|
</template>
|
||||||
|
<template v-slot:buttons>
|
||||||
|
<AppButton :disabled="!canSave()" @click="doSave()">Save</AppButton>
|
||||||
|
<AppButton button-style="secondary" @click="modal?.close()">Cancel</AppButton>
|
||||||
|
</template>
|
||||||
|
</ModalWrapper>
|
||||||
|
</template>
|
||||||
|
|
@ -1,17 +1,19 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { AccountApiClient, type Account } from '@/api/account';
|
import { AccountApiClient, type Account } from '@/api/account';
|
||||||
|
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 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';
|
||||||
import { onMounted, ref, type Ref } from 'vue';
|
import { onMounted, ref, useTemplateRef, type Ref } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const profileStore = useProfileStore()
|
const profileStore = useProfileStore()
|
||||||
|
const addValueRecordModal = useTemplateRef("addValueRecordModal")
|
||||||
const account: Ref<Account | null> = ref(null)
|
const account: Ref<Account | null> = ref(null)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
|
@ -42,6 +44,13 @@ async function deleteAccount() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function addValueRecord() {
|
||||||
|
const result = await addValueRecordModal.value?.show()
|
||||||
|
if (result) {
|
||||||
|
console.info('Value record added', result)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<AppPage :title="account?.name ?? ''">
|
<AppPage :title="account?.name ?? ''">
|
||||||
|
|
@ -80,9 +89,12 @@ async function deleteAccount() {
|
||||||
</tr>
|
</tr>
|
||||||
</PropertiesTable>
|
</PropertiesTable>
|
||||||
<div>
|
<div>
|
||||||
|
<AppButton @click="addValueRecord()">Record Value</AppButton>
|
||||||
<AppButton icon="wrench"
|
<AppButton icon="wrench"
|
||||||
@click="router.push(`/profiles/${profileStore.state?.name}/accounts/${account?.id}/edit`)">Edit</AppButton>
|
@click="router.push(`/profiles/${profileStore.state?.name}/accounts/${account?.id}/edit`)">Edit</AppButton>
|
||||||
<AppButton icon="trash" @click="deleteAccount()">Delete</AppButton>
|
<AppButton icon="trash" @click="deleteAccount()">Delete</AppButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AddValueRecordModal v-if="account" :account="account" ref="addValueRecordModal" />
|
||||||
</AppPage>
|
</AppPage>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,13 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { AuthApiClient } from '@/api/auth';
|
import { AuthApiClient } from '@/api/auth';
|
||||||
import { ApiError } from '@/api/base';
|
import { ApiError } from '@/api/base';
|
||||||
|
import AppButton from '@/components/AppButton.vue';
|
||||||
|
import AppForm from '@/components/form/AppForm.vue';
|
||||||
|
import FormControl from '@/components/form/FormControl.vue';
|
||||||
|
import FormGroup from '@/components/form/FormGroup.vue';
|
||||||
import { useAuthStore } from '@/stores/auth-store';
|
import { useAuthStore } from '@/stores/auth-store';
|
||||||
import { showAlert } from '@/util/alert';
|
import { showAlert } from '@/util/alert';
|
||||||
|
import { hideLoader, showLoader } from '@/util/loader';
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
|
@ -17,15 +22,18 @@ const disableForm = ref(false)
|
||||||
|
|
||||||
async function doLogin() {
|
async function doLogin() {
|
||||||
disableForm.value = true
|
disableForm.value = true
|
||||||
|
showLoader()
|
||||||
try {
|
try {
|
||||||
const token = await apiClient.login(username.value, password.value)
|
const token = await apiClient.login(username.value, password.value)
|
||||||
authStore.onUserLoggedIn(username.value, token)
|
authStore.onUserLoggedIn(username.value, token)
|
||||||
|
hideLoader()
|
||||||
if ('next' in route.query && typeof (route.query.next) === 'string') {
|
if ('next' in route.query && typeof (route.query.next) === 'string') {
|
||||||
await router.replace(route.query.next)
|
await router.replace(route.query.next)
|
||||||
} else {
|
} else {
|
||||||
await router.replace('/')
|
await router.replace('/')
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
hideLoader()
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
await showAlert(err.message)
|
await showAlert(err.message)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -36,6 +44,10 @@ async function doLogin() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isDataValid() {
|
||||||
|
return username.value.length > 0 && password.value.length >= 8
|
||||||
|
}
|
||||||
|
|
||||||
function generateSampleData() {
|
function generateSampleData() {
|
||||||
fetch('http://localhost:8080/api/sample-data', {
|
fetch('http://localhost:8080/api/sample-data', {
|
||||||
method: 'POST'
|
method: 'POST'
|
||||||
|
|
@ -43,24 +55,30 @@ function generateSampleData() {
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="app-login-panel">
|
||||||
<h1>Login</h1>
|
<h1 style="text-align: center;">Login to <span style="font-family: 'PlaywriteNL';">Finnow</span></h1>
|
||||||
<form @submit.prevent="doLogin()">
|
<AppForm @submit="doLogin()">
|
||||||
<div>
|
<FormGroup>
|
||||||
<label for="username-input">Username</label>
|
<FormControl label="Username">
|
||||||
<input id="username-input" type="text" v-model="username" :disabled="disableForm" />
|
<input type="text" v-model="username" :disabled="disableForm" />
|
||||||
</div>
|
</FormControl>
|
||||||
<div>
|
<FormControl label="Password">
|
||||||
<label for="password-input">Password</label>
|
|
||||||
<input id="password-input" type="password" v-model="password" :disabled="disableForm" />
|
<input id="password-input" type="password" v-model="password" :disabled="disableForm" />
|
||||||
</div>
|
</FormControl>
|
||||||
|
</FormGroup>
|
||||||
<div>
|
<div>
|
||||||
<button type="submit" :disabled="disableForm">Login</button>
|
<AppButton button-type="submit" :disabled="disableForm || !isDataValid()" style="width: 100%;">Login</AppButton>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</AppForm>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<button type="button" @click="generateSampleData()">Generate Sample Data</button>
|
<button type="button" @click="generateSampleData()">Generate Sample Data</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<style lang="css">
|
||||||
|
.app-login-panel {
|
||||||
|
max-width: 40ch;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,7 @@ async function deleteTransaction() {
|
||||||
|
|
||||||
<div v-if="transaction.lineItems.length > 0">
|
<div v-if="transaction.lineItems.length > 0">
|
||||||
<h3>Line Items</h3>
|
<h3>Line Items</h3>
|
||||||
<table>
|
<table class="app-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>#</th>
|
<th>#</th>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { TransactionApiClient, type TransactionVendor } from '@/api/transaction';
|
||||||
|
import AppButton from '@/components/AppButton.vue';
|
||||||
|
import AppPage from '@/components/AppPage.vue';
|
||||||
|
import EditVendorModal from '@/components/EditVendorModal.vue';
|
||||||
|
import { useProfileStore } from '@/stores/profile-store';
|
||||||
|
import { showConfirm } from '@/util/alert';
|
||||||
|
import { onMounted, ref, useTemplateRef, type Ref } from 'vue';
|
||||||
|
|
||||||
|
const profileStore = useProfileStore()
|
||||||
|
const vendors: Ref<TransactionVendor[]> = ref([])
|
||||||
|
const editVendorModal = useTemplateRef('editVendorModal')
|
||||||
|
const editedVendor: Ref<TransactionVendor | undefined> = ref()
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadVendors()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadVendors() {
|
||||||
|
if (!profileStore.state) return
|
||||||
|
const api = new TransactionApiClient(profileStore.state)
|
||||||
|
vendors.value = await api.getVendors()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addVendor() {
|
||||||
|
editedVendor.value = undefined
|
||||||
|
const result = await editVendorModal.value?.show()
|
||||||
|
if (result === 'saved') {
|
||||||
|
await loadVendors()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editVendor(vendor: TransactionVendor) {
|
||||||
|
editedVendor.value = vendor
|
||||||
|
const result = await editVendorModal.value?.show()
|
||||||
|
if (result === 'saved') {
|
||||||
|
await loadVendors()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteVendor(vendor: TransactionVendor) {
|
||||||
|
if (!profileStore.state) return
|
||||||
|
const confirmed = await showConfirm('Are you sure you want to delete this vendor? It will be permanently removed from all associated transactions.')
|
||||||
|
if (!confirmed) return
|
||||||
|
const api = new TransactionApiClient(profileStore.state)
|
||||||
|
await api.deleteVendor(vendor.id)
|
||||||
|
await loadVendors()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<AppPage title="Vendors">
|
||||||
|
<p>
|
||||||
|
Vendors are businesses and other entities with which you exchange money.
|
||||||
|
Adding a vendor to Finnow allows you to track when you interact with that
|
||||||
|
vendor on a transaction.
|
||||||
|
</p>
|
||||||
|
<table class="app-table">
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="vendor in vendors" :key="vendor.id">
|
||||||
|
<td>{{ vendor.name }}</td>
|
||||||
|
<td>{{ vendor.description }}</td>
|
||||||
|
<td style="min-width: 130px;">
|
||||||
|
<AppButton icon="wrench" @click="editVendor(vendor)" />
|
||||||
|
<AppButton icon="trash" @click="deleteVendor(vendor)" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div style="text-align: right;">
|
||||||
|
<AppButton button-type="button" icon="plus" @click="addVendor()">Add Vendor</AppButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EditVendorModal ref="editVendorModal" :vendor="editedVendor" />
|
||||||
|
</AppPage>
|
||||||
|
</template>
|
||||||
|
|
@ -105,7 +105,8 @@ async function doSubmit() {
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
|
|
||||||
<FormActions @cancel="router.replace('/')" :disabled="loading" :submit-text="editing ? 'Save' : 'Add'" />
|
<FormActions @cancel="router.replace(`/profiles/${profileStore.state?.name}`)" :disabled="loading"
|
||||||
|
:submit-text="editing ? 'Save' : 'Add'" />
|
||||||
</AppForm>
|
</AppForm>
|
||||||
</AppPage>
|
</AppPage>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ onMounted(async () => {
|
||||||
<template>
|
<template>
|
||||||
<HomeModule title="Accounts">
|
<HomeModule title="Accounts">
|
||||||
<template v-slot:default>
|
<template v-slot:default>
|
||||||
<table>
|
<table class="app-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,12 @@ async function deleteProfile() {
|
||||||
<HomeModule title="Profile">
|
<HomeModule title="Profile">
|
||||||
<template v-slot:default>
|
<template v-slot:default>
|
||||||
<p>Your currently selected profile is: {{ profileStore.state?.name }}</p>
|
<p>Your currently selected profile is: {{ profileStore.state?.name }}</p>
|
||||||
|
<p>
|
||||||
|
<RouterLink :to="`/profiles/${profileStore.state?.name}/vendors`">View all vendors here.</RouterLink>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<RouterLink :to="`/profiles/${profileStore.state?.name}/categories`">View all categories here.</RouterLink>
|
||||||
|
</p>
|
||||||
|
|
||||||
<ConfirmModal ref="confirmDeleteModal">
|
<ConfirmModal ref="confirmDeleteModal">
|
||||||
<p>Are you sure you want to delete this profile?</p>
|
<p>Are you sure you want to delete this profile?</p>
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ async function fetchPage(pageRequest: PageRequest) {
|
||||||
<template>
|
<template>
|
||||||
<HomeModule title="Transactions">
|
<HomeModule title="Transactions">
|
||||||
<template v-slot:default>
|
<template v-slot:default>
|
||||||
<table>
|
<table class="app-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Date</th>
|
<th>Date</th>
|
||||||
|
|
@ -39,6 +39,7 @@ async function fetchPage(pageRequest: PageRequest) {
|
||||||
<th>Description</th>
|
<th>Description</th>
|
||||||
<th>Credited Account</th>
|
<th>Credited Account</th>
|
||||||
<th>Debited Account</th>
|
<th>Debited Account</th>
|
||||||
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
@ -49,8 +50,18 @@ async function fetchPage(pageRequest: PageRequest) {
|
||||||
<td style="text-align: right;">{{ formatMoney(tx.amount, tx.currency) }}</td>
|
<td style="text-align: right;">{{ formatMoney(tx.amount, tx.currency) }}</td>
|
||||||
<td>{{ tx.currency.code }}</td>
|
<td>{{ tx.currency.code }}</td>
|
||||||
<td>{{ tx.description }}</td>
|
<td>{{ tx.description }}</td>
|
||||||
<td>{{ tx.creditedAccount?.name }}</td>
|
<td>
|
||||||
<td>{{ tx.debitedAccount?.name }}</td>
|
<RouterLink v-if="tx.creditedAccount"
|
||||||
|
:to="`/profiles/${profileStore.state?.name}/accounts/${tx.creditedAccount.id}`">
|
||||||
|
{{ tx.creditedAccount?.name }}
|
||||||
|
</RouterLink>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<RouterLink v-if="tx.debitedAccount"
|
||||||
|
:to="`/profiles/${profileStore.state?.name}/accounts/${tx.debitedAccount.id}`">
|
||||||
|
{{ tx.debitedAccount?.name }}
|
||||||
|
</RouterLink>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<RouterLink :to="`/profiles/${profileStore.state?.name}/transactions/${tx.id}`">View</RouterLink>
|
<RouterLink :to="`/profiles/${profileStore.state?.name}/transactions/${tx.id}`">View</RouterLink>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,11 @@ const router = createRouter({
|
||||||
component: () => import('@/pages/forms/EditTransactionPage.vue'),
|
component: () => import('@/pages/forms/EditTransactionPage.vue'),
|
||||||
meta: { title: 'Add Transaction' },
|
meta: { title: 'Add Transaction' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'vendors',
|
||||||
|
component: () => import('@/pages/VendorsPage.vue'),
|
||||||
|
meta: { title: 'Vendors' },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue