From 0b67bb605b2136ce8d9831d3cc65f2cf6a519f0a Mon Sep 17 00:00:00 2001 From: andrewlalis Date: Mon, 11 Aug 2025 13:18:23 -0400 Subject: [PATCH] Refactor entire process of adding transactions. --- finnow-api/dub.selections.json | 5 +- finnow-api/source/account/data.d | 1 + finnow-api/source/account/data_impl_sqlite.d | 4 + finnow-api/source/profile/data_impl_sqlite.d | 2 +- finnow-api/source/transaction/api.d | 70 +------- finnow-api/source/transaction/data.d | 18 +- .../source/transaction/data_impl_sqlite.d | 169 ++++++++++++++---- finnow-api/source/transaction/dto.d | 111 ++++++++++++ finnow-api/source/transaction/model.d | 3 +- finnow-api/source/transaction/service.d | 147 +++++++++------ finnow-api/source/util/sample_data.d | 113 +++++++----- finnow-api/sql/get_line_items.sql | 15 ++ finnow-api/sql/get_transaction.sql | 46 +++++ finnow-api/sql/insert_line_item.sql | 8 + finnow-api/sql/insert_transaction.sql | 9 + finnow-api/sql/schema.sql | 4 +- web-app/src/api/transaction.ts | 42 +++++ web-app/src/pages/ProfilesPage.vue | 2 +- web-app/src/pages/TransactionPage.vue | 112 ++++++++++++ web-app/src/pages/home/TransactionsModule.vue | 3 + web-app/src/router/index.ts | 33 ++-- 21 files changed, 683 insertions(+), 234 deletions(-) create mode 100644 finnow-api/source/transaction/dto.d create mode 100644 finnow-api/sql/get_line_items.sql create mode 100644 finnow-api/sql/get_transaction.sql create mode 100644 finnow-api/sql/insert_line_item.sql create mode 100644 finnow-api/sql/insert_transaction.sql create mode 100644 web-app/src/pages/TransactionPage.vue diff --git a/finnow-api/dub.selections.json b/finnow-api/dub.selections.json index 61b5f07..86cc434 100644 --- a/finnow-api/dub.selections.json +++ b/finnow-api/dub.selections.json @@ -1,7 +1,10 @@ { "fileVersion": 1, "versions": { - "asdf": "0.7.17", + "asdf": { + "repository": "git+https://github.com/libmir/asdf.git", + "version": "7f77a3031975816b604a513ddeefbc9e514f236c" + }, "d2sqlite3": "1.0.0", "dxml": "0.4.4", "handy-http-data": "1.3.0", diff --git a/finnow-api/source/account/data.d b/finnow-api/source/account/data.d index 6eab494..0abaab0 100644 --- a/finnow-api/source/account/data.d +++ b/finnow-api/source/account/data.d @@ -10,6 +10,7 @@ import std.datetime : SysTime; interface AccountRepository { Optional!Account findById(ulong id); + bool existsById(ulong id); Account insert(AccountType type, string numberSuffix, string name, Currency currency, string description); void setArchived(ulong id, bool archived); Account update(ulong id, in Account newData); diff --git a/finnow-api/source/account/data_impl_sqlite.d b/finnow-api/source/account/data_impl_sqlite.d index 60a0723..315c7f0 100644 --- a/finnow-api/source/account/data_impl_sqlite.d +++ b/finnow-api/source/account/data_impl_sqlite.d @@ -21,6 +21,10 @@ class SqliteAccountRepository : AccountRepository { return findOne(db, "SELECT * FROM account WHERE id = ?", &parseAccount, id); } + bool existsById(ulong id) { + return util.sqlite.exists(db, "SELECT id FROM account WHERE id = ?", id); + } + Account insert(AccountType type, string numberSuffix, string name, Currency currency, string description) { util.sqlite.update( db, diff --git a/finnow-api/source/profile/data_impl_sqlite.d b/finnow-api/source/profile/data_impl_sqlite.d index 7ffe139..d128d8f 100644 --- a/finnow-api/source/profile/data_impl_sqlite.d +++ b/finnow-api/source/profile/data_impl_sqlite.d @@ -145,7 +145,7 @@ class SqliteProfileDataSource : ProfileDataSource { const SCHEMA = import("sql/schema.sql"); private const string dbPath; - private Database db; + Database db; this(string path) { this.dbPath = path; diff --git a/finnow-api/source/transaction/api.d b/finnow-api/source/transaction/api.d index bd82195..489f4a5 100644 --- a/finnow-api/source/transaction/api.d +++ b/finnow-api/source/transaction/api.d @@ -9,6 +9,7 @@ import std.typecons; import transaction.model; import transaction.data; import transaction.service; +import transaction.dto; import profile.data; import profile.service; import account.api; @@ -20,47 +21,6 @@ import util.data; immutable DEFAULT_TRANSACTION_PAGE = PageRequest(1, 10, [Sort("timestamp", SortDir.DESC)]); -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; - Currency currency; - string description; - - @serdeTransformOut!serializeOptional - Optional!TransactionsListItemVendor vendor; - @serdeTransformOut!serializeOptional - Optional!TransactionsListItemCategory category; - @serdeTransformOut!serializeOptional - Optional!TransactionsListItemAccount creditedAccount; - @serdeTransformOut!serializeOptional - Optional!TransactionsListItemAccount debitedAccount; - - string[] tags; -} - void handleGetTransactions(ref ServerHttpRequest request, ref ServerHttpResponse response) { ProfileDataSource ds = getProfileDataSource(request); PageRequest pr = PageRequest.parse(request, DEFAULT_TRANSACTION_PAGE); @@ -69,33 +29,17 @@ void handleGetTransactions(ref ServerHttpRequest request, ref ServerHttpResponse } void handleGetTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) { - // TODO -} - -struct AddTransactionPayloadLineItem { - long valuePerItem; - ulong quantity; - string description; - Nullable!ulong categoryId; -} - -struct AddTransactionPayload { - string timestamp; - ulong amount; - string currencyCode; - string description; - Nullable!ulong vendorId; - Nullable!ulong categoryId; - Nullable!ulong creditedAccountId; - Nullable!ulong debitedAccountId; - string[] tags; - AddTransactionPayloadLineItem[] lineItems; + ProfileDataSource ds = getProfileDataSource(request); + TransactionDetail txn = getTransaction(ds, getTransactionIdOrThrow(request)); + import asdf : serializeToJson; + string jsonStr = serializeToJson(txn); + response.writeBodyString(jsonStr, "application/json"); } void handleAddTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) { ProfileDataSource ds = getProfileDataSource(request); auto payload = readJsonBodyAs!AddTransactionPayload(request); - addTransaction2(ds, payload); + addTransaction(ds, payload); } void handleUpdateTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) { diff --git a/finnow-api/source/transaction/data.d b/finnow-api/source/transaction/data.d index 932c1a4..be2a4f0 100644 --- a/finnow-api/source/transaction/data.d +++ b/finnow-api/source/transaction/data.d @@ -4,7 +4,7 @@ import handy_http_primitives : Optional; import std.datetime; import transaction.model; -import transaction.api : TransactionsListItem; +import transaction.dto; import util.money; import util.pagination; @@ -12,6 +12,7 @@ interface TransactionVendorRepository { Optional!TransactionVendor findById(ulong id); TransactionVendor[] findAll(); bool existsByName(string name); + bool existsById(ulong id); TransactionVendor insert(string name, string description); void deleteById(ulong id); TransactionVendor updateById(ulong id, string name, string description); @@ -19,6 +20,7 @@ interface TransactionVendorRepository { interface TransactionCategoryRepository { Optional!TransactionCategory findById(ulong id); + bool existsById(ulong id); TransactionCategory[] findAllByParentId(Optional!ulong parentId); TransactionCategory insert(Optional!ulong parentId, string name, string description, string color); void deleteById(ulong id); @@ -27,21 +29,13 @@ interface TransactionCategoryRepository { interface TransactionTagRepository { string[] findAllByTransactionId(ulong transactionId); - void updateTags(ulong transactionId, string[] tags); + void updateTags(ulong transactionId, in string[] tags); string[] findAll(); } interface TransactionRepository { Page!TransactionsListItem findAll(PageRequest pr); - Optional!Transaction findById(ulong id); - Transaction insert( - SysTime timestamp, - SysTime addedAt, - ulong amount, - Currency currency, - string description, - Optional!ulong vendorId, - Optional!ulong categoryId - ); + Optional!TransactionDetail findById(ulong id); + TransactionDetail insert(in AddTransactionPayload data); void deleteById(ulong id); } diff --git a/finnow-api/source/transaction/data_impl_sqlite.d b/finnow-api/source/transaction/data_impl_sqlite.d index 753b98c..2441b31 100644 --- a/finnow-api/source/transaction/data_impl_sqlite.d +++ b/finnow-api/source/transaction/data_impl_sqlite.d @@ -7,7 +7,7 @@ import d2sqlite3; import transaction.model; import transaction.data; -import transaction.api; +import transaction.dto; import util.sqlite; import util.money; import util.pagination; @@ -35,6 +35,10 @@ class SqliteTransactionVendorRepository : TransactionVendorRepository { return util.sqlite.exists(db, "SELECT id FROM transaction_vendor WHERE name = ?", name); } + bool existsById(ulong id) { + return util.sqlite.exists(db, "SELECT id FROM transaction_vendor WHERE id = ?", id); + } + TransactionVendor insert(string name, string description) { util.sqlite.update( db, @@ -77,6 +81,10 @@ class SqliteTransactionCategoryRepository : TransactionCategoryRepository { return util.sqlite.findById(db, "transaction_category", &parseCategory, id); } + bool existsById(ulong id) { + return util.sqlite.exists(db, "SELECT id FROM transaction_category WHERE id = ?", id); + } + TransactionCategory[] findAllByParentId(Optional!ulong parentId) { if (parentId) { return util.sqlite.findAll( @@ -147,7 +155,7 @@ class SqliteTransactionTagRepository : TransactionTagRepository { ); } - void updateTags(ulong transactionId, string[] tags) { + void updateTags(ulong transactionId, in string[] tags) { util.sqlite.update( db, "DELETE FROM transaction_tag WHERE transaction_id = ?", @@ -200,15 +208,15 @@ class SqliteTransactionRepository : TransactionRepository { 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)); + 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!TransactionsListItemCategory.of( - TransactionsListItemCategory(categoryId.get, categoryName, categoryColor)); + item.category = Optional!(TransactionsListItem.Category).of( + TransactionsListItem.Category(categoryId.get, categoryName, categoryColor)); } Nullable!ulong creditedAccountId = row.peek!(Nullable!ulong)(11); if (!creditedAccountId.isNull) { @@ -216,8 +224,8 @@ class SqliteTransactionRepository : TransactionRepository { 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)); + item.creditedAccount = Optional!(TransactionsListItem.Account).of( + TransactionsListItem.Account(id, name, type, suffix)); } Nullable!ulong debitedAccountId = row.peek!(Nullable!ulong)(15); if (!debitedAccountId.isNull) { @@ -225,13 +233,15 @@ class SqliteTransactionRepository : TransactionRepository { 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)); + item.debitedAccount = Optional!(TransactionsListItem.Account).of( + TransactionsListItem.Account(id, name, type, suffix)); } string tagsStr = row.peek!string(19); - if (tagsStr.length > 0) { + if (tagsStr !is null && tagsStr.length > 0) { import std.string : split; item.tags = tagsStr.split(","); + } else { + item.tags = []; } return item; }); @@ -239,34 +249,125 @@ class SqliteTransactionRepository : TransactionRepository { return Page!(TransactionsListItem).of(results, pr, totalCount); } - Optional!Transaction findById(ulong id) { - return util.sqlite.findById(db, TABLE_NAME, &parseTransaction, id); + Optional!TransactionDetail findById(ulong id) { + Optional!TransactionDetail item = util.sqlite.findOne( + db, + import("sql/get_transaction.sql"), + (row) { + TransactionDetail 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) { + item.vendor = Optional!(TransactionDetail.Vendor).of( + TransactionDetail.Vendor( + vendorId.get, + row.peek!string(7), + row.peek!string(8) + )).toNullable; + } + Nullable!ulong categoryId = row.peek!(Nullable!ulong)(9); + if (!categoryId.isNull) { + item.category = Optional!(TransactionDetail.Category).of( + TransactionDetail.Category( + categoryId.get, + row.peek!(Nullable!ulong)(10), + row.peek!string(11), + row.peek!string(12), + row.peek!string(13) + )).toNullable; + } + Nullable!ulong creditedAccountId = row.peek!(Nullable!ulong)(14); + if (!creditedAccountId.isNull) { + item.creditedAccount = Optional!(TransactionDetail.Account).of( + TransactionDetail.Account( + creditedAccountId.get, + row.peek!string(15), + row.peek!string(16), + row.peek!string(17) + )).toNullable; + } + Nullable!ulong debitedAccountId = row.peek!(Nullable!ulong)(18); + if (!debitedAccountId.isNull) { + item.debitedAccount = Optional!(TransactionDetail.Account).of( + TransactionDetail.Account( + debitedAccountId.get, + row.peek!string(19), + row.peek!string(20), + row.peek!string(21) + )).toNullable; + } + string tagsStr = row.peek!string(22); + if (tagsStr !is null && tagsStr.length > 0) { + import std.string : split; + item.tags = tagsStr.split(","); + } else { + item.tags = []; + } + return item; + }, + id + ); + if (item.isNull) return item; + item.value.lineItems = util.sqlite.findAll( + db, + import("sql/get_line_items.sql"), + (row) { + TransactionDetail.LineItem li; + li.idx = row.peek!uint(0); + li.valuePerItem = row.peek!long(1); + li.quantity = row.peek!ulong(2); + li.description = row.peek!string(3); + Nullable!ulong categoryId = row.peek!(Nullable!ulong)(4); + if (!categoryId.isNull) { + li.category = Optional!(TransactionDetail.Category).of( + TransactionDetail.Category( + categoryId.get, + row.peek!(Nullable!ulong)(5), + row.peek!string(6), + row.peek!string(7), + row.peek!string(8) + )).toNullable; + } + return li; + }, + id + ); + return item; } - Transaction insert( - SysTime timestamp, - SysTime addedAt, - ulong amount, - Currency currency, - string description, - Optional!ulong vendorId, - Optional!ulong categoryId - ) { + TransactionDetail insert(in AddTransactionPayload data) { util.sqlite.update( db, - "INSERT INTO " ~ TABLE_NAME ~ " - (timestamp, added_at, amount, currency, description, vendor_id, category_id) - VALUES (?, ?, ?, ?, ?, ?, ?)", - timestamp.toISOExtString(), - addedAt.toISOExtString(), - amount, - currency.code, - description, - toNullable(vendorId), - toNullable(categoryId) + import("sql/insert_transaction.sql"), + data.timestamp, + Clock.currTime(UTC()), + data.amount, + data.currencyCode, + data.description, + data.vendorId, + data.categoryId ); - ulong id = db.lastInsertRowid(); - return findById(id).orElseThrow(); + ulong transactionId = db.lastInsertRowid(); + // Insert line items: + foreach (size_t idx, lineItem; data.lineItems) { + util.sqlite.update( + db, + import("sql/insert_line_item.sql"), + transactionId, + idx, + lineItem.valuePerItem, + lineItem.quantity, + lineItem.description, + lineItem.categoryId + ); + } + return findById(transactionId).orElseThrow(); } void deleteById(ulong id) { diff --git a/finnow-api/source/transaction/dto.d b/finnow-api/source/transaction/dto.d new file mode 100644 index 0000000..d2cb5a3 --- /dev/null +++ b/finnow-api/source/transaction/dto.d @@ -0,0 +1,111 @@ +module transaction.dto; + +import handy_http_primitives : Optional; +import asdf : serdeTransformOut; +import std.typecons; + +import util.data; +import util.money; + +/// The transaction data provided when a list of transactions is requested. +struct TransactionsListItem { + ulong id; + string timestamp; + string addedAt; + ulong amount; + Currency currency; + string description; + @serdeTransformOut!serializeOptional + Optional!Vendor vendor; + @serdeTransformOut!serializeOptional + Optional!Category category; + @serdeTransformOut!serializeOptional + Optional!Account creditedAccount; + @serdeTransformOut!serializeOptional + Optional!Account debitedAccount; + string[] tags; + + static struct Account { + ulong id; + string name; + string type; + string numberSuffix; + } + + static struct Vendor { + ulong id; + string name; + } + + static struct Category { + ulong id; + string name; + string color; + } +} + +/// Transaction data provided when fetching a single transaction. +struct TransactionDetail { + ulong id; + string timestamp; + string addedAt; + ulong amount; + Currency currency; + string description; + Nullable!Vendor vendor; + Nullable!Category category; + Nullable!Account creditedAccount; + Nullable!Account debitedAccount; + string[] tags; + LineItem[] lineItems; + + static struct Vendor { + ulong id; + string name; + string description; + } + + static struct Category { + ulong id; + Nullable!ulong parentId; + string name; + string description; + string color; + } + + static struct LineItem { + uint idx; + long valuePerItem; + ulong quantity; + string description; + Nullable!Category category; + } + + static struct Account { + ulong id; + string name; + string type; + string numberSuffix; + } +} + +/// Data provided when a new transaction is added by a user. +struct AddTransactionPayload { + string timestamp; + ulong amount; + string currencyCode; + string description; + Nullable!ulong vendorId; + Nullable!ulong categoryId; + Nullable!ulong creditedAccountId; + Nullable!ulong debitedAccountId; + string[] tags; + LineItem[] lineItems; + + static struct LineItem { + long valuePerItem; + ulong quantity; + string description; + Nullable!ulong categoryId; + } +} diff --git a/finnow-api/source/transaction/model.d b/finnow-api/source/transaction/model.d index 5bb3c03..f69e111 100644 --- a/finnow-api/source/transaction/model.d +++ b/finnow-api/source/transaction/model.d @@ -33,11 +33,10 @@ struct Transaction { } struct TransactionLineItem { - immutable ulong id; immutable ulong transactionId; + immutable uint idx; immutable long valuePerItem; immutable ulong quantity; - immutable uint idx; immutable string description; immutable Optional!ulong categoryId; } diff --git a/finnow-api/source/transaction/service.d b/finnow-api/source/transaction/service.d index 56b0fb1..41c75f5 100644 --- a/finnow-api/source/transaction/service.d +++ b/finnow-api/source/transaction/service.d @@ -6,8 +6,10 @@ import std.datetime; import transaction.api; import transaction.model; import transaction.data; +import transaction.dto; import profile.data; import account.model; +import account.data; import util.money; import util.pagination; @@ -16,64 +18,109 @@ import util.pagination; Page!TransactionsListItem getTransactions(ProfileDataSource ds, in PageRequest pageRequest) { Page!TransactionsListItem page = ds.getTransactionRepository() .findAll(pageRequest); - return page; // Return an empty page for now! + return page; } -void addTransaction2(ProfileDataSource ds, in AddTransactionPayload payload) { - // TODO +TransactionDetail getTransaction(ProfileDataSource ds, ulong transactionId) { + return ds.getTransactionRepository().findById(transactionId) + .orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND)); } -void addTransaction( - ProfileDataSource ds, - SysTime timestamp, - SysTime addedAt, - ulong amount, - Currency currency, - string description, - Optional!ulong vendorId, - Optional!ulong categoryId, - Optional!ulong creditedAccountId, - Optional!ulong debitedAccountId, - TransactionLineItem[] lineItems, - string[] tags -) { - if (creditedAccountId.isNull && debitedAccountId.isNull) { - throw new Exception("At least one account must be linked to a transaction."); +TransactionDetail addTransaction(ProfileDataSource ds, in AddTransactionPayload payload) { + TransactionVendorRepository vendorRepo = ds.getTransactionVendorRepository(); + TransactionCategoryRepository categoryRepo = ds.getTransactionCategoryRepository(); + AccountRepository accountRepo = ds.getAccountRepository(); + + // Validate transaction details: + if (payload.creditedAccountId.isNull && payload.debitedAccountId.isNull) { + throw new HttpStatusException(HttpStatus.BAD_REQUEST, "At least one account must be linked."); } - auto journalEntryRepo = ds.getAccountJournalEntryRepository(); - auto txRepo = ds.getTransactionRepository(); - Transaction tx = txRepo.insert( - timestamp, - addedAt, - amount, - currency, - description, - vendorId, - categoryId - ); - if (creditedAccountId) { - journalEntryRepo.insert( - timestamp, - creditedAccountId.value, - tx.id, - amount, - AccountJournalEntryType.CREDIT, - currency - ); + if ( + !payload.creditedAccountId.isNull && + !payload.debitedAccountId.isNull && + payload.creditedAccountId.get == payload.debitedAccountId.get + ) { + throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Cannot link the same account as both credit and debit."); } - if (debitedAccountId) { - journalEntryRepo.insert( - timestamp, - debitedAccountId.value, - tx.id, - amount, - AccountJournalEntryType.DEBIT, - currency - ); + if (payload.amount == 0) { + throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Amount should be greater than 0."); } - if (tags.length > 0) { - ds.getTransactionTagRepository().updateTags(tx.id, tags); + SysTime now = Clock.currTime(UTC()); + SysTime timestamp = SysTime.fromISOExtString(payload.timestamp, UTC()); + if (timestamp > now) { + throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Cannot create transaction in the future."); } + if (!payload.vendorId.isNull && !vendorRepo.existsById(payload.vendorId.get)) { + throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Vendor doesn't exist."); + } + if (!payload.categoryId.isNull && !categoryRepo.existsById(payload.categoryId.get)) { + throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Category doesn't exist."); + } + if (!payload.creditedAccountId.isNull && !accountRepo.existsById(payload.creditedAccountId.get)) { + throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Credited account doesn't exist."); + } + if (!payload.debitedAccountId.isNull && !accountRepo.existsById(payload.debitedAccountId.get)) { + throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Debited account doesn't exist."); + } + foreach (tag; payload.tags) { + import std.regex; + auto r = ctRegex!(`^[a-z0-9-_]{3,32}$`); + if (!matchFirst(tag, r)) { + throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid tag: \"" ~ tag ~ "\"."); + } + } + if (payload.lineItems.length > 0) { + long lineItemsTotal = 0; + foreach (lineItem; payload.lineItems) { + if (!lineItem.categoryId.isNull && !categoryRepo.existsById(lineItem.categoryId.get)) { + throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Line item's category doesn't exist."); + } + if (lineItem.quantity == 0) { + throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Line item's quantity should greater than zero."); + } + for (ulong i = 0; i < lineItem.quantity; i++) { + lineItemsTotal += lineItem.valuePerItem; + } + } + if (lineItemsTotal != payload.amount) { + throw new HttpStatusException( + HttpStatus.BAD_REQUEST, + "Total of all line items doesn't equal the transaction's total." + ); + } + } + + // Add the transaction: + ulong txnId; + ds.doTransaction(() { + TransactionRepository txRepo = ds.getTransactionRepository(); + TransactionDetail txn = txRepo.insert(payload); + AccountJournalEntryRepository jeRepo = ds.getAccountJournalEntryRepository(); + if (!payload.creditedAccountId.isNull) { + jeRepo.insert( + timestamp, + payload.creditedAccountId.get, + txn.id, + txn.amount, + AccountJournalEntryType.CREDIT, + txn.currency + ); + } + if (!payload.debitedAccountId.isNull) { + jeRepo.insert( + timestamp, + payload.debitedAccountId.get, + txn.id, + txn.amount, + AccountJournalEntryType.DEBIT, + txn.currency + ); + } + TransactionTagRepository tagRepo = ds.getTransactionTagRepository(); + tagRepo.updateTags(txn.id, payload.tags); + txnId = txn.id; + }); + return ds.getTransactionRepository().findById(txnId).orElseThrow(); } // Vendors Services diff --git a/finnow-api/source/util/sample_data.d b/finnow-api/source/util/sample_data.d index 0f3b85d..5ae8588 100644 --- a/finnow-api/source/util/sample_data.d +++ b/finnow-api/source/util/sample_data.d @@ -7,12 +7,15 @@ import auth; import profile; import account; import transaction; +import transaction.dto; import util.money; +import util.data; import std.random; import std.conv; import std.array; import std.datetime; +import std.typecons; void generateSampleData() { UserRepository userRepo = new FileSystemUserRepository; @@ -51,31 +54,30 @@ void generateRandomProfile(int idx, ProfileRepository profileRepo) { infoF!" Generating random profile %s."(profileName); Profile profile = profileRepo.createProfile(profileName); ProfileDataSource ds = profileRepo.getDataSource(profile); - ds.doTransaction(() { - ds.getPropertiesRepository().setProperty("sample-data-idx", idx.to!string); - Currency preferredCurrency = choice(ALL_CURRENCIES); - const int accountCount = uniform(3, 10); - for (int i = 0; i < accountCount; i++) { - generateRandomAccount(i, ds, preferredCurrency); - } + ds.getPropertiesRepository().setProperty("sample-data-idx", idx.to!string); + Currency preferredCurrency = choice(ALL_CURRENCIES); - 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); + const int accountCount = uniform(3, 10); + for (int i = 0; i < accountCount; i++) { + generateRandomAccount(i, ds, preferredCurrency); + } - 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 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); - generateRandomTransactions(ds); - }); + 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); } void generateRandomAccount(int idx, ProfileDataSource ds, Currency preferredCurrency) { @@ -105,24 +107,21 @@ void generateRandomTransactions(ProfileDataSource ds) { .findAllByParentId(Optional!ulong.empty); const Account[] accounts = ds.getAccountRepository().findAll(); - SysTime now = Clock.currTime(UTC()); SysTime timestamp = Clock.currTime(UTC()) - seconds(1); for (int i = 0; i < 100; i++) { - Optional!ulong vendorId; + AddTransactionPayload data; + data.timestamp = timestamp.toISOExtString(); if (uniform01() < 0.7) { - vendorId = Optional!ulong.of(choice(vendors).id); + data.vendorId = Optional!ulong.of(choice(vendors).id).toNullable; } - - Optional!ulong categoryId; if (uniform01() < 0.8) { - categoryId = Optional!ulong.of(choice(categories).id); + data.categoryId = Optional!ulong.of(choice(categories).id).toNullable; } // Randomly choose an account to credit / debit the transaction to. - Optional!ulong creditedAccountId; - Optional!ulong debitedAccountId; Account primaryAccount = choice(accounts); + data.currencyCode = primaryAccount.currency.code; Optional!ulong secondaryAccountId; if (uniform01() < 0.25) { foreach (acc; accounts) { @@ -133,11 +132,11 @@ void generateRandomTransactions(ProfileDataSource ds) { } } if (uniform01() < 0.5) { - creditedAccountId = Optional!ulong.of(primaryAccount.id); - if (secondaryAccountId) debitedAccountId = secondaryAccountId; + data.creditedAccountId = Optional!ulong.of(primaryAccount.id).toNullable; + if (secondaryAccountId) data.debitedAccountId = secondaryAccountId.toNullable; } else { - debitedAccountId = Optional!ulong.of(primaryAccount.id); - if (secondaryAccountId) creditedAccountId = secondaryAccountId; + data.debitedAccountId = Optional!ulong.of(primaryAccount.id).toNullable; + if (secondaryAccountId) data.creditedAccountId = secondaryAccountId.toNullable; } // Randomly choose some tags to add. @@ -147,24 +146,40 @@ void generateRandomTransactions(ProfileDataSource ds) { tags ~= "tag-" ~ n.to!string; } } + data.tags = tags; - ulong value = uniform(0, 1_000_000); + data.amount = uniform(0, 1_000_000); + data.description = "This is a sample transaction which was generated as part of sample data."; - addTransaction( - ds, - timestamp, - now, - value, - primaryAccount.currency, - "Test transaction " ~ to!string(i), - vendorId, - categoryId, - creditedAccountId, - debitedAccountId, - [], - tags - ); - infoF!" Generated transaction %d"(i); + // Generate random line items: + if (uniform01 < 0.5) { + long lineItemTotal = 0; + foreach (n; 1..uniform(1, 20)) { + AddTransactionPayload.LineItem item; + item.valuePerItem = uniform(1, 10_000); + item.quantity = uniform(1, 5); + lineItemTotal += item.quantity * item.valuePerItem; + item.description = "Sample item " ~ n.to!string; + if (uniform01 < 0.5) { + TransactionCategory category = choice(categories); + item.categoryId = category.id; + } + data.lineItems ~= item; + } + long diff = data.amount - lineItemTotal; + // Add one final line item that adds up to the transaction total. + if (diff != 0) { + data.lineItems ~= AddTransactionPayload.LineItem( + diff, + 1, + "Last item which reconciles line items total with transaction amount.", + Nullable!ulong.init + ); + } + } + + auto txn = addTransaction(ds, data); + infoF!" Generated transaction %d"(txn.id); timestamp -= seconds(uniform(10, 1_000_000)); } } diff --git a/finnow-api/sql/get_line_items.sql b/finnow-api/sql/get_line_items.sql new file mode 100644 index 0000000..135e8c0 --- /dev/null +++ b/finnow-api/sql/get_line_items.sql @@ -0,0 +1,15 @@ +SELECT +i.idx, +i.value_per_item, +i.quantity, +i.description, +i.category_id, +category.parent_id, +category.name, +category.description, +category.color +FROM transaction_line_item i +LEFT JOIN transaction_category category + ON category.id = i.category_id +WHERE i.transaction_id = ? +ORDER BY idx; \ No newline at end of file diff --git a/finnow-api/sql/get_transaction.sql b/finnow-api/sql/get_transaction.sql new file mode 100644 index 0000000..8761086 --- /dev/null +++ b/finnow-api/sql/get_transaction.sql @@ -0,0 +1,46 @@ +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, +vendor.description AS vendor_description, + +txn.category_id AS category_id, +category.parent_id AS category_parent_id, +category.name AS category_name, +category.description AS category_description, +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 +WHERE txn.id = ? +GROUP BY txn.id \ No newline at end of file diff --git a/finnow-api/sql/insert_line_item.sql b/finnow-api/sql/insert_line_item.sql new file mode 100644 index 0000000..be6a5fc --- /dev/null +++ b/finnow-api/sql/insert_line_item.sql @@ -0,0 +1,8 @@ +INSERT INTO transaction_line_item ( + transaction_id, + idx, + value_per_item, + quantity, + description, + category_id +) VALUES (?, ?, ?, ?, ?, ?) \ No newline at end of file diff --git a/finnow-api/sql/insert_transaction.sql b/finnow-api/sql/insert_transaction.sql new file mode 100644 index 0000000..9785711 --- /dev/null +++ b/finnow-api/sql/insert_transaction.sql @@ -0,0 +1,9 @@ +INSERT INTO "transaction" ( + timestamp, + added_at, + amount, + currency, + description, + vendor_id, + category_id +) VALUES (?, ?, ?, ?, ?, ?, ?) \ No newline at end of file diff --git a/finnow-api/sql/schema.sql b/finnow-api/sql/schema.sql index 27f8e93..c969e97 100644 --- a/finnow-api/sql/schema.sql +++ b/finnow-api/sql/schema.sql @@ -93,13 +93,13 @@ CREATE TABLE transaction_attachment ( ); CREATE TABLE transaction_line_item ( - id INTEGER PRIMARY KEY, transaction_id INTEGER NOT NULL, + idx INTEGER NOT NULL DEFAULT 0, value_per_item INTEGER NOT NULL, quantity INTEGER NOT NULL DEFAULT 1, - idx INTEGER NOT NULL DEFAULT 0, description TEXT NOT NULL, category_id INTEGER, + CONSTRAINT pk_transaction_line_item PRIMARY KEY (transaction_id, idx), CONSTRAINT fk_transaction_line_item_transaction FOREIGN KEY (transaction_id) REFERENCES "transaction"(id) ON UPDATE CASCADE ON DELETE CASCADE, diff --git a/web-app/src/api/transaction.ts b/web-app/src/api/transaction.ts index 13ed1b6..e32fab6 100644 --- a/web-app/src/api/transaction.ts +++ b/web-app/src/api/transaction.ts @@ -14,6 +14,14 @@ export interface TransactionVendorPayload { description: string } +export interface TransactionCategory { + id: number + parentId: number | null + name: string + description: string + color: string +} + export interface Transaction { id: number timestamp: string @@ -56,6 +64,36 @@ export interface TransactionsListItemAccount { numberSuffix: string } +export interface TransactionDetail { + id: number + timestamp: string + addedAt: string + amount: number + currency: Currency + description: string + vendor: TransactionVendor | null + category: TransactionCategory | null + creditedAccount: TransactionDetailAccount | null + debitedAccount: TransactionDetailAccount | null + tags: string[] + lineItems: TransactionDetailLineItem[] +} + +export interface TransactionDetailAccount { + id: number + name: string + type: string + numberSuffix: string +} + +export interface TransactionDetailLineItem { + idx: number + valuePerItem: number + quantity: number + description: number + category: TransactionCategory | null +} + export class TransactionApiClient extends ApiClient { readonly path: string @@ -89,4 +127,8 @@ export class TransactionApiClient extends ApiClient { ): Promise> { return await super.getJsonPage(this.path + '/transactions', paginationOptions) } + + async getTransaction(id: number): Promise { + return await super.getJson(this.path + '/transactions/' + id) + } } diff --git a/web-app/src/pages/ProfilesPage.vue b/web-app/src/pages/ProfilesPage.vue index cfdb70f..fce0749 100644 --- a/web-app/src/pages/ProfilesPage.vue +++ b/web-app/src/pages/ProfilesPage.vue @@ -36,7 +36,7 @@ async function fetchProfiles() { function selectProfile(profile: Profile) { profileStore.onProfileSelected(profile) - router.push('/') + router.push('/profiles/' + profile.name) } async function addProfile() { diff --git a/web-app/src/pages/TransactionPage.vue b/web-app/src/pages/TransactionPage.vue new file mode 100644 index 0000000..b0ef49f --- /dev/null +++ b/web-app/src/pages/TransactionPage.vue @@ -0,0 +1,112 @@ + + diff --git a/web-app/src/pages/home/TransactionsModule.vue b/web-app/src/pages/home/TransactionsModule.vue index 92a4b22..7041731 100644 --- a/web-app/src/pages/home/TransactionsModule.vue +++ b/web-app/src/pages/home/TransactionsModule.vue @@ -48,6 +48,9 @@ async function fetchPage(pageRequest: PageRequest) { {{ tx.description }} {{ tx.creditedAccount?.name }} {{ tx.debitedAccount?.name }} + + View + diff --git a/web-app/src/router/index.ts b/web-app/src/router/index.ts index 63b66a5..145a056 100644 --- a/web-app/src/router/index.ts +++ b/web-app/src/router/index.ts @@ -1,42 +1,32 @@ -import UserAccountLayout from '@/pages/UserAccountLayout.vue' -import LoginPage from '@/pages/LoginPage.vue' -import ProfilePage from '@/pages/ProfilePage.vue' import { useAuthStore } from '@/stores/auth-store' import { createRouter, createWebHistory, type RouteLocationNormalized } from 'vue-router' -import UserHomePage from '@/pages/UserHomePage.vue' -import ProfilesPage from '@/pages/ProfilesPage.vue' import { useProfileStore } from '@/stores/profile-store' -import AccountPage from '@/pages/AccountPage.vue' -import EditAccountPage from '@/pages/forms/EditAccountPage.vue' -import MyUserPage from '@/pages/MyUserPage.vue' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes: [ { path: '/login', - component: async () => LoginPage, + component: () => import('@/pages/LoginPage.vue'), meta: { title: 'Login' }, }, { path: '/', - component: async () => UserAccountLayout, + component: () => import('@/pages/UserAccountLayout.vue'), beforeEnter: onlyAuthenticated, children: [ { path: '', - component: async () => UserHomePage, - meta: { title: 'Home' }, - beforeEnter: profileSelected, + redirect: '/profiles', }, { path: 'me', - component: async () => MyUserPage, + component: () => import('@/pages/MyUserPage.vue'), meta: { title: 'My User' }, }, { path: 'profiles', - component: async () => ProfilesPage, + component: () => import('@/pages/ProfilesPage.vue'), meta: { title: 'Profiles' }, }, { @@ -45,24 +35,29 @@ const router = createRouter({ children: [ { path: '', - component: async () => ProfilePage, + component: () => import('@/pages/UserHomePage.vue'), meta: { title: (to: RouteLocationNormalized) => 'Profile ' + to.params.name }, }, { path: 'accounts/:id', - component: async () => AccountPage, + component: () => import('@/pages/AccountPage.vue'), meta: { title: 'Account' }, }, { path: 'accounts/:id/edit', - component: async () => EditAccountPage, + component: () => import('@/pages/forms/EditAccountPage.vue'), meta: { title: 'Edit Account' }, }, { path: 'add-account', - component: async () => EditAccountPage, + component: () => import('@/pages/forms/EditAccountPage.vue'), meta: { title: 'Add Account' }, }, + { + path: 'transactions/:id', + component: () => import('@/pages/TransactionPage.vue'), + meta: { title: 'Transaction' }, + }, ], }, ],