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_data.json;
|
||||
import handy_http_handlers.path_handler;
|
||||
import std.datetime;
|
||||
|
||||
import profile.service;
|
||||
import profile.data;
|
||||
import account.model;
|
||||
import account.service;
|
||||
import util.money;
|
||||
import util.pagination;
|
||||
import account.data;
|
||||
|
||||
/// The data the API provides for an Account entity.
|
||||
struct AccountResponse {
|
||||
import asdf : serdeTransformOut;
|
||||
import util.data;
|
||||
|
||||
ulong id;
|
||||
string createdAt;
|
||||
bool archived;
|
||||
|
|
@ -23,8 +30,10 @@ struct AccountResponse {
|
|||
string name;
|
||||
string currency;
|
||||
string description;
|
||||
@serdeTransformOut!serializeOptional
|
||||
Optional!long currentBalance;
|
||||
|
||||
static AccountResponse of(in Account account) {
|
||||
static AccountResponse of(in Account account, Optional!long currentBalance) {
|
||||
AccountResponse r;
|
||||
r.id = account.id;
|
||||
r.createdAt = account.createdAt.toISOExtString();
|
||||
|
|
@ -34,6 +43,7 @@ struct AccountResponse {
|
|||
r.name = account.name;
|
||||
r.currency = account.currency.code.dup;
|
||||
r.description = account.description;
|
||||
r.currentBalance = currentBalance;
|
||||
return r;
|
||||
}
|
||||
}
|
||||
|
|
@ -43,7 +53,7 @@ void handleGetAccounts(ref ServerHttpRequest request, ref ServerHttpResponse res
|
|||
import std.array;
|
||||
auto ds = getProfileDataSource(request);
|
||||
auto accounts = ds.getAccountRepository().findAll()
|
||||
.map!(a => AccountResponse.of(a)).array;
|
||||
.map!(a => AccountResponse.of(a, getBalance(ds, a.id))).array;
|
||||
writeJsonBody(response, accounts);
|
||||
}
|
||||
|
||||
|
|
@ -52,7 +62,7 @@ void handleGetAccount(ref ServerHttpRequest request, ref ServerHttpResponse resp
|
|||
auto ds = getProfileDataSource(request);
|
||||
auto account = ds.getAccountRepository().findById(accountId)
|
||||
.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.
|
||||
|
|
@ -77,7 +87,7 @@ void handleCreateAccount(ref ServerHttpRequest request, ref ServerHttpResponse r
|
|||
currency,
|
||||
payload.description
|
||||
);
|
||||
writeJsonBody(response, AccountResponse.of(account));
|
||||
writeJsonBody(response, AccountResponse.of(account, getBalance(ds, account.id)));
|
||||
}
|
||||
|
||||
void handleUpdateAccount(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
|
|
@ -96,7 +106,7 @@ void handleUpdateAccount(ref ServerHttpRequest request, ref ServerHttpResponse r
|
|||
Currency.ofCode(payload.currency),
|
||||
payload.description
|
||||
));
|
||||
writeJsonBody(response, AccountResponse.of(updated));
|
||||
writeJsonBody(response, AccountResponse.of(updated, getBalance(ds, updated.id)));
|
||||
}
|
||||
|
||||
void handleDeleteAccount(ref ServerHttpRequest request, ref ServerHttpResponse response) {
|
||||
|
|
@ -104,3 +114,75 @@ void handleDeleteAccount(ref ServerHttpRequest request, ref ServerHttpResponse r
|
|||
auto ds = getProfileDataSource(request);
|
||||
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 util.money;
|
||||
import util.pagination;
|
||||
import history.model;
|
||||
|
||||
import std.datetime : SysTime;
|
||||
|
|
@ -33,4 +34,104 @@ interface AccountJournalEntryRepository {
|
|||
);
|
||||
void deleteById(ulong id);
|
||||
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 util.sqlite;
|
||||
import util.money;
|
||||
import util.pagination;
|
||||
|
||||
class SqliteAccountRepository : AccountRepository {
|
||||
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) {
|
||||
string typeStr = row.peek!(string, PeekMode.slice)(5);
|
||||
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 ];
|
||||
|
||||
struct Account {
|
||||
immutable ulong id;
|
||||
immutable SysTime createdAt;
|
||||
immutable bool archived;
|
||||
immutable AccountType type;
|
||||
immutable string numberSuffix;
|
||||
immutable string name;
|
||||
immutable Currency currency;
|
||||
immutable string description;
|
||||
ulong id;
|
||||
SysTime createdAt;
|
||||
bool archived;
|
||||
AccountType type;
|
||||
string numberSuffix;
|
||||
string name;
|
||||
Currency currency;
|
||||
string description;
|
||||
}
|
||||
|
||||
struct AccountCreditCardProperties {
|
||||
|
|
@ -49,13 +49,13 @@ enum AccountJournalEntryType : string {
|
|||
}
|
||||
|
||||
struct AccountJournalEntry {
|
||||
immutable ulong id;
|
||||
immutable SysTime timestamp;
|
||||
immutable ulong accountId;
|
||||
immutable ulong transactionId;
|
||||
immutable ulong amount;
|
||||
immutable AccountJournalEntryType type;
|
||||
immutable Currency currency;
|
||||
ulong id;
|
||||
SysTime timestamp;
|
||||
ulong accountId;
|
||||
ulong transactionId;
|
||||
ulong amount;
|
||||
AccountJournalEntryType type;
|
||||
Currency currency;
|
||||
}
|
||||
|
||||
enum AccountValueRecordType : string {
|
||||
|
|
@ -63,10 +63,10 @@ enum AccountValueRecordType : string {
|
|||
}
|
||||
|
||||
struct AccountValueRecord {
|
||||
immutable ulong id;
|
||||
immutable SysTime timestamp;
|
||||
immutable ulong accountId;
|
||||
immutable AccountValueRecordType type;
|
||||
immutable long value;
|
||||
immutable Currency currency;
|
||||
ulong id;
|
||||
SysTime timestamp;
|
||||
ulong accountId;
|
||||
AccountValueRecordType type;
|
||||
long value;
|
||||
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;
|
||||
a.map(HttpMethod.GET, PROFILE_PATH ~ "/accounts", &handleGetAccounts);
|
||||
a.map(HttpMethod.POST, PROFILE_PATH ~ "/accounts", &handleCreateAccount);
|
||||
a.map(HttpMethod.GET, PROFILE_PATH ~ "/accounts/:accountId:ulong", &handleGetAccount);
|
||||
a.map(HttpMethod.PUT, PROFILE_PATH ~ "/accounts/:accountId:ulong", &handleUpdateAccount);
|
||||
a.map(HttpMethod.DELETE, PROFILE_PATH ~ "/accounts/:accountId:ulong", &handleDeleteAccount);
|
||||
const ACCOUNT_PATH = PROFILE_PATH ~ "/accounts/:accountId:ulong";
|
||||
a.map(HttpMethod.GET, ACCOUNT_PATH, &handleGetAccount);
|
||||
a.map(HttpMethod.PUT, ACCOUNT_PATH, &handleUpdateAccount);
|
||||
a.map(HttpMethod.DELETE, ACCOUNT_PATH, &handleDeleteAccount);
|
||||
a.map(HttpMethod.GET, ACCOUNT_PATH ~ "/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;
|
||||
// Transaction vendor endpoints:
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ interface ProfileDataSource {
|
|||
|
||||
AccountRepository getAccountRepository();
|
||||
AccountJournalEntryRepository getAccountJournalEntryRepository();
|
||||
AccountValueRecordRepository getAccountValueRecordRepository();
|
||||
|
||||
TransactionVendorRepository getTransactionVendorRepository();
|
||||
TransactionCategoryRepository getTransactionCategoryRepository();
|
||||
|
|
@ -41,3 +42,38 @@ interface ProfileDataSource {
|
|||
|
||||
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);
|
||||
}
|
||||
|
||||
AccountValueRecordRepository getAccountValueRecordRepository() {
|
||||
return new SqliteAccountValueRecordRepository(db);
|
||||
}
|
||||
|
||||
TransactionVendorRepository getTransactionVendorRepository() {
|
||||
return new SqliteTransactionVendorRepository(db);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -121,17 +121,38 @@ void handleGetCategories(ref ServerHttpRequest request, ref ServerHttpResponse r
|
|||
}
|
||||
|
||||
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) {
|
||||
// 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) {
|
||||
// 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) {
|
||||
// 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 {
|
||||
Optional!TransactionCategory findById(ulong id);
|
||||
bool existsById(ulong id);
|
||||
bool existsByName(string name);
|
||||
TransactionCategory[] findAll();
|
||||
TransactionCategory[] findAllByParentId(Optional!ulong parentId);
|
||||
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);
|
||||
}
|
||||
|
||||
bool existsByName(string name) {
|
||||
return util.sqlite.exists(db, "SELECT id FROM transaction_category WHERE name = ?", name);
|
||||
}
|
||||
|
||||
TransactionCategory[] findAll() {
|
||||
return util.sqlite.findAll(
|
||||
db,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import handy_http_primitives : Optional;
|
|||
import asdf : serdeTransformOut;
|
||||
import std.typecons;
|
||||
|
||||
import transaction.model : TransactionCategory;
|
||||
import util.data;
|
||||
import util.money;
|
||||
|
||||
|
|
@ -121,3 +122,22 @@ struct TransactionCategoryTree {
|
|||
TransactionCategoryTree[] children;
|
||||
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 util.money;
|
||||
import util.pagination;
|
||||
import util.data;
|
||||
import core.internal.container.common;
|
||||
|
||||
// Transactions Services
|
||||
|
|
@ -267,3 +268,55 @@ private TransactionCategoryTree[] getCategoriesRecursive(
|
|||
}
|
||||
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.
|
||||
Account primaryAccount = choice(accounts);
|
||||
data.currencyCode = primaryAccount.currency.code;
|
||||
data.currencyCode = primaryAccount.currency.code.idup;
|
||||
Optional!ulong secondaryAccountId;
|
||||
if (uniform01() < 0.25) {
|
||||
foreach (acc; accounts) {
|
||||
|
|
|
|||
|
|
@ -70,7 +70,9 @@ CREATE TABLE "transaction" (
|
|||
ON UPDATE CASCADE ON DELETE SET NULL,
|
||||
CONSTRAINT fk_transaction_category
|
||||
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);
|
||||
|
||||
|
|
@ -126,7 +128,9 @@ CREATE TABLE account_journal_entry (
|
|||
FOREIGN KEY (transaction_id) REFERENCES "transaction"(id)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { ApiClient } from './base'
|
||||
import type { Currency } from './data'
|
||||
import type { Page, PageRequest } from './pagination'
|
||||
import type { Profile } from './profile'
|
||||
|
||||
export interface AccountType {
|
||||
|
|
@ -47,6 +49,7 @@ export interface Account {
|
|||
name: string
|
||||
currency: string
|
||||
description: string
|
||||
currentBalance: number | null
|
||||
}
|
||||
|
||||
export interface AccountCreationPayload {
|
||||
|
|
@ -57,6 +60,40 @@ export interface AccountCreationPayload {
|
|||
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 {
|
||||
readonly path: string
|
||||
|
||||
|
|
@ -65,23 +102,42 @@ export class AccountApiClient extends ApiClient {
|
|||
this.path = `/profiles/${profile.name}/accounts`
|
||||
}
|
||||
|
||||
async getAccounts(): Promise<Account[]> {
|
||||
getAccounts(): Promise<Account[]> {
|
||||
return super.getJson(this.path)
|
||||
}
|
||||
|
||||
async getAccount(id: number): Promise<Account> {
|
||||
getAccount(id: number): Promise<Account> {
|
||||
return super.getJson(this.path + '/' + id)
|
||||
}
|
||||
|
||||
async createAccount(data: AccountCreationPayload): Promise<Account> {
|
||||
createAccount(data: AccountCreationPayload): Promise<Account> {
|
||||
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)
|
||||
}
|
||||
|
||||
async deleteAccount(id: number): Promise<void> {
|
||||
deleteAccount(id: number): Promise<void> {
|
||||
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
|
||||
}
|
||||
|
||||
async getMyUser(): Promise<string> {
|
||||
return await super.getText('/me')
|
||||
getMyUser(): Promise<string> {
|
||||
return super.getText('/me')
|
||||
}
|
||||
|
||||
async deleteMyUser(): Promise<void> {
|
||||
return await super.delete('/me')
|
||||
deleteMyUser(): Promise<void> {
|
||||
return super.delete('/me')
|
||||
}
|
||||
|
||||
async getNewToken(): Promise<string> {
|
||||
return await super.getText('/me/token')
|
||||
getNewToken(): Promise<string> {
|
||||
return super.getText('/me/token')
|
||||
}
|
||||
|
||||
async changeMyPassword(currentPassword: string, newPassword: string): Promise<void> {
|
||||
return await super.postNoResponse('/me/password', { currentPassword, newPassword })
|
||||
changeMyPassword(currentPassword: string, newPassword: string): Promise<void> {
|
||||
return super.postNoResponse('/me/password', { currentPassword, newPassword })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ export interface Currency {
|
|||
}
|
||||
|
||||
export class DataApiClient extends ApiClient {
|
||||
async getCurrencies(): Promise<Currency[]> {
|
||||
return await super.getJson('/currencies')
|
||||
getCurrencies(): Promise<Currency[]> {
|
||||
return super.getJson('/currencies')
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,23 +10,23 @@ export interface ProfileProperty {
|
|||
}
|
||||
|
||||
export class ProfileApiClient extends ApiClient {
|
||||
async getProfiles(): Promise<Profile[]> {
|
||||
return await super.getJson('/profiles')
|
||||
getProfiles(): Promise<Profile[]> {
|
||||
return super.getJson('/profiles')
|
||||
}
|
||||
|
||||
async getProfile(name: string): Promise<Profile> {
|
||||
return await super.getJson('/profiles/' + name)
|
||||
getProfile(name: string): Promise<Profile> {
|
||||
return super.getJson('/profiles/' + name)
|
||||
}
|
||||
|
||||
async createProfile(name: string): Promise<Profile> {
|
||||
return await super.postJson('/profiles', { name })
|
||||
createProfile(name: string): Promise<Profile> {
|
||||
return super.postJson('/profiles', { name })
|
||||
}
|
||||
|
||||
async deleteProfile(name: string): Promise<void> {
|
||||
return await super.delete(`/profiles/${name}`)
|
||||
deleteProfile(name: string): Promise<void> {
|
||||
return super.delete(`/profiles/${name}`)
|
||||
}
|
||||
|
||||
async getProperties(profileName: string): Promise<ProfileProperty[]> {
|
||||
return await super.getJson(`/profiles/${profileName}/properties`)
|
||||
getProperties(profileName: string): Promise<ProfileProperty[]> {
|
||||
return super.getJson(`/profiles/${profileName}/properties`)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,6 +124,13 @@ export interface AddTransactionPayloadLineItem {
|
|||
categoryId: number | null
|
||||
}
|
||||
|
||||
export interface CreateCategoryPayload {
|
||||
name: string
|
||||
description: string
|
||||
color: string
|
||||
parentId: number | null
|
||||
}
|
||||
|
||||
export class TransactionApiClient extends ApiClient {
|
||||
readonly path: string
|
||||
|
||||
|
|
@ -132,53 +139,69 @@ export class TransactionApiClient extends ApiClient {
|
|||
this.path = `/profiles/${profile.name}`
|
||||
}
|
||||
|
||||
async getVendors(): Promise<TransactionVendor[]> {
|
||||
return await super.getJson(this.path + '/vendors')
|
||||
getVendors(): Promise<TransactionVendor[]> {
|
||||
return super.getJson(this.path + '/vendors')
|
||||
}
|
||||
|
||||
async getVendor(id: number): Promise<TransactionVendor> {
|
||||
return await super.getJson(this.path + '/vendors/' + id)
|
||||
getVendor(id: number): Promise<TransactionVendor> {
|
||||
return super.getJson(this.path + '/vendors/' + id)
|
||||
}
|
||||
|
||||
async createVendor(data: TransactionVendorPayload): Promise<TransactionVendor> {
|
||||
return await super.postJson(this.path + '/vendors', data)
|
||||
createVendor(data: TransactionVendorPayload): Promise<TransactionVendor> {
|
||||
return super.postJson(this.path + '/vendors', data)
|
||||
}
|
||||
|
||||
async updateVendor(id: number, data: TransactionVendorPayload): Promise<TransactionVendor> {
|
||||
return await super.putJson(this.path + '/vendors/' + id, data)
|
||||
updateVendor(id: number, data: TransactionVendorPayload): Promise<TransactionVendor> {
|
||||
return super.putJson(this.path + '/vendors/' + id, data)
|
||||
}
|
||||
|
||||
async deleteVendor(id: number): Promise<void> {
|
||||
return await super.delete(this.path + '/vendors/' + id)
|
||||
deleteVendor(id: number): Promise<void> {
|
||||
return super.delete(this.path + '/vendors/' + id)
|
||||
}
|
||||
|
||||
async getCategories(): Promise<TransactionCategoryTree[]> {
|
||||
return await super.getJson(this.path + '/categories')
|
||||
getCategories(): Promise<TransactionCategoryTree[]> {
|
||||
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,
|
||||
): Promise<Page<TransactionsListItem>> {
|
||||
return await super.getJsonPage(this.path + '/transactions', paginationOptions)
|
||||
return super.getJsonPage(this.path + '/transactions', paginationOptions)
|
||||
}
|
||||
|
||||
async getTransaction(id: number): Promise<TransactionDetail> {
|
||||
return await super.getJson(this.path + '/transactions/' + id)
|
||||
getTransaction(id: number): Promise<TransactionDetail> {
|
||||
return super.getJson(this.path + '/transactions/' + id)
|
||||
}
|
||||
|
||||
async addTransaction(data: AddTransactionPayload): Promise<TransactionDetail> {
|
||||
return await super.postJson(this.path + '/transactions', data)
|
||||
addTransaction(data: AddTransactionPayload): Promise<TransactionDetail> {
|
||||
return super.postJson(this.path + '/transactions', data)
|
||||
}
|
||||
|
||||
async updateTransaction(id: number, data: AddTransactionPayload): Promise<TransactionDetail> {
|
||||
return await super.putJson(this.path + '/transactions/' + id, data)
|
||||
updateTransaction(id: number, data: AddTransactionPayload): Promise<TransactionDetail> {
|
||||
return super.putJson(this.path + '/transactions/' + id, data)
|
||||
}
|
||||
|
||||
async deleteTransaction(id: number): Promise<void> {
|
||||
return await super.delete(this.path + '/transactions/' + id)
|
||||
deleteTransaction(id: number): Promise<void> {
|
||||
return super.delete(this.path + '/transactions/' + id)
|
||||
}
|
||||
|
||||
async getAllTags(): Promise<string[]> {
|
||||
return await super.getJson(this.path + '/transaction-tags')
|
||||
getAllTags(): Promise<string[]> {
|
||||
return super.getJson(this.path + '/transaction-tags')
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,3 +48,16 @@ a:hover {
|
|||
gap: 20px;
|
||||
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 }"
|
||||
@click="$emit('click')" :type="buttonType" :disabled="disabled ?? false">
|
||||
<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>
|
||||
<slot></slot>
|
||||
</button>
|
||||
|
|
@ -73,4 +74,14 @@ defineEmits(['click'])
|
|||
.app-button-secondary {
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
import { AccountApiClient, type Account } from '@/api/account';
|
||||
import AddValueRecordModal from '@/components/AddValueRecordModal.vue';
|
||||
import AppButton from '@/components/AppButton.vue';
|
||||
import AppPage from '@/components/AppPage.vue';
|
||||
import PropertiesTable from '@/components/PropertiesTable.vue';
|
||||
import { useProfileStore } from '@/stores/profile-store';
|
||||
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';
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const profileStore = useProfileStore()
|
||||
const addValueRecordModal = useTemplateRef("addValueRecordModal")
|
||||
const account: Ref<Account | null> = ref(null)
|
||||
|
||||
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>
|
||||
<template>
|
||||
<AppPage :title="account?.name ?? ''">
|
||||
|
|
@ -80,9 +89,12 @@ async function deleteAccount() {
|
|||
</tr>
|
||||
</PropertiesTable>
|
||||
<div>
|
||||
<AppButton @click="addValueRecord()">Record Value</AppButton>
|
||||
<AppButton icon="wrench"
|
||||
@click="router.push(`/profiles/${profileStore.state?.name}/accounts/${account?.id}/edit`)">Edit</AppButton>
|
||||
<AppButton icon="trash" @click="deleteAccount()">Delete</AppButton>
|
||||
</div>
|
||||
|
||||
<AddValueRecordModal v-if="account" :account="account" ref="addValueRecordModal" />
|
||||
</AppPage>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
<script setup lang="ts">
|
||||
import { AuthApiClient } from '@/api/auth';
|
||||
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 { showAlert } from '@/util/alert';
|
||||
import { hideLoader, showLoader } from '@/util/loader';
|
||||
import { ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
|
|
@ -17,15 +22,18 @@ const disableForm = ref(false)
|
|||
|
||||
async function doLogin() {
|
||||
disableForm.value = true
|
||||
showLoader()
|
||||
try {
|
||||
const token = await apiClient.login(username.value, password.value)
|
||||
authStore.onUserLoggedIn(username.value, token)
|
||||
hideLoader()
|
||||
if ('next' in route.query && typeof (route.query.next) === 'string') {
|
||||
await router.replace(route.query.next)
|
||||
} else {
|
||||
await router.replace('/')
|
||||
}
|
||||
} catch (err) {
|
||||
hideLoader()
|
||||
if (err instanceof ApiError) {
|
||||
await showAlert(err.message)
|
||||
} else {
|
||||
|
|
@ -36,6 +44,10 @@ async function doLogin() {
|
|||
}
|
||||
}
|
||||
|
||||
function isDataValid() {
|
||||
return username.value.length > 0 && password.value.length >= 8
|
||||
}
|
||||
|
||||
function generateSampleData() {
|
||||
fetch('http://localhost:8080/api/sample-data', {
|
||||
method: 'POST'
|
||||
|
|
@ -43,24 +55,30 @@ function generateSampleData() {
|
|||
}
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<h1>Login</h1>
|
||||
<form @submit.prevent="doLogin()">
|
||||
<div class="app-login-panel">
|
||||
<h1 style="text-align: center;">Login to <span style="font-family: 'PlaywriteNL';">Finnow</span></h1>
|
||||
<AppForm @submit="doLogin()">
|
||||
<FormGroup>
|
||||
<FormControl label="Username">
|
||||
<input type="text" v-model="username" :disabled="disableForm" />
|
||||
</FormControl>
|
||||
<FormControl label="Password">
|
||||
<input id="password-input" type="password" v-model="password" :disabled="disableForm" />
|
||||
</FormControl>
|
||||
</FormGroup>
|
||||
<div>
|
||||
<label for="username-input">Username</label>
|
||||
<input id="username-input" type="text" v-model="username" :disabled="disableForm" />
|
||||
<AppButton button-type="submit" :disabled="disableForm || !isDataValid()" style="width: 100%;">Login</AppButton>
|
||||
</div>
|
||||
<div>
|
||||
<label for="password-input">Password</label>
|
||||
<input id="password-input" type="password" v-model="password" :disabled="disableForm" />
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" :disabled="disableForm">Login</button>
|
||||
</div>
|
||||
</form>
|
||||
</AppForm>
|
||||
|
||||
<div>
|
||||
<button type="button" @click="generateSampleData()">Generate Sample Data</button>
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
<h3>Line Items</h3>
|
||||
<table>
|
||||
<table class="app-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<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>
|
||||
</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>
|
||||
</AppPage>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ onMounted(async () => {
|
|||
<template>
|
||||
<HomeModule title="Accounts">
|
||||
<template v-slot:default>
|
||||
<table>
|
||||
<table class="app-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
|
|
|
|||
|
|
@ -27,6 +27,12 @@ async function deleteProfile() {
|
|||
<HomeModule title="Profile">
|
||||
<template v-slot:default>
|
||||
<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">
|
||||
<p>Are you sure you want to delete this profile?</p>
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ async function fetchPage(pageRequest: PageRequest) {
|
|||
<template>
|
||||
<HomeModule title="Transactions">
|
||||
<template v-slot:default>
|
||||
<table>
|
||||
<table class="app-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
|
|
@ -39,6 +39,7 @@ async function fetchPage(pageRequest: PageRequest) {
|
|||
<th>Description</th>
|
||||
<th>Credited Account</th>
|
||||
<th>Debited Account</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -49,8 +50,18 @@ async function fetchPage(pageRequest: PageRequest) {
|
|||
<td style="text-align: right;">{{ formatMoney(tx.amount, tx.currency) }}</td>
|
||||
<td>{{ tx.currency.code }}</td>
|
||||
<td>{{ tx.description }}</td>
|
||||
<td>{{ tx.creditedAccount?.name }}</td>
|
||||
<td>{{ tx.debitedAccount?.name }}</td>
|
||||
<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>
|
||||
<RouterLink :to="`/profiles/${profileStore.state?.name}/transactions/${tx.id}`">View</RouterLink>
|
||||
</td>
|
||||
|
|
|
|||
|
|
@ -68,6 +68,11 @@ const router = createRouter({
|
|||
component: () => import('@/pages/forms/EditTransactionPage.vue'),
|
||||
meta: { title: 'Add Transaction' },
|
||||
},
|
||||
{
|
||||
path: 'vendors',
|
||||
component: () => import('@/pages/VendorsPage.vue'),
|
||||
meta: { title: 'Vendors' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
|
|||
Loading…
Reference in New Issue