Added transaction searching, optimized transaction fetching as well.
Build and Deploy API / build-and-deploy (push) Failing after 6s Details
Build and Deploy Web App / build-and-deploy (push) Successful in 25s Details

This commit is contained in:
andrewlalis 2025-10-19 10:38:35 -04:00
parent 7150b0b259
commit 8f3a334ce4
25 changed files with 938 additions and 208 deletions

View File

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

View File

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

View File

@ -0,0 +1,10 @@
Code,Symbol
USD,$
CAD,$
AUD,$
GBP,£
EUR,€
CHF,Fr
ZAR,R
JPY,¥
INR,₹
1 Code Symbol
2 USD $
3 CAD $
4 AUD $
5 GBP £
6 EUR
7 CHF Fr
8 ZAR R
9 JPY ¥
10 INR

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -29,3 +29,14 @@ export interface Page<T> {
isFirst: boolean
isLast: boolean
}
export function defaultPage<T>(): Page<T> {
return {
items: [],
pageRequest: { page: 1, size: 5, sorts: [] },
totalElements: 0,
totalPages: 0,
isFirst: true,
isLast: true,
}
}

View File

@ -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<Page<TransactionsListItem>> {
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<TransactionDetail> {
return super.getJson(this.path + '/transactions/' + id)
}

View File

@ -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() {
}
</script>
<template>
<div
class="transaction-card"
@click="goToTransaction()"
>
<div class="transaction-card" @click="goToTransaction()">
<!-- Top row contains timestamp and amount. -->
<div class="transaction-card-top-row">
<div>
@ -42,25 +40,16 @@ function goToTransaction() {
</div>
</div>
<div>
<div
class="font-mono align-right font-size-small"
:class="{
<div class="font-mono align-right font-size-small" :class="{
'text-positive': moneyStyle === 'positive',
'text-negative': moneyStyle === 'negative',
}"
>
}">
{{ formatMoney(tx.amount, tx.currency) }}
</div>
<div
v-if="tx.creditedAccount !== null"
class="font-size-small text-muted"
>
<div v-if="tx.creditedAccount !== null" class="font-size-small text-muted">
Credited to <span class="text-normal font-bold">{{ tx.creditedAccount.name }}</span>
</div>
<div
v-if="tx.debitedAccount !== null"
class="font-size-small text-muted"
>
<div v-if="tx.debitedAccount !== null" class="font-size-small text-muted">
Debited to <span class="text-normal font-bold">{{ tx.debitedAccount.name }}</span>
</div>
</div>
@ -72,14 +61,15 @@ function goToTransaction() {
</div>
<!-- Bottom row contains other links. -->
<div style="display: flex; justify-content: space-between;">
<div>
<CategoryLabel
:category="tx.category"
v-if="tx.category"
style="margin-left: 0"
/>
<CategoryLabel :category="tx.category" v-if="tx.category" style="margin-left: 0" />
<AppBadge v-if="tx.vendor">{{ tx.vendor.name }}</AppBadge>
</div>
<div>
<TagLabel v-for="tag in tx.tags" :key="tag" :tag="tag" />
</div>
</div>
</div>
</template>
<style lang="css">

View File

@ -28,37 +28,23 @@ function incrementPage(step: number) {
<template>
<div>
<div v-if="page && page.totalElements > 0">
<AppButton
size="sm"
:disabled="!page || page.isFirst"
@click="updatePage(1)"
>
<AppButton size="sm" :disabled="!page || page.isFirst" @click="updatePage(1)">
First Page
</AppButton>
<AppButton
size="sm"
:disabled="!page || page.isFirst"
@click="incrementPage(-1)"
>
<AppButton size="sm" :disabled="!page || page.isFirst" @click="incrementPage(-1)">
Previous Page
</AppButton>
<span>Page {{ page?.pageRequest.page }} / {{ page?.totalPages }}</span>
<span style="min-width: 100px; text-align: center; display: inline-block;" class="font-size-xsmall">
Page <span class="font-bold">{{ page?.pageRequest.page }}</span> of {{ page?.totalPages }}
</span>
<AppButton
size="sm"
:disabled="!page || page.isLast"
@click="incrementPage(1)"
>
<AppButton size="sm" :disabled="!page || page.isLast" @click="incrementPage(1)">
Next Page
</AppButton>
<AppButton
size="sm"
:disabled="!page || page.isLast"
@click="updatePage(page?.totalPages ?? 0)"
>
<AppButton size="sm" :disabled="!page || page.isLast" @click="updatePage(page?.totalPages ?? 0)">
Last Page
</AppButton>
</div>

View File

@ -0,0 +1,293 @@
<script setup lang="ts">
import { type Account, AccountApiClient } from '@/api/account';
import { defaultPage, type Page, type PageRequest, type SortDir } from '@/api/pagination';
import { getSelectedProfile } from '@/api/profile';
import { TransactionApiClient, type TransactionCategory, type TransactionVendor, type TransactionsListItem } from '@/api/transaction';
import AppBadge from '@/components/common/AppBadge.vue';
import AppButton from '@/components/common/AppButton.vue';
import AppPage from '@/components/common/AppPage.vue';
import ButtonBar from '@/components/common/ButtonBar.vue';
import AppForm from '@/components/common/form/AppForm.vue';
import FormControl from '@/components/common/form/FormControl.vue';
import FormGroup from '@/components/common/form/FormGroup.vue';
import PaginationControls from '@/components/common/PaginationControls.vue';
import TransactionCard from '@/components/TransactionCard.vue';
import { computed, onMounted, ref, watch, type Ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import VueSelect from 'vue3-select-component';
interface SortOption {
label: string
property: string
}
const SORT_PROPERTIES: SortOption[] = [
{ label: "Timestamp", property: "txn.timestamp" },
{ label: "Added at", property: "txn.added_at" },
{ label: "Amount", property: "txn.amount" }
]
const FETCH_DEBOUNCE_DELAY = 300
const router = useRouter()
const route = useRoute()
const page: Ref<Page<TransactionsListItem>> = ref(defaultPage())
const lastFetchTime = ref(0)
const fetchTimeoutId = ref<number | undefined>(undefined)
// Sorting options:
const selectedSort: Ref<string> = ref(SORT_PROPERTIES[0].property)
const selectedSortDir: Ref<SortDir> = ref('DESC')
// Filtering options:
const searchQuery = ref('')
const tagFilters: Ref<string[]> = ref([])
const availableTags: Ref<string[]> = ref([])
const tagOptions = computed(() => {
return availableTags.value.map(tag => {
return { label: tag, value: tag }
})
})
const vendorFilters = ref<number[]>([])
const availableVendors = ref<TransactionVendor[]>([])
const vendorOptions = computed(() => {
return availableVendors.value.map(vendor => {
return { label: vendor.name, value: vendor.id }
})
})
const categoryFilters = ref<number[]>([])
const availableCategories = ref<TransactionCategory[]>([])
const categoryOptions = computed(() => availableCategories.value.map(category => {
return { label: category.name, value: category.id }
}))
const accountFilters = ref<number[]>([])
const availableAccounts = ref<Account[]>([])
const accountOptions = computed(() => availableAccounts.value.map(acc => {
return { label: `${acc.name} - #${acc.numberSuffix}`, value: acc.id }
}))
const minAmountFilter = ref<number | undefined>(undefined)
const maxAmountFilter = ref<number | undefined>(undefined)
onMounted(async () => {
loadFiltersFromRoute()
const api = new TransactionApiClient(getSelectedProfile(route))
api.getAllTags().then(tags => availableTags.value = tags)
api.getVendors().then(vendors => availableVendors.value = vendors)
api.getCategoriesFlattened().then(categories => availableCategories.value = categories)
const accountApi = new AccountApiClient(route)
accountApi.getAccounts().then(accounts => availableAccounts.value = accounts)
await fetchPage(1, 10)
watch(
[
selectedSort,
selectedSortDir,
searchQuery,
tagFilters,
vendorFilters,
categoryFilters,
accountFilters,
minAmountFilter,
maxAmountFilter
],
() => {
window.clearTimeout(fetchTimeoutId.value)
fetchTimeoutId.value = window.setTimeout(() => {
fetchPage(1, 10)
}, FETCH_DEBOUNCE_DELAY)
}
)
})
/**
* Fetches a page of results based on the currently-configured filters, and
* once a successful result is obtained, the current route is updated to
* include the latest filters.
* @param pg The page number to fetch.
* @param size The number of items in the page.
*/
async function fetchPage(pg: number, size: number) {
const pageRequest: PageRequest = {
page: pg,
size,
sorts: [
{
attribute: selectedSort.value,
dir: selectedSortDir.value
}
]
}
const params = buildFiltersQuery()
const urlWithParams = params.size == 0 ? route.path : route.path + '?' + params.toString()
const api = new TransactionApiClient(getSelectedProfile(route))
try {
const startTime = performance.now()
page.value = await api.searchTransactions(params, pageRequest)
const endTime = performance.now()
lastFetchTime.value = endTime - startTime
await router.replace(urlWithParams)
} catch (err) {
console.error(err)
page.value = defaultPage()
}
}
function buildFiltersQuery(): URLSearchParams {
const p = new URLSearchParams()
if (searchQuery.value.trim().length > 0) {
p.append("q", searchQuery.value.trim())
}
for (const tag of tagFilters.value) {
p.append("tag", tag)
}
for (const vendorId of vendorFilters.value) {
p.append("vendor", vendorId + "")
}
for (const categoryId of categoryFilters.value) {
p.append("category", categoryId + "")
}
for (const accountId of accountFilters.value) {
p.append("account", accountId + "")
}
if (minAmountFilter.value !== undefined && minAmountFilter.value > 0) {
p.append("min-amount", minAmountFilter.value * 100 + "")
}
if (maxAmountFilter.value !== undefined && maxAmountFilter.value > 0) {
p.append("max-amount", maxAmountFilter.value * 100 + "")
}
return p
}
function clearFilters() {
searchQuery.value = ''
tagFilters.value = []
vendorFilters.value = []
categoryFilters.value = []
accountFilters.value = []
minAmountFilter.value = undefined
maxAmountFilter.value = undefined
}
function goToHome() {
router.push(`/profiles/${getSelectedProfile(route)}`)
}
function loadFiltersFromRoute() {
searchQuery.value = loadFirstParamValue("q") ?? ''
tagFilters.value = loadAllParamValues("tag")
vendorFilters.value = loadAllParamValues("vendor").map(s => parseInt(s))
categoryFilters.value = loadAllParamValues("category").map(s => parseInt(s))
accountFilters.value = loadAllParamValues("account").map(s => parseInt(s))
const minAmount = loadFirstParamValue("min-amount")
if (minAmount !== undefined) {
minAmountFilter.value = Math.round(parseInt(minAmount) / 100)
}
const maxAmount = loadFirstParamValue("max-amount")
if (maxAmount !== undefined) {
maxAmountFilter.value = Math.round(parseInt(maxAmount) / 100)
}
}
function loadFirstParamValue(key: string): string | undefined {
if (key in route.query) {
if (Array.isArray(route.query[key]) && route.query[key][0] !== null) {
return route.query[key][0]
} else if (typeof route.query[key] === 'string') {
return route.query[key]
}
}
}
function loadAllParamValues(key: string): string[] {
if (key in route.query) {
if (Array.isArray(route.query[key])) {
return route.query[key].filter(s => s !== null)
} else if (route.query[key] !== null) {
return [route.query[key]]
}
}
return []
}
</script>
<template>
<AppPage title="Transactions">
<AppForm>
<FormGroup>
<FormControl label="Search" hint="Free-form text search against description, tags, vendor, category, account.">
<input v-model="searchQuery" type="text" placeholder="Search for transactions..." />
</FormControl>
</FormGroup>
<FormGroup>
<div class="vueselect-control">
<h5>Tag</h5>
<VueSelect v-model="tagFilters" :options="tagOptions" placeholder="Select tags" is-multi />
</div>
<div class="vueselect-control">
<h5>Vendor</h5>
<VueSelect v-model="vendorFilters" :options="vendorOptions" placeholder="Select vendors" is-multi />
</div>
<div class="vueselect-control">
<h5>Category</h5>
<VueSelect v-model="categoryFilters" :options="categoryOptions" placeholder="Select categories" is-multi />
</div>
<div class="vueselect-control">
<h5>Account</h5>
<VueSelect v-model="accountFilters" :options="accountOptions" placeholder="Select accounts" is-multi />
</div>
</FormGroup>
<FormGroup>
<FormControl label="Max Amount">
<input v-model="maxAmountFilter" type="number" min="0" step="1" />
</FormControl>
<FormControl label="Min Amount">
<input v-model="minAmountFilter" type="number" min="0" step="1" />
</FormControl>
</FormGroup>
<FormGroup>
<FormControl label="Sort By">
<select v-model="selectedSort">
<option v-for="sortOpt in SORT_PROPERTIES" :key="sortOpt.property" :value="sortOpt.property">{{
sortOpt.label }}
</option>
</select>
</FormControl>
<FormControl label="Sort Direction">
<select v-model="selectedSortDir">
<option value="DESC">Descending</option>
<option value="ASC">Ascending</option>
</select>
</FormControl>
</FormGroup>
<ButtonBar>
<AppButton size="sm" icon="home" @click="goToHome()">Back to Homepage</AppButton>
<AppButton size="sm" icon="trash" @click="clearFilters()">Clear Filters</AppButton>
</ButtonBar>
</AppForm>
<PaginationControls :page="page" @update="(pr) => fetchPage(pr.page, pr.size)" class="align-right" />
<AppBadge size="sm">
{{ page.totalElements }} search
{{ page.totalElements == 1 ? 'result' : 'results' }}
in {{ lastFetchTime }} milliseconds
</AppBadge>
<TransactionCard v-for="txn in page.items" :key="txn.id" :tx="txn" />
</AppPage>
</template>
<style lang="css" scoped>
.vueselect-control {
flex-grow: 1;
margin: 0.5rem;
}
.vueselect-control>h5 {
font-size: 0.9rem;
font-weight: 700;
margin: 0;
}
</style>

View File

@ -61,31 +61,22 @@ async function checkAuth() {
<div>
<header class="app-header-bar">
<div>
<h1
class="app-header-text"
@click="onHeaderClicked()"
>
<h1 class="app-header-text" @click="onHeaderClicked()">
Finnow
</h1>
</div>
<div>
<span
class="app-user-widget"
@click="router.push('/me')"
>
<span class="app-user-widget" @click="router.push('/me')">
<font-awesome-icon icon="fa-user"></font-awesome-icon>
</span>
<span
class="app-logout-button"
@click="authStore.onUserLoggedOut()"
>
<span class="app-logout-button" @click="authStore.onUserLoggedOut()">
<font-awesome-icon icon="fa-solid fa-arrow-right-from-bracket"></font-awesome-icon>
</span>
</div>
</header>
<RouterView :key="$route.fullPath"></RouterView>
<RouterView></RouterView>
</div>
</template>

View File

@ -32,29 +32,22 @@ async function fetchPage(pageRequest: PageRequest) {
console.error(err)
}
}
function goToSearch() {
router.push(`/profiles/${getSelectedProfile(route)}/transactions/search`)
}
</script>
<template>
<HomeModule title="Transactions">
<template v-slot:default>
<TransactionCard
v-for="tx in transactions.items"
:key="tx.id"
:tx="tx"
/>
<PaginationControls
:page="transactions"
@update="(pr) => fetchPage(pr)"
></PaginationControls>
<PaginationControls :page="transactions" @update="(pr) => fetchPage(pr)" class="align-right" />
<TransactionCard v-for="tx in transactions.items" :key="tx.id" :tx="tx" />
<p v-if="transactions.totalElements === 0">You haven't added any transactions.</p>
</template>
<template v-slot:actions>
<AppButton
icon="plus"
@click="router.push(`/profiles/${getSelectedProfile(route)}/add-transaction`)"
>
Add Transaction</AppButton
>
<AppButton icon="plus" @click="router.push(`/profiles/${getSelectedProfile(route)}/add-transaction`)">
Add Transaction</AppButton>
<AppButton @click="goToSearch()">Search</AppButton>
</template>
</HomeModule>
</template>

View File

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