From e29d4e1c0fcc3e20dbce2e769ae182b7c3c62b22 Mon Sep 17 00:00:00 2001 From: andrewlalis Date: Sat, 16 Aug 2025 20:34:42 -0400 Subject: [PATCH] Added functioning account editor. --- finnow-api/sample-data/vendors.csv | 86 ++--- finnow-api/source/account/data.d | 1 + finnow-api/source/account/data_impl_sqlite.d | 17 + finnow-api/source/api_mapping.d | 10 +- finnow-api/source/transaction/api.d | 43 ++- finnow-api/source/transaction/data.d | 2 + .../source/transaction/data_impl_sqlite.d | 61 ++- finnow-api/source/transaction/dto.d | 12 + finnow-api/source/transaction/service.d | 177 +++++++-- finnow-api/source/util/sample_data.d | 42 ++- finnow-api/sql/schema.sql | 19 +- finnow-api/sql/update_transaction.sql | 9 + web-app/src/api/transaction.ts | 52 ++- web-app/src/components/AppButton.vue | 15 +- web-app/src/components/LineItemsEditor.vue | 122 ++++++ web-app/src/components/form/FormActions.vue | 4 +- web-app/src/components/form/FormControl.vue | 20 + web-app/src/pages/AccountPage.vue | 2 +- web-app/src/pages/TransactionPage.vue | 21 +- web-app/src/pages/forms/EditAccountPage.vue | 1 - .../src/pages/forms/EditTransactionPage.vue | 351 ++++++++++++++++++ web-app/src/pages/home/TransactionsModule.vue | 7 + web-app/src/router/index.ts | 10 + 23 files changed, 962 insertions(+), 122 deletions(-) create mode 100644 finnow-api/sql/update_transaction.sql create mode 100644 web-app/src/components/LineItemsEditor.vue create mode 100644 web-app/src/pages/forms/EditTransactionPage.vue diff --git a/finnow-api/sample-data/vendors.csv b/finnow-api/sample-data/vendors.csv index be6ab54..4219410 100644 --- a/finnow-api/sample-data/vendors.csv +++ b/finnow-api/sample-data/vendors.csv @@ -1,43 +1,43 @@ -Amazon -eBay -Walmart -Target -Best Buy -Costco -Home Depot -Lowe's -Kroger -CVS -Walgreens -Starbucks -McDonald's -Burger King -Subway -Pizza Hut -Domino's -Chipotle -Taco Bell -Panera Bread -Dunkin' -Chick-fil-A -Advance Auto Parts -AutoZone -Delta Air Lines -American Airlines -United Airlines -Squarespace -DigitalOcean -GitHub -Heroku -Stripe -PayPal -Verizon -ALDI -IKEA -Primark -H&M -Petco -China King -GEICO -Bank of America -Citi +Amazon,Online retailer for pretty much anything. +eBay,Website to buy and bid on stuff from people. +Walmart,"Big, ugly general store." +Target,Slightly less ugly general store. +Best Buy,Large tech-focused store. +Costco,Wholesale club grocery store. +Home Depot,Hardware store that’s painted orange. +Lowe's,Hardware store that’s painted blue. +Kroger,A giant grocery conglomerate. +CVS,A chain of corner-store pharmacies. +Walgreens,Another chain of corner-store pharmacies. +Starbucks,Coffee distributor. +McDonald's,The most famous fast-food. +Burger King,Second to McDonald’s for burger fast-food. +Subway,An outdated sandwich shop. +Pizza Hut,Pizza chain restaurant. +Domino's,Another pizza chain restaurant. +Chipotle,“Mexican” fast food place. +Taco Bell,Another “Mexican” fast food place. +Panera Bread,Some random breakfast restaurant. +Dunkin',Coffee and donuts shop. +Chick-fil-A,Chicken shop that contributes to anti-gay politics. +Advance Auto Parts,Car parts store. +AutoZone,Another car parts store. +Delta Air Lines,The most popular american air carrier. +American Airlines,"Another american air carrier, based in Charlotte, North Carolina." +United Airlines,"An american air carrier based in Chicago, Illinois." +Squarespace,Online website and domain name seller. +DigitalOcean,Cloud hosting provider. +GitHub,Source code repository provider owned by Microsoft. +Heroku,Some other cloud hosting provider. +Stripe,Payment provider. +PayPal,Another payment provider. +Verizon,American phone carrier. +ALDI,Multinational minimalist grocery store. +IKEA,Swedish build-it-yourself furniture store. +Primark,Irish budget clothing store. +H&M,Budget european clothing store. +Petco,Pet store. +China King,Some random chinese store. +GEICO,Car insurance. +Bank of America,A bank. +Citi,Another bank. diff --git a/finnow-api/source/account/data.d b/finnow-api/source/account/data.d index 0abaab0..7d5e3dd 100644 --- a/finnow-api/source/account/data.d +++ b/finnow-api/source/account/data.d @@ -32,4 +32,5 @@ interface AccountJournalEntryRepository { Currency currency ); void deleteById(ulong id); + void deleteByAccountIdAndTransactionId(ulong accountId, ulong transactionId); } diff --git a/finnow-api/source/account/data_impl_sqlite.d b/finnow-api/source/account/data_impl_sqlite.d index 315c7f0..ff8555a 100644 --- a/finnow-api/source/account/data_impl_sqlite.d +++ b/finnow-api/source/account/data_impl_sqlite.d @@ -83,6 +83,7 @@ SQL", void deleteById(ulong id) { doTransaction(db, () { + // Delete associated history. util.sqlite.update( db, "DELETE FROM history @@ -92,6 +93,14 @@ SQL", )", id ); + // Delete all associated transactions. + util.sqlite.update( + db, + "DELETE FROM \"transaction\" WHERE id IN " ~ + "(SELECT transaction_id FROM account_journal_entry WHERE account_id = ?)", + id + ); + // Finally delete the account itself (and all cascaded entities, like journal entries). util.sqlite.update(db, "DELETE FROM account WHERE id = ?", id); }); } @@ -229,6 +238,14 @@ class SqliteAccountJournalEntryRepository : AccountJournalEntryRepository { util.sqlite.deleteById(db, "account_journal_entry", id); } + void deleteByAccountIdAndTransactionId(ulong accountId, ulong transactionId) { + util.sqlite.update( + db, + "DELETE FROM account_journal_entry WHERE account_id = ? AND transaction_id = ?", + accountId, transactionId + ); + } + static AccountJournalEntry parseEntry(Row row) { string typeStr = row.peek!(string, PeekMode.slice)(5); AccountJournalEntryType type; diff --git a/finnow-api/source/api_mapping.d b/finnow-api/source/api_mapping.d index ef3b4b6..eee39ca 100644 --- a/finnow-api/source/api_mapping.d +++ b/finnow-api/source/api_mapping.d @@ -62,12 +62,20 @@ HttpRequestHandler mapApiHandlers(string webOrigin) { 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); - + // Transaction category endpoints: + a.map(HttpMethod.GET, PROFILE_PATH ~ "/categories", &handleGetCategories); + a.map(HttpMethod.GET, PROFILE_PATH ~ "/categories/:categoryId:ulong", &handleGetCategory); + a.map(HttpMethod.POST, PROFILE_PATH ~ "/categories", &handleCreateCategory); + a.map(HttpMethod.PUT, PROFILE_PATH ~ "/categories/:categoryId:ulong", &handleUpdateCategory); + 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/:transactionId:ulong", &handleGetTransaction); a.map(HttpMethod.POST, PROFILE_PATH ~ "/transactions", &handleAddTransaction); a.map(HttpMethod.PUT, PROFILE_PATH ~ "/transactions/:transactionId:ulong", &handleUpdateTransaction); a.map(HttpMethod.DELETE, PROFILE_PATH ~ "/transactions/:transactionId:ulong", &handleDeleteTransaction); + + a.map(HttpMethod.GET, PROFILE_PATH ~ "/transaction-tags", &handleGetAllTags); import data_api; // Various other data endpoints: diff --git a/finnow-api/source/transaction/api.d b/finnow-api/source/transaction/api.d index 489f4a5..ff8142a 100644 --- a/finnow-api/source/transaction/api.d +++ b/finnow-api/source/transaction/api.d @@ -39,16 +39,32 @@ void handleGetTransaction(ref ServerHttpRequest request, ref ServerHttpResponse void handleAddTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) { ProfileDataSource ds = getProfileDataSource(request); auto payload = readJsonBodyAs!AddTransactionPayload(request); - addTransaction(ds, payload); + TransactionDetail txn = addTransaction(ds, payload); + import asdf : serializeToJson; + string jsonStr = serializeToJson(txn); + response.writeBodyString(jsonStr, "application/json"); } void handleUpdateTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) { - // TODO + ProfileDataSource ds = getProfileDataSource(request); + ulong txnId = getTransactionIdOrThrow(request); + auto payload = readJsonBodyAs!AddTransactionPayload(request); + TransactionDetail txn = updateTransaction(ds, txnId, payload); + import asdf : serializeToJson; + string jsonStr = serializeToJson(txn); + response.writeBodyString(jsonStr, "application/json"); } void handleDeleteTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) { ProfileDataSource ds = getProfileDataSource(request); - // TODO + ulong txnId = getTransactionIdOrThrow(request); + deleteTransaction(ds, txnId); +} + +void handleGetAllTags(ref ServerHttpRequest request, ref ServerHttpResponse response) { + ProfileDataSource ds = getProfileDataSource(request); + string[] tags = ds.getTransactionTagRepository().findAll(); + writeJsonBody(response, tags); } private ulong getTransactionIdOrThrow(in ServerHttpRequest request) { @@ -98,3 +114,24 @@ private ulong getVendorId(in ServerHttpRequest request) { } // Categories API + +void handleGetCategories(ref ServerHttpRequest request, ref ServerHttpResponse response) { + TransactionCategoryTree[] categories = getCategories(getProfileDataSource(request)); + writeJsonBody(response, categories); +} + +void handleGetCategory(ref ServerHttpRequest request, ref ServerHttpResponse response) { + // TODO +} + +void handleCreateCategory(ref ServerHttpRequest request, ref ServerHttpResponse response) { + // TODO +} + +void handleUpdateCategory(ref ServerHttpRequest request, ref ServerHttpResponse response) { + // TODO +} + +void handleDeleteCategory(ref ServerHttpRequest request, ref ServerHttpResponse response) { + // TODO +} diff --git a/finnow-api/source/transaction/data.d b/finnow-api/source/transaction/data.d index be2a4f0..f7dba74 100644 --- a/finnow-api/source/transaction/data.d +++ b/finnow-api/source/transaction/data.d @@ -21,6 +21,7 @@ interface TransactionVendorRepository { interface TransactionCategoryRepository { Optional!TransactionCategory findById(ulong id); bool existsById(ulong id); + TransactionCategory[] findAll(); TransactionCategory[] findAllByParentId(Optional!ulong parentId); TransactionCategory insert(Optional!ulong parentId, string name, string description, string color); void deleteById(ulong id); @@ -37,5 +38,6 @@ interface TransactionRepository { Page!TransactionsListItem findAll(PageRequest pr); Optional!TransactionDetail findById(ulong id); TransactionDetail insert(in AddTransactionPayload data); + TransactionDetail update(ulong transactionId, 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 2441b31..3055087 100644 --- a/finnow-api/source/transaction/data_impl_sqlite.d +++ b/finnow-api/source/transaction/data_impl_sqlite.d @@ -85,6 +85,14 @@ class SqliteTransactionCategoryRepository : TransactionCategoryRepository { return util.sqlite.exists(db, "SELECT id FROM transaction_category WHERE id = ?", id); } + TransactionCategory[] findAll() { + return util.sqlite.findAll( + db, + "SELECT * FROM transaction_category ORDER BY parent_id, name", + &parseCategory + ); + } + TransactionCategory[] findAllByParentId(Optional!ulong parentId) { if (parentId) { return util.sqlite.findAll( @@ -346,7 +354,7 @@ class SqliteTransactionRepository : TransactionRepository { db, import("sql/insert_transaction.sql"), data.timestamp, - Clock.currTime(UTC()), + Clock.currTime(UTC()).toISOExtString(), data.amount, data.currencyCode, data.description, @@ -354,19 +362,29 @@ class SqliteTransactionRepository : TransactionRepository { data.categoryId ); 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 - ); - } + insertLineItems(transactionId, data); + return findById(transactionId).orElseThrow(); + } + + TransactionDetail update(ulong transactionId, in AddTransactionPayload data) { + util.sqlite.update( + db, + import("sql/update_transaction.sql"), + data.timestamp, + data.amount, + data.currencyCode, + data.description, + data.vendorId, + data.categoryId, + transactionId + ); + // Re-write all line items: + util.sqlite.update( + db, + "DELETE FROM transaction_line_item WHERE transaction_id = ?", + transactionId + ); + insertLineItems(transactionId, data); return findById(transactionId).orElseThrow(); } @@ -387,4 +405,19 @@ class SqliteTransactionRepository : TransactionRepository { toOptional(row.peek!(Nullable!ulong)(7)) ); } + + private void insertLineItems(ulong transactionId, in AddTransactionPayload data) { + 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 + ); + } + } } diff --git a/finnow-api/source/transaction/dto.d b/finnow-api/source/transaction/dto.d index d2cb5a3..e28ac6f 100644 --- a/finnow-api/source/transaction/dto.d +++ b/finnow-api/source/transaction/dto.d @@ -109,3 +109,15 @@ struct AddTransactionPayload { Nullable!ulong categoryId; } } + +/// Structure for depicting an entire hierarchical tree structure of categories. +struct TransactionCategoryTree { + ulong id; + @serdeTransformOut!serializeOptional + Optional!ulong parentId; + string name; + string description; + string color; + TransactionCategoryTree[] children; + uint depth; +} diff --git a/finnow-api/source/transaction/service.d b/finnow-api/source/transaction/service.d index 41c75f5..7be5edc 100644 --- a/finnow-api/source/transaction/service.d +++ b/finnow-api/source/transaction/service.d @@ -12,6 +12,7 @@ import account.model; import account.data; import util.money; import util.pagination; +import core.internal.container.common; // Transactions Services @@ -31,7 +32,114 @@ TransactionDetail addTransaction(ProfileDataSource ds, in AddTransactionPayload TransactionCategoryRepository categoryRepo = ds.getTransactionCategoryRepository(); AccountRepository accountRepo = ds.getAccountRepository(); - // Validate transaction details: + validateTransactionPayload(vendorRepo, categoryRepo, accountRepo, payload); + SysTime timestamp = SysTime.fromISOExtString(payload.timestamp, UTC()); + + // 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(); +} + +TransactionDetail updateTransaction(ProfileDataSource ds, ulong transactionId, in AddTransactionPayload payload) { + TransactionVendorRepository vendorRepo = ds.getTransactionVendorRepository(); + TransactionCategoryRepository categoryRepo = ds.getTransactionCategoryRepository(); + AccountRepository accountRepo = ds.getAccountRepository(); + TransactionRepository transactionRepo = ds.getTransactionRepository(); + AccountJournalEntryRepository jeRepo = ds.getAccountJournalEntryRepository(); + TransactionTagRepository tagRepo = ds.getTransactionTagRepository(); + + validateTransactionPayload(vendorRepo, categoryRepo, accountRepo, payload); + SysTime timestamp = SysTime.fromISOExtString(payload.timestamp, UTC()); + + const TransactionDetail prev = transactionRepo.findById(transactionId) + .orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND)); + + // Update the transaction: + ds.doTransaction(() { + TransactionDetail curr = transactionRepo.update(transactionId, payload); + bool amountOrCurrencyChanged = prev.amount != curr.amount || prev.currency.code != curr.currency.code; + bool updateCreditEntry = amountOrCurrencyChanged || ( + prev.creditedAccount != curr.creditedAccount + ); + bool updateDebitEntry = amountOrCurrencyChanged || ( + prev.debitedAccount != curr.debitedAccount + ); + + // Update journal entries if necessary: + if (updateCreditEntry && !prev.creditedAccount.isNull) { + jeRepo.deleteByAccountIdAndTransactionId(prev.creditedAccount.get.id, transactionId); + } + if (updateCreditEntry && !curr.creditedAccount.isNull) { + jeRepo.insert( + timestamp, + curr.creditedAccount.get.id, + transactionId, + curr.amount, + AccountJournalEntryType.CREDIT, + curr.currency + ); + } + if (updateDebitEntry && !prev.debitedAccount.isNull) { + jeRepo.deleteByAccountIdAndTransactionId(prev.debitedAccount.get.id, transactionId); + } + if (updateDebitEntry && !curr.debitedAccount.isNull) { + jeRepo.insert( + timestamp, + curr.debitedAccount.get.id, + transactionId, + curr.amount, + AccountJournalEntryType.DEBIT, + curr.currency + ); + } + + // Update tags. + tagRepo.updateTags(transactionId, payload.tags); + }); + return transactionRepo.findById(transactionId).orElseThrow(); +} + +void deleteTransaction(ProfileDataSource ds, ulong transactionId) { + TransactionRepository txnRepo = ds.getTransactionRepository(); + TransactionDetail txn = txnRepo.findById(transactionId) + .orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND)); + txnRepo.deleteById(txn.id); +} + +private void validateTransactionPayload( + TransactionVendorRepository vendorRepo, + TransactionCategoryRepository categoryRepo, + AccountRepository accountRepo, + in AddTransactionPayload payload +) { if (payload.creditedAccountId.isNull && payload.debitedAccountId.isNull) { throw new HttpStatusException(HttpStatus.BAD_REQUEST, "At least one account must be linked."); } @@ -46,7 +154,12 @@ TransactionDetail addTransaction(ProfileDataSource ds, in AddTransactionPayload throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Amount should be greater than 0."); } SysTime now = Clock.currTime(UTC()); - SysTime timestamp = SysTime.fromISOExtString(payload.timestamp, UTC()); + SysTime timestamp; + try { + timestamp = SysTime.fromISOExtString(payload.timestamp, UTC()); + } catch (TimeException e) { + throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid timestamp format. Expected ISO-8601 datetime."); + } if (timestamp > now) { throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Cannot create transaction in the future."); } @@ -89,38 +202,6 @@ TransactionDetail addTransaction(ProfileDataSource ds, in AddTransactionPayload ); } } - - // 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 @@ -158,3 +239,31 @@ TransactionVendor updateVendor(ProfileDataSource ds, ulong vendorId, in VendorPa void deleteVendor(ProfileDataSource ds, ulong vendorId) { ds.getTransactionVendorRepository().deleteById(vendorId); } + +// Categories Services + +TransactionCategoryTree[] getCategories(ProfileDataSource ds) { + TransactionCategoryRepository repo = ds.getTransactionCategoryRepository(); + return getCategoriesRecursive(repo, Optional!ulong.empty, 0); +} + +private TransactionCategoryTree[] getCategoriesRecursive( + TransactionCategoryRepository repo, + Optional!ulong parentId, + uint depth +) { + import util.data : toNullable; + TransactionCategoryTree[] nodes; + foreach (category; repo.findAllByParentId(parentId)) { + nodes ~= TransactionCategoryTree( + category.id, + parentId, + category.name, + category.description, + category.color, + getCategoriesRecursive(repo, Optional!ulong.of(category.id), depth + 1), + depth + ); + } + return nodes; +} diff --git a/finnow-api/source/util/sample_data.d b/finnow-api/source/util/sample_data.d index 5ae8588..bce85d3 100644 --- a/finnow-api/source/util/sample_data.d +++ b/finnow-api/source/util/sample_data.d @@ -16,6 +16,9 @@ import std.conv; import std.array; import std.datetime; import std.typecons; +import std.file; +import std.algorithm; +import std.csv; void generateSampleData() { UserRepository userRepo = new FileSystemUserRepository; @@ -63,21 +66,42 @@ void generateRandomProfile(int idx, ProfileRepository profileRepo) { generateRandomAccount(i, ds, preferredCurrency); } + ds.doTransaction(() { + generateVendors(ds); + generateCategories(ds.getTransactionCategoryRepository(), Optional!ulong.empty); + }); + generateRandomTransactions(ds); +} + +void generateVendors(ProfileDataSource ds) { 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."); + string vendorsCsv = readText("sample-data/vendors.csv"); + uint vendorCount = 0; + foreach (record; csvReader!(Tuple!(string, string))(vendorsCsv)) { + vendorRepo.insert(record[0], record[1]); + vendorCount++; } infoF!" Generated %d random vendors."(vendorCount); +} - auto categoryRepo = ds.getTransactionCategoryRepository(); - const int categoryCount = uniform(5, 30); +void generateCategories(TransactionCategoryRepository repo, Optional!ulong parentId, size_t depth = 0) { + const int categoryCount = uniform(5, 10); for (int i = 0; i < categoryCount; i++) { - categoryRepo.insert(Optional!ulong.empty, "Test Category " ~ to!string(i), "Testing category.", "FFFFFF"); + string name = "Test Category " ~ to!string(i); + if (parentId) { + name ~= " (child of " ~ parentId.value.to!string ~ ")"; + } + TransactionCategory category = repo.insert( + parentId, + name, + "Testing category.", + "FFFFFF" + ); + infoF!" Generating child categories for %d, depth = %d"(i, depth); + if (depth < 2) { + generateCategories(repo, Optional!ulong.of(category.id), depth + 1); + } } - infoF!" Generated %d random categories."(categoryCount); - - generateRandomTransactions(ds); } void generateRandomAccount(int idx, ProfileDataSource ds, Currency preferredCurrency) { diff --git a/finnow-api/sql/schema.sql b/finnow-api/sql/schema.sql index c969e97..446a7ea 100644 --- a/finnow-api/sql/schema.sql +++ b/finnow-api/sql/schema.sql @@ -56,12 +56,6 @@ CREATE TABLE transaction_category ( ON UPDATE CASCADE ON DELETE CASCADE ); -CREATE TABLE transaction_tag ( - transaction_id INTEGER NOT NULL, - tag TEXT NOT NULL, - CONSTRAINT pk_transaction_tag PRIMARY KEY (transaction_id, tag) -); - CREATE TABLE "transaction" ( id INTEGER PRIMARY KEY, timestamp TEXT NOT NULL, @@ -80,6 +74,15 @@ CREATE TABLE "transaction" ( ); CREATE INDEX idx_transaction_by_timestamp ON "transaction"(timestamp); +CREATE TABLE transaction_tag ( + transaction_id INTEGER NOT NULL, + tag TEXT NOT NULL, + CONSTRAINT pk_transaction_tag PRIMARY KEY (transaction_id, tag), + CONSTRAINT fk_transaction_tag_transaction + FOREIGN KEY (transaction_id) REFERENCES "transaction"(id) + ON UPDATE CASCADE ON DELETE CASCADE +); + CREATE TABLE transaction_attachment ( transaction_id INTEGER NOT NULL, attachment_id INTEGER NOT NULL, @@ -121,7 +124,9 @@ CREATE TABLE account_journal_entry ( ON UPDATE CASCADE ON DELETE CASCADE, CONSTRAINT fk_account_journal_entry_transaction FOREIGN KEY (transaction_id) REFERENCES "transaction"(id) - ON UPDATE CASCADE ON DELETE CASCADE + ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT uq_account_journal_entry_ids + UNIQUE (account_id, transaction_id) ); -- Value records diff --git a/finnow-api/sql/update_transaction.sql b/finnow-api/sql/update_transaction.sql new file mode 100644 index 0000000..cabc56e --- /dev/null +++ b/finnow-api/sql/update_transaction.sql @@ -0,0 +1,9 @@ +UPDATE "transaction" +SET + timestamp = ?, + amount = ?, + currency = ?, + description = ?, + vendor_id = ?, + category_id = ? +WHERE id = ? \ No newline at end of file diff --git a/web-app/src/api/transaction.ts b/web-app/src/api/transaction.ts index e32fab6..1391a1d 100644 --- a/web-app/src/api/transaction.ts +++ b/web-app/src/api/transaction.ts @@ -22,6 +22,16 @@ export interface TransactionCategory { color: string } +export interface TransactionCategoryTree { + id: number + parentId: number | null + name: string + description: string + color: string + children: TransactionCategoryTree[] + depth: number +} + export interface Transaction { id: number timestamp: string @@ -90,10 +100,30 @@ export interface TransactionDetailLineItem { idx: number valuePerItem: number quantity: number - description: number + description: string category: TransactionCategory | null } +export interface AddTransactionPayload { + timestamp: string + amount: number + currencyCode: string + description: string + vendorId: number | null + categoryId: number | null + creditedAccountId: number | null + debitedAccountId: number | null + tags: string[] + lineItems: AddTransactionPayloadLineItem[] +} + +export interface AddTransactionPayloadLineItem { + valuePerItem: number + quantity: number + description: string + categoryId: number | null +} + export class TransactionApiClient extends ApiClient { readonly path: string @@ -122,6 +152,10 @@ export class TransactionApiClient extends ApiClient { return await super.delete(this.path + '/vendors/' + id) } + async getCategories(): Promise { + return await super.getJson(this.path + '/categories') + } + async getTransactions( paginationOptions: PageRequest | undefined = undefined, ): Promise> { @@ -131,4 +165,20 @@ export class TransactionApiClient extends ApiClient { async getTransaction(id: number): Promise { return await super.getJson(this.path + '/transactions/' + id) } + + async addTransaction(data: AddTransactionPayload): Promise { + return await super.postJson(this.path + '/transactions', data) + } + + async updateTransaction(id: number, data: AddTransactionPayload): Promise { + return await super.putJson(this.path + '/transactions/' + id, data) + } + + async deleteTransaction(id: number): Promise { + return await super.delete(this.path + '/transactions/' + id) + } + + async getAllTags(): Promise { + return await super.getJson(this.path + '/transaction-tags') + } } diff --git a/web-app/src/components/AppButton.vue b/web-app/src/components/AppButton.vue index 249ae72..a2f412b 100644 --- a/web-app/src/components/AppButton.vue +++ b/web-app/src/components/AppButton.vue @@ -9,8 +9,9 @@ defineProps<{ defineEmits(['click']) + diff --git a/web-app/src/router/index.ts b/web-app/src/router/index.ts index 145a056..de40def 100644 --- a/web-app/src/router/index.ts +++ b/web-app/src/router/index.ts @@ -58,6 +58,16 @@ const router = createRouter({ component: () => import('@/pages/TransactionPage.vue'), meta: { title: 'Transaction' }, }, + { + path: 'transactions/:id/edit', + component: () => import('@/pages/forms/EditTransactionPage.vue'), + meta: { title: 'Edit Transaction' }, + }, + { + path: 'add-transaction', + component: () => import('@/pages/forms/EditTransactionPage.vue'), + meta: { title: 'Add Transaction' }, + }, ], }, ],