diff --git a/finnow-api/source/account/api.d b/finnow-api/source/account/api.d index c1c7173..e90c784 100644 --- a/finnow-api/source/account/api.d +++ b/finnow-api/source/account/api.d @@ -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); +} diff --git a/finnow-api/source/account/data.d b/finnow-api/source/account/data.d index 7d5e3dd..2bc0f37 100644 --- a/finnow-api/source/account/data.d +++ b/finnow-api/source/account/data.d @@ -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"); + } + } } diff --git a/finnow-api/source/account/data_impl_sqlite.d b/finnow-api/source/account/data_impl_sqlite.d index ff8555a..cebe9c2 100644 --- a/finnow-api/source/account/data_impl_sqlite.d +++ b/finnow-api/source/account/data_impl_sqlite.d @@ -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)) + ); + } +} diff --git a/finnow-api/source/account/model.d b/finnow-api/source/account/model.d index d636196..8b5137c 100644 --- a/finnow-api/source/account/model.d +++ b/finnow-api/source/account/model.d @@ -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; } diff --git a/finnow-api/source/account/service.d b/finnow-api/source/account/service.d new file mode 100644 index 0000000..09d740a --- /dev/null +++ b/finnow-api/source/account/service.d @@ -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); +} diff --git a/finnow-api/source/api_mapping.d b/finnow-api/source/api_mapping.d index eee39ca..c9105ca 100644 --- a/finnow-api/source/api_mapping.d +++ b/finnow-api/source/api_mapping.d @@ -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: diff --git a/finnow-api/source/profile/data.d b/finnow-api/source/profile/data.d index 72c2fbc..84d153a 100644 --- a/finnow-api/source/profile/data.d +++ b/finnow-api/source/profile/data.d @@ -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"); + } + } +} diff --git a/finnow-api/source/profile/data_impl_sqlite.d b/finnow-api/source/profile/data_impl_sqlite.d index d128d8f..9eb0405 100644 --- a/finnow-api/source/profile/data_impl_sqlite.d +++ b/finnow-api/source/profile/data_impl_sqlite.d @@ -170,6 +170,10 @@ class SqliteProfileDataSource : ProfileDataSource { return new SqliteAccountJournalEntryRepository(db); } + AccountValueRecordRepository getAccountValueRecordRepository() { + return new SqliteAccountValueRecordRepository(db); + } + TransactionVendorRepository getTransactionVendorRepository() { return new SqliteTransactionVendorRepository(db); } diff --git a/finnow-api/source/transaction/api.d b/finnow-api/source/transaction/api.d index ff8142a..970898e 100644 --- a/finnow-api/source/transaction/api.d +++ b/finnow-api/source/transaction/api.d @@ -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"); } diff --git a/finnow-api/source/transaction/data.d b/finnow-api/source/transaction/data.d index f7dba74..06ab6eb 100644 --- a/finnow-api/source/transaction/data.d +++ b/finnow-api/source/transaction/data.d @@ -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); diff --git a/finnow-api/source/transaction/data_impl_sqlite.d b/finnow-api/source/transaction/data_impl_sqlite.d index 3055087..eac85a6 100644 --- a/finnow-api/source/transaction/data_impl_sqlite.d +++ b/finnow-api/source/transaction/data_impl_sqlite.d @@ -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, diff --git a/finnow-api/source/transaction/dto.d b/finnow-api/source/transaction/dto.d index e28ac6f..a836781 100644 --- a/finnow-api/source/transaction/dto.d +++ b/finnow-api/source/transaction/dto.d @@ -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 + ); + } +} diff --git a/finnow-api/source/transaction/service.d b/finnow-api/source/transaction/service.d index 7be5edc..975dcb2 100644 --- a/finnow-api/source/transaction/service.d +++ b/finnow-api/source/transaction/service.d @@ -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); +} diff --git a/finnow-api/source/util/sample_data.d b/finnow-api/source/util/sample_data.d index bce85d3..b3412dc 100644 --- a/finnow-api/source/util/sample_data.d +++ b/finnow-api/source/util/sample_data.d @@ -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) { diff --git a/finnow-api/sql/schema.sql b/finnow-api/sql/schema.sql index 446a7ea..2c8dbe6 100644 --- a/finnow-api/sql/schema.sql +++ b/finnow-api/sql/schema.sql @@ -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 diff --git a/web-app/src/api/account.ts b/web-app/src/api/account.ts index 972986c..eb48f6e 100644 --- a/web-app/src/api/account.ts +++ b/web-app/src/api/account.ts @@ -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 { + getAccounts(): Promise { return super.getJson(this.path) } - async getAccount(id: number): Promise { + getAccount(id: number): Promise { return super.getJson(this.path + '/' + id) } - async createAccount(data: AccountCreationPayload): Promise { + createAccount(data: AccountCreationPayload): Promise { return super.postJson(this.path, data) } - async updateAccount(id: number, data: AccountCreationPayload): Promise { + updateAccount(id: number, data: AccountCreationPayload): Promise { return super.putJson(this.path + '/' + id, data) } - async deleteAccount(id: number): Promise { + deleteAccount(id: number): Promise { return super.delete(this.path + '/' + id) } + + getValueRecords(accountId: number, pageRequest: PageRequest): Promise> { + return super.getJsonPage(this.path + '/' + accountId + '/value-records', pageRequest) + } + + getValueRecord(accountId: number, valueRecordId: number): Promise { + return super.getJson(this.path + '/' + accountId + '/value-records/' + valueRecordId) + } + + createValueRecord( + accountId: number, + payload: AccountValueRecordCreationPayload, + ): Promise { + return super.postJson(this.path + '/' + accountId + '/value-records', payload) + } + + deleteValueRecord(accountId: number, valueRecordId: number): Promise { + return super.delete(this.path + '/' + accountId + '/value-records/' + valueRecordId) + } } diff --git a/web-app/src/api/auth.ts b/web-app/src/api/auth.ts index afd84a0..754c55b 100644 --- a/web-app/src/api/auth.ts +++ b/web-app/src/api/auth.ts @@ -20,19 +20,19 @@ export class AuthApiClient extends ApiClient { return r.available } - async getMyUser(): Promise { - return await super.getText('/me') + getMyUser(): Promise { + return super.getText('/me') } - async deleteMyUser(): Promise { - return await super.delete('/me') + deleteMyUser(): Promise { + return super.delete('/me') } - async getNewToken(): Promise { - return await super.getText('/me/token') + getNewToken(): Promise { + return super.getText('/me/token') } - async changeMyPassword(currentPassword: string, newPassword: string): Promise { - return await super.postNoResponse('/me/password', { currentPassword, newPassword }) + changeMyPassword(currentPassword: string, newPassword: string): Promise { + return super.postNoResponse('/me/password', { currentPassword, newPassword }) } } diff --git a/web-app/src/api/data.ts b/web-app/src/api/data.ts index ecac08b..a2946e0 100644 --- a/web-app/src/api/data.ts +++ b/web-app/src/api/data.ts @@ -8,8 +8,8 @@ export interface Currency { } export class DataApiClient extends ApiClient { - async getCurrencies(): Promise { - return await super.getJson('/currencies') + getCurrencies(): Promise { + return super.getJson('/currencies') } } diff --git a/web-app/src/api/profile.ts b/web-app/src/api/profile.ts index a47a007..754ca59 100644 --- a/web-app/src/api/profile.ts +++ b/web-app/src/api/profile.ts @@ -10,23 +10,23 @@ export interface ProfileProperty { } export class ProfileApiClient extends ApiClient { - async getProfiles(): Promise { - return await super.getJson('/profiles') + getProfiles(): Promise { + return super.getJson('/profiles') } - async getProfile(name: string): Promise { - return await super.getJson('/profiles/' + name) + getProfile(name: string): Promise { + return super.getJson('/profiles/' + name) } - async createProfile(name: string): Promise { - return await super.postJson('/profiles', { name }) + createProfile(name: string): Promise { + return super.postJson('/profiles', { name }) } - async deleteProfile(name: string): Promise { - return await super.delete(`/profiles/${name}`) + deleteProfile(name: string): Promise { + return super.delete(`/profiles/${name}`) } - async getProperties(profileName: string): Promise { - return await super.getJson(`/profiles/${profileName}/properties`) + getProperties(profileName: string): Promise { + return super.getJson(`/profiles/${profileName}/properties`) } } diff --git a/web-app/src/api/transaction.ts b/web-app/src/api/transaction.ts index 1391a1d..9234bda 100644 --- a/web-app/src/api/transaction.ts +++ b/web-app/src/api/transaction.ts @@ -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 { - return await super.getJson(this.path + '/vendors') + getVendors(): Promise { + return super.getJson(this.path + '/vendors') } - async getVendor(id: number): Promise { - return await super.getJson(this.path + '/vendors/' + id) + getVendor(id: number): Promise { + return super.getJson(this.path + '/vendors/' + id) } - async createVendor(data: TransactionVendorPayload): Promise { - return await super.postJson(this.path + '/vendors', data) + createVendor(data: TransactionVendorPayload): Promise { + return super.postJson(this.path + '/vendors', data) } - async updateVendor(id: number, data: TransactionVendorPayload): Promise { - return await super.putJson(this.path + '/vendors/' + id, data) + updateVendor(id: number, data: TransactionVendorPayload): Promise { + return super.putJson(this.path + '/vendors/' + id, data) } - async deleteVendor(id: number): Promise { - return await super.delete(this.path + '/vendors/' + id) + deleteVendor(id: number): Promise { + return super.delete(this.path + '/vendors/' + id) } - async getCategories(): Promise { - return await super.getJson(this.path + '/categories') + getCategories(): Promise { + return super.getJson(this.path + '/categories') } - async getTransactions( + getCategory(id: number): Promise { + return super.getJson(this.path + '/categories/' + id) + } + + createCategory(data: CreateCategoryPayload): Promise { + return super.postJson(this.path + '/categories', data) + } + + updateCategory(id: number, data: CreateCategoryPayload): Promise { + return super.postJson(this.path + '/categories/' + id, data) + } + + deleteCategory(id: number): Promise { + return super.delete(this.path + '/categories/' + id) + } + + getTransactions( paginationOptions: PageRequest | undefined = undefined, ): Promise> { - return await super.getJsonPage(this.path + '/transactions', paginationOptions) + return super.getJsonPage(this.path + '/transactions', paginationOptions) } - async getTransaction(id: number): Promise { - return await super.getJson(this.path + '/transactions/' + id) + getTransaction(id: number): Promise { + return super.getJson(this.path + '/transactions/' + id) } - async addTransaction(data: AddTransactionPayload): Promise { - return await super.postJson(this.path + '/transactions', data) + addTransaction(data: AddTransactionPayload): Promise { + return super.postJson(this.path + '/transactions', data) } - async updateTransaction(id: number, data: AddTransactionPayload): Promise { - return await super.putJson(this.path + '/transactions/' + id, data) + updateTransaction(id: number, data: AddTransactionPayload): Promise { + return super.putJson(this.path + '/transactions/' + id, data) } - async deleteTransaction(id: number): Promise { - return await super.delete(this.path + '/transactions/' + id) + deleteTransaction(id: number): Promise { + return super.delete(this.path + '/transactions/' + id) } - async getAllTags(): Promise { - return await super.getJson(this.path + '/transaction-tags') + getAllTags(): Promise { + return super.getJson(this.path + '/transaction-tags') } } diff --git a/web-app/src/assets/main.css b/web-app/src/assets/main.css index ac4120a..61ca417 100644 --- a/web-app/src/assets/main.css +++ b/web-app/src/assets/main.css @@ -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; +} diff --git a/web-app/src/components/AddValueRecordModal.vue b/web-app/src/components/AddValueRecordModal.vue new file mode 100644 index 0000000..e8ef984 --- /dev/null +++ b/web-app/src/components/AddValueRecordModal.vue @@ -0,0 +1,78 @@ + + diff --git a/web-app/src/components/AppButton.vue b/web-app/src/components/AppButton.vue index a2f412b..e4eb125 100644 --- a/web-app/src/components/AppButton.vue +++ b/web-app/src/components/AppButton.vue @@ -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"> - + @@ -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; +} diff --git a/web-app/src/components/EditVendorModal.vue b/web-app/src/components/EditVendorModal.vue new file mode 100644 index 0000000..8efa78b --- /dev/null +++ b/web-app/src/components/EditVendorModal.vue @@ -0,0 +1,84 @@ + + diff --git a/web-app/src/pages/AccountPage.vue b/web-app/src/pages/AccountPage.vue index 33c9b86..0f52cae 100644 --- a/web-app/src/pages/AccountPage.vue +++ b/web-app/src/pages/AccountPage.vue @@ -1,17 +1,19 @@ diff --git a/web-app/src/pages/LoginPage.vue b/web-app/src/pages/LoginPage.vue index 516d327..29fc00a 100644 --- a/web-app/src/pages/LoginPage.vue +++ b/web-app/src/pages/LoginPage.vue @@ -1,8 +1,13 @@ + diff --git a/web-app/src/pages/TransactionPage.vue b/web-app/src/pages/TransactionPage.vue index d08044b..833a07e 100644 --- a/web-app/src/pages/TransactionPage.vue +++ b/web-app/src/pages/TransactionPage.vue @@ -100,7 +100,7 @@ async function deleteTransaction() {

Line Items

- +
diff --git a/web-app/src/pages/VendorsPage.vue b/web-app/src/pages/VendorsPage.vue new file mode 100644 index 0000000..595d2d5 --- /dev/null +++ b/web-app/src/pages/VendorsPage.vue @@ -0,0 +1,75 @@ + + diff --git a/web-app/src/pages/forms/EditAccountPage.vue b/web-app/src/pages/forms/EditAccountPage.vue index 38c91b4..42cf3d9 100644 --- a/web-app/src/pages/forms/EditAccountPage.vue +++ b/web-app/src/pages/forms/EditAccountPage.vue @@ -105,7 +105,8 @@ async function doSubmit() { - + diff --git a/web-app/src/pages/home/AccountsModule.vue b/web-app/src/pages/home/AccountsModule.vue index 45d04aa..a86687b 100644 --- a/web-app/src/pages/home/AccountsModule.vue +++ b/web-app/src/pages/home/AccountsModule.vue @@ -25,7 +25,7 @@ onMounted(async () => {
#