From 6cc29589ba82b96378b22fd2239fba19bcac6da7 Mon Sep 17 00:00:00 2001 From: andrewlalis Date: Thu, 23 Apr 2026 16:48:48 -0400 Subject: [PATCH] Added aggregate data for search page. --- finnow-api/source/transaction/api.d | 12 ++- finnow-api/source/transaction/data.d | 5 +- .../source/transaction/data_impl_sqlite.d | 38 ++++++++- finnow-api/source/transaction/dto.d | 15 ++++ .../source/transaction/search_filters.d | 85 ++++++++++--------- finnow-api/source/transaction/service.d | 3 +- web-app/src/api/transaction.ts | 15 ++++ web-app/src/assets/styles/spacing.css | 4 + web-app/src/pages/TransactionSearchPage.vue | 47 ++++++++-- 9 files changed, 172 insertions(+), 52 deletions(-) diff --git a/finnow-api/source/transaction/api.d b/finnow-api/source/transaction/api.d index 2dd4e31..b6d4cbd 100644 --- a/finnow-api/source/transaction/api.d +++ b/finnow-api/source/transaction/api.d @@ -17,6 +17,7 @@ import account.api; import util.money; import util.pagination; import util.data; +import transaction.search_filters; // Transactions API @@ -32,12 +33,21 @@ void handleGetTransactions(ref ServerHttpRequest request, ref ServerHttpResponse @GetMapping(PROFILE_PATH ~ "/transactions/search") void handleSearchTransactions(ref ServerHttpRequest request, ref ServerHttpResponse response) { + import transaction.search_filters : extractSearchParams; ProfileDataSource ds = getProfileDataSource(request); PageRequest pr = PageRequest.parse(request, DEFAULT_TRANSACTION_PAGE); - auto page = ds.getTransactionRepository().search(pr, request); + auto page = ds.getTransactionRepository().search(pr, extractSearchParams(request)); writeJsonBody(response, page); } +@GetMapping(PROFILE_PATH ~ "/transactions/aggregate-data") +void handleGetTransactionAggregateData(ref ServerHttpRequest request, ref ServerHttpResponse response) { + import transaction.search_filters : extractSearchParams; + ProfileDataSource ds = getProfileDataSource(request); + auto aggregateData = ds.getTransactionRepository().getAggregateData(extractSearchParams(request)); + writeJsonBody(response, aggregateData); +} + @GetMapping(PROFILE_PATH ~ "/transactions/export") void handleExportTransactions(ref ServerHttpRequest request, ref ServerHttpResponse response) { ProfileDataSource ds = getProfileDataSource(request); diff --git a/finnow-api/source/transaction/data.d b/finnow-api/source/transaction/data.d index d7b498b..a6c38c8 100644 --- a/finnow-api/source/transaction/data.d +++ b/finnow-api/source/transaction/data.d @@ -1,6 +1,6 @@ module transaction.data; -import handy_http_primitives : Optional, ServerHttpRequest; +import handy_http_primitives : Optional, StringMultiValueMap; import std.datetime; import transaction.model; @@ -44,7 +44,8 @@ interface TransactionTagRepository { interface TransactionRepository { Page!TransactionsListItem findAll(in PageRequest pr); - Page!TransactionsListItem search(in PageRequest pr, in ServerHttpRequest request); + Page!TransactionsListItem search(in PageRequest pr, in StringMultiValueMap searchParams); + AggregateTransactionData getAggregateData(in StringMultiValueMap searchParams); Optional!TransactionDetail findById(ulong id); TransactionDetail insert(in AddTransactionPayload data); void linkAttachment(ulong transactionId, ulong attachmentId); diff --git a/finnow-api/source/transaction/data_impl_sqlite.d b/finnow-api/source/transaction/data_impl_sqlite.d index 6fb20aa..1991cbe 100644 --- a/finnow-api/source/transaction/data_impl_sqlite.d +++ b/finnow-api/source/transaction/data_impl_sqlite.d @@ -1,6 +1,6 @@ module transaction.data_impl_sqlite; -import handy_http_primitives : Optional, ServerHttpRequest; +import handy_http_primitives : Optional, StringMultiValueMap; import std.datetime; import std.typecons; import d2sqlite3; @@ -293,7 +293,7 @@ class SqliteTransactionRepository : TransactionRepository { return Page!(TransactionsListItem).of(results, pr, totalCount); } - Page!TransactionsListItem search(in PageRequest pr, in ServerHttpRequest request) { + Page!TransactionsListItem search(in PageRequest pr, in StringMultiValueMap searchParams) { import std.algorithm; import std.conv; import std.string : join; @@ -303,7 +303,7 @@ class SqliteTransactionRepository : TransactionRepository { // 1. Get the total count of transactions that match the search filters. qb.select("COUNT (DISTINCT txn.id)"); - applyFilters(qb, request, new SqliteTransactionCategoryRepository(db)); + applyFilters(qb, searchParams, new SqliteTransactionCategoryRepository(db)); string countQuery = qb.build(); Statement countStmt = db.prepare(countQuery); qb.applyArgBindings(countStmt); @@ -335,6 +335,38 @@ class SqliteTransactionRepository : TransactionRepository { return Page!TransactionsListItem.of(results, pr, count); } + AggregateTransactionData getAggregateData(in StringMultiValueMap searchParams) { + import transaction.search_filters; + // Start by using the standard transactions-list query builder to apply filters. + QueryBuilder qb = getBuilderForTransactionsList(); + qb.select("txn.currency AS currency, j_credit.amount AS credits, j_debit.amount AS debits"); + applyFilters(qb, searchParams, new SqliteTransactionCategoryRepository(db)); + string baseQuery = qb.build() ~ "\nGROUP BY txn.id"; + // Now wrap that in a separate query that aggregates credits & debits for each transaction. + string aggregateQuery = QueryBuilder("(" ~ baseQuery ~ ") AS base") + .select("base.currency") + .select("SUM(base.credits)") + .select("SUM(base.debits)") + .build() ~ " GROUP BY base.currency"; + Statement stmt = db.prepare(aggregateQuery); + // Use the base query's argument bindings to apply to the DB statement. + qb.applyArgBindings(stmt); + ResultRange result = stmt.execute(); + + // Collect the results for each currency into + AggregateTransactionData aggregateData; + while (!result.empty()) { + AggregateTransactionData.CurrencyData currencyData; + currencyData.credits = result.front.peek!ulong(1); + currencyData.debits = result.front.peek!ulong(2); + currencyData.balance = currencyData.debits - currencyData.credits; + currencyData.currency = Currency.ofCode(result.front.peek!(string, PeekMode.slice)(0)); + aggregateData.currencies ~= currencyData; + result.popFront(); + } + return aggregateData; + } + Optional!TransactionDetail findById(ulong id) { Optional!TransactionDetail item = util.sqlite.findOne( db, diff --git a/finnow-api/source/transaction/dto.d b/finnow-api/source/transaction/dto.d index fc30304..694626e 100644 --- a/finnow-api/source/transaction/dto.d +++ b/finnow-api/source/transaction/dto.d @@ -155,3 +155,18 @@ struct TransactionCategoryBalance { long balance; Currency currency; } + +/** + * A set of aggregate data computed from a set of transactions obtained in a + * search. Data is grouped by currency. + */ +struct AggregateTransactionData { + /// Aggregate data about all transactions with a common currency. + static struct CurrencyData { + ulong credits; + ulong debits; + long balance; + Currency currency; + } + CurrencyData[] currencies; +} diff --git a/finnow-api/source/transaction/search_filters.d b/finnow-api/source/transaction/search_filters.d index 1e9f735..7d99297 100644 --- a/finnow-api/source/transaction/search_filters.d +++ b/finnow-api/source/transaction/search_filters.d @@ -20,24 +20,24 @@ import transaction.data; * Applies a set of filters to a query builder for searching over transactions. * Params: * qb = The query builder to add WHERE clauses and argument bindings to. - * request = The request to get filter options from. + * searchParams = The set of search parameters provided by the user. * categoryRepo = Repository for fetching category data, which might be * needed if the user is filtering by a parent category. */ void applyFilters( ref QueryBuilder qb, - in ServerHttpRequest request, + in StringMultiValueMap searchParams, TransactionCategoryRepository categoryRepo ) { - applyPropertyInFilter!string(qb, request, "tags.tag", "tag"); - applyPropertyInFilter!ulong(qb, request, "vendor.id", "vendor"); - applyPropertyInFilter!string(qb, request, "txn.currency", "currency"); - applyPropertyInFilter!ulong(qb, request, "account_credit.id", "credited-account"); - applyPropertyInFilter!ulong(qb, request, "account_debit.id", "debited-account"); + applyPropertyInFilter!string(qb, searchParams, "tags.tag", "tag"); + applyPropertyInFilter!ulong(qb, searchParams, "vendor.id", "vendor"); + applyPropertyInFilter!string(qb, searchParams, "txn.currency", "currency"); + applyPropertyInFilter!ulong(qb, searchParams, "account_credit.id", "credited-account"); + applyPropertyInFilter!ulong(qb, searchParams, "account_debit.id", "debited-account"); // Separate filter that combines both credit and debit accounts. - if (request.hasParam("account")) { - ulong[] accountIds = request.getParamValues!ulong("account"); + if (searchParams.contains("account")) { + ulong[] accountIds = searchParams.getAllValuesAs!ulong("account"); string inStr = "(" ~ "?".repeat(accountIds.length).join(",") ~ ")"; qb.where("(account_credit.id IN " ~ inStr ~ " OR account_debit.id IN " ~ inStr ~ ")"); qb.withArgBinding((ref stmt, ref idx) { @@ -53,8 +53,8 @@ void applyFilters( // Separate filter that handles the hierarchical category relationship, so // if a parent category is filtered, all children are also included. - if (request.hasParam("category")) { - ulong[] categoryIds = request.getParamValues!ulong("category"); + if (searchParams.contains("category")) { + ulong[] categoryIds = searchParams.getAllValuesAs!ulong("category"); auto app = appender!(ulong[]); foreach (id; categoryIds) { app ~= getAllPossibleCategoryIds(id, categoryRepo); @@ -70,8 +70,8 @@ void applyFilters( } } - if (request.hasParam("min-amount")) { - ulong[] values = request.getParamValues!ulong("min-amount"); + if (searchParams.contains("min-amount")) { + ulong[] values = searchParams.getAllValuesAs!ulong("min-amount"); if (values.length > 0) { ulong minAmount = values[0]; qb.where("txn.amount >= ?"); @@ -81,8 +81,8 @@ void applyFilters( } } - if (request.hasParam("max-amount")) { - ulong[] values = request.getParamValues!ulong("max-amount"); + if (searchParams.contains("max-amount")) { + ulong[] values = searchParams.getAllValuesAs!ulong("max-amount"); if (values.length > 0) { ulong minAmount = values[0]; qb.where("txn.amount <= ?"); @@ -93,10 +93,9 @@ void applyFilters( } // Boolean filter for internal transfer. - if (request.hasParam("internal-transfer")) { - string value = request.getParamValues("internal-transfer")[0] - .strip() - .toUpper(); + if (searchParams.contains("internal-transfer")) { + string value = searchParams.getFirst("internal-transfer").orElseThrow() + .strip().toUpper(); Optional!bool internalTransferFilter = Optional!bool.empty(); if (value == "Y" || value == "YES" || value == "TRUE" || value == "1") { internalTransferFilter = Optional!bool.of(true); @@ -112,8 +111,8 @@ void applyFilters( } // Textual search query: - if (request.hasParam("q")) { - string searchQuery = request.getParamValues!string("q")[0]; + if (searchParams.contains("q")) { + string searchQuery = searchParams.getFirst("q").orElseThrow(); string likeStr = "%" ~ toUpper(strip(searchQuery)) ~ "%"; const string[] conditions = [ "UPPER(txn.description) LIKE ?", @@ -132,14 +131,36 @@ void applyFilters( } } +/** + * Helper function to extract query parameters into a multi-value map to be + * used more generally for other functions that require this format for search + * parameters. + * Params: + * request = The HTTP request to extract parameters from. + * Returns: A string-string multi-value map. + */ +StringMultiValueMap extractSearchParams(in ServerHttpRequest request) { + auto searchParamsBuilder = StringMultiValueMap.Builder(); + foreach (queryParam; request.queryParams) { + foreach (value; queryParam.values) { + searchParamsBuilder.add(queryParam.key, value); + } + } + return searchParamsBuilder.build(); +} + +private T[] getAllValuesAs(T)(in StringMultiValueMap m, string key) { + return m.getAll(key).map!(v => v.to!T).array; +} + private void applyPropertyInFilter(T)( ref QueryBuilder qb, - in ServerHttpRequest request, + in StringMultiValueMap searchParams, string property, string key ) { - if (request.hasParam(key)) { - T[] values = request.getParamValues!T(key); + if (searchParams.contains(key)) { + T[] values = searchParams.getAllValuesAs!T(key); qb.where(property ~ " IN (" ~ "?".repeat(values.length).join(",") ~ ")"); qb.withArgBinding((ref stmt, ref idx) { foreach (value; values) { @@ -150,22 +171,6 @@ private void applyPropertyInFilter(T)( } } -private bool hasParam(in ServerHttpRequest request, string key) { - foreach (param; request.queryParams) { - if (param.key == key && param.values.length > 0) return true; - } - return false; -} - -private T[] getParamValues(T = string)(in ServerHttpRequest request, string key) { - foreach (param; request.queryParams) { - if (param.key == key) { - return param.values.map!(s => s.to!T).array; - } - } - return []; -} - private ulong[] getAllPossibleCategoryIds(ulong parentId, TransactionCategoryRepository categoryRepo) { auto app = appender!(ulong[]); app ~= parentId; diff --git a/finnow-api/source/transaction/service.d b/finnow-api/source/transaction/service.d index 76cda56..b6fe647 100644 --- a/finnow-api/source/transaction/service.d +++ b/finnow-api/source/transaction/service.d @@ -9,6 +9,7 @@ import transaction.api; import transaction.model; import transaction.data; import transaction.dto; +import transaction.search_filters : extractSearchParams; import profile.data; import account.model; import account.data; @@ -285,7 +286,7 @@ void exportTransactionsToFile( ref ServerHttpResponse response ) { Page!TransactionsListItem data = ds.getTransactionRepository() - .search(PageRequest.unpaged(), request); + .search(PageRequest.unpaged(), extractSearchParams(request)); ExportFileFormat fileFormat = getPreferredExportFileFormat(request); if (fileFormat == ExportFileFormat.JSON) { import handy_http_data : writeJsonBody; diff --git a/web-app/src/api/transaction.ts b/web-app/src/api/transaction.ts index a04eedb..ff74b2f 100644 --- a/web-app/src/api/transaction.ts +++ b/web-app/src/api/transaction.ts @@ -133,6 +133,17 @@ export interface CreateCategoryPayload { parentId: number | null } +export interface AggregateTransactionCurrencyData { + credits: number + debits: number + balance: number + currency: Currency +} + +export interface AggregateTransactionData { + currencies: AggregateTransactionCurrencyData[] +} + export class TransactionApiClient extends ApiClient { readonly path: string @@ -225,6 +236,10 @@ export class TransactionApiClient extends ApiClient { return super.getJson(this.path + '/transactions/search?' + params.toString()) } + getAggregateTransactionData(params: URLSearchParams): Promise { + return super.getJson(this.path + '/transactions/aggregate-data?' + params.toString()) + } + exportTransactions(params: URLSearchParams): Promise { return super.getFile(this.path + '/transactions/export?' + params.toString()) } diff --git a/web-app/src/assets/styles/spacing.css b/web-app/src/assets/styles/spacing.css index 27436c9..f03800e 100644 --- a/web-app/src/assets/styles/spacing.css +++ b/web-app/src/assets/styles/spacing.css @@ -14,6 +14,10 @@ margin-top: 0; } +.mb-1 { + margin-bottom: 0.5rem; +} + .mb-0 { margin-bottom: 0; } diff --git a/web-app/src/pages/TransactionSearchPage.vue b/web-app/src/pages/TransactionSearchPage.vue index 047f7c2..ed09c98 100644 --- a/web-app/src/pages/TransactionSearchPage.vue +++ b/web-app/src/pages/TransactionSearchPage.vue @@ -1,9 +1,11 @@