Added value record API and modal, and balance computations.

This commit is contained in:
andrewlalis 2025-08-20 21:23:11 -04:00
parent e29d4e1c0f
commit 844f17c80d
33 changed files with 1246 additions and 106 deletions

View File

@ -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);
}

View File

@ -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");
}
}
} }

View File

@ -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))
);
}
}

View File

@ -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;
} }

View File

@ -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);
}

View File

@ -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:

View File

@ -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");
}
}
}

View File

@ -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);
} }

View File

@ -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");
} }

View File

@ -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);

View File

@ -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,

View File

@ -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
);
}
}

View File

@ -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);
}

View File

@ -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) {

View File

@ -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

View File

@ -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)
}
} }

View File

@ -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 })
} }
} }

View File

@ -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')
} }
} }

View File

@ -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`)
} }
} }

View File

@ -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')
} }
} }

View File

@ -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;
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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' },
},
], ],
}, },
], ],