From 1e35494f9d8410424f84120fffc5a5798ba51b0c Mon Sep 17 00:00:00 2001 From: andrewlalis Date: Fri, 13 Feb 2026 11:26:43 -0500 Subject: [PATCH] Add category balance display. --- finnow-api/source/transaction/api.d | 13 +-- finnow-api/source/transaction/data.d | 6 ++ .../source/transaction/data_impl_sqlite.d | 81 +++++++++++++++++++ finnow-api/source/transaction/dto.d | 8 ++ finnow-api/source/transaction/service.d | 9 +++ web-app/src/api/transaction.ts | 11 +++ web-app/src/pages/CategoryPage.vue | 59 +++++++++++++- 7 files changed, 181 insertions(+), 6 deletions(-) diff --git a/finnow-api/source/transaction/api.d b/finnow-api/source/transaction/api.d index 3db020b..8997f83 100644 --- a/finnow-api/source/transaction/api.d +++ b/finnow-api/source/transaction/api.d @@ -155,11 +155,14 @@ void handleGetChildCategories(ref ServerHttpRequest request, ref ServerHttpRespo @GetMapping(PROFILE_PATH ~ "/categories/:categoryId:ulong/balances") void handleGetCategoryBalances(ref ServerHttpRequest request, ref ServerHttpResponse response) { - response.status = HttpStatus.NOT_IMPLEMENTED; - // TODO: Add an API endpoint to provide a "balance" for the category. - // This would be the sum of credits and debits for all transactions set - // to this category or any child of it, over a specified interval, or for - // some default interval if none is provided. + bool includeChildren = request.getParamAs!bool("includeChildren", true); + // TODO: Support optional before/after timestamps to limit the scope. + auto balances = getCategoryBalances( + getProfileDataSource(request), + getCategoryId(request), + includeChildren + ); + writeJsonBody(response, balances); } struct CategoryPayload { diff --git a/finnow-api/source/transaction/data.d b/finnow-api/source/transaction/data.d index 33b629c..d7b498b 100644 --- a/finnow-api/source/transaction/data.d +++ b/finnow-api/source/transaction/data.d @@ -28,6 +28,12 @@ interface TransactionCategoryRepository { TransactionCategory insert(Optional!ulong parentId, string name, string description, string color); void deleteById(ulong id); TransactionCategory updateById(ulong id, string name, string description, string color, Optional!ulong parentId); + TransactionCategoryBalance[] getBalance( + ulong id, + bool includeChildren, + Optional!SysTime afterTimestamp, + Optional!SysTime beforeTimestamp + ); } interface TransactionTagRepository { diff --git a/finnow-api/source/transaction/data_impl_sqlite.d b/finnow-api/source/transaction/data_impl_sqlite.d index 2a342f8..1942bbd 100644 --- a/finnow-api/source/transaction/data_impl_sqlite.d +++ b/finnow-api/source/transaction/data_impl_sqlite.d @@ -12,6 +12,7 @@ import util.sqlite; import util.money; import util.pagination; import util.data; +import account.model; class SqliteTransactionVendorRepository : TransactionVendorRepository { private Database db; @@ -144,6 +145,86 @@ class SqliteTransactionCategoryRepository : TransactionCategoryRepository { return findById(id).orElseThrow(); } + TransactionCategoryBalance[] getBalance( + ulong id, + bool includeChildren, + Optional!SysTime afterTimestamp, + Optional!SysTime beforeTimestamp + ) { + import std.algorithm : map; + import std.conv : to; + import std.string : join; + // First collect the list of IDs to include in the query. + ulong[] idsForQuery = [id]; + if (includeChildren) { + import std.range : front, popFront; + ulong[] idQueue = [id]; + while (idQueue.length > 0) { + ulong nextId = idQueue.front; + idQueue.popFront; + auto children = findAllByParentId(Optional!ulong.of(nextId)); + foreach (child; children) { + idsForQuery ~= child.id; + idQueue ~= child.id; + } + } + } + const categoryIdsString = idsForQuery.map!(id => id.to!string).join(","); + + // Now build the query, taking into account the optional timestamp constraints. + QueryBuilder qb = QueryBuilder("\"transaction\" txn") + .join("LEFT JOIN account_journal_entry je ON je.transaction_id = txn.id") + .select("je.currency") + .select("je.type") + .select("SUM(je.amount)") + .where("txn.category_id IN (" ~ categoryIdsString ~ ")"); + if (!afterTimestamp.isNull) { + qb.where("txn.timestamp > ?") + .withArgBinding((ref stmt, ref idx) { + stmt.bind(idx++, afterTimestamp.value.toISOExtString()); + }); + } + if (!beforeTimestamp.isNull) { + qb.where("txn.timestamp < ?") + .withArgBinding((ref stmt, ref idx) { + stmt.bind(idx++, beforeTimestamp.value.toISOExtString()); + }); + } + string query = qb.build() ~ " ORDER BY je.currency ASC, je.type ASC"; + Statement stmt = db.prepare(query); + qb.applyArgBindings(stmt); + ResultRange result = stmt.execute(); + + // Process the results into a set of category balances for each currency. + TransactionCategoryBalance[ushort] balancesGroupedByCurrency; + foreach (row; result) { + Currency currency = Currency.ofCode(row.peek!(string, PeekMode.slice)(0)); + string journalEntryType = row.peek!(string, PeekMode.slice)(1); + ulong amountSum = row.peek!ulong(2); + + + TransactionCategoryBalance balance; + if (currency.numericCode in balancesGroupedByCurrency) { + balance = balancesGroupedByCurrency[currency.numericCode]; + } else { + balance = TransactionCategoryBalance(0, 0, 0, currency); + } + if (journalEntryType == AccountJournalEntryType.CREDIT) { + balance.credits = amountSum; + } else if (journalEntryType == AccountJournalEntryType.DEBIT) { + balance.debits = amountSum; + } + balancesGroupedByCurrency[currency.numericCode] = balance; + } + + // Post-process into a list of balances for returning: + TransactionCategoryBalance[] balances = balancesGroupedByCurrency.values; + foreach (ref bal; balances) { + bal.balance = cast(long) bal.debits - cast(long) bal.credits; + } + return balances; + } + private static TransactionCategory parseCategory(Row row) { import std.typecons; return TransactionCategory( diff --git a/finnow-api/source/transaction/dto.d b/finnow-api/source/transaction/dto.d index 0e6e541..fc30304 100644 --- a/finnow-api/source/transaction/dto.d +++ b/finnow-api/source/transaction/dto.d @@ -147,3 +147,11 @@ struct TransactionCategoryResponse { ); } } + +/// Structure representing the balance information for a category, for a given currency. +struct TransactionCategoryBalance { + ulong credits; + ulong debits; + long balance; + Currency currency; +} diff --git a/finnow-api/source/transaction/service.d b/finnow-api/source/transaction/service.d index c35a0e8..9b7f5be 100644 --- a/finnow-api/source/transaction/service.d +++ b/finnow-api/source/transaction/service.d @@ -348,6 +348,15 @@ TransactionCategoryResponse[] getChildCategories(ProfileDataSource ds, ulong cat return categories.map!(c => TransactionCategoryResponse.of(c)).array; } +TransactionCategoryBalance[] getCategoryBalances(ProfileDataSource ds, ulong categoryId, bool includeChildren) { + return ds.getTransactionCategoryRepository().getBalance( + categoryId, + includeChildren, + Optional!SysTime.empty(), + Optional!SysTime.empty() + ); +} + TransactionCategoryResponse createCategory(ProfileDataSource ds, in CategoryPayload payload) { TransactionCategoryRepository repo = ds.getTransactionCategoryRepository(); if (payload.name is null || payload.name.length == 0) { diff --git a/web-app/src/api/transaction.ts b/web-app/src/api/transaction.ts index 8cc7ed9..cc7b055 100644 --- a/web-app/src/api/transaction.ts +++ b/web-app/src/api/transaction.ts @@ -32,6 +32,13 @@ export interface TransactionCategoryTree { depth: number } +export interface TransactionCategoryBalance { + credits: number + debits: number + balance: number + currency: Currency +} + export interface TransactionsListItem { id: number timestamp: string @@ -180,6 +187,10 @@ export class TransactionApiClient extends ApiClient { return super.getJson(this.path + '/categories/' + parentId + '/children') } + getCategoryBalances(id: number): Promise { + return super.getJson(this.path + '/categories/' + id + '/balances') + } + createCategory(data: CreateCategoryPayload): Promise { return super.postJson(this.path + '/categories', data) } diff --git a/web-app/src/pages/CategoryPage.vue b/web-app/src/pages/CategoryPage.vue index 1a47a26..8a55ad2 100644 --- a/web-app/src/pages/CategoryPage.vue +++ b/web-app/src/pages/CategoryPage.vue @@ -1,12 +1,17 @@