From 4b9e859c85d59583e3c9880615b9eef54c24ac9e Mon Sep 17 00:00:00 2001 From: andrewlalis Date: Sun, 10 Aug 2025 18:59:08 -0400 Subject: [PATCH] Added more currency support, refactored tags, and optimized transactions query. --- finnow-api/dub.json | 2 +- finnow-api/dub.selections.json | 4 +- finnow-api/source/api_mapping.d | 16 ++- finnow-api/source/app.d | 4 +- finnow-api/source/data_api.d | 9 ++ finnow-api/source/profile/data_impl_sqlite.d | 2 +- finnow-api/source/transaction/api.d | 117 +++++++----------- finnow-api/source/transaction/data.d | 11 +- .../source/transaction/data_impl_sqlite.d | 111 +++++++++++------ finnow-api/source/transaction/model.d | 5 - finnow-api/source/transaction/service.d | 112 ++++++++++++----- finnow-api/source/util/data.d | 16 +++ finnow-api/source/util/money.d | 28 +++-- finnow-api/source/util/sample_data.d | 87 +++++++------ finnow-api/sql/get_transactions.sql | 42 +++++++ finnow-api/{ => sql}/schema.sql | 18 +-- web-app/src/api/data.ts | 29 +++++ web-app/src/api/transaction.ts | 34 ++++- web-app/src/pages/LoginPage.vue | 10 ++ web-app/src/pages/home/TransactionsModule.vue | 13 +- 20 files changed, 437 insertions(+), 233 deletions(-) create mode 100644 finnow-api/source/data_api.d create mode 100644 finnow-api/sql/get_transactions.sql rename finnow-api/{ => sql}/schema.sql (92%) create mode 100644 web-app/src/api/data.ts diff --git a/finnow-api/dub.json b/finnow-api/dub.json index 737451d..35fae6d 100644 --- a/finnow-api/dub.json +++ b/finnow-api/dub.json @@ -5,7 +5,7 @@ "copyright": "Copyright © 2024, Andrew Lalis", "dependencies": { "d2sqlite3": "~>1.0", - "handy-http-starter": "~>1.5", + "handy-http-starter": "~>1.6", "jwt4d": "~>0.0.2", "secured": "~>3.0" }, diff --git a/finnow-api/dub.selections.json b/finnow-api/dub.selections.json index 370a910..61b5f07 100644 --- a/finnow-api/dub.selections.json +++ b/finnow-api/dub.selections.json @@ -7,8 +7,8 @@ "handy-http-data": "1.3.0", "handy-http-handlers": "1.1.0", "handy-http-primitives": "1.8.1", - "handy-http-starter": "1.5.0", - "handy-http-transport": "1.7.3", + "handy-http-starter": "1.6.0", + "handy-http-transport": "1.8.0", "handy-http-websockets": "1.2.0", "jwt4d": "0.0.2", "mir-algorithm": "3.22.4", diff --git a/finnow-api/source/api_mapping.d b/finnow-api/source/api_mapping.d index 84efb5d..4512919 100644 --- a/finnow-api/source/api_mapping.d +++ b/finnow-api/source/api_mapping.d @@ -57,13 +57,17 @@ HttpRequestHandler mapApiHandlers(string webOrigin) { import transaction.api; // Transaction vendor endpoints: - a.map(HttpMethod.GET, PROFILE_PATH ~ "/vendors", &getVendors); - a.map(HttpMethod.GET, PROFILE_PATH ~ "/vendors/:vendorId:ulong", &getVendor); - a.map(HttpMethod.POST, PROFILE_PATH ~ "/vendors", &createVendor); - a.map(HttpMethod.PUT, PROFILE_PATH ~ "/vendors/:vendorId:ulong", &updateVendor); - a.map(HttpMethod.DELETE, PROFILE_PATH ~ "/vendors/:vendorId:ulong", &deleteVendor); + a.map(HttpMethod.GET, PROFILE_PATH ~ "/vendors", &handleGetVendors); + a.map(HttpMethod.GET, PROFILE_PATH ~ "/vendors/:vendorId:ulong", &handleGetVendor); + a.map(HttpMethod.POST, PROFILE_PATH ~ "/vendors", &handleCreateVendor); + a.map(HttpMethod.PUT, PROFILE_PATH ~ "/vendors/:vendorId:ulong", &handleUpdateVendor); + a.map(HttpMethod.DELETE, PROFILE_PATH ~ "/vendors/:vendorId:ulong", &handleDeleteVendor); - a.map(HttpMethod.GET, PROFILE_PATH ~ "/transactions", &getTransactions); + a.map(HttpMethod.GET, PROFILE_PATH ~ "/transactions", &handleGetTransactions); + + import data_api; + // Various other data endpoints: + a.map(HttpMethod.GET, "/currencies", &handleGetCurrencies); // Protect all authenticated paths with a filter. import auth.service : AuthenticationFilter; diff --git a/finnow-api/source/app.d b/finnow-api/source/app.d index da2d3a6..4b3d203 100644 --- a/finnow-api/source/app.d +++ b/finnow-api/source/app.d @@ -26,7 +26,9 @@ void main() { configureLoggingProvider(provider); infoF!"Loaded app config: port = %d, webOrigin = %s"(config.port, config.webOrigin); - HttpTransport transport = new TaskPoolHttp1Transport(mapApiHandlers(config.webOrigin), config.port); + Http1TransportConfig transportConfig = defaultConfig(); + transportConfig.port = config.port; + HttpTransport transport = new TaskPoolHttp1Transport(mapApiHandlers(config.webOrigin), transportConfig); transport.start(); } diff --git a/finnow-api/source/data_api.d b/finnow-api/source/data_api.d new file mode 100644 index 0000000..6397bbc --- /dev/null +++ b/finnow-api/source/data_api.d @@ -0,0 +1,9 @@ +module data_api; + +import handy_http_primitives; +import handy_http_data; +import util.money; + +void handleGetCurrencies(ref ServerHttpRequest request, ref ServerHttpResponse response) { + writeJsonBody(response, ALL_CURRENCIES); +} \ No newline at end of file diff --git a/finnow-api/source/profile/data_impl_sqlite.d b/finnow-api/source/profile/data_impl_sqlite.d index d07370c..7ffe139 100644 --- a/finnow-api/source/profile/data_impl_sqlite.d +++ b/finnow-api/source/profile/data_impl_sqlite.d @@ -143,7 +143,7 @@ class SqliteProfileDataSource : ProfileDataSource { import transaction.data; import transaction.data_impl_sqlite; - const SCHEMA = import("schema.sql"); + const SCHEMA = import("sql/schema.sql"); private const string dbPath; private Database db; diff --git a/finnow-api/source/transaction/api.d b/finnow-api/source/transaction/api.d index 26f4a95..fad121c 100644 --- a/finnow-api/source/transaction/api.d +++ b/finnow-api/source/transaction/api.d @@ -10,66 +10,72 @@ import transaction.data; import transaction.service; import profile.data; import profile.service; +import account.api; import util.money; import util.pagination; import util.data; immutable DEFAULT_TRANSACTION_PAGE = PageRequest(1, 10, [Sort("timestamp", SortDir.DESC)]); -struct TransactionResponse { +struct TransactionsListItemAccount { + ulong id; + string name; + string type; + string numberSuffix; +} + +struct TransactionsListItemVendor { + ulong id; + string name; +} + +struct TransactionsListItemCategory { + ulong id; + string name; + string color; +} + +/// The transaction data provided when a list of transactions is requested. +struct TransactionsListItem { import asdf : serdeTransformOut; ulong id; string timestamp; string addedAt; ulong amount; - string currency; + Currency currency; string description; + @serdeTransformOut!serializeOptional - Optional!ulong vendorId; + Optional!TransactionsListItemVendor vendor; @serdeTransformOut!serializeOptional - Optional!ulong categoryId; + Optional!TransactionsListItemCategory category; + @serdeTransformOut!serializeOptional + Optional!TransactionsListItemAccount creditedAccount; + @serdeTransformOut!serializeOptional + Optional!TransactionsListItemAccount debitedAccount; - static TransactionResponse of(in Transaction tx) { - return TransactionResponse( - tx.id, - tx.timestamp.toISOExtString(), - tx.addedAt.toISOExtString(), - tx.amount, - tx.currency.code.idup, - tx.description, - tx.vendorId, - tx.categoryId - ); - } + string[] tags; } -void getTransactions(ref ServerHttpRequest request, ref ServerHttpResponse response) { +void handleGetTransactions(ref ServerHttpRequest request, ref ServerHttpResponse response) { ProfileDataSource ds = getProfileDataSource(request); PageRequest pr = PageRequest.parse(request, DEFAULT_TRANSACTION_PAGE); - Page!Transaction page = ds.getTransactionRepository().findAll(pr); - Page!TransactionResponse responsePage = page.mapTo!()(&TransactionResponse.of); + auto responsePage = getTransactions(ds, pr); writeJsonBody(response, responsePage); } -void getVendors(ref ServerHttpRequest request, ref ServerHttpResponse response) { +// Vendors API + +void handleGetVendors(ref ServerHttpRequest request, ref ServerHttpResponse response) { ProfileDataSource ds = getProfileDataSource(request); - auto vendorRepo = ds.getTransactionVendorRepository(); - TransactionVendor[] vendors = vendorRepo.findAll(); + TransactionVendor[] vendors = getAllVendors(ds); writeJsonBody(response, vendors); } -void getVendor(ref ServerHttpRequest request, ref ServerHttpResponse response) { - long vendorId = request.getPathParamAs!long("vendorId", -1); - if (vendorId == -1) { - response.status = HttpStatus.NOT_FOUND; - response.writeBodyString("Missing vendorId path parameter."); - return; - } +void handleGetVendor(ref ServerHttpRequest request, ref ServerHttpResponse response) { ProfileDataSource ds = getProfileDataSource(request); - auto vendorRepo = ds.getTransactionVendorRepository(); - TransactionVendor vendor = vendorRepo.findById(vendorId) - .orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND)); + TransactionVendor vendor = getVendor(ds, getVendorId(request)); writeJsonBody(response, vendor); } @@ -78,54 +84,27 @@ struct VendorPayload { string description; } -void createVendor(ref ServerHttpRequest request, ref ServerHttpResponse response) { +void handleCreateVendor(ref ServerHttpRequest request, ref ServerHttpResponse response) { VendorPayload payload = readJsonBodyAs!VendorPayload(request); ProfileDataSource ds = getProfileDataSource(request); - auto vendorRepo = ds.getTransactionVendorRepository(); - if (vendorRepo.existsByName(payload.name)) { - response.status = HttpStatus.BAD_REQUEST; - response.writeBodyString("Vendor name is already in use."); - return; - } - TransactionVendor vendor = vendorRepo.insert(payload.name, payload.description); + TransactionVendor vendor = createVendor(ds, payload); writeJsonBody(response, vendor); } -void updateVendor(ref ServerHttpRequest request, ref ServerHttpResponse response) { +void handleUpdateVendor(ref ServerHttpRequest request, ref ServerHttpResponse response) { VendorPayload payload = readJsonBodyAs!VendorPayload(request); - long vendorId = request.getPathParamAs!long("vendorId", -1); - if (vendorId == -1) { - response.status = HttpStatus.NOT_FOUND; - response.writeBodyString("Missing vendorId path parameter."); - return; - } ProfileDataSource ds = getProfileDataSource(request); - auto vendorRepo = ds.getTransactionVendorRepository(); - TransactionVendor existingVendor = vendorRepo.findById(vendorId) - .orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND)); - if (payload.name != existingVendor.name && vendorRepo.existsByName(payload.name)) { - response.status = HttpStatus.BAD_REQUEST; - response.writeBodyString("Vendor name is already in use."); - return; - } - TransactionVendor updated = vendorRepo.updateById( - vendorId, - payload.name, - payload.description - ); + TransactionVendor updated = updateVendor(ds, getVendorId(request), payload); writeJsonBody(response, updated); } -void deleteVendor(ref ServerHttpRequest request, ref ServerHttpResponse response) { - long vendorId = request.getPathParamAs!long("vendorId", -1); - if (vendorId == -1) { - response.status = HttpStatus.NOT_FOUND; - response.writeBodyString("Missing vendorId path parameter."); - return; - } +void handleDeleteVendor(ref ServerHttpRequest request, ref ServerHttpResponse response) { ProfileDataSource ds = getProfileDataSource(request); - auto vendorRepo = ds.getTransactionVendorRepository(); - vendorRepo.deleteById(vendorId); + deleteVendor(ds, getVendorId(request)); } +private ulong getVendorId(in ServerHttpRequest request) { + return getPathParamOrThrow(request, "vendorId"); +} +// Categories API diff --git a/finnow-api/source/transaction/data.d b/finnow-api/source/transaction/data.d index 2867cea..932c1a4 100644 --- a/finnow-api/source/transaction/data.d +++ b/finnow-api/source/transaction/data.d @@ -4,6 +4,7 @@ import handy_http_primitives : Optional; import std.datetime; import transaction.model; +import transaction.api : TransactionsListItem; import util.money; import util.pagination; @@ -25,15 +26,13 @@ interface TransactionCategoryRepository { } interface TransactionTagRepository { - Optional!TransactionTag findById(ulong id); - Optional!TransactionTag findByName(string name); - TransactionTag[] findAll(); - TransactionTag insert(string name); - void deleteById(ulong id); + string[] findAllByTransactionId(ulong transactionId); + void updateTags(ulong transactionId, string[] tags); + string[] findAll(); } interface TransactionRepository { - Page!Transaction findAll(PageRequest pr); + Page!TransactionsListItem findAll(PageRequest pr); Optional!Transaction findById(ulong id); Transaction insert( SysTime timestamp, diff --git a/finnow-api/source/transaction/data_impl_sqlite.d b/finnow-api/source/transaction/data_impl_sqlite.d index e0d1f34..753b98c 100644 --- a/finnow-api/source/transaction/data_impl_sqlite.d +++ b/finnow-api/source/transaction/data_impl_sqlite.d @@ -2,10 +2,12 @@ module transaction.data_impl_sqlite; import handy_http_primitives : Optional; import std.datetime; +import std.typecons; import d2sqlite3; import transaction.model; import transaction.data; +import transaction.api; import util.sqlite; import util.money; import util.pagination; @@ -136,48 +138,35 @@ class SqliteTransactionTagRepository : TransactionTagRepository { this.db = db; } - Optional!TransactionTag findById(ulong id) { - return findOne(db, "SELECT * FROM transaction_tag WHERE id = ?", &parseTag, id); - } - - Optional!TransactionTag findByName(string name) { - return findOne(db, "SELECT * FROM transaction_tag WHERE name = ?", &parseTag, name); - } - - TransactionTag[] findAll() { + string[] findAllByTransactionId(ulong transactionId) { return util.sqlite.findAll( db, - "SELECT * FROM transaction_tag ORDER BY name ASC", - &parseTag + "SELECT tag FROM transaction_tag WHERE transaction_id = ? ORDER BY tag", + r => r.peek!string(0), + transactionId ); } - TransactionTag insert(string name) { - auto existingTag = findByName(name); - if (existingTag) { - return existingTag.value; + void updateTags(ulong transactionId, string[] tags) { + util.sqlite.update( + db, + "DELETE FROM transaction_tag WHERE transaction_id = ?", + transactionId + ); + foreach (tag; tags) { + util.sqlite.update( + db, + "INSERT INTO transaction_tag (transaction_id, tag) VALUES (?, ?)", + transactionId, tag + ); } - util.sqlite.update( - db, - "INSERT INTO transaction_tag (name) VALUES (?)", - name - ); - ulong id = db.lastInsertRowid(); - return findById(id).orElseThrow(); } - void deleteById(ulong id) { - util.sqlite.update( + string[] findAll() { + return util.sqlite.findAll( db, - "DELETE FROM transaction_tag WHERE id = ?", - id - ); - } - - private static TransactionTag parseTag(Row row) { - return TransactionTag( - row.peek!ulong(0), - row.peek!string(1) + "SELECT DISTINCT tag FROM transaction_tag ORDER BY tag", + r => r.peek!string(0) ); } } @@ -189,19 +178,65 @@ class SqliteTransactionRepository : TransactionRepository { this.db = db; } - Page!Transaction findAll(PageRequest pr) { + Page!TransactionsListItem findAll(PageRequest pr) { + const BASE_QUERY = import("sql/get_transactions.sql"); // TODO: Implement filtering or something! import std.array; - const string rootQuery = "SELECT * FROM " ~ TABLE_NAME; const string countQuery = "SELECT COUNT(ID) FROM " ~ TABLE_NAME; auto sqlBuilder = appender!string; - sqlBuilder ~= rootQuery; + sqlBuilder ~= BASE_QUERY; sqlBuilder ~= " "; sqlBuilder ~= pr.toSql(); string query = sqlBuilder[]; - Transaction[] results = util.sqlite.findAll(db, query, &parseTransaction); + 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!TransactionsListItemVendor.of( + TransactionsListItemVendor(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!TransactionsListItemCategory.of( + TransactionsListItemCategory(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!TransactionsListItemAccount.of( + TransactionsListItemAccount(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!TransactionsListItemAccount.of( + TransactionsListItemAccount(id, name, type, suffix)); + } + string tagsStr = row.peek!string(19); + if (tagsStr.length > 0) { + import std.string : split; + item.tags = tagsStr.split(","); + } + return item; + }); ulong totalCount = util.sqlite.count(db, countQuery); - return Page!(Transaction).of(results, pr, totalCount); + return Page!(TransactionsListItem).of(results, pr, totalCount); } Optional!Transaction findById(ulong id) { diff --git a/finnow-api/source/transaction/model.d b/finnow-api/source/transaction/model.d index 7270e6c..5bb3c03 100644 --- a/finnow-api/source/transaction/model.d +++ b/finnow-api/source/transaction/model.d @@ -19,11 +19,6 @@ struct TransactionCategory { immutable string color; } -struct TransactionTag { - immutable ulong id; - immutable string name; -} - struct Transaction { immutable ulong id; /// The time at which the transaction happened. diff --git a/finnow-api/source/transaction/service.d b/finnow-api/source/transaction/service.d index 76af6d8..a18d8f6 100644 --- a/finnow-api/source/transaction/service.d +++ b/finnow-api/source/transaction/service.d @@ -1,8 +1,9 @@ module transaction.service; -import handy_http_primitives : Optional; +import handy_http_primitives; import std.datetime; +import transaction.api; import transaction.model; import transaction.data; import profile.data; @@ -10,6 +11,14 @@ import account.model; import util.money; import util.pagination; +// Transactions Services + +Page!TransactionsListItem getTransactions(ProfileDataSource ds, in PageRequest pageRequest) { + Page!TransactionsListItem page = ds.getTransactionRepository() + .findAll(pageRequest); + return page; // Return an empty page for now! +} + void addTransaction( ProfileDataSource ds, SysTime timestamp, @@ -21,43 +30,80 @@ void addTransaction( Optional!ulong categoryId, Optional!ulong creditedAccountId, Optional!ulong debitedAccountId, - TransactionLineItem[] lineItems - // TODO: Add attachments and tags! + TransactionLineItem[] lineItems, + string[] tags ) { if (creditedAccountId.isNull && debitedAccountId.isNull) { throw new Exception("At least one account must be linked to a transaction."); } - ds.doTransaction(() { - auto journalEntryRepo = ds.getAccountJournalEntryRepository(); - auto txRepo = ds.getTransactionRepository(); - Transaction tx = txRepo.insert( + auto journalEntryRepo = ds.getAccountJournalEntryRepository(); + auto txRepo = ds.getTransactionRepository(); + Transaction tx = txRepo.insert( + timestamp, + addedAt, + amount, + currency, + description, + vendorId, + categoryId + ); + if (creditedAccountId) { + journalEntryRepo.insert( timestamp, - addedAt, + creditedAccountId.value, + tx.id, amount, - currency, - description, - vendorId, - categoryId + AccountJournalEntryType.CREDIT, + currency ); - if (creditedAccountId) { - journalEntryRepo.insert( - timestamp, - creditedAccountId.value, - tx.id, - amount, - AccountJournalEntryType.CREDIT, - currency - ); - } - if (debitedAccountId) { - journalEntryRepo.insert( - timestamp, - debitedAccountId.value, - tx.id, - amount, - AccountJournalEntryType.DEBIT, - currency - ); - } - }); + } + if (debitedAccountId) { + journalEntryRepo.insert( + timestamp, + debitedAccountId.value, + tx.id, + amount, + AccountJournalEntryType.DEBIT, + currency + ); + } + if (tags.length > 0) { + ds.getTransactionTagRepository().updateTags(tx.id, tags); + } +} + +// Vendors Services + +TransactionVendor[] getAllVendors(ProfileDataSource ds) { + return ds.getTransactionVendorRepository().findAll(); +} + +TransactionVendor getVendor(ProfileDataSource ds, ulong vendorId) { + return ds.getTransactionVendorRepository().findById(vendorId) + .orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND)); +} + +TransactionVendor createVendor(ProfileDataSource ds, in VendorPayload payload) { + auto vendorRepo = ds.getTransactionVendorRepository(); + if (vendorRepo.existsByName(payload.name)) { + throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Vendor name is already in use."); + } + return vendorRepo.insert(payload.name, payload.description); +} + +TransactionVendor updateVendor(ProfileDataSource ds, ulong vendorId, in VendorPayload payload) { + TransactionVendorRepository repo = ds.getTransactionVendorRepository(); + TransactionVendor existingVendor = repo.findById(vendorId) + .orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND)); + if (payload.name != existingVendor.name && repo.existsByName(payload.name)) { + throw new HttpStatusException( + HttpStatus.BAD_REQUEST, + "Vendor name is already in use." + ); + } + return repo.updateById(vendorId, payload.name, payload.description); +} + +void deleteVendor(ProfileDataSource ds, ulong vendorId) { + ds.getTransactionVendorRepository().deleteById(vendorId); } diff --git a/finnow-api/source/util/data.d b/finnow-api/source/util/data.d index 4ae7845..c518f84 100644 --- a/finnow-api/source/util/data.d +++ b/finnow-api/source/util/data.d @@ -25,3 +25,19 @@ auto serializeOptional(T)(Optional!T value) { } return Nullable!T(value.value); } + +ulong getPathParamOrThrow(T = ulong)(in ServerHttpRequest req, string name) { + import handy_http_handlers.path_handler; + import std.conv; + foreach (param; getPathParams(req)) { + if (param.name == name) { + try { + return param.value.to!T; + } catch (ConvException e) { + // Skip and throw if no params match. + } + } + } + // No params matched, so throw a NOT FOUND error. + throw new HttpStatusException(HttpStatus.NOT_FOUND, "Missing required path parameter \"" ~ name ~ "\"."); +} diff --git a/finnow-api/source/util/money.d b/finnow-api/source/util/money.d index 2e6ea7b..6088205 100644 --- a/finnow-api/source/util/money.d +++ b/finnow-api/source/util/money.d @@ -8,13 +8,15 @@ import std.traits : isSomeString, EnumMembers; */ struct Currency { /// The common 3-character code for the currency, like "USD". - immutable char[3] code; + char[3] code; /// The number of digits after the decimal place that the currency supports. - immutable ubyte fractionalDigits; + ubyte fractionalDigits; /// The ISO 4217 numeric code for the currency. - immutable ushort numericCode; + ushort numericCode; + /// The symbol used when writing monetary values of this currency. + string symbol; - static Currency ofCode(S)(S code) if (isSomeString!S) { + static Currency ofCode(S)(in S code) if (isSomeString!S) { if (code.length != 3) { throw new Exception("Invalid currency code: " ~ code); } @@ -27,15 +29,15 @@ 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), - ZAR = Currency("ZAR", 2, 710), - JPY = Currency("JPY", 0, 392), - INR = Currency("INR", 2, 356) + 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, "₹") } immutable(Currency[]) ALL_CURRENCIES = cast(Currency[]) [EnumMembers!Currencies]; diff --git a/finnow-api/source/util/sample_data.d b/finnow-api/source/util/sample_data.d index 4e7895b..0f3b85d 100644 --- a/finnow-api/source/util/sample_data.d +++ b/finnow-api/source/util/sample_data.d @@ -51,44 +51,43 @@ void generateRandomProfile(int idx, ProfileRepository profileRepo) { infoF!" Generating random profile %s."(profileName); Profile profile = profileRepo.createProfile(profileName); ProfileDataSource ds = profileRepo.getDataSource(profile); - ds.getPropertiesRepository().setProperty("sample-data-idx", idx.to!string); + ds.doTransaction(() { + ds.getPropertiesRepository().setProperty("sample-data-idx", idx.to!string); + Currency preferredCurrency = choice(ALL_CURRENCIES); - const int accountCount = uniform(1, 10); - for (int i = 0; i < accountCount; i++) { - generateRandomAccount(i, ds); - } + const int accountCount = uniform(3, 10); + for (int i = 0; i < accountCount; i++) { + generateRandomAccount(i, ds, preferredCurrency); + } - auto vendorRepo = ds.getTransactionVendorRepository(); - const int vendorCount = uniform(5, 30); - for (int i = 0; i < vendorCount; i++) { - vendorRepo.insert("Test Vendor " ~ to!string(i), "Testing vendor for sample data."); - } - infoF!" Generated %d random vendors."(vendorCount); + auto vendorRepo = ds.getTransactionVendorRepository(); + const int vendorCount = uniform(5, 30); + for (int i = 0; i < vendorCount; i++) { + vendorRepo.insert("Test Vendor " ~ to!string(i), "Testing vendor for sample data."); + } + infoF!" Generated %d random vendors."(vendorCount); - auto tagRepo = ds.getTransactionTagRepository(); - const int tagCount = uniform(5, 30); - for (int i = 0; i < tagCount; i++) { - tagRepo.insert("test-tag-" ~ to!string(i)); - } - infoF!" Generated %d random tags."(tagCount); + auto categoryRepo = ds.getTransactionCategoryRepository(); + const int categoryCount = uniform(5, 30); + for (int i = 0; i < categoryCount; i++) { + categoryRepo.insert(Optional!ulong.empty, "Test Category " ~ to!string(i), "Testing category.", "FFFFFF"); + } + infoF!" Generated %d random categories."(categoryCount); - auto categoryRepo = ds.getTransactionCategoryRepository(); - const int categoryCount = uniform(5, 30); - for (int i = 0; i < categoryCount; i++) { - categoryRepo.insert(Optional!ulong.empty, "Test Category " ~ to!string(i), "Testing category.", "FFFFFF"); - } - infoF!" Generated %d random categories."(categoryCount); - - generateRandomTransactions(ds); + generateRandomTransactions(ds); + }); } -void generateRandomAccount(int idx, ProfileDataSource ds) { +void generateRandomAccount(int idx, ProfileDataSource ds, Currency preferredCurrency) { AccountRepository accountRepo = ds.getAccountRepository(); string idxStr = idx.to!string; string numberSuffix = "0".replicate(4 - idxStr.length) ~ idxStr; string name = "Test Account " ~ idxStr; AccountType type = choice(ALL_ACCOUNT_TYPES); - Currency currency = choice(ALL_CURRENCIES); + Currency currency = preferredCurrency; + if (uniform01() < 0.1) { + currency = choice(ALL_CURRENCIES); + } string description = "This is a testing account generated by util.sample_data.generateRandomAccount()."; Account account = accountRepo.insert( type, @@ -97,16 +96,13 @@ void generateRandomAccount(int idx, ProfileDataSource ds) { currency, description ); - infoF!" Generated random account: %s, #%s"(name, numberSuffix); + infoF!" Generated random account: %s, #%s, %s"(name, numberSuffix, currency.code); } void generateRandomTransactions(ProfileDataSource ds) { - const bool hasVendor = uniform01() > 0.3; - const bool hasCategory = uniform01() > 0.2; const TransactionVendor[] vendors = ds.getTransactionVendorRepository.findAll(); const TransactionCategory[] categories = ds.getTransactionCategoryRepository() .findAllByParentId(Optional!ulong.empty); - const TransactionTag[] tags = ds.getTransactionTagRepository().findAll(); const Account[] accounts = ds.getAccountRepository().findAll(); SysTime now = Clock.currTime(UTC()); @@ -114,32 +110,44 @@ void generateRandomTransactions(ProfileDataSource ds) { for (int i = 0; i < 100; i++) { Optional!ulong vendorId; - if (hasVendor) { + if (uniform01() < 0.7) { vendorId = Optional!ulong.of(choice(vendors).id); } + Optional!ulong categoryId; - if (hasCategory) { + if (uniform01() < 0.8) { categoryId = Optional!ulong.of(choice(categories).id); } + + // Randomly choose an account to credit / debit the transaction to. Optional!ulong creditedAccountId; Optional!ulong debitedAccountId; Account primaryAccount = choice(accounts); - Optional!ulong secondaryAccount; + Optional!ulong secondaryAccountId; if (uniform01() < 0.25) { foreach (acc; accounts) { if (acc.id != primaryAccount.id && acc.currency == primaryAccount.currency) { - secondaryAccount.value = acc.id; + secondaryAccountId = Optional!ulong.of(acc.id); break; } } } - if (uniform01() > 0.5) { + if (uniform01() < 0.5) { creditedAccountId = Optional!ulong.of(primaryAccount.id); - if (secondaryAccount) debitedAccountId = secondaryAccount; + if (secondaryAccountId) debitedAccountId = secondaryAccountId; } else { debitedAccountId = Optional!ulong.of(primaryAccount.id); - if (secondaryAccount) creditedAccountId = secondaryAccount; + if (secondaryAccountId) creditedAccountId = secondaryAccountId; } + + // Randomly choose some tags to add. + string[] tags; + foreach (n; 1..10) { + if (uniform01 < 0.25) { + tags ~= "tag-" ~ n.to!string; + } + } + ulong value = uniform(0, 1_000_000); addTransaction( @@ -153,7 +161,8 @@ void generateRandomTransactions(ProfileDataSource ds) { categoryId, creditedAccountId, debitedAccountId, - [] + [], + tags ); infoF!" Generated transaction %d"(i); timestamp -= seconds(uniform(10, 1_000_000)); diff --git a/finnow-api/sql/get_transactions.sql b/finnow-api/sql/get_transactions.sql new file mode 100644 index 0000000..c001ff7 --- /dev/null +++ b/finnow-api/sql/get_transactions.sql @@ -0,0 +1,42 @@ +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/finnow-api/schema.sql b/finnow-api/sql/schema.sql similarity index 92% rename from finnow-api/schema.sql rename to finnow-api/sql/schema.sql index 5f58e66..27f8e93 100644 --- a/finnow-api/schema.sql +++ b/finnow-api/sql/schema.sql @@ -57,8 +57,9 @@ CREATE TABLE transaction_category ( ); CREATE TABLE transaction_tag ( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL UNIQUE + transaction_id INTEGER NOT NULL, + tag TEXT NOT NULL, + CONSTRAINT pk_transaction_tag PRIMARY KEY (transaction_id, tag) ); CREATE TABLE "transaction" ( @@ -77,6 +78,7 @@ CREATE TABLE "transaction" ( FOREIGN KEY (category_id) REFERENCES transaction_category(id) ON UPDATE CASCADE ON DELETE SET NULL ); +CREATE INDEX idx_transaction_by_timestamp ON "transaction"(timestamp); CREATE TABLE transaction_attachment ( transaction_id INTEGER NOT NULL, @@ -90,18 +92,6 @@ CREATE TABLE transaction_attachment ( ON UPDATE CASCADE ON DELETE CASCADE ); -CREATE TABLE transaction_tag_join ( - transaction_id INTEGER NOT NULL, - tag_id INTEGER NOT NULL, - PRIMARY KEY (transaction_id, tag_id), - CONSTRAINT fk_transaction_tag_join_transaction - FOREIGN KEY (transaction_id) REFERENCES "transaction"(id) - ON UPDATE CASCADE ON DELETE CASCADE, - CONSTRAINT fk_transaction_tag_join_tag - FOREIGN KEY (tag_id) REFERENCES transaction_tag(id) - ON UPDATE CASCADE ON DELETE CASCADE -); - CREATE TABLE transaction_line_item ( id INTEGER PRIMARY KEY, transaction_id INTEGER NOT NULL, diff --git a/web-app/src/api/data.ts b/web-app/src/api/data.ts new file mode 100644 index 0000000..ecac08b --- /dev/null +++ b/web-app/src/api/data.ts @@ -0,0 +1,29 @@ +import { ApiClient } from './base' + +export interface Currency { + code: string + fractionalDigits: number + numericCode: number + symbol: string +} + +export class DataApiClient extends ApiClient { + async getCurrencies(): Promise { + return await super.getJson('/currencies') + } +} + +/** + * Formats a money value (integer amount and currency) as a string. + * @param amount The integer amount to format. + * @param currency The currency of the value. + * @returns A string representation of the money value. + */ +export function formatMoney(amount: number, currency: Currency) { + const format = new Intl.NumberFormat(undefined, { + currency: currency.code, + style: 'currency', + currencyDisplay: 'narrowSymbol', + }) + return format.format(amount / Math.pow(10, currency.fractionalDigits)) +} diff --git a/web-app/src/api/transaction.ts b/web-app/src/api/transaction.ts index 6bfd0c7..13ed1b6 100644 --- a/web-app/src/api/transaction.ts +++ b/web-app/src/api/transaction.ts @@ -1,4 +1,5 @@ import { ApiClient } from './base' +import type { Currency } from './data' import { type Page, type PageRequest } from './pagination' import type { Profile } from './profile' @@ -24,6 +25,37 @@ export interface Transaction { categoryId: number | null } +export interface TransactionsListItem { + id: number + timestamp: string + addedAt: string + amount: number + currency: Currency + description: string + vendor: TransactionsListItemVendor | null + category: TransactionsListItemCategory | null + creditedAccount: TransactionsListItemAccount | null + debitedAccount: TransactionsListItemAccount | null +} + +export interface TransactionsListItemVendor { + id: number + name: string +} + +export interface TransactionsListItemCategory { + id: number + name: string + color: string +} + +export interface TransactionsListItemAccount { + id: number + name: string + type: string + numberSuffix: string +} + export class TransactionApiClient extends ApiClient { readonly path: string @@ -54,7 +86,7 @@ export class TransactionApiClient extends ApiClient { async getTransactions( paginationOptions: PageRequest | undefined = undefined, - ): Promise> { + ): Promise> { return await super.getJsonPage(this.path + '/transactions', paginationOptions) } } diff --git a/web-app/src/pages/LoginPage.vue b/web-app/src/pages/LoginPage.vue index 68f9870..516d327 100644 --- a/web-app/src/pages/LoginPage.vue +++ b/web-app/src/pages/LoginPage.vue @@ -35,6 +35,12 @@ async function doLogin() { disableForm.value = false } } + +function generateSampleData() { + fetch('http://localhost:8080/api/sample-data', { + method: 'POST' + }) +} diff --git a/web-app/src/pages/home/TransactionsModule.vue b/web-app/src/pages/home/TransactionsModule.vue index 169d532..92a4b22 100644 --- a/web-app/src/pages/home/TransactionsModule.vue +++ b/web-app/src/pages/home/TransactionsModule.vue @@ -1,13 +1,14 @@