diff --git a/.gitea/workflows/api.yaml b/.gitea/workflows/api.yaml index a988fc8..76ee277 100644 --- a/.gitea/workflows/api.yaml +++ b/.gitea/workflows/api.yaml @@ -6,12 +6,15 @@ on: - '.gitea/workflows/api.yaml' jobs: build-and-deploy: - runs-ons: ubuntu-latest + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: dlang-community/setup-dlang@v2 with: - compiler: ldc-latest + compiler: ldc-latest\ + - name: Test + run: dub test + working-directory: ./finnow-api - name: Build run: dub build --build=release working-directory: ./finnow-api diff --git a/finnow-api/bruno-api/Finnow/Search Test.bru b/finnow-api/bruno-api/Finnow/Search Test.bru new file mode 100644 index 0000000..8d7011d --- /dev/null +++ b/finnow-api/bruno-api/Finnow/Search Test.bru @@ -0,0 +1,15 @@ +meta { + name: Search Test + type: http + seq: 6 +} + +get { + url: {{base_url}}/transactions/search + body: none + auth: inherit +} + +settings { + encodeUrl: true +} diff --git a/finnow-api/currency_symbols.csv b/finnow-api/currency_symbols.csv new file mode 100644 index 0000000..a5d1ca2 --- /dev/null +++ b/finnow-api/currency_symbols.csv @@ -0,0 +1,10 @@ +Code,Symbol +USD,$ +CAD,$ +AUD,$ +GBP,£ +EUR,€ +CHF,Fr +ZAR,R +JPY,¥ +INR,₹ diff --git a/finnow-api/source/account/data.d b/finnow-api/source/account/data.d index 926de3f..01ffe69 100644 --- a/finnow-api/source/account/data.d +++ b/finnow-api/source/account/data.d @@ -83,7 +83,7 @@ version(unittest) { void setCreditCardProperties(ulong id, in AccountCreditCardProperties props) { throw new Exception("Not implemented"); } - History getHistory(ulong id) { + Page!AccountHistoryItemResponse getHistory(ulong accountId, in PageRequest pagination) { throw new Exception("Not implemented"); } } @@ -108,13 +108,13 @@ version(unittest) { void deleteByAccountIdAndTransactionId(ulong accountId, ulong transactionId) { throw new Exception("Not implemented"); } - AccountJournalEntry[] findAllBetween(SysTime startIncl, SysTime endIncl) { + AccountJournalEntry[] findAllBetween(SysTime startIncl, SysTime endIncl, ulong accountId) { throw new Exception("Not implemented"); } } class TestAccountValueRecordRepositoryStub : AccountValueRecordRepository { - Optional!AccountValueRecord findById(ulong id) { + Optional!AccountValueRecord findById(ulong accountId, ulong id) { throw new Exception("Not implemented"); } AccountValueRecord insert( @@ -135,5 +135,14 @@ version(unittest) { Optional!AccountValueRecord findNearestByAccountIdAfter(ulong accountId, SysTime timestamp) { throw new Exception("Not implemented"); } + void linkAttachment(ulong valueRecordId, ulong attachmentId) { + throw new Exception("Not implemented"); + } + Page!AccountValueRecord findAllByAccountId(ulong accountId, in PageRequest pr) { + throw new Exception("Not implemented"); + } + void deleteById(ulong accountId, ulong id) { + throw new Exception("Not implemented"); + } } } diff --git a/finnow-api/source/account/service.d b/finnow-api/source/account/service.d index 223720f..605fc3e 100644 --- a/finnow-api/source/account/service.d +++ b/finnow-api/source/account/service.d @@ -188,7 +188,7 @@ unittest { this.journalEntries = journalEntries; } - override AccountJournalEntry[] findAllBetween(SysTime startIncl, SysTime endIncl) { + override AccountJournalEntry[] findAllBetween(SysTime startIncl, SysTime endIncl, ulong accountId) { auto matches = journalEntries.filter!(je => je.timestamp >= startIncl && je.timestamp <= endIncl) .array; matches.sort!((a, b) => a.timestamp < b.timestamp); diff --git a/finnow-api/source/api_mapping.d b/finnow-api/source/api_mapping.d index 1ddaa90..803aafb 100644 --- a/finnow-api/source/api_mapping.d +++ b/finnow-api/source/api_mapping.d @@ -80,6 +80,7 @@ HttpRequestHandler mapApiHandlers(string webOrigin) { a.map(HttpMethod.DELETE, PROFILE_PATH ~ "/categories/:categoryId:ulong", &handleDeleteCategory); // Transaction endpoints: a.map(HttpMethod.GET, PROFILE_PATH ~ "/transactions", &handleGetTransactions); + a.map(HttpMethod.GET, PROFILE_PATH ~ "/transactions/search", &handleSearchTransactions); a.map(HttpMethod.GET, PROFILE_PATH ~ "/transactions/:transactionId:ulong", &handleGetTransaction); a.map(HttpMethod.POST, PROFILE_PATH ~ "/transactions", &handleAddTransaction); a.map(HttpMethod.PUT, PROFILE_PATH ~ "/transactions/:transactionId:ulong", &handleUpdateTransaction); diff --git a/finnow-api/source/profile/data.d b/finnow-api/source/profile/data.d index ee411ef..afd18f7 100644 --- a/finnow-api/source/profile/data.d +++ b/finnow-api/source/profile/data.d @@ -49,10 +49,14 @@ version(unittest) { class TestProfileDataSourceStub : ProfileDataSource { import account.data; import transaction.data; + import attachment.data; PropertiesRepository getPropertiesRepository() { throw new Exception("Not implemented"); } + AttachmentRepository getAttachmentRepository() { + throw new Exception("Not implemented"); + } AccountRepository getAccountRepository() { throw new Exception("Not implemented"); } diff --git a/finnow-api/source/transaction/api.d b/finnow-api/source/transaction/api.d index 4a5acf8..02566eb 100644 --- a/finnow-api/source/transaction/api.d +++ b/finnow-api/source/transaction/api.d @@ -19,7 +19,7 @@ import util.data; // Transactions API -immutable DEFAULT_TRANSACTION_PAGE = PageRequest(1, 10, [Sort("timestamp", SortDir.DESC)]); +immutable DEFAULT_TRANSACTION_PAGE = PageRequest(1, 10, [Sort("txn.timestamp", SortDir.DESC)]); void handleGetTransactions(ref ServerHttpRequest request, ref ServerHttpResponse response) { ProfileDataSource ds = getProfileDataSource(request); @@ -28,6 +28,13 @@ void handleGetTransactions(ref ServerHttpRequest request, ref ServerHttpResponse writeJsonBody(response, responsePage); } +void handleSearchTransactions(ref ServerHttpRequest request, ref ServerHttpResponse response) { + ProfileDataSource ds = getProfileDataSource(request); + PageRequest pr = PageRequest.parse(request, DEFAULT_TRANSACTION_PAGE); + auto page = ds.getTransactionRepository().search(pr, request); + writeJsonBody(response, page); +} + void handleGetTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) { ProfileDataSource ds = getProfileDataSource(request); TransactionDetail txn = getTransaction(ds, getTransactionIdOrThrow(request)); diff --git a/finnow-api/source/transaction/data.d b/finnow-api/source/transaction/data.d index 39afb9c..53b63ed 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; +import handy_http_primitives : Optional, ServerHttpRequest; import std.datetime; import transaction.model; @@ -36,7 +36,8 @@ interface TransactionTagRepository { } interface TransactionRepository { - Page!TransactionsListItem findAll(PageRequest pr); + Page!TransactionsListItem findAll(in PageRequest pr); + Page!TransactionsListItem search(in PageRequest pr, in ServerHttpRequest request); 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 3cc7ccb..3a0b887 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; +import handy_http_primitives : Optional, ServerHttpRequest; import std.datetime; import std.typecons; import d2sqlite3; @@ -198,69 +198,60 @@ class SqliteTransactionRepository : TransactionRepository { this.db = db; } - Page!TransactionsListItem findAll(PageRequest pr) { - const BASE_QUERY = import("sql/get_transactions.sql"); - // TODO: Implement filtering or something! - import std.array; - const string countQuery = "SELECT COUNT(ID) FROM " ~ TABLE_NAME; - auto sqlBuilder = appender!string; - sqlBuilder ~= BASE_QUERY; - sqlBuilder ~= " "; - sqlBuilder ~= pr.toSql(); - string query = sqlBuilder[]; - TransactionsListItem[] results = util.sqlite.findAll(db, query, (row) { - TransactionsListItem item; - item.id = row.peek!ulong(0); - item.timestamp = row.peek!string(1); - item.addedAt = row.peek!string(2); - item.amount = row.peek!ulong(3); - item.currency = Currency.ofCode(row.peek!(string, PeekMode.slice)(4)); - item.description = row.peek!string(5); - - Nullable!ulong vendorId = row.peek!(Nullable!ulong)(6); - if (!vendorId.isNull) { - string vendorName = row.peek!string(7); - item.vendor = Optional!(TransactionsListItem.Vendor).of( - TransactionsListItem.Vendor(vendorId.get, vendorName)); - } - Nullable!ulong categoryId = row.peek!(Nullable!ulong)(8); - if (!categoryId.isNull) { - string categoryName = row.peek!string(9); - string categoryColor = row.peek!string(10); - item.category = Optional!(TransactionsListItem.Category).of( - TransactionsListItem.Category(categoryId.get, categoryName, categoryColor)); - } - Nullable!ulong creditedAccountId = row.peek!(Nullable!ulong)(11); - if (!creditedAccountId.isNull) { - ulong id = creditedAccountId.get; - string name = row.peek!string(12); - string type = row.peek!string(13); - string suffix = row.peek!string(14); - item.creditedAccount = Optional!(TransactionsListItem.Account).of( - TransactionsListItem.Account(id, name, type, suffix)); - } - Nullable!ulong debitedAccountId = row.peek!(Nullable!ulong)(15); - if (!debitedAccountId.isNull) { - ulong id = debitedAccountId.get; - string name = row.peek!string(16); - string type = row.peek!string(17); - string suffix = row.peek!string(18); - item.debitedAccount = Optional!(TransactionsListItem.Account).of( - TransactionsListItem.Account(id, name, type, suffix)); - } - string tagsStr = row.peek!string(19); - if (tagsStr !is null && tagsStr.length > 0) { - import std.string : split; - item.tags = tagsStr.split(","); - } else { - item.tags = []; - } - return item; - }); - ulong totalCount = util.sqlite.count(db, countQuery); + Page!TransactionsListItem findAll(in PageRequest pr) { + const pageIdsQuery = "SELECT DISTINCT txn.id FROM \"transaction\" txn " ~ pr.toSql(); + QueryBuilder qb = getBuilderForTransactionsList(); + addSelectsForTransactionsList(qb); + qb.where("txn.id IN (" ~ pageIdsQuery ~ ")"); + string query = qb.build() ~ "\n" ~ pr.toOrderClause(); + TransactionsListItem[] results = util.sqlite.findAllDirect(db, query, &parseListItems); + ulong totalCount = util.sqlite.count(db, "SELECT COUNT(DISTINCT id) FROM \"transaction\""); return Page!(TransactionsListItem).of(results, pr, totalCount); } + Page!TransactionsListItem search(in PageRequest pr, in ServerHttpRequest request) { + import std.algorithm; + import std.conv; + import std.string : join; + import transaction.search_filters; + import std.stdio; + QueryBuilder qb = getBuilderForTransactionsList(); + + // 1. Get the total count of transactions that match the search filters. + qb.select("COUNT (DISTINCT txn.id)"); + applyFilters(qb, request); + string countQuery = qb.build(); + // writeln(countQuery); + Statement countStmt = db.prepare(countQuery); + qb.applyArgBindings(countStmt); + auto countResult = countStmt.execute(); + ulong count = countResult.empty ? 0 : countResult.front.peek!ulong(0); + if (count == 0) return Page!TransactionsListItem.of([], pr, 0); + + // 2. Select the ordered list of IDs of transactions to include in the page of results. + // This is because one transaction may be split over multiple db rows. + qb.selections = []; + qb.select("DISTINCT txn.id"); + string idsQuery = qb.build() ~ "\n" ~ pr.toSql(); + Statement idsStmt = db.prepare(idsQuery); + qb.applyArgBindings(idsStmt); + string idsStr = idsStmt.execute() + .map!(r => r.peek!ulong(0)) + .map!(id => id.to!string) + .join(","); + + // 3. Select the list of transactions for the results based on the IDs found in step 2. + qb.selections = []; + qb.conditions = []; + qb.argBinders = []; + addSelectsForTransactionsList(qb); + qb.where("txn.id IN (" ~ idsStr ~ ")"); + string query = qb.build() ~ "\n" ~ pr.toOrderClause(); + Statement stmt = db.prepare(query); + auto results = parseListItems(stmt.execute()); + return Page!TransactionsListItem.of(results, pr, count); + } + Optional!TransactionDetail findById(ulong id) { Optional!TransactionDetail item = util.sqlite.findOne( db, @@ -426,6 +417,144 @@ class SqliteTransactionRepository : TransactionRepository { ); } + /** + * Function to parse a list of transaction list items as obtained from the + * `get_transactions.sql` query. Because there are possibly multiple rows + * per transaction, we have to deal with the result range as a whole. + * Params: + * r = The result range to read. + * Returns: The list of transaction list items. + */ + private static TransactionsListItem[] parseListItems(ResultRange r) { + import std.array : appender; + auto app = appender!(TransactionsListItem[]); + TransactionsListItem item; + + /// Helper function that appends the current item to the list, and resets state. + void appendItem() { + import std.algorithm : sort; + sort(item.tags); + app ~= item; + item.id = 0; + item.tags = []; + } + + size_t rowCount = 0; + foreach (Row row; r) { + rowCount++; + ulong txnId = row.peek!ulong(0); + if (item.id != txnId) { + // We're parsing a new item. First, append the current one if there is one. + if (item.id != 0) { + appendItem(); + } + + item.id = txnId; + item.timestamp = row.peek!string(1); + item.addedAt = row.peek!string(2); + item.amount = row.peek!ulong(3); + item.currency = Currency.ofCode(row.peek!(string, PeekMode.slice)(4)); + item.description = row.peek!string(5); + // Read the nullable Vendor information. + Nullable!ulong vendorId = row.peek!(Nullable!ulong)(6); + if (!vendorId.isNull) { + string vendorName = row.peek!string(7); + item.vendor = Optional!(TransactionsListItem.Vendor).of( + TransactionsListItem.Vendor(vendorId.get, vendorName)); + } + // Read the nullable Category information. + Nullable!ulong categoryId = row.peek!(Nullable!ulong)(8); + if (!categoryId.isNull) { + string categoryName = row.peek!string(9); + string categoryColor = row.peek!string(10); + item.category = Optional!(TransactionsListItem.Category).of( + TransactionsListItem.Category(categoryId.get, categoryName, categoryColor)); + } + // Read the nullable creditedAccount. + Nullable!ulong creditedAccountId = row.peek!(Nullable!ulong)(11); + if (!creditedAccountId.isNull) { + ulong id = creditedAccountId.get; + string name = row.peek!string(12); + string type = row.peek!string(13); + string suffix = row.peek!string(14); + item.creditedAccount = Optional!(TransactionsListItem.Account).of( + TransactionsListItem.Account(id, name, type, suffix)); + } + // Read the nullable debitedAccount. + Nullable!ulong debitedAccountId = row.peek!(Nullable!ulong)(15); + if (!debitedAccountId.isNull) { + ulong id = debitedAccountId.get; + string name = row.peek!string(16); + string type = row.peek!string(17); + string suffix = row.peek!string(18); + item.debitedAccount = Optional!(TransactionsListItem.Account).of( + TransactionsListItem.Account(id, name, type, suffix)); + } + } + + // Read multi-row properties, like tags, to the current item. + string tag = row.peek!string(19); + if (tag !is null) { + item.tags ~= tag; + } + } + // If there's one last cached item, append it to the list. + if (item.id != 0) { + appendItem(); + } + return app[]; + } + + static TransactionsListItem parseTransactionsListItem(Row row) { + TransactionsListItem item; + item.id = row.peek!ulong(0); + item.timestamp = row.peek!string(1); + item.addedAt = row.peek!string(2); + item.amount = row.peek!ulong(3); + item.currency = Currency.ofCode(row.peek!(string, PeekMode.slice)(4)); + item.description = row.peek!string(5); + + Nullable!ulong vendorId = row.peek!(Nullable!ulong)(6); + if (!vendorId.isNull) { + string vendorName = row.peek!string(7); + item.vendor = Optional!(TransactionsListItem.Vendor).of( + TransactionsListItem.Vendor(vendorId.get, vendorName)); + } + Nullable!ulong categoryId = row.peek!(Nullable!ulong)(8); + if (!categoryId.isNull) { + string categoryName = row.peek!string(9); + string categoryColor = row.peek!string(10); + item.category = Optional!(TransactionsListItem.Category).of( + TransactionsListItem.Category(categoryId.get, categoryName, categoryColor)); + } + Nullable!ulong creditedAccountId = row.peek!(Nullable!ulong)(11); + if (!creditedAccountId.isNull) { + ulong id = creditedAccountId.get; + string name = row.peek!string(12); + string type = row.peek!string(13); + string suffix = row.peek!string(14); + item.creditedAccount = Optional!(TransactionsListItem.Account).of( + TransactionsListItem.Account(id, name, type, suffix)); + } + Nullable!ulong debitedAccountId = row.peek!(Nullable!ulong)(15); + if (!debitedAccountId.isNull) { + ulong id = debitedAccountId.get; + string name = row.peek!string(16); + string type = row.peek!string(17); + string suffix = row.peek!string(18); + item.debitedAccount = Optional!(TransactionsListItem.Account).of( + TransactionsListItem.Account(id, name, type, suffix)); + } + string tagsStr = row.peek!string(19); + if (tagsStr !is null && tagsStr.length > 0) { + import std.string : split; + item.tags = tagsStr.split(","); + } else { + item.tags = []; + } + return item; + } + private void insertLineItems(ulong transactionId, in AddTransactionPayload data) { foreach (size_t idx, lineItem; data.lineItems) { util.sqlite.update( @@ -440,4 +569,41 @@ class SqliteTransactionRepository : TransactionRepository { ); } } + + private QueryBuilder getBuilderForTransactionsList() { + return QueryBuilder("\"transaction\" txn") + .join("LEFT JOIN transaction_vendor vendor ON vendor.id = txn.vendor_id") + .join("LEFT JOIN transaction_category category ON category.id = txn.category_id") + .join("LEFT JOIN account_journal_entry j_credit " ~ + "ON j_credit.transaction_id = txn.id AND UPPER(j_credit.type) = 'CREDIT'") + .join("LEFT JOIN account account_credit ON account_credit.id = j_credit.account_id") + .join("LEFT JOIN account_journal_entry j_debit " ~ + "ON j_debit.transaction_id = txn.id AND UPPER(j_debit.type) = 'DEBIT'") + .join("LEFT JOIN account account_debit ON account_debit.id = j_debit.account_id") + .join("LEFT JOIN transaction_tag tags ON tags.transaction_id = txn.id"); + } + + private void addSelectsForTransactionsList(ref QueryBuilder qb) { + qb + .select("txn.id") + .select("txn.timestamp") + .select("txn.added_at") + .select("txn.amount") + .select("txn.currency") + .select("txn.description") + .select("txn.vendor_id") + .select("vendor.name") + .select("txn.category_id") + .select("category.name") + .select("category.color") + .select("account_credit.id") + .select("account_credit.name") + .select("account_credit.type") + .select("account_credit.number_suffix") + .select("account_debit.id") + .select("account_debit.name") + .select("account_debit.type") + .select("account_debit.number_suffix") + .select("tags.tag"); + } } diff --git a/finnow-api/source/transaction/search_filters.d b/finnow-api/source/transaction/search_filters.d new file mode 100644 index 0000000..dec6057 --- /dev/null +++ b/finnow-api/source/transaction/search_filters.d @@ -0,0 +1,123 @@ +/** + * This module provides helper logic for applying filters when searching over + * transactions. + */ +module transaction.search_filters; + +import handy_http_primitives; +import std.algorithm; +import std.conv; +import std.array; +import std.range; +import std.string; +import std.uri; +import std.uni; + +import util.sqlite; + +/** + * 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. + */ +void applyFilters(ref QueryBuilder qb, in ServerHttpRequest request) { + applyPropertyInFilter!string(qb, request, "tags.tag", "tag"); + applyPropertyInFilter!ulong(qb, request, "vendor.id", "vendor"); + applyPropertyInFilter!ulong(qb, request, "category.id", "category"); + 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"); + + // Separate filter that combines both credit and debit accounts. + if (request.hasParam("account")) { + ulong[] accountIds = request.getParamValues!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) { + foreach (value; accountIds) { + stmt.bind(idx++, value); + } + // Again for the second IN clause. + foreach (value; accountIds) { + stmt.bind(idx++, value); + } + }); + } + + if (request.hasParam("min-amount")) { + ulong[] values = request.getParamValues!ulong("min-amount"); + if (values.length > 0) { + ulong minAmount = values[0]; + qb.where("txn.amount >= ?"); + qb.withArgBinding((ref stmt, ref idx) { + stmt.bind(idx++, minAmount); + }); + } + } + + if (request.hasParam("max-amount")) { + ulong[] values = request.getParamValues!ulong("max-amount"); + if (values.length > 0) { + ulong minAmount = values[0]; + qb.where("txn.amount <= ?"); + qb.withArgBinding((ref stmt, ref idx) { + stmt.bind(idx++, minAmount); + }); + } + } + + // Textual search query: + if (request.hasParam("q")) { + string searchQuery = request.getParamValues!string("q")[0]; + string likeStr = "%" ~ toUpper(strip(searchQuery)) ~ "%"; + const string[] conditions = [ + "UPPER(txn.description) LIKE ?", + "UPPER(vendor.name) LIKE ?", + "UPPER(category.name) LIKE ?", + "UPPER(account_credit.name) LIKE ?", + "UPPER(account_debit.name) LIKE ?", + "UPPER(tags.tag) LIKE ?" + ]; + qb.where("(" ~ conditions.join(" OR ") ~ ")"); + qb.withArgBinding((ref stmt, ref idx) { + for (int i = 0; i < conditions.length; i++) { + stmt.bind(idx++, likeStr); + } + }); + } +} + +private void applyPropertyInFilter(T)( + ref QueryBuilder qb, + in ServerHttpRequest request, + string property, + string key +) { + if (request.hasParam(key)) { + T[] values = request.getParamValues!T(key); + qb.where(property ~ " IN (" ~ "?".repeat(values.length).join(",") ~ ")"); + qb.withArgBinding((ref stmt, ref idx) { + foreach (value; values) { + stmt.bind(idx++, value); + } + }); + return; + } +} + +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 []; +} diff --git a/finnow-api/source/util/money.d b/finnow-api/source/util/money.d index 6088205..94fc7bc 100644 --- a/finnow-api/source/util/money.d +++ b/finnow-api/source/util/money.d @@ -7,6 +7,8 @@ import std.traits : isSomeString, EnumMembers; * https://en.wikipedia.org/wiki/ISO_4217 */ struct Currency { + // The common name of the currency. + string name; /// The common 3-character code for the currency, like "USD". char[3] code; /// The number of digits after the decimal place that the currency supports. @@ -27,20 +29,85 @@ struct Currency { } } -/// An enumeration of all available currencies. -enum Currencies : Currency { - AUD = Currency("AUD", 2, 36, "$"), - USD = Currency("USD", 2, 840, "$"), - CAD = Currency("CAD", 2, 124, "$"), - GBP = Currency("GBP", 2, 826, "£"), - EUR = Currency("EUR", 2, 978, "€"), - CHF = Currency("CHF", 2, 756, "Fr"), - ZAR = Currency("ZAR", 2, 710, "R"), - JPY = Currency("JPY", 0, 392, "¥"), - INR = Currency("INR", 2, 356, "₹") +/** + * An enumeration defining all available currencies. This is generated at + * compile time by reading currency data from CSV files and generating a list + * of currency declarations. + */ +mixin("enum Currencies : Currency {\n" ~ getCurrenciesEnumMembers() ~ "\n}"); + +/** + * A list of all currencies, as a convenience for getting all members of the + * `Currencies` enum. + */ +immutable(Currency[]) ALL_CURRENCIES = cast(Currency[]) [EnumMembers!Currencies]; + +private Currency[] readCurrenciesFromFile() { + import std.csv; + import std.stdio; + import std.algorithm; + import std.typecons; + import std.array; + import std.string; + import std.conv; + // First read the list of known currency symbols and use it as a lookup table. + string[string] knownCurrencySymbols; + const string currencySymbolsFile = import("currency_symbols.csv"); + foreach (record; currencySymbolsFile.csvReader!(Tuple!(string, string))) { + string code = record[0].strip(); + string symbol = record[1].strip(); + knownCurrencySymbols[code] = symbol; + } + // Then read the list of currencies. + auto app = appender!(Currency[]); + auto codes = appender!(string[]); + const string currenciesFile = import("currency_codes_ISO4217.csv"); + foreach (record; currenciesFile.csvReader!(Tuple!(string, string, string, string, string, string))) { + string currencyName = record[1].strip(); + string code = record[2].strip(); + string numericCode = record[3].strip(); + string minorUnit = record[4].strip(); + string withdrawalDate = record[5].strip(); + string symbol; + if (code in knownCurrencySymbols) { + symbol = knownCurrencySymbols[code]; + } else { + symbol = "$"; + } + if ( + withdrawalDate.length > 0 || + canFind(codes[], code) || + code.length != 3 + ) { + continue; + } + if (minorUnit == "-") { + minorUnit = "0"; + } + app ~= Currency(currencyName, code[0..3], minorUnit.to!ubyte, numericCode.to!ushort, symbol); + codes ~= code; + } + return app[]; } -immutable(Currency[]) ALL_CURRENCIES = cast(Currency[]) [EnumMembers!Currencies]; +private string getCurrenciesEnumMembers() { + import std.algorithm; + import std.array; + import std.format; + import std.conv; + auto currencies = readCurrenciesFromFile(); + return currencies + .map!(c => format!"%s = Currency(\"%s\", \"%s\", %d, %d, \"%s\")"( + c.code, + c.name, + c.code, + c.fractionalDigits, + c.numericCode, + c.symbol + )) + .joiner(",\n") + .array.to!string; +} unittest { assert(Currency.ofCode("USD") == Currencies.USD); diff --git a/finnow-api/source/util/pagination.d b/finnow-api/source/util/pagination.d index d251c1b..3e8f4ab 100644 --- a/finnow-api/source/util/pagination.d +++ b/finnow-api/source/util/pagination.d @@ -80,7 +80,19 @@ struct PageRequest { string toSql() const { import std.array; auto app = appender!string; + app ~= this.toOrderClause(); + if (!isUnpaged()) { + app ~= "LIMIT "; + app ~= size.to!string; + app ~= " OFFSET "; + app ~= ((page - 1) * size).to!string; + } + return app[]; + } + string toOrderClause() const { + import std.array; + auto app = appender!string; if (sorts.length > 0) { app ~= "ORDER BY "; for (size_t i = 0; i < sorts.length; i++) { @@ -91,12 +103,6 @@ struct PageRequest { } app ~= " "; } - if (!isUnpaged()) { - app ~= "LIMIT "; - app ~= size.to!string; - app ~= " OFFSET "; - app ~= ((page - 1) * size).to!string; - } return app[]; } diff --git a/finnow-api/source/util/sqlite.d b/finnow-api/source/util/sqlite.d index 0b45729..5324d88 100644 --- a/finnow-api/source/util/sqlite.d +++ b/finnow-api/source/util/sqlite.d @@ -65,6 +65,23 @@ T[] findAll(T, Args...)(Database db, string query, T function(Row) resultMapper, return stmt.execute().map!(r => resultMapper(r)).array; } +/** + * Finds a list of records from a database, using a single function to parse + * the entire result set at once, useful for cases where records may be spread + * over multiple rows due to joined properties. + * Params: + * db = The database to use. + * query = The query to execute. + * resultMapper = A function to map the result range to the list of results. + * args = Arguments for the query. + * Returns: A list of results. + */ +T[] findAllDirect(T, Args...)(Database db, string query, T[] function(ResultRange) resultMapper, Args args) { + Statement stmt = db.prepare(query); + stmt.bindAll(args); + return resultMapper(stmt.execute()); +} + /** * Determines if at least one record exists. * Params: @@ -174,3 +191,62 @@ SysTime parseISOTimestamp(Row row, size_t idx) { immutable(ubyte[]) parseBlob(Row row, size_t idx) { return row.peek!(ubyte[], PeekMode.slice)(idx).idup; } + +struct QueryBuilder { + string fromTable; + string[] selections; + string[] joins; + string[] conditions; + void delegate(ref Statement, ref int)[] argBinders; + + this(string fromTable) { + this.fromTable = fromTable; + } + + ref select(string expr) { + selections ~= expr; + return this; + } + + ref join(string expr) { + joins ~= expr; + return this; + } + + ref where(string expr) { + conditions ~= expr; + return this; + } + + ref withArgBinding(void delegate(ref Statement, ref int) dg) { + argBinders ~= dg; + return this; + } + + string build() const { + import std.algorithm : map; + import std.string : join; + import std.array : appender; + auto app = appender!string; + app ~= "SELECT\n"; + if (selections.length > 0) { + app ~= selections.map!(s => " " ~ s).join(",\n"); + } else { + app ~= " *"; + } + app ~= "\nFROM " ~ fromTable ~ "\n"; + app ~= joins.join("\n"); + if (conditions.length > 0) { + app ~= "\nWHERE\n"; + app ~= conditions.map!(s => " " ~ s).join(" AND\n"); + } + return app[]; + } + + void applyArgBindings(ref Statement stmt) const { + int idx = 1; + foreach (binding; argBinders) { + binding(stmt, idx); + } + } +} diff --git a/finnow-api/sql/get_transactions.sql b/finnow-api/sql/get_transactions.sql deleted file mode 100644 index c001ff7..0000000 --- a/finnow-api/sql/get_transactions.sql +++ /dev/null @@ -1,42 +0,0 @@ -SELECT -txn.id AS id, -txn.timestamp AS timestamp, -txn.added_at AS added_at, -txn.amount AS amount, -txn.currency AS currency, -txn.description AS description, - -txn.vendor_id AS vendor_id, -vendor.name AS vendor_name, - -txn.category_id AS category_id, -category.name AS category_name, -category.color AS category_color, - -account_credit.id AS credited_account_id, -account_credit.name AS credited_account_name, -account_credit.type AS credited_account_type, -account_credit.number_suffix AS credited_account_number_suffix, - -account_debit.id AS debited_account_id, -account_debit.name AS debited_account_name, -account_debit.type AS debited_account_type, -account_debit.number_suffix AS debited_account_number_suffix, - -GROUP_CONCAT(tag) AS tags -FROM -"transaction" txn -LEFT JOIN transaction_vendor vendor - ON vendor.id = txn.vendor_id -LEFT JOIN transaction_category category - ON category.id = txn.category_id -LEFT JOIN account_journal_entry j_credit - ON j_credit.transaction_id = txn.id AND UPPER(j_credit.type) = 'CREDIT' -LEFT JOIN account account_credit - ON account_credit.id = j_credit.account_id -LEFT JOIN account_journal_entry j_debit - ON j_debit.transaction_id = txn.id AND UPPER(j_debit.type) = 'DEBIT' -LEFT JOIN account account_debit - ON account_debit.id = j_debit.account_id -LEFT JOIN transaction_tag tags ON tags.transaction_id = txn.id -GROUP BY txn.id \ No newline at end of file diff --git a/web-app/package-lock.json b/web-app/package-lock.json index 63f27d5..98acf5d 100644 --- a/web-app/package-lock.json +++ b/web-app/package-lock.json @@ -15,7 +15,7 @@ "pinia": "^3.0.3", "vue": "^3.5.18", "vue-router": "^4.5.1", - "vue3-select-component": "^0.12.1" + "vue3-select-component": "^0.12.3" }, "devDependencies": { "@tsconfig/node22": "^22.0.2", diff --git a/web-app/package.json b/web-app/package.json index b8aadc4..13d855d 100644 --- a/web-app/package.json +++ b/web-app/package.json @@ -23,7 +23,7 @@ "pinia": "^3.0.3", "vue": "^3.5.18", "vue-router": "^4.5.1", - "vue3-select-component": "^0.12.1" + "vue3-select-component": "^0.12.3" }, "devDependencies": { "@tsconfig/node22": "^22.0.2", diff --git a/web-app/src/api/pagination.ts b/web-app/src/api/pagination.ts index 72f309d..a148e93 100644 --- a/web-app/src/api/pagination.ts +++ b/web-app/src/api/pagination.ts @@ -29,3 +29,14 @@ export interface Page { isFirst: boolean isLast: boolean } + +export function defaultPage(): Page { + return { + items: [], + pageRequest: { page: 1, size: 5, sorts: [] }, + totalElements: 0, + totalPages: 0, + isFirst: true, + isLast: true, + } +} diff --git a/web-app/src/api/transaction.ts b/web-app/src/api/transaction.ts index d380cc5..83ed3a2 100644 --- a/web-app/src/api/transaction.ts +++ b/web-app/src/api/transaction.ts @@ -54,6 +54,7 @@ export interface TransactionsListItem { category: TransactionsListItemCategory | null creditedAccount: TransactionsListItemAccount | null debitedAccount: TransactionsListItemAccount | null + tags: string[] } export interface TransactionsListItemVendor { @@ -201,6 +202,20 @@ export class TransactionApiClient extends ApiClient { return super.getJsonPage(this.path + '/transactions', paginationOptions) } + searchTransactions( + params: URLSearchParams, + paginationOptions: PageRequest | undefined = undefined, + ): Promise> { + if (paginationOptions !== undefined) { + params.append('page', paginationOptions.page + '') + params.append('size', paginationOptions.size + '') + for (const sort of paginationOptions.sorts) { + params.append('sort', sort.attribute + ',' + sort.dir) + } + } + return super.getJson(this.path + '/transactions/search?' + params.toString()) + } + getTransaction(id: number): Promise { return super.getJson(this.path + '/transactions/' + id) } diff --git a/web-app/src/components/TransactionCard.vue b/web-app/src/components/TransactionCard.vue index 41bd69f..d42fb4d 100644 --- a/web-app/src/components/TransactionCard.vue +++ b/web-app/src/components/TransactionCard.vue @@ -6,6 +6,7 @@ import { useRoute, useRouter } from 'vue-router' import CategoryLabel from './CategoryLabel.vue' import { computed, type Ref } from 'vue' import AppBadge from './common/AppBadge.vue' +import TagLabel from './TagLabel.vue' const router = useRouter() const route = useRoute() @@ -29,10 +30,7 @@ function goToTransaction() { } diff --git a/web-app/src/components/common/PaginationControls.vue b/web-app/src/components/common/PaginationControls.vue index 3a49b5e..0aecf97 100644 --- a/web-app/src/components/common/PaginationControls.vue +++ b/web-app/src/components/common/PaginationControls.vue @@ -28,37 +28,23 @@ function incrementPage(step: number) { diff --git a/web-app/src/pages/home/TransactionsModule.vue b/web-app/src/pages/home/TransactionsModule.vue index 60e83b0..4c34a43 100644 --- a/web-app/src/pages/home/TransactionsModule.vue +++ b/web-app/src/pages/home/TransactionsModule.vue @@ -32,29 +32,22 @@ async function fetchPage(pageRequest: PageRequest) { console.error(err) } } + +function goToSearch() { + router.push(`/profiles/${getSelectedProfile(route)}/transactions/search`) +} diff --git a/web-app/src/router/index.ts b/web-app/src/router/index.ts index 781dbdb..123a6f7 100644 --- a/web-app/src/router/index.ts +++ b/web-app/src/router/index.ts @@ -67,6 +67,11 @@ const router = createRouter({ component: () => import('@/pages/forms/EditTransactionPage.vue'), meta: { title: 'Edit Transaction' }, }, + { + path: 'transactions/search', + component: () => import('@/pages/TransactionSearchPage.vue'), + meta: { title: 'Search Transactions' }, + }, { path: 'add-transaction', component: () => import('@/pages/forms/EditTransactionPage.vue'),