From d165ac0753097a4c3645f3d03e6d0be4d0321bd2 Mon Sep 17 00:00:00 2001 From: andrewlalis Date: Mon, 15 Jun 2026 18:44:23 -0400 Subject: [PATCH 01/18] Refactored API to use optional globally, prepped draft stuff. --- finnow-api/dub.selections.json | 8 +- finnow-api/source/account/dto.d | 12 +- finnow-api/source/analytics/data.d | 2 - finnow-api/source/api_mapping.d | 2 +- finnow-api/source/profile/data.d | 4 + finnow-api/source/profile/data_impl_sqlite.d | 13 +- finnow-api/source/transaction/api.d | 36 +- finnow-api/source/transaction/data.d | 10 + .../source/transaction/data_impl_sqlite.d | 384 ++++++++++++------ finnow-api/source/transaction/dto.d | 156 +++---- finnow-api/source/transaction/model.d | 34 +- finnow-api/source/transaction/service.d | 91 +++-- finnow-api/source/util/data.d | 7 - finnow-api/source/util/sample_data.d | 22 +- finnow-api/source/util/sqlite.d | 33 ++ finnow-api/sql/migrations/2.sql | 74 ++++ finnow-api/sql/{ => query}/get_line_items.sql | 2 + .../sql/{ => query}/get_transaction.sql | 0 finnow-api/sql/query/get_transactions.sql | 38 ++ finnow-api/sql/schema.sql | 84 +++- 20 files changed, 721 insertions(+), 291 deletions(-) create mode 100644 finnow-api/sql/migrations/2.sql rename finnow-api/sql/{ => query}/get_line_items.sql (94%) rename finnow-api/sql/{ => query}/get_transaction.sql (100%) create mode 100644 finnow-api/sql/query/get_transactions.sql diff --git a/finnow-api/dub.selections.json b/finnow-api/dub.selections.json index a584455..c8b98c1 100644 --- a/finnow-api/dub.selections.json +++ b/finnow-api/dub.selections.json @@ -4,10 +4,10 @@ "asdf": "0.8.0", "d2sqlite3": "1.0.0", "dxml": "0.4.5", - "handy-http-data": "1.3.0", + "handy-http-data": "1.3.2", "handy-http-handlers": "1.3.0", - "handy-http-primitives": "1.8.1", - "handy-http-starter": "1.7.0", + "handy-http-primitives": "1.10.0", + "handy-http-starter": "1.7.1", "handy-http-transport": "1.10.1", "handy-http-websockets": "1.2.0", "jwt4d": "0.0.2", @@ -15,7 +15,7 @@ "mir-core": "1.7.4", "openssl": "3.3.4", "path-matcher": "1.2.0", - "photon": "0.18.11", + "photon": "0.18.12", "scheduled": "1.4.0", "secured": "3.0.0", "sharded-map": "2.7.0", diff --git a/finnow-api/source/account/dto.d b/finnow-api/source/account/dto.d index 445e60b..5871e44 100644 --- a/finnow-api/source/account/dto.d +++ b/finnow-api/source/account/dto.d @@ -5,12 +5,9 @@ import account.model; import attachment.data; import attachment.dto; import util.money; -import util.data : serializeOptional; /// The data the API provides for an Account entity. struct AccountResponse { - import asdf : serdeTransformOut; - ulong id; string createdAt; bool archived; @@ -19,7 +16,6 @@ struct AccountResponse { string name; Currency currency; string description; - @serdeTransformOut!serializeOptional Optional!long currentBalance; static AccountResponse of(in Account account, Optional!long currentBalance) { @@ -37,6 +33,14 @@ struct AccountResponse { } } +/// A limited set of account data, usually for use in other responses. +struct SimpleAccountResponse { + ulong id; + string name; + string type; + string numberSuffix; +} + // The data provided by a user to create a new account. struct AccountCreationPayload { string type; diff --git a/finnow-api/source/analytics/data.d b/finnow-api/source/analytics/data.d index 6020b29..ba83e1b 100644 --- a/finnow-api/source/analytics/data.d +++ b/finnow-api/source/analytics/data.d @@ -2,7 +2,6 @@ module analytics.data; import std.datetime; import handy_http_primitives : Optional; -import asdf : serdeTransformOut; import util.money; import util.data; @@ -36,7 +35,6 @@ struct CategorySpendData { ulong categoryId; string categoryName; string categoryColor; - @serdeTransformOut!serializeOptional Optional!ulong parentCategoryId; long amount; } diff --git a/finnow-api/source/api_mapping.d b/finnow-api/source/api_mapping.d index 1fc4ccf..3e43b6b 100644 --- a/finnow-api/source/api_mapping.d +++ b/finnow-api/source/api_mapping.d @@ -25,7 +25,7 @@ HttpRequestHandler mapApiHandlers(string webOrigin) { publicHandler.registerHandlers!(auth.api_public); // Dev endpoint for sample data: REMOVE BEFORE DEPLOYING!!! - // h.map(HttpMethod.POST, "/sample-data", &sampleDataEndpoint); + publicHandler.addMapping(HttpMethod.POST, "/api/sample-data", HttpRequestHandler.of(&sampleDataEndpoint)); // Authenticated endpoints: import auth.api; diff --git a/finnow-api/source/profile/data.d b/finnow-api/source/profile/data.d index 6186fc0..c065cf6 100644 --- a/finnow-api/source/profile/data.d +++ b/finnow-api/source/profile/data.d @@ -53,6 +53,7 @@ interface ProfileDataSource { TransactionCategoryRepository getTransactionCategoryRepository(); TransactionTagRepository getTransactionTagRepository(); TransactionRepository getTransactionRepository(); + TransactionDraftRepository getTransactionDraftRepository(); AnalyticsRepository getAnalyticsRepository(); @@ -93,6 +94,9 @@ version(unittest) { TransactionRepository getTransactionRepository() { throw new Exception("Not implemented"); } + TransactionDraftRepository getTransactionDraftRepository() { + throw new Exception("Not implemented"); + } AnalyticsRepository getAnalyticsRepository() { throw new Exception("Not implemented"); } diff --git a/finnow-api/source/profile/data_impl_sqlite.d b/finnow-api/source/profile/data_impl_sqlite.d index 07220de..fcd0ecb 100644 --- a/finnow-api/source/profile/data_impl_sqlite.d +++ b/finnow-api/source/profile/data_impl_sqlite.d @@ -185,7 +185,7 @@ class SqlitePropertiesRepository : PropertiesRepository { } private const SCHEMA = import("sql/schema.sql"); -private const uint SCHEMA_VERSION = 1; +private const uint SCHEMA_VERSION = 2; private const SCHEMA_VERSION_PROPERTY = "database-schema-version"; /** @@ -215,6 +215,7 @@ class SqliteProfileDataSource : ProfileDataSource { TransactionCategoryRepository transactionCategoryRepo; TransactionTagRepository transactionTagRepo; TransactionRepository transactionRepo; + TransactionDraftRepository transactionDraftRepo; AnalyticsRepository analyticsRepo; this(string path) { @@ -297,6 +298,13 @@ class SqliteProfileDataSource : ProfileDataSource { return transactionRepo; } + TransactionDraftRepository getTransactionDraftRepository() { + if (transactionDraftRepo is null) { + transactionDraftRepo = new SqliteTransactionDraftRepository(db); + } + return transactionDraftRepo; + } + AnalyticsRepository getAnalyticsRepository() { if (analyticsRepo is null) { analyticsRepo = new SqliteAnalyticsRepository(db); @@ -322,7 +330,8 @@ class SqliteProfileDataSource : ProfileDataSource { if (currentVersion == SCHEMA_VERSION) return; static const migrations = [ - import("sql/migrations/1.sql") + import("sql/migrations/1.sql"), + import("sql/migrations/2.sql") ]; static if (migrations.length != SCHEMA_VERSION) { static assert(false, "Schema version doesn't match the list of defined migrations."); diff --git a/finnow-api/source/transaction/api.d b/finnow-api/source/transaction/api.d index b6d4cbd..f5bd528 100644 --- a/finnow-api/source/transaction/api.d +++ b/finnow-api/source/transaction/api.d @@ -185,7 +185,7 @@ struct CategoryPayload { string name; string description; string color; - Nullable!ulong parentId; + Optional!ulong parentId; } @PostMapping(PROFILE_PATH ~ "/categories") @@ -215,3 +215,37 @@ void handleDeleteCategory(ref ServerHttpRequest request, ref ServerHttpResponse private ulong getCategoryId(in ServerHttpRequest request) { return getPathParamOrThrow!ulong(request, "categoryId"); } + +// Drafts & Templates + +immutable DEFAULT_DRAFT_PAGE = PageRequest(1, 10, [Sort("txn.id", SortDir.DESC)]); + +@GetMapping(PROFILE_PATH ~ "/transaction-drafts") +void handleGetDrafts(ref ServerHttpRequest request, ref ServerHttpResponse response) { + ProfileDataSource ds = getProfileDataSource(request); + PageRequest pr = PageRequest.parse(request, DEFAULT_DRAFT_PAGE); + Page!TransactionDraftListItem page = getDrafts(ds, pr); + writeJsonBody(response, page); +} + +@GetMapping(PROFILE_PATH ~ "/transaction-templates") +void handleGetTemplates(ref ServerHttpRequest request, ref ServerHttpResponse response) { + ProfileDataSource ds = getProfileDataSource(request); + PageRequest pr = PageRequest.parse(request, DEFAULT_DRAFT_PAGE); + Page!TransactionDraftListItem page = getTemplates(ds, pr); + writeJsonBody(response, page); +} + +@GetMapping(PROFILE_PATH ~ "/transaction-drafts/:draftId:ulong") +@GetMapping(PROFILE_PATH ~ "/transaction-templates/:draftId:ulong") +void handleGetDraft(ref ServerHttpRequest request, ref ServerHttpResponse response) { + ProfileDataSource ds = getProfileDataSource(request); + TransactionDraftResponse draft = getDraft(ds, getDraftId(request)); + import asdf : serializeToJson; + string jsonStr = serializeToJson(draft); + response.writeBodyString(jsonStr, "application/json"); +} + +private ulong getDraftId(in ServerHttpRequest request) { + return getPathParamOrThrow!ulong(request, "draftId"); +} diff --git a/finnow-api/source/transaction/data.d b/finnow-api/source/transaction/data.d index a6c38c8..8191134 100644 --- a/finnow-api/source/transaction/data.d +++ b/finnow-api/source/transaction/data.d @@ -52,3 +52,13 @@ interface TransactionRepository { TransactionDetail update(ulong transactionId, in AddTransactionPayload data); void deleteById(ulong id); } + +interface TransactionDraftRepository { + Page!TransactionDraftListItem findAllDrafts(in PageRequest pr); + Page!TransactionDraftListItem findAllTemplates(in PageRequest pr); + Optional!TransactionDraftResponse findById(ulong id); + TransactionDraftResponse insert(in TransactionDraftPayload data); + void linkAttachment(ulong draftId, ulong attachmentId); + TransactionDraftResponse update(ulong draftId, in TransactionDraftPayload 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 1991cbe..4dbffd0 100644 --- a/finnow-api/source/transaction/data_impl_sqlite.d +++ b/finnow-api/source/transaction/data_impl_sqlite.d @@ -1,6 +1,7 @@ module transaction.data_impl_sqlite; -import handy_http_primitives : Optional, StringMultiValueMap; +import handy_http_primitives : Optional, StringMultiValueMap, mapIfPresent, toOptional; +import util.data; import std.datetime; import std.typecons; import d2sqlite3; @@ -13,6 +14,8 @@ import util.money; import util.pagination; import util.data; import account.model; +import account.dto; +import attachment.dto; class SqliteTransactionVendorRepository : TransactionVendorRepository { private Database db; @@ -228,7 +231,7 @@ class SqliteTransactionCategoryRepository : TransactionCategoryRepository { import std.typecons; return TransactionCategory( row.peek!ulong(0), - toOptional(row.peek!(Nullable!ulong)(1)), + row.parseOptional!ulong(1), row.peek!string(2), row.peek!string(3), row.peek!string(4) @@ -283,12 +286,8 @@ class SqliteTransactionRepository : TransactionRepository { } Page!TransactionsListItem findAll(in PageRequest pr) { - const pageIdsQuery = "SELECT DISTINCT txn.id FROM \"transaction\" txn " ~ pr.toSql(); - QueryBuilder qb = getBuilderForTransactionsList(); - addSelectsForTransactionsList(qb); - qb.where("txn.id IN (" ~ pageIdsQuery ~ ")"); - string query = qb.build() ~ "\n" ~ pr.toOrderClause(); - TransactionsListItem[] results = util.sqlite.findAllDirect(db, query, &parseListItems); + string query = import("sql/query/get_transactions.sql") ~ "\n" ~ pr.toSql(); + TransactionsListItem[] results = util.sqlite.findAll(db, query, &parseListItem); ulong totalCount = util.sqlite.count(db, "SELECT COUNT(DISTINCT id) FROM \"transaction\""); return Page!(TransactionsListItem).of(results, pr, totalCount); } @@ -328,10 +327,10 @@ class SqliteTransactionRepository : TransactionRepository { qb.conditions = []; qb.argBinders = []; addSelectsForTransactionsList(qb); + qb.groupBy("txn.id"); qb.where("txn.id IN (" ~ idsStr ~ ")"); string query = qb.build() ~ "\n" ~ pr.toOrderClause(); - Statement stmt = db.prepare(query); - auto results = parseListItems(stmt.execute()); + TransactionsListItem[] results = util.sqlite.findAll(db, query, &parseListItem); return Page!TransactionsListItem.of(results, pr, count); } @@ -370,7 +369,7 @@ class SqliteTransactionRepository : TransactionRepository { Optional!TransactionDetail findById(ulong id) { Optional!TransactionDetail item = util.sqlite.findOne( db, - import("sql/get_transaction.sql"), + import("sql/query/get_transaction.sql"), (row) { TransactionDetail item; item.id = row.peek!ulong(0); @@ -383,43 +382,41 @@ class SqliteTransactionRepository : TransactionRepository { Nullable!ulong vendorId = row.peek!(Nullable!ulong)(7); if (!vendorId.isNull) { - item.vendor = Optional!(TransactionDetail.Vendor).of( - TransactionDetail.Vendor( - vendorId.get, - row.peek!string(8), - row.peek!string(9) - )).toNullable; + item.vendor = Optional!TransactionVendor.of(TransactionVendor( + vendorId.get, + row.peek!string(8), + row.peek!string(9) + )); } Nullable!ulong categoryId = row.peek!(Nullable!ulong)(10); if (!categoryId.isNull) { - item.category = Optional!(TransactionDetail.Category).of( - TransactionDetail.Category( - categoryId.get, - row.peek!(Nullable!ulong)(11), - row.peek!string(12), - row.peek!string(13), - row.peek!string(14) - )).toNullable; + item.category = Optional!TransactionCategory.of(TransactionCategory( + categoryId.get, + row.parseOptional!ulong(11), + row.peek!string(12), + row.peek!string(13), + row.peek!string(14) + )); } Nullable!ulong creditedAccountId = row.peek!(Nullable!ulong)(15); if (!creditedAccountId.isNull) { - item.creditedAccount = Optional!(TransactionDetail.Account).of( - TransactionDetail.Account( + item.creditedAccount = Optional!SimpleAccountResponse.of( + SimpleAccountResponse( creditedAccountId.get, row.peek!string(16), row.peek!string(17), row.peek!string(18) - )).toNullable; + )); } Nullable!ulong debitedAccountId = row.peek!(Nullable!ulong)(19); if (!debitedAccountId.isNull) { - item.debitedAccount = Optional!(TransactionDetail.Account).of( - TransactionDetail.Account( + item.debitedAccount = Optional!SimpleAccountResponse.of( + SimpleAccountResponse( debitedAccountId.get, row.peek!string(20), row.peek!string(21), row.peek!string(22) - )).toNullable; + )); } string tagsStr = row.peek!string(23); if (tagsStr !is null && tagsStr.length > 0) { @@ -435,23 +432,23 @@ class SqliteTransactionRepository : TransactionRepository { if (item.isNull) return item; item.value.lineItems = util.sqlite.findAll( db, - import("sql/get_line_items.sql"), + import("sql/query/get_line_items.sql"), (row) { - TransactionDetail.LineItem li; + TransactionLineItemResponse 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), + Optional!ulong categoryId = row.parseOptional!ulong(4); + if (categoryId) { + li.category = Optional!TransactionCategory.of( + TransactionCategory( + categoryId.value, + row.parseOptional!ulong(5), row.peek!string(6), row.peek!string(7), row.peek!string(8) - )).toNullable; + )); } return li; }, @@ -470,8 +467,8 @@ class SqliteTransactionRepository : TransactionRepository { data.currencyCode, data.description, data.internalTransfer, - data.vendorId, - data.categoryId + data.vendorId.toNullable(), + data.categoryId.toNullable() ); ulong transactionId = db.lastInsertRowid(); insertLineItems(transactionId, data); @@ -496,8 +493,8 @@ class SqliteTransactionRepository : TransactionRepository { data.currencyCode, data.description, data.internalTransfer, - data.vendorId, - data.categoryId, + data.vendorId.toNullable(), + data.categoryId.toNullable(), transactionId ); // Re-write all line items: @@ -521,97 +518,56 @@ class SqliteTransactionRepository : TransactionRepository { ); } - /** - * Function to parse a list of transaction list items as obtained from the - * `get_transactions.sql` query. Because there are possibly multiple rows - * per transaction, we have to deal with the result range as a whole. - * Params: - * r = The result range to read. - * Returns: The list of transaction list items. - */ - private static TransactionsListItem[] parseListItems(ResultRange r) { - import std.array : appender; - auto app = appender!(TransactionsListItem[]); + private static TransactionsListItem parseListItem(Row row) { TransactionsListItem item; - - /// Helper function that appends the current item to the list, and resets state. - void appendItem() { + 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); + item.internalTransfer = row.peek!bool(6); + Optional!ulong vendorId = row.parseOptional!ulong(7); + if (vendorId) { + item.vendor = SimpleVendorResponse( + vendorId.value, + row.peek!string(8) + ).toOptional; + } + Optional!ulong categoryId = row.parseOptional!ulong(9); + if (categoryId) { + item.category = SimpleCategoryResponse( + categoryId.value, + row.peek!string(10), + row.peek!string(11) + ).toOptional; + } + Optional!ulong creditedAccountId = row.parseOptional!ulong(12); + if (creditedAccountId) { + item.creditedAccount = SimpleAccountResponse( + creditedAccountId.value, + row.peek!string(13), + row.peek!string(14), + row.peek!string(15) + ).toOptional; + } + Optional!ulong debitedAccountId = row.parseOptional!ulong(16); + if (debitedAccountId) { + item.debitedAccount = SimpleAccountResponse( + debitedAccountId.value, + row.peek!string(17), + row.peek!string(18), + row.peek!string(19) + ).toOptional; + } + string aggregateTags = row.peek!string(20); + if (aggregateTags !is null) { + import std.string : split; import std.algorithm : sort; + item.tags = aggregateTags.split(","); sort(item.tags); - app ~= item; - item.id = 0; - item.tags = []; - item.vendor = Optional!(TransactionsListItem.Vendor).empty(); - item.category = Optional!(TransactionsListItem.Category).empty(); - item.creditedAccount = Optional!(TransactionsListItem.Account).empty(); - item.debitedAccount = Optional!(TransactionsListItem.Account).empty(); } - - size_t rowCount = 0; - foreach (Row row; r) { - rowCount++; - ulong txnId = row.peek!ulong(0); - if (item.id != txnId) { - // We're parsing a new item. First, append the current one if there is one. - if (item.id != 0) { - appendItem(); - } - - item.id = txnId; - item.timestamp = row.peek!string(1); - item.addedAt = row.peek!string(2); - item.amount = row.peek!ulong(3); - item.currency = Currency.ofCode(row.peek!(string, PeekMode.slice)(4)); - item.description = row.peek!string(5); - item.internalTransfer = row.peek!bool(6); - // Read the nullable Vendor information. - Nullable!ulong vendorId = row.peek!(Nullable!ulong)(7); - if (!vendorId.isNull) { - string vendorName = row.peek!string(8); - item.vendor = Optional!(TransactionsListItem.Vendor).of( - TransactionsListItem.Vendor(vendorId.get, vendorName)); - } - // Read the nullable Category information. - Nullable!ulong categoryId = row.peek!(Nullable!ulong)(9); - if (!categoryId.isNull) { - string categoryName = row.peek!string(10); - string categoryColor = row.peek!string(11); - item.category = Optional!(TransactionsListItem.Category).of( - TransactionsListItem.Category(categoryId.get, categoryName, categoryColor)); - } - // Read the nullable creditedAccount. - Nullable!ulong creditedAccountId = row.peek!(Nullable!ulong)(12); - if (!creditedAccountId.isNull) { - ulong id = creditedAccountId.get; - string name = row.peek!string(13); - string type = row.peek!string(14); - string suffix = row.peek!string(15); - item.creditedAccount = Optional!(TransactionsListItem.Account).of( - TransactionsListItem.Account(id, name, type, suffix)); - } - // Read the nullable debitedAccount. - Nullable!ulong debitedAccountId = row.peek!(Nullable!ulong)(16); - if (!debitedAccountId.isNull) { - ulong id = debitedAccountId.get; - string name = row.peek!string(17); - string type = row.peek!string(18); - string suffix = row.peek!string(19); - item.debitedAccount = Optional!(TransactionsListItem.Account).of( - TransactionsListItem.Account(id, name, type, suffix)); - } - } - - // Read multi-row properties, like tags, to the current item. - string tag = row.peek!string(20); - if (tag !is null) { - item.tags ~= tag; - } - } - // If there's one last cached item, append it to the list. - if (item.id != 0) { - appendItem(); - } - return app[]; + return item; } private void insertLineItems(ulong transactionId, in AddTransactionPayload data) { @@ -624,7 +580,7 @@ class SqliteTransactionRepository : TransactionRepository { lineItem.valuePerItem, lineItem.quantity, lineItem.description, - lineItem.categoryId + lineItem.categoryId.toNullable() ); } } @@ -664,6 +620,168 @@ class SqliteTransactionRepository : TransactionRepository { .select("account_debit.name") .select("account_debit.type") .select("account_debit.number_suffix") - .select("tags.tag"); + .select("GROUP_CONCAT(tags.tag)"); + } +} + +class SqliteTransactionDraftRepository : TransactionDraftRepository { + private Database db; + this(Database db) { + this.db = db; + } + + Page!TransactionDraftListItem findAllDrafts(in PageRequest pr) { + return findAllInternal(pr, DraftType.DRAFT); + } + + Page!TransactionDraftListItem findAllTemplates(in PageRequest pr) { + return findAllInternal(pr, DraftType.TEMPLATE); + } + + private static enum DraftType { + DRAFT, + TEMPLATE + } + + private Page!TransactionDraftListItem findAllInternal(in PageRequest pr, DraftType type) { + QueryBuilder qb = getBuilderForDraftsList(); + addSelectsForDraftsList(qb); + qb.groupBy("draft.id"); + if (type == DraftType.DRAFT) { + qb.where("template_name IS NULL"); + } else { + qb.where("template_name IS NOT NULL"); + } + string query = qb.build() ~ "\n" ~ pr.toSql(); + TransactionDraftListItem[] results = util.sqlite.findAll(db, query, &parseDraftListItem); + ulong totalCount = util.sqlite.count(db, "SELECT COUNT(DISTINCT id) FROM transaction_draft"); + return Page!(TransactionDraftListItem).of(results, pr, totalCount); + } + + Optional!TransactionDraftResponse findById(ulong id) { + QueryBuilder qb = getBuilderForDraftsList(); + addSelectsForDraftsList(qb); + qb.where("draft.id = ?"); + string query = qb.build(); + // return util.sqlite.findOne(db, query, &parseDraft, id); + // TODO! + return Optional!TransactionDraftResponse.empty(); + } + + TransactionDraftResponse insert(in TransactionDraftPayload data) { + // TODO + return TransactionDraftResponse.init; + } + + void linkAttachment(ulong draftId, ulong attachmentId) { + util.sqlite.update( + db, + "INSERT INTO transaction_draft_attachment (draft_id, attachment_id) VALUES (?, ?)", + draftId, + attachmentId + ); + } + + TransactionDraftResponse update(ulong draftId, in TransactionDraftPayload data) { + // TODO + return TransactionDraftResponse.init; + } + + void deleteById(ulong id) { + util.sqlite.update( + db, + "DELETE FROM attachment WHERE id IN ( + SELECT attachment_id FROM transaction_draft_attachment WHERE draft_id = ? + )", + id + ); + util.sqlite.deleteById(db, "transaction_draft", id); + } + + private QueryBuilder getBuilderForDraftsList() { + return QueryBuilder("transaction_draft draft") + .join("LEFT JOIN transaction_vendor vendor ON vendor.id = draft.vendor_id") + .join("LEFT JOIN transaction_category category ON category.id = draft.category_id") + .join("LEFT JOIN account account_credit ON account_credit.id = draft.credited_account_id") + .join("LEFT JOIN account account_debit ON account_debit.id = draft.debited_account_id") + .join("LEFT JOIN transaction_draft_tag tags ON tags.draft_id = draft.id"); + } + + private void addSelectsForDraftsList(ref QueryBuilder qb) { + qb + .select("draft.id") + .select("draft.added_at") + .select("draft.template_name") + .select("draft.timestamp") + .select("draft.amount")// 5 + .select("draft.currency") + .select("draft.description") + .select("draft.internal_transfer") + .select("vendor.id") + .select("vendor.name")// 10 + .select("category.id") + .select("category.name") + .select("category.color") + .select("account_credit.id") + .select("account_credit.name")// 15 + .select("account_credit.type") + .select("account_credit.number_suffix") + .select("account_debit.id") + .select("account_debit.name") + .select("account_debit.type")// 20 + .select("account_debit.number_suffix") + .select("string_agg(tags.tag, ',' ORDER BY tags.tag ASC)"); + } + + private static TransactionDraftListItem parseDraftListItem(Row row) { + TransactionDraftListItem item; + item.id = row.peek!ulong(0); + item.addedAt = row.peek!string(1); + item.templateName = row.parseOptional!string(2); + item.timestamp = row.parseOptional!string(3); + item.amount = row.parseOptional!ulong(4); + item.currency = row.parseOptional!(string, PeekMode.slice)(5) + .mapIfPresent!(s => Currency.ofCode(s)); + item.description = row.parseOptional!string(6); + item.internalTransfer = row.parseOptional!bool(7); + Optional!ulong vendorId = row.parseOptional!ulong(8); + if (vendorId) { + item.vendor = SimpleVendorResponse( + vendorId.value, + row.peek!string(9) + ).toOptional; + } + Optional!ulong categoryId = row.parseOptional!ulong(10); + if (categoryId) { + item.category = SimpleCategoryResponse( + categoryId.value, + row.peek!string(11), + row.peek!string(12), + ).toOptional; + } + Optional!ulong creditedAccountId = row.parseOptional!ulong(13); + if (creditedAccountId) { + item.creditedAccount = SimpleAccountResponse( + creditedAccountId.value, + row.peek!string(14), + row.peek!string(15), + row.peek!string(16) + ).toOptional; + } + Optional!ulong debitedAccountId = row.parseOptional!ulong(17); + if (debitedAccountId) { + item.debitedAccount = SimpleAccountResponse( + debitedAccountId.value, + row.peek!string(18), + row.peek!string(19), + row.peek!string(20) + ).toOptional; + } + string aggregateTags = row.peek!(string, PeekMode.slice)(21); + if (aggregateTags !is null) { + import std.string : split; + item.tags = aggregateTags.split(","); + } + return item; } } diff --git a/finnow-api/source/transaction/dto.d b/finnow-api/source/transaction/dto.d index 694626e..3533dd7 100644 --- a/finnow-api/source/transaction/dto.d +++ b/finnow-api/source/transaction/dto.d @@ -1,14 +1,36 @@ module transaction.dto; import handy_http_primitives : Optional; -import asdf : serdeTransformOut; import std.typecons; -import transaction.model : TransactionCategory; +import transaction.model : TransactionCategory, TransactionVendor; import attachment.dto; +import account.dto; import util.data; import util.money; +/// A simple selection of vendor data to be included in other responses. +struct SimpleVendorResponse { + ulong id; + string name; +} + +/// A simple selection of category data to be included in other responses. +struct SimpleCategoryResponse { + ulong id; + string name; + string color; +} + +/// Response data for a transaction's line item. +struct TransactionLineItemResponse { + uint idx; + long valuePerItem; + ulong quantity; + string description; + Optional!TransactionCategory category; +} + /// The transaction data provided when a list of transactions is requested. struct TransactionsListItem { ulong id; @@ -18,33 +40,11 @@ struct TransactionsListItem { Currency currency; string description; bool internalTransfer; - @serdeTransformOut!serializeOptional - Optional!Vendor vendor; - @serdeTransformOut!serializeOptional - Optional!Category category; - @serdeTransformOut!serializeOptional - Optional!Account creditedAccount; - @serdeTransformOut!serializeOptional - Optional!Account debitedAccount; + Optional!SimpleVendorResponse vendor; + Optional!SimpleCategoryResponse category; + Optional!SimpleAccountResponse creditedAccount; + Optional!SimpleAccountResponse 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. @@ -56,42 +56,13 @@ struct TransactionDetail { Currency currency; string description; bool internalTransfer; - Nullable!Vendor vendor; - Nullable!Category category; - Nullable!Account creditedAccount; - Nullable!Account debitedAccount; + Optional!TransactionVendor vendor; + Optional!TransactionCategory category; + Optional!SimpleAccountResponse creditedAccount; + Optional!SimpleAccountResponse debitedAccount; string[] tags; - LineItem[] lineItems; + TransactionLineItemResponse[] lineItems; AttachmentResponse[] attachments; - - 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. @@ -101,26 +72,25 @@ struct AddTransactionPayload { string currencyCode; string description; bool internalTransfer; - Nullable!ulong vendorId; - Nullable!ulong categoryId; - Nullable!ulong creditedAccountId; - Nullable!ulong debitedAccountId; + Optional!ulong vendorId; + Optional!ulong categoryId; + Optional!ulong creditedAccountId; + Optional!ulong debitedAccountId; string[] tags; - LineItem[] lineItems; + LineItemPayload[] lineItems; ulong[] attachmentIdsToRemove; - static struct LineItem { + static struct LineItemPayload { long valuePerItem; ulong quantity; string description; - Nullable!ulong categoryId; + Optional!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; @@ -131,7 +101,6 @@ struct TransactionCategoryTree { struct TransactionCategoryResponse { ulong id; - @serdeTransformOut!serializeOptional Optional!ulong parentId; string name; string description; @@ -170,3 +139,48 @@ struct AggregateTransactionData { } CurrencyData[] currencies; } + +/// Response data for drafts as they'd appear in a list, with less data. +struct TransactionDraftListItem { + ulong id; + string addedAt; + Optional!string templateName; + Optional!string timestamp; + Optional!ulong amount; + Optional!Currency currency; + Optional!string description; + Optional!bool internalTransfer; + + Optional!SimpleVendorResponse vendor; + Optional!SimpleCategoryResponse category; + Optional!SimpleAccountResponse creditedAccount; + Optional!SimpleAccountResponse debitedAccount; + + string[] tags; +} + +/// Data representing a draft (or template). +struct TransactionDraftResponse { + ulong id; + string addedAt; + Optional!string templateName; + Optional!string timestamp; + Optional!ulong amount; + Optional!Currency currency; + Optional!string description; + Optional!bool internalTransfer; + + Optional!SimpleVendorResponse vendor; + Optional!SimpleCategoryResponse category; + Optional!SimpleAccountResponse creditedAccount; + Optional!SimpleAccountResponse debitedAccount; + + string[] tags; + TransactionLineItemResponse[] lineItems; + AttachmentResponse[] attachments; +} + +/// Data provided by users when creating or updating drafts. +struct TransactionDraftPayload { + // TODO. +} diff --git a/finnow-api/source/transaction/model.d b/finnow-api/source/transaction/model.d index 11c9513..57055b2 100644 --- a/finnow-api/source/transaction/model.d +++ b/finnow-api/source/transaction/model.d @@ -6,9 +6,9 @@ import std.datetime; import util.money; struct TransactionVendor { - immutable ulong id; - immutable string name; - immutable string description; + ulong id; + string name; + string description; } struct TransactionCategory { @@ -20,23 +20,23 @@ struct TransactionCategory { } struct Transaction { - immutable ulong id; + ulong id; /// The time at which the transaction happened. - immutable SysTime timestamp; + SysTime timestamp; /// The time at which the transaction entity was saved. - immutable SysTime addedAt; - immutable ulong amount; - immutable Currency currency; - immutable string description; - immutable Optional!ulong vendorId; - immutable Optional!ulong categoryId; + SysTime addedAt; + ulong amount; + Currency currency; + string description; + Optional!ulong vendorId; + Optional!ulong categoryId; } struct TransactionLineItem { - immutable ulong transactionId; - immutable uint idx; - immutable long valuePerItem; - immutable ulong quantity; - immutable string description; - immutable Optional!ulong categoryId; + ulong transactionId; + uint idx; + long valuePerItem; + ulong quantity; + string description; + Optional!ulong categoryId; } diff --git a/finnow-api/source/transaction/service.d b/finnow-api/source/transaction/service.d index b6fe647..b657444 100644 --- a/finnow-api/source/transaction/service.d +++ b/finnow-api/source/transaction/service.d @@ -22,9 +22,8 @@ import attachment.dto; // Transactions Services Page!TransactionsListItem getTransactions(ProfileDataSource ds, in PageRequest pageRequest) { - Page!TransactionsListItem page = ds.getTransactionRepository() + return ds.getTransactionRepository() .findAll(pageRequest); - return page; } TransactionDetail getTransaction(ProfileDataSource ds, ulong transactionId) { @@ -53,20 +52,20 @@ TransactionDetail addTransaction(ProfileDataSource ds, in AddTransactionPayload ds.doTransaction(() { TransactionDetail txn = txnRepo.insert(payload); AccountJournalEntryRepository jeRepo = ds.getAccountJournalEntryRepository(); - if (!payload.creditedAccountId.isNull) { + if (payload.creditedAccountId) { jeRepo.insert( timestamp, - payload.creditedAccountId.get, + payload.creditedAccountId.value, txn.id, txn.amount, AccountJournalEntryType.CREDIT, txn.currency ); } - if (!payload.debitedAccountId.isNull) { + if (payload.debitedAccountId) { jeRepo.insert( timestamp, - payload.debitedAccountId.get, + payload.debitedAccountId.value, txn.id, txn.amount, AccountJournalEntryType.DEBIT, @@ -120,45 +119,45 @@ private void updateLinkedAccountJournalEntries( AccountJournalEntryRepository jeRepo = ds.getAccountJournalEntryRepository(); const bool amountOrCurrencyChanged = prev.amount != curr.amount || prev.currency.code != curr.currency.code; const bool updateCreditEntry = amountOrCurrencyChanged || ( - (prev.creditedAccount.isNull && !payload.creditedAccountId.isNull) || - (!prev.creditedAccount.isNull && payload.creditedAccountId.isNull) || + (!prev.creditedAccount && payload.creditedAccountId) || + (prev.creditedAccount && !payload.creditedAccountId) || ( - !prev.creditedAccount.isNull && - !payload.creditedAccountId.isNull && - prev.creditedAccount.get.id != payload.creditedAccountId.get + prev.creditedAccount && + payload.creditedAccountId && + prev.creditedAccount.value.id != payload.creditedAccountId.value ) ); const bool updateDebitEntry = amountOrCurrencyChanged || ( - (prev.debitedAccount.isNull && !payload.creditedAccountId.isNull) || - (!prev.debitedAccount.isNull && payload.debitedAccountId.isNull) || + (!prev.debitedAccount && payload.creditedAccountId) || + (prev.debitedAccount && !payload.debitedAccountId) || ( - !prev.debitedAccount.isNull && - !payload.debitedAccountId.isNull && - prev.debitedAccount.get.id != payload.debitedAccountId.get + prev.debitedAccount && + payload.debitedAccountId && + prev.debitedAccount.value.id != payload.debitedAccountId.value ) ); // Update journal entries if necessary: - if (updateCreditEntry && !prev.creditedAccount.isNull) { - jeRepo.deleteByAccountIdAndTransactionId(prev.creditedAccount.get.id, prev.id); + if (updateCreditEntry && prev.creditedAccount) { + jeRepo.deleteByAccountIdAndTransactionId(prev.creditedAccount.value.id, prev.id); } - if (updateCreditEntry && !payload.creditedAccountId.isNull) { + if (updateCreditEntry && payload.creditedAccountId) { jeRepo.insert( timestamp, - payload.creditedAccountId.get, + payload.creditedAccountId.value, curr.id, curr.amount, AccountJournalEntryType.CREDIT, curr.currency ); } - if (updateDebitEntry && !prev.debitedAccount.isNull) { - jeRepo.deleteByAccountIdAndTransactionId(prev.debitedAccount.get.id, prev.id); + if (updateDebitEntry && prev.debitedAccount) { + jeRepo.deleteByAccountIdAndTransactionId(prev.debitedAccount.value.id, prev.id); } - if (updateDebitEntry && !payload.debitedAccountId.isNull) { + if (updateDebitEntry && payload.debitedAccountId) { jeRepo.insert( timestamp, - payload.debitedAccountId.get, + payload.debitedAccountId.value, curr.id, curr.amount, AccountJournalEntryType.DEBIT, @@ -187,13 +186,13 @@ private void validateTransactionPayload( AccountRepository accountRepo, in AddTransactionPayload payload ) { - if (payload.creditedAccountId.isNull && payload.debitedAccountId.isNull) { + if (!payload.creditedAccountId && !payload.debitedAccountId) { throw new HttpStatusException(HttpStatus.BAD_REQUEST, "At least one account must be linked."); } if ( - !payload.creditedAccountId.isNull && - !payload.debitedAccountId.isNull && - payload.creditedAccountId.get == payload.debitedAccountId.get + payload.creditedAccountId && + payload.debitedAccountId && + payload.creditedAccountId.value == payload.debitedAccountId.value ) { throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Cannot link the same account as both credit and debit."); } @@ -210,16 +209,16 @@ private void validateTransactionPayload( if (timestamp > now) { throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Cannot create transaction in the future."); } - if (!payload.vendorId.isNull && !vendorRepo.existsById(payload.vendorId.get)) { + if (payload.vendorId && !vendorRepo.existsById(payload.vendorId.value)) { throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Vendor doesn't exist."); } - if (!payload.categoryId.isNull && !categoryRepo.existsById(payload.categoryId.get)) { + if (payload.categoryId && !categoryRepo.existsById(payload.categoryId.value)) { throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Category doesn't exist."); } - if (!payload.creditedAccountId.isNull && !accountRepo.existsById(payload.creditedAccountId.get)) { + if (payload.creditedAccountId && !accountRepo.existsById(payload.creditedAccountId.value)) { throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Credited account doesn't exist."); } - if (!payload.debitedAccountId.isNull && !accountRepo.existsById(payload.debitedAccountId.get)) { + if (payload.debitedAccountId && !accountRepo.existsById(payload.debitedAccountId.value)) { throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Debited account doesn't exist."); } foreach (tag; payload.tags) { @@ -232,7 +231,7 @@ private void validateTransactionPayload( if (payload.lineItems.length > 0) { long lineItemsTotal = 0; foreach (lineItem; payload.lineItems) { - if (!lineItem.categoryId.isNull && !categoryRepo.existsById(lineItem.categoryId.get)) { + if (lineItem.categoryId && !categoryRepo.existsById(lineItem.categoryId.value)) { throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Line item's category doesn't exist."); } if (lineItem.quantity == 0) { @@ -454,7 +453,7 @@ TransactionCategoryResponse createCategory(ProfileDataSource ds, in CategoryPayl if (repo.existsByName(payload.name)) { throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Name already in use."); } - if (!payload.parentId.isNull && !repo.existsById(payload.parentId.get)) { + if (payload.parentId && !repo.existsById(payload.parentId.value)) { throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid parent id."); } import std.regex; @@ -463,7 +462,7 @@ TransactionCategoryResponse createCategory(ProfileDataSource ds, in CategoryPayl throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid color hex string."); } auto category = repo.insert( - toOptional(payload.parentId), + payload.parentId, payload.name, payload.description, payload.color @@ -481,7 +480,7 @@ TransactionCategoryResponse updateCategory(ProfileDataSource ds, ulong categoryI if (payload.name != prev.name && repo.existsByName(payload.name)) { throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Name already in use."); } - if (!payload.parentId.isNull && !repo.existsById(payload.parentId.get)) { + if (payload.parentId && !repo.existsById(payload.parentId.value)) { throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid parent id."); } TransactionCategory curr = repo.updateById( @@ -489,7 +488,7 @@ TransactionCategoryResponse updateCategory(ProfileDataSource ds, ulong categoryI payload.name, payload.description, payload.color, - toOptional!ulong(payload.parentId) + payload.parentId ); return TransactionCategoryResponse.of(curr); } @@ -497,3 +496,21 @@ TransactionCategoryResponse updateCategory(ProfileDataSource ds, ulong categoryI void deleteCategory(ProfileDataSource ds, ulong categoryId) { ds.getTransactionCategoryRepository().deleteById(categoryId); } + +// Draft services + +Page!TransactionDraftListItem getDrafts(ProfileDataSource ds, in PageRequest pr) { + return ds.getTransactionDraftRepository() + .findAllDrafts(pr); +} + +Page!TransactionDraftListItem getTemplates(ProfileDataSource ds, in PageRequest pr) { + return ds.getTransactionDraftRepository() + .findAllTemplates(pr); +} + +TransactionDraftResponse getDraft(ProfileDataSource ds, ulong draftId) { + return ds.getTransactionDraftRepository() + .findById(draftId) + .orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND)); +} diff --git a/finnow-api/source/util/data.d b/finnow-api/source/util/data.d index 7b82347..c4152c4 100644 --- a/finnow-api/source/util/data.d +++ b/finnow-api/source/util/data.d @@ -21,13 +21,6 @@ Nullable!T toNullable(T)(Optional!T value) { } } -auto serializeOptional(T)(Optional!T value) { - if (value.isNull) { - return Nullable!T(); - } - return Nullable!T(value.value); -} - T getPathParamOrThrow(T)(in ServerHttpRequest req, string name) { import handy_http_handlers.path_handler; import std.conv : to, ConvException; diff --git a/finnow-api/source/util/sample_data.d b/finnow-api/source/util/sample_data.d index a44e0b1..0ef6e92 100644 --- a/finnow-api/source/util/sample_data.d +++ b/finnow-api/source/util/sample_data.d @@ -1,7 +1,7 @@ module util.sample_data; import slf4d; -import handy_http_primitives : Optional, mapIfPresent; +import handy_http_primitives : Optional, mapIfPresent, toOptional; import auth; import profile; @@ -143,10 +143,10 @@ void generateRandomTransactions(ProfileDataSource ds) { AddTransactionPayload data; data.timestamp = timestamp.toISOExtString(); if (uniform01() < 0.7) { - data.vendorId = Optional!ulong.of(choice(vendors).id).toNullable; + data.vendorId = Optional!ulong.of(choice(vendors).id); } if (uniform01() < 0.8) { - data.categoryId = Optional!ulong.of(choice(categories).id).toNullable; + data.categoryId = Optional!ulong.of(choice(categories).id); } // Randomly choose an account to credit / debit the transaction to. @@ -162,11 +162,11 @@ void generateRandomTransactions(ProfileDataSource ds) { } } if (uniform01() < 0.5) { - data.creditedAccountId = Optional!ulong.of(primaryAccount.id).toNullable; - if (secondaryAccountId) data.debitedAccountId = secondaryAccountId.toNullable; + data.creditedAccountId = Optional!ulong.of(primaryAccount.id); + if (secondaryAccountId) data.debitedAccountId = secondaryAccountId; } else { - data.debitedAccountId = Optional!ulong.of(primaryAccount.id).toNullable; - if (secondaryAccountId) data.creditedAccountId = secondaryAccountId.toNullable; + data.debitedAccountId = Optional!ulong.of(primaryAccount.id); + if (secondaryAccountId) data.creditedAccountId = secondaryAccountId; } // Randomly choose some tags to add. @@ -185,25 +185,25 @@ void generateRandomTransactions(ProfileDataSource ds) { if (uniform01 < 0.5) { long lineItemTotal = 0; foreach (n; 1..uniform(1, 20)) { - AddTransactionPayload.LineItem item; + AddTransactionPayload.LineItemPayload 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; + item.categoryId = Optional!ulong.of(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( + data.lineItems ~= AddTransactionPayload.LineItemPayload( diff, 1, "Last item which reconciles line items total with transaction amount.", - Nullable!ulong.init + Optional!ulong.empty() ); } } diff --git a/finnow-api/source/util/sqlite.d b/finnow-api/source/util/sqlite.d index 5324d88..60d0755 100644 --- a/finnow-api/source/util/sqlite.d +++ b/finnow-api/source/util/sqlite.d @@ -192,11 +192,35 @@ immutable(ubyte[]) parseBlob(Row row, size_t idx) { return row.peek!(ubyte[], PeekMode.slice)(idx).idup; } +/** + * Reads an Optional primitive value from a result row. + * Params: + * row = The row to read from. + * idx = The column index in the row to read. + * Returns: An optional containing the data, if present. + */ +Optional!T parseOptional(T, PeekMode mode = PeekMode.copy)(Row row, size_t idx) { + import std.typecons : Nullable; + import std.traits : isSomeString; + static if (isSomeString!T) { + Nullable!T n = row.peek!(Nullable!T, mode)(idx); + } else { + Nullable!T n = row.peek!(Nullable!T)(idx); + } + if (n.isNull) return Optional!(T).empty(); + static if (isSomeString!T) { + // If the string value is null, return empty as well. + if (n.get() is null) return Optional!(T).empty(); + } + return Optional!(T).of(n.get()); +} + struct QueryBuilder { string fromTable; string[] selections; string[] joins; string[] conditions; + string[] groupings; void delegate(ref Statement, ref int)[] argBinders; this(string fromTable) { @@ -223,6 +247,11 @@ struct QueryBuilder { return this; } + ref groupBy(string grouping) { + groupings ~= grouping; + return this; + } + string build() const { import std.algorithm : map; import std.string : join; @@ -240,6 +269,10 @@ struct QueryBuilder { app ~= "\nWHERE\n"; app ~= conditions.map!(s => " " ~ s).join(" AND\n"); } + if (groupings.length > 0) { + app ~= "\nGROUP BY\n"; + app ~= groupings.map!(s => " " ~ s).join(",\n"); + } return app[]; } diff --git a/finnow-api/sql/migrations/2.sql b/finnow-api/sql/migrations/2.sql new file mode 100644 index 0000000..38b37b5 --- /dev/null +++ b/finnow-api/sql/migrations/2.sql @@ -0,0 +1,74 @@ +CREATE TABLE transaction_draft ( + id INTEGER PRIMARY KEY, + added_at TEXT NOT NULL, + template_name TEXT, + timestamp TEXT, + amount INTEGER, + currency TEXT, + description TEXT, + internal_transfer BOOLEAN DEFAULT FALSE, + vendor_id INTEGER, + category_id INTEGER, + credited_account_id INTEGER, + debited_account_id INTEGER, + CONSTRAINT fk_transaction_draft_vendor + FOREIGN KEY (vendor_id) REFERENCES transaction_vendor(id) + ON UPDATE CASCADE ON DELETE SET NULL, + CONSTRAINT fk_transaction_draft_category + FOREIGN KEY (category_id) REFERENCES transaction_category(id) + ON UPDATE CASCADE ON DELETE SET NULL, + CONSTRAINT fk_transaction_draft_credited_account + FOREIGN KEY (credited_account_id) REFERENCES account(id) + ON UPDATE CASCADE ON DELETE SET NULL, + CONSTRAINT fk_transaction_draft_debited_account + FOREIGN KEY (debited_account_id) REFERENCES account(id) + ON UPDATE CASCADE ON DELETE SET NULL, + CONSTRAINT ck_transaction_amount_positive + CHECK (amount IS NULL OR amount > 0) +); + +CREATE TABLE transaction_draft_tag ( + draft_id INTEGER NOT NULL, + tag TEXT NOT NULL, + CONSTRAINT pk_transaction_draft_tag PRIMARY KEY (draft_id, tag), + CONSTRAINT fk_transaction_draft_tag_draft + FOREIGN KEY (draft_id) REFERENCES transaction_draft(id) + ON UPDATE CASCADE ON DELETE CASCADE +); + +CREATE TABLE transaction_draft_attachment ( + draft_id INTEGER NOT NULL, + attachment_id INTEGER NOT NULL, + PRIMARY KEY (draft_id, attachment_id), + CONSTRAINT fk_transaction_draft_attachment_transaction + FOREIGN KEY (draft_id) REFERENCES transaction_draft(id) + ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT fk_transaction_draft_attachment_attachment + FOREIGN KEY (attachment_id) REFERENCES attachment(id) + ON UPDATE CASCADE ON DELETE CASCADE +); + +CREATE TABLE transaction_draft_line_item ( + draft_id INTEGER NOT NULL, + idx INTEGER NOT NULL DEFAULT 0, + value_per_item INTEGER NOT NULL, + quantity INTEGER NOT NULL DEFAULT 1, + description TEXT NOT NULL, + category_id INTEGER, + CONSTRAINT pk_transaction_draft_line_item PRIMARY KEY (draft_id, idx), + CONSTRAINT fk_transaction_draft_line_item_transaction + FOREIGN KEY (draft_id) REFERENCES transaction_draft(id) + ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT fk_transaction_draft_line_item_category + FOREIGN KEY (category_id) REFERENCES transaction_category(id) + ON UPDATE CASCADE ON DELETE SET NULL +); + +CREATE TABLE recurring_transaction ( + id INTEGER PRIMARY KEY, + draft_id INTEGER NOT NULL, + schedule_expr TEXT NOT NULL, + CONSTRAINT fk_recurring_transaction_draft + FOREIGN KEY (draft_id) REFERENCES transaction_draft(id) + ON UPDATE CASCADE ON DELETE CASCADE +); \ No newline at end of file diff --git a/finnow-api/sql/get_line_items.sql b/finnow-api/sql/query/get_line_items.sql similarity index 94% rename from finnow-api/sql/get_line_items.sql rename to finnow-api/sql/query/get_line_items.sql index 135e8c0..f1ecddf 100644 --- a/finnow-api/sql/get_line_items.sql +++ b/finnow-api/sql/query/get_line_items.sql @@ -3,11 +3,13 @@ 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 diff --git a/finnow-api/sql/get_transaction.sql b/finnow-api/sql/query/get_transaction.sql similarity index 100% rename from finnow-api/sql/get_transaction.sql rename to finnow-api/sql/query/get_transaction.sql diff --git a/finnow-api/sql/query/get_transactions.sql b/finnow-api/sql/query/get_transactions.sql new file mode 100644 index 0000000..7b81ed2 --- /dev/null +++ b/finnow-api/sql/query/get_transactions.sql @@ -0,0 +1,38 @@ +SELECT + txn.id, + txn.timestamp, + txn.added_at, + txn.amount, + txn.currency, + txn.description, + txn.internal_transfer, + + txn.vendor_id, + vendor.name, + + txn.category_id, + category.name, + category.color, + + account_credit.id, + account_credit.name, + account_credit.type, + account_credit.number_suffix, + + account_debit.id, + account_debit.name, + account_debit.type, + account_debit.number_suffix, + + GROUP_CONCAT(tags.tag) +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/sql/schema.sql b/finnow-api/sql/schema.sql index dfb24c7..8229df6 100644 --- a/finnow-api/sql/schema.sql +++ b/finnow-api/sql/schema.sql @@ -1,4 +1,9 @@ --- This schema is included at compile-time into source/profile/data_impl_sqlite.d SqliteProfileDataSource +-- This schema is included at compile-time into +-- source/profile/data_impl_sqlite.d SqliteProfileDataSource +-- ---------------------------------------------------------------------------- +-- This is the full current schema for Finnow's database. Tables and statements +-- are defined in the order in which they're executed to build a new database +-- for a newly-created profile. -- Basic/Utility Entities @@ -218,3 +223,80 @@ CREATE TABLE history_item_linked_journal_entry ( FOREIGN KEY (journal_entry_id) REFERENCES account_journal_entry(id) ON UPDATE CASCADE ON DELETE CASCADE ); + +-- Drafts / Templates / Recurring Transactions + +CREATE TABLE transaction_draft ( + id INTEGER PRIMARY KEY, + added_at TEXT NOT NULL, + template_name TEXT, + timestamp TEXT, + amount INTEGER, + currency TEXT, + description TEXT, + internal_transfer BOOLEAN DEFAULT FALSE, + vendor_id INTEGER, + category_id INTEGER, + credited_account_id INTEGER, + debited_account_id INTEGER, + CONSTRAINT fk_transaction_draft_vendor + FOREIGN KEY (vendor_id) REFERENCES transaction_vendor(id) + ON UPDATE CASCADE ON DELETE SET NULL, + CONSTRAINT fk_transaction_draft_category + FOREIGN KEY (category_id) REFERENCES transaction_category(id) + ON UPDATE CASCADE ON DELETE SET NULL, + CONSTRAINT fk_transaction_draft_credited_account + FOREIGN KEY (credited_account_id) REFERENCES account(id) + ON UPDATE CASCADE ON DELETE SET NULL, + CONSTRAINT fk_transaction_draft_debited_account + FOREIGN KEY (debited_account_id) REFERENCES account(id) + ON UPDATE CASCADE ON DELETE SET NULL, + CONSTRAINT ck_transaction_amount_positive + CHECK (amount IS NULL OR amount > 0) +); + +CREATE TABLE transaction_draft_tag ( + draft_id INTEGER NOT NULL, + tag TEXT NOT NULL, + CONSTRAINT pk_transaction_draft_tag PRIMARY KEY (draft_id, tag), + CONSTRAINT fk_transaction_draft_tag_draft + FOREIGN KEY (draft_id) REFERENCES transaction_draft(id) + ON UPDATE CASCADE ON DELETE CASCADE +); + +CREATE TABLE transaction_draft_attachment ( + draft_id INTEGER NOT NULL, + attachment_id INTEGER NOT NULL, + PRIMARY KEY (draft_id, attachment_id), + CONSTRAINT fk_transaction_draft_attachment_transaction + FOREIGN KEY (draft_id) REFERENCES transaction_draft(id) + ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT fk_transaction_draft_attachment_attachment + FOREIGN KEY (attachment_id) REFERENCES attachment(id) + ON UPDATE CASCADE ON DELETE CASCADE +); + +CREATE TABLE transaction_draft_line_item ( + draft_id INTEGER NOT NULL, + idx INTEGER NOT NULL DEFAULT 0, + value_per_item INTEGER NOT NULL, + quantity INTEGER NOT NULL DEFAULT 1, + description TEXT NOT NULL, + category_id INTEGER, + CONSTRAINT pk_transaction_draft_line_item PRIMARY KEY (draft_id, idx), + CONSTRAINT fk_transaction_draft_line_item_transaction + FOREIGN KEY (draft_id) REFERENCES transaction_draft(id) + ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT fk_transaction_draft_line_item_category + FOREIGN KEY (category_id) REFERENCES transaction_category(id) + ON UPDATE CASCADE ON DELETE SET NULL +); + +CREATE TABLE recurring_transaction ( + id INTEGER PRIMARY KEY, + draft_id INTEGER NOT NULL, + schedule_expr TEXT NOT NULL, + CONSTRAINT fk_recurring_transaction_draft + FOREIGN KEY (draft_id) REFERENCES transaction_draft(id) + ON UPDATE CASCADE ON DELETE CASCADE +); -- 2.34.1 From 5bda3ee4af9ad4109c3685edb0091598dc40f447 Mon Sep 17 00:00:00 2001 From: andrewlalis Date: Sat, 27 Jun 2026 18:34:59 -0400 Subject: [PATCH 02/18] Restrict deployment to main branch CI --- .gitea/workflows/api-dev.yaml | 22 ++++++++++++++++++++++ .gitea/workflows/api.yaml | 2 ++ .gitea/workflows/web-app-dev.yaml | 24 ++++++++++++++++++++++++ .gitea/workflows/web-app.yaml | 2 ++ 4 files changed, 50 insertions(+) create mode 100644 .gitea/workflows/api-dev.yaml create mode 100644 .gitea/workflows/web-app-dev.yaml diff --git a/.gitea/workflows/api-dev.yaml b/.gitea/workflows/api-dev.yaml new file mode 100644 index 0000000..c188ef0 --- /dev/null +++ b/.gitea/workflows/api-dev.yaml @@ -0,0 +1,22 @@ +name: Build and Deploy API +on: + push: + paths: + - 'finnow-api/**' + - '.gitea/workflows/api.yaml' + branches-ignore: + - main +jobs: + build-and-deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dlang-community/setup-dlang@v2 + with: + compiler: ldc-latest + - name: Test + run: dub test + working-directory: ./finnow-api + - name: Build + run: dub build --build=release + working-directory: ./finnow-api diff --git a/.gitea/workflows/api.yaml b/.gitea/workflows/api.yaml index b0e1dea..1256e6b 100644 --- a/.gitea/workflows/api.yaml +++ b/.gitea/workflows/api.yaml @@ -4,6 +4,8 @@ on: paths: - 'finnow-api/**' - '.gitea/workflows/api.yaml' + branches: + - main jobs: build-and-deploy: runs-on: ubuntu-latest diff --git a/.gitea/workflows/web-app-dev.yaml b/.gitea/workflows/web-app-dev.yaml new file mode 100644 index 0000000..bc65bb0 --- /dev/null +++ b/.gitea/workflows/web-app-dev.yaml @@ -0,0 +1,24 @@ +name: Build and Deploy Web App +on: + push: + paths: + - 'web-app/**' + - '.gitea/workflows/web-app.yaml' + branches-ignore: + - main +jobs: + build-and-deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22.x' + - name: Enable Corepack + run: corepack enable + - name: Install Dependencies + run: yarn install --immutable + working-directory: ./web-app + - name: Build + run: yarn build + working-directory: ./web-app diff --git a/.gitea/workflows/web-app.yaml b/.gitea/workflows/web-app.yaml index 1a83423..29cfe6b 100644 --- a/.gitea/workflows/web-app.yaml +++ b/.gitea/workflows/web-app.yaml @@ -4,6 +4,8 @@ on: paths: - 'web-app/**' - '.gitea/workflows/web-app.yaml' + branches: + - main jobs: build-and-deploy: runs-on: ubuntu-latest -- 2.34.1 From 4a3e840fce6281ace3ea1b60ab63aea16c2d9a6b Mon Sep 17 00:00:00 2001 From: andrewlalis Date: Sat, 27 Jun 2026 18:35:59 -0400 Subject: [PATCH 03/18] Updated titles of dev workflows. --- .gitea/workflows/api-dev.yaml | 2 +- .gitea/workflows/web-app.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/api-dev.yaml b/.gitea/workflows/api-dev.yaml index c188ef0..79aae6d 100644 --- a/.gitea/workflows/api-dev.yaml +++ b/.gitea/workflows/api-dev.yaml @@ -1,4 +1,4 @@ -name: Build and Deploy API +name: Build and Test API on: push: paths: diff --git a/.gitea/workflows/web-app.yaml b/.gitea/workflows/web-app.yaml index 29cfe6b..f6d8ccb 100644 --- a/.gitea/workflows/web-app.yaml +++ b/.gitea/workflows/web-app.yaml @@ -1,4 +1,4 @@ -name: Build and Deploy Web App +name: Build Web App on: push: paths: -- 2.34.1 From 55878a13f6c09213a539b5095163a2a8180f31c7 Mon Sep 17 00:00:00 2001 From: andrewlalis Date: Sat, 27 Jun 2026 18:36:43 -0400 Subject: [PATCH 04/18] Fix names of workflows. --- .gitea/workflows/web-app-dev.yaml | 2 +- .gitea/workflows/web-app.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/web-app-dev.yaml b/.gitea/workflows/web-app-dev.yaml index bc65bb0..18f7ee5 100644 --- a/.gitea/workflows/web-app-dev.yaml +++ b/.gitea/workflows/web-app-dev.yaml @@ -1,4 +1,4 @@ -name: Build and Deploy Web App +name: Build Web App on: push: paths: diff --git a/.gitea/workflows/web-app.yaml b/.gitea/workflows/web-app.yaml index f6d8ccb..29cfe6b 100644 --- a/.gitea/workflows/web-app.yaml +++ b/.gitea/workflows/web-app.yaml @@ -1,4 +1,4 @@ -name: Build Web App +name: Build and Deploy Web App on: push: paths: -- 2.34.1 From 7e7458c2f592d2c41a5e9514ea72426d61791dac Mon Sep 17 00:00:00 2001 From: andrewlalis Date: Sat, 27 Jun 2026 18:37:31 -0400 Subject: [PATCH 05/18] Update trigger path to dev workflow files. --- .gitea/workflows/api-dev.yaml | 2 +- .gitea/workflows/web-app-dev.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/api-dev.yaml b/.gitea/workflows/api-dev.yaml index 79aae6d..e49551f 100644 --- a/.gitea/workflows/api-dev.yaml +++ b/.gitea/workflows/api-dev.yaml @@ -3,7 +3,7 @@ on: push: paths: - 'finnow-api/**' - - '.gitea/workflows/api.yaml' + - '.gitea/workflows/api-dev.yaml' branches-ignore: - main jobs: diff --git a/.gitea/workflows/web-app-dev.yaml b/.gitea/workflows/web-app-dev.yaml index 18f7ee5..e6013e3 100644 --- a/.gitea/workflows/web-app-dev.yaml +++ b/.gitea/workflows/web-app-dev.yaml @@ -3,7 +3,7 @@ on: push: paths: - 'web-app/**' - - '.gitea/workflows/web-app.yaml' + - '.gitea/workflows/web-app-dev.yaml' branches-ignore: - main jobs: -- 2.34.1 From df4737d0a51b13122a0da6d4265c4219f1843cae Mon Sep 17 00:00:00 2001 From: andrewlalis Date: Sat, 27 Jun 2026 19:51:29 -0400 Subject: [PATCH 06/18] Fixed issue #47 - history timestamp not updating. --- finnow-api/source/account/data_impl_sqlite.d | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/finnow-api/source/account/data_impl_sqlite.d b/finnow-api/source/account/data_impl_sqlite.d index 1e46eed..119ee4c 100644 --- a/finnow-api/source/account/data_impl_sqlite.d +++ b/finnow-api/source/account/data_impl_sqlite.d @@ -202,12 +202,12 @@ SQL", private AccountHistoryValueRecordItemResponse fetchValueRecordHistoryItem(in BaseHistoryItem item) { return util.sqlite.findOne( db, - "SELECT vr.id, vr.type, vr.value, vr.currency FROM history_item_linked_value_record h " ~ + "SELECT vr.id, vr.type, vr.value, vr.currency, vr.timestamp FROM history_item_linked_value_record h " ~ "LEFT JOIN account_value_record vr ON vr.id = h.value_record_id " ~ "WHERE h.item_id = ?", (row) { auto obj = new AccountHistoryValueRecordItemResponse(); - obj.timestamp = item.timestamp; + obj.timestamp = row.peek!string(4); obj.type = item.type; obj.valueRecordId = row.peek!ulong(0); obj.valueRecordType = row.peek!string(1); @@ -222,13 +222,14 @@ SQL", private AccountHistoryJournalEntryItemResponse fetchJournalEntryHistoryItem(in BaseHistoryItem item) { return util.sqlite.findOne( db, - "SELECT je.type, je.amount, je.currency, tx.id, tx.description FROM history_item_linked_journal_entry h " ~ + "SELECT je.type, je.amount, je.currency, tx.id, tx.description, tx.timestamp " ~ + "FROM history_item_linked_journal_entry h " ~ "LEFT JOIN account_journal_entry je ON je.id = h.journal_entry_id " ~ "LEFT JOIN \"transaction\" tx ON tx.id = je.transaction_id " ~ "WHERE h.item_id = ?", (row) { auto obj = new AccountHistoryJournalEntryItemResponse(); - obj.timestamp = item.timestamp; + obj.timestamp = row.peek!string(5); obj.type = item.type; obj.journalEntryType = row.peek!string(0); obj.amount = row.peek!ulong(1); -- 2.34.1 From 3b35f8f16d96be85d868d710b40efe82dbba1cad Mon Sep 17 00:00:00 2001 From: andrewlalis Date: Sat, 27 Jun 2026 19:54:23 -0400 Subject: [PATCH 07/18] Removed unused import --- finnow-api/source/analytics/modules/balances.d | 1 - 1 file changed, 1 deletion(-) diff --git a/finnow-api/source/analytics/modules/balances.d b/finnow-api/source/analytics/modules/balances.d index 7ab596b..cfc0dd1 100644 --- a/finnow-api/source/analytics/modules/balances.d +++ b/finnow-api/source/analytics/modules/balances.d @@ -9,7 +9,6 @@ import std.algorithm; import std.array; import std.conv; import slf4d; -import asdf; import profile.data; import profile.model; -- 2.34.1 From 69c307917481f6b2f17c20d78226042ae3865b91 Mon Sep 17 00:00:00 2001 From: andrewlalis Date: Sat, 27 Jun 2026 21:57:52 -0400 Subject: [PATCH 08/18] Updated API with hopefully the rest of the draft implementation. --- finnow-api/source/attachment/data.d | 1 + .../source/attachment/data_impl_sqlite.d | 7 + finnow-api/source/transaction/api.d | 39 ++++-- finnow-api/source/transaction/data.d | 4 + .../source/transaction/data_impl_sqlite.d | 132 +++++++++++++++++- finnow-api/source/transaction/dto.d | 23 ++- finnow-api/source/transaction/service.d | 99 +++++++++++-- finnow-api/sql/insert_line_item_draft.sql | 8 ++ finnow-api/sql/insert_transaction_draft.sql | 13 ++ finnow-api/sql/query/get_line_items_draft.sql | 17 +++ .../sql/query/get_transaction_draft.sql | 0 finnow-api/sql/update_transaction_draft.sql | 13 ++ 12 files changed, 330 insertions(+), 26 deletions(-) create mode 100644 finnow-api/sql/insert_line_item_draft.sql create mode 100644 finnow-api/sql/insert_transaction_draft.sql create mode 100644 finnow-api/sql/query/get_line_items_draft.sql create mode 100644 finnow-api/sql/query/get_transaction_draft.sql create mode 100644 finnow-api/sql/update_transaction_draft.sql diff --git a/finnow-api/source/attachment/data.d b/finnow-api/source/attachment/data.d index 30413f4..ba866f6 100644 --- a/finnow-api/source/attachment/data.d +++ b/finnow-api/source/attachment/data.d @@ -9,6 +9,7 @@ interface AttachmentRepository { Attachment[] findAllByLinkedEntity(string subquery, ulong entityId); Attachment[] findAllByTransactionId(ulong transactionId); Attachment[] findAllByValueRecordId(ulong valueRecordId); + Attachment[] findAllByTransactionDraftId(ulong draftId); ulong save(SysTime uploadedAt, string filename, string contentType, in ubyte[] content); void remove(ulong id); Optional!(ubyte[]) getContent(ulong id); diff --git a/finnow-api/source/attachment/data_impl_sqlite.d b/finnow-api/source/attachment/data_impl_sqlite.d index 96ec5fc..144f4fb 100644 --- a/finnow-api/source/attachment/data_impl_sqlite.d +++ b/finnow-api/source/attachment/data_impl_sqlite.d @@ -45,6 +45,13 @@ class SqliteAttachmentRepository : AttachmentRepository { valueRecordId ); } + + Attachment[] findAllByTransactionDraftId(ulong draftId) { + return findAllByLinkedEntity( + "SELECT attachment_id FROM transaction_draft_attachment WHERE draft_id = ?", + draftId + ); + } ulong save(SysTime uploadedAt, string filename, string contentType, in ubyte[] content) { util.sqlite.update( diff --git a/finnow-api/source/transaction/api.d b/finnow-api/source/transaction/api.d index f5bd528..0e5624f 100644 --- a/finnow-api/source/transaction/api.d +++ b/finnow-api/source/transaction/api.d @@ -224,20 +224,12 @@ immutable DEFAULT_DRAFT_PAGE = PageRequest(1, 10, [Sort("txn.id", SortDir.DESC)] void handleGetDrafts(ref ServerHttpRequest request, ref ServerHttpResponse response) { ProfileDataSource ds = getProfileDataSource(request); PageRequest pr = PageRequest.parse(request, DEFAULT_DRAFT_PAGE); - Page!TransactionDraftListItem page = getDrafts(ds, pr); - writeJsonBody(response, page); -} - -@GetMapping(PROFILE_PATH ~ "/transaction-templates") -void handleGetTemplates(ref ServerHttpRequest request, ref ServerHttpResponse response) { - ProfileDataSource ds = getProfileDataSource(request); - PageRequest pr = PageRequest.parse(request, DEFAULT_DRAFT_PAGE); - Page!TransactionDraftListItem page = getTemplates(ds, pr); + bool shouldFetchTemplates = request.getParamAs!bool("template", false); + Page!TransactionDraftListItem page = getDrafts(ds, pr, shouldFetchTemplates); writeJsonBody(response, page); } @GetMapping(PROFILE_PATH ~ "/transaction-drafts/:draftId:ulong") -@GetMapping(PROFILE_PATH ~ "/transaction-templates/:draftId:ulong") void handleGetDraft(ref ServerHttpRequest request, ref ServerHttpResponse response) { ProfileDataSource ds = getProfileDataSource(request); TransactionDraftResponse draft = getDraft(ds, getDraftId(request)); @@ -246,6 +238,33 @@ void handleGetDraft(ref ServerHttpRequest request, ref ServerHttpResponse respon response.writeBodyString(jsonStr, "application/json"); } +@PostMapping(PROFILE_PATH ~ "/transaction-drafts") +void handleAddDraft(ref ServerHttpRequest request, ref ServerHttpResponse response) { + auto fullPayload = parseMultipartFilesAndBody!TransactionDraftPayload(request); + ProfileDataSource ds = getProfileDataSource(request); + TransactionDraftResponse draft = addDraft(ds, fullPayload.payload, fullPayload.files); + import asdf : serializeToJson; + string jsonStr = serializeToJson(draft); + response.writeBodyString(jsonStr, "application/json"); +} + +@PutMapping(PROFILE_PATH ~ "/transaction-drafts/:draftId:ulong") +void handleUpdateDraft(ref ServerHttpRequest request, ref ServerHttpResponse response) { + ProfileDataSource ds = getProfileDataSource(request); + auto fullPayload = parseMultipartFilesAndBody!TransactionDraftPayload(request); + TransactionDraftResponse draft = updateDraft(ds, getDraftId(request), fullPayload.payload, fullPayload.files); + import asdf : serializeToJson; + string jsonStr = serializeToJson(draft); + response.writeBodyString(jsonStr, "application/json"); +} + +@DeleteMapping(PROFILE_PATH ~ "/transaction-drafts/:draftId:ulong") +void handleDeleteDraft(ref ServerHttpRequest request, ref ServerHttpResponse response) { + ProfileDataSource ds = getProfileDataSource(request); + ulong draftId = getDraftId(request); + deleteDraft(ds, draftId); +} + private ulong getDraftId(in ServerHttpRequest request) { return getPathParamOrThrow!ulong(request, "draftId"); } diff --git a/finnow-api/source/transaction/data.d b/finnow-api/source/transaction/data.d index 8191134..91edd66 100644 --- a/finnow-api/source/transaction/data.d +++ b/finnow-api/source/transaction/data.d @@ -36,6 +36,7 @@ interface TransactionCategoryRepository { ); } +// TODO: Migrate into transaction repo, similar to drafts! interface TransactionTagRepository { string[] findAllByTransactionId(ulong transactionId); void updateTags(ulong transactionId, in string[] tags); @@ -61,4 +62,7 @@ interface TransactionDraftRepository { void linkAttachment(ulong draftId, ulong attachmentId); TransactionDraftResponse update(ulong draftId, in TransactionDraftPayload data); void deleteById(ulong id); + + void updateTags(ulong draftId, in string[] tags); + string[] findAllTags(); } diff --git a/finnow-api/source/transaction/data_impl_sqlite.d b/finnow-api/source/transaction/data_impl_sqlite.d index 4dbffd0..72a1cc6 100644 --- a/finnow-api/source/transaction/data_impl_sqlite.d +++ b/finnow-api/source/transaction/data_impl_sqlite.d @@ -659,18 +659,75 @@ class SqliteTransactionDraftRepository : TransactionDraftRepository { } Optional!TransactionDraftResponse findById(ulong id) { + // First fetch the draft list item (contains all basic properties). QueryBuilder qb = getBuilderForDraftsList(); addSelectsForDraftsList(qb); + qb.groupBy("draft.id"); qb.where("draft.id = ?"); string query = qb.build(); - // return util.sqlite.findOne(db, query, &parseDraft, id); - // TODO! - return Optional!TransactionDraftResponse.empty(); + Optional!TransactionDraftListItem li = util.sqlite.findOne(db, query, &parseDraftListItem, id); + if (li.isNull) return Optional!TransactionDraftResponse.empty(); + TransactionDraftListItem draft = li.value; + // Then fetch line items. + TransactionDraftResponse response; + response.id = draft.id; + response.addedAt = draft.addedAt; + response.templateName = draft.templateName; + response.timestamp = draft.timestamp; + response.amount = draft.amount; + response.currency = draft.currency; + response.description = draft.description; + response.internalTransfer = draft.internalTransfer; + response.vendor = draft.vendor; + response.category = draft.category; + response.creditedAccount = draft.creditedAccount; + response.debitedAccount = draft.debitedAccount; + response.lineItems = util.sqlite.findAll( + db, + import("sql/query/get_line_items_draft.sql"), + (row) { + TransactionLineItemResponse item; + item.idx = row.peek!uint(0); + item.valuePerItem = row.peek!long(1); + item.quantity = row.peek!ulong(2); + item.description = row.peek!string(3); + Optional!ulong categoryId = row.parseOptional!ulong(4); + if (categoryId) { + item.category = Optional!TransactionCategory.of( + TransactionCategory( + categoryId.value, + row.parseOptional!ulong(5), + row.peek!string(6), + row.peek!string(7), + row.peek!string(8) + )); + } + return item; + } + ); + // Return the response, excluding attachments (they are fetched using the attachment repo). + return Optional!TransactionDraftResponse.of(response); } TransactionDraftResponse insert(in TransactionDraftPayload data) { - // TODO - return TransactionDraftResponse.init; + util.sqlite.update( + db, + import("sql/insert_transaction_draft.sql"), + Clock.currTime(UTC()).toISOExtString(), + data.templateName.toNullable(), + data.timestamp.toNullable(), + data.amount.toNullable(), + data.currencyCode.toNullable(), + data.description.toNullable(), + data.internalTransfer.toNullable(), + data.vendorId.toNullable(), + data.categoryId.toNullable(), + data.creditedAccountId.toNullable(), + data.debitedAccountId.toNullable() + ); + ulong draftId = db.lastInsertRowid(); + insertLineItems(draftId, data); + return findById(draftId).orElseThrow(); } void linkAttachment(ulong draftId, ulong attachmentId) { @@ -683,8 +740,29 @@ class SqliteTransactionDraftRepository : TransactionDraftRepository { } TransactionDraftResponse update(ulong draftId, in TransactionDraftPayload data) { - // TODO - return TransactionDraftResponse.init; + util.sqlite.update( + db, + import("sql/update_transaction_draft.sql"), + data.templateName.toNullable(), + data.timestamp.toNullable(), + data.amount.toNullable(), + data.currencyCode.toNullable(), + data.description.toNullable(), + data.internalTransfer.toNullable(), + data.vendorId.toNullable(), + data.categoryId.toNullable(), + data.creditedAccountId.toNullable(), + data.debitedAccountId.toNullable(), + draftId + ); + // Re-write all line items: + util.sqlite.update( + db, + "DELETE FROM transaction_draft_line_item WHERE draft_id = ?", + draftId + ); + insertLineItems(draftId, data); + return findById(draftId).orElseThrow(); } void deleteById(ulong id) { @@ -698,6 +776,31 @@ class SqliteTransactionDraftRepository : TransactionDraftRepository { util.sqlite.deleteById(db, "transaction_draft", id); } + void updateTags(ulong draftId, in string[] tags) { + util.sqlite.update( + db, + "DELETE FROM transaction_draft_tag WHERE draft_id = ?", + draftId + ); + foreach (tag; tags) { + util.sqlite.update( + db, + "INSERT INTO transaction_draft_tag (draft_id, tag) VALUES (?, ?)", + draftId, tag + ); + } + } + + string[] findAllTags() { + return util.sqlite.findAll( + db, + "SELECT DISTINCT tag FROM transaction_draft_tag ORDER BY tag", + r => r.peek!string(0) + ); + } + + + private QueryBuilder getBuilderForDraftsList() { return QueryBuilder("transaction_draft draft") .join("LEFT JOIN transaction_vendor vendor ON vendor.id = draft.vendor_id") @@ -784,4 +887,19 @@ class SqliteTransactionDraftRepository : TransactionDraftRepository { } return item; } + + private void insertLineItems(ulong draftId, in TransactionDraftPayload payload) { + foreach (size_t idx, lineItem; payload.lineItems) { + util.sqlite.update( + db, + import("sql/insert_line_item_draft.sql"), + draftId, + idx, + lineItem.valuePerItem, + lineItem.quantity, + lineItem.description, + lineItem.categoryId.toNullable() + ); + } + } } diff --git a/finnow-api/source/transaction/dto.d b/finnow-api/source/transaction/dto.d index 3533dd7..aa23d3e 100644 --- a/finnow-api/source/transaction/dto.d +++ b/finnow-api/source/transaction/dto.d @@ -182,5 +182,26 @@ struct TransactionDraftResponse { /// Data provided by users when creating or updating drafts. struct TransactionDraftPayload { - // TODO. + Optional!string templateName; + Optional!string timestamp; + Optional!ulong amount; + Optional!string currencyCode; + Optional!string description; + Optional!bool internalTransfer; + + Optional!ulong vendorId; + Optional!ulong categoryId; + Optional!ulong creditedAccountId; + Optional!ulong debitedAccountId; + + string[] tags; + LineItemPayload[] lineItems; + ulong[] attachmentIdsToRemove; + + static struct LineItemPayload { + long valuePerItem; + ulong quantity; + string description; + Optional!ulong categoryId; + } } diff --git a/finnow-api/source/transaction/service.d b/finnow-api/source/transaction/service.d index b657444..cae94b6 100644 --- a/finnow-api/source/transaction/service.d +++ b/finnow-api/source/transaction/service.d @@ -45,6 +45,7 @@ TransactionDetail addTransaction(ProfileDataSource ds, in AddTransactionPayload AttachmentRepository attachmentRepo = ds.getAttachmentRepository(); validateTransactionPayload(vendorRepo, categoryRepo, accountRepo, payload); + SysTime now = Clock.currTime(UTC()); SysTime timestamp = SysTime.fromISOExtString(payload.timestamp, UTC()); // Add the transaction: @@ -74,7 +75,7 @@ TransactionDetail addTransaction(ProfileDataSource ds, in AddTransactionPayload } TransactionTagRepository tagRepo = ds.getTransactionTagRepository(); tagRepo.updateTags(txn.id, payload.tags); - updateAttachments(txn.id, timestamp, payload.attachmentIdsToRemove, files, attachmentRepo, txnRepo); + updateAttachments(txn.id, now, payload.attachmentIdsToRemove, files, attachmentRepo, txnRepo); txnId = txn.id; }); return getTransaction(ds, txnId); @@ -95,6 +96,7 @@ TransactionDetail updateTransaction( validateTransactionPayload(vendorRepo, categoryRepo, accountRepo, payload); SysTime timestamp = SysTime.fromISOExtString(payload.timestamp, UTC()); + SysTime now = Clock.currTime(UTC()); const TransactionDetail prev = transactionRepo.findById(transactionId) .orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND)); @@ -104,7 +106,7 @@ TransactionDetail updateTransaction( TransactionDetail curr = transactionRepo.update(transactionId, payload); updateLinkedAccountJournalEntries(prev, curr, payload, ds, timestamp); tagRepo.updateTags(transactionId, payload.tags); - updateAttachments(curr.id, timestamp, payload.attachmentIdsToRemove, files, attachmentRepo, transactionRepo); + updateAttachments(curr.id, now, payload.attachmentIdsToRemove, files, attachmentRepo, transactionRepo); }); return getTransaction(ds, transactionId); } @@ -499,18 +501,99 @@ void deleteCategory(ProfileDataSource ds, ulong categoryId) { // Draft services -Page!TransactionDraftListItem getDrafts(ProfileDataSource ds, in PageRequest pr) { +Page!TransactionDraftListItem getDrafts(ProfileDataSource ds, in PageRequest pr, bool shouldFetchTemplates) { + if (shouldFetchTemplates) { + return ds.getTransactionDraftRepository() + .findAllTemplates(pr); + } return ds.getTransactionDraftRepository() .findAllDrafts(pr); } -Page!TransactionDraftListItem getTemplates(ProfileDataSource ds, in PageRequest pr) { - return ds.getTransactionDraftRepository() - .findAllTemplates(pr); -} - TransactionDraftResponse getDraft(ProfileDataSource ds, ulong draftId) { return ds.getTransactionDraftRepository() .findById(draftId) + // Populate the list of attachments for the draft. + .mapIfPresent!((draft) { + import std.algorithm : map; + import std.array : array; + draft.attachments = ds.getAttachmentRepository() + .findAllByTransactionDraftId(draft.id) + .map!(AttachmentResponse.of) + .array; + return draft; + }) .orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND)); } + +TransactionDraftResponse addDraft(ProfileDataSource ds, in TransactionDraftPayload payload, in MultipartFile[] files) { + TransactionDraftRepository draftRepo = ds.getTransactionDraftRepository(); + AttachmentRepository attachmentRepo = ds.getAttachmentRepository(); + + validateDraftPayload(payload); + SysTime now = Clock.currTime(UTC()); + + ulong draftId; + ds.doTransaction(() { + TransactionDraftResponse draft = draftRepo.insert(payload); + draftRepo.updateTags(draft.id, payload.tags); + updateDraftAttachments(draft.id, now, payload.attachmentIdsToRemove, files, attachmentRepo, draftRepo); + draftId = draft.id; + }); + return getDraft(ds, draftId); +} + +TransactionDraftResponse updateDraft( + ProfileDataSource ds, + ulong draftId, + in TransactionDraftPayload payload, + in MultipartFile[] files +) { + TransactionDraftRepository draftRepo = ds.getTransactionDraftRepository(); + AttachmentRepository attachmentRepo = ds.getAttachmentRepository(); + + validateDraftPayload(payload); + SysTime now = Clock.currTime(UTC()); + + ds.doTransaction(() { + draftRepo.update(draftId, payload); + draftRepo.updateTags(draftId, payload.tags); + updateDraftAttachments(draftId, now, payload.attachmentIdsToRemove, files, attachmentRepo, draftRepo); + }); + return getDraft(ds, draftId); +} + +void deleteDraft(ProfileDataSource ds, ulong draftId) { + TransactionDraftRepository draftRepo = ds.getTransactionDraftRepository(); + AttachmentRepository attachmentRepo = ds.getAttachmentRepository(); + TransactionDraftResponse draft = draftRepo.findById(draftId) + .orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND)); + ds.doTransaction(() { + // First delete all attachments. + foreach (a; attachmentRepo.findAllByTransactionDraftId(draft.id)) { + attachmentRepo.remove(a.id); + } + draftRepo.deleteById(draft.id); + }); +} + +private void validateDraftPayload(in TransactionDraftPayload payload) { + // TODO! +} + +private void updateDraftAttachments( + ulong draftId, + SysTime timestamp, + in ulong[] attachmentIdsToRemove, + in MultipartFile[] attachmentsToAdd, + AttachmentRepository attachmentRepo, + TransactionDraftRepository draftRepo +) { + foreach (file; attachmentsToAdd) { + ulong attachmentId = attachmentRepo.save(timestamp, file.name, file.contentType, file.content); + draftRepo.linkAttachment(draftId, attachmentId); + } + foreach (idToRemove; attachmentIdsToRemove) { + attachmentRepo.remove(idToRemove); + } +} diff --git a/finnow-api/sql/insert_line_item_draft.sql b/finnow-api/sql/insert_line_item_draft.sql new file mode 100644 index 0000000..94a0a8f --- /dev/null +++ b/finnow-api/sql/insert_line_item_draft.sql @@ -0,0 +1,8 @@ +INSERT INTO transaction_draft_line_item ( + draft_id, + idx, + value_per_item, + quantity, + description, + category_id +) VALUES (?, ?, ?, ?, ?, ?) \ No newline at end of file diff --git a/finnow-api/sql/insert_transaction_draft.sql b/finnow-api/sql/insert_transaction_draft.sql new file mode 100644 index 0000000..3061be1 --- /dev/null +++ b/finnow-api/sql/insert_transaction_draft.sql @@ -0,0 +1,13 @@ +INSERT INTO transaction_draft ( + added_at, + template_name, + timestamp, + amount, + currency, + description, + internal_transfer, + vendor_id, + category_id, + credited_account_id, + debited_account_id +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \ No newline at end of file diff --git a/finnow-api/sql/query/get_line_items_draft.sql b/finnow-api/sql/query/get_line_items_draft.sql new file mode 100644 index 0000000..ed6c59b --- /dev/null +++ b/finnow-api/sql/query/get_line_items_draft.sql @@ -0,0 +1,17 @@ +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_draft_line_item i +LEFT JOIN transaction_category category + ON category.id = i.category_id +WHERE i.draft_id = ? +ORDER BY idx; \ No newline at end of file diff --git a/finnow-api/sql/query/get_transaction_draft.sql b/finnow-api/sql/query/get_transaction_draft.sql new file mode 100644 index 0000000..e69de29 diff --git a/finnow-api/sql/update_transaction_draft.sql b/finnow-api/sql/update_transaction_draft.sql new file mode 100644 index 0000000..61c59d6 --- /dev/null +++ b/finnow-api/sql/update_transaction_draft.sql @@ -0,0 +1,13 @@ +UPDATE transaction_draft +SET + template_name = ?, + timestamp = ?, + amount = ?, + currency = ?, + description = ?, + internal_transfer = ?, + vendor_id = ?, + category_id = ?, + credited_account_id = ?, + debited_account_id = ? +WHERE id = ? \ No newline at end of file -- 2.34.1 From 13aadc2358f623335c723fbf68b29641ef2fd054 Mon Sep 17 00:00:00 2001 From: andrewlalis Date: Sat, 27 Jun 2026 21:59:05 -0400 Subject: [PATCH 09/18] Updated API client code to match new API endpoints. --- web-app/src/api/account.ts | 7 + web-app/src/api/transaction.ts | 152 +++++++++++++++--- web-app/src/components/LineItemCard.vue | 4 +- web-app/src/components/LineItemsEditor.vue | 4 +- .../src/pages/forms/EditTransactionPage.vue | 4 +- 5 files changed, 143 insertions(+), 28 deletions(-) diff --git a/web-app/src/api/account.ts b/web-app/src/api/account.ts index fcc57c4..2ba5c7a 100644 --- a/web-app/src/api/account.ts +++ b/web-app/src/api/account.ts @@ -59,6 +59,13 @@ export interface Account { currentBalance: number | null } +export interface SimpleAccountResponse { + id: number + name: string + type: string + numberSuffix: string +} + export interface AccountCreationPayload { type: string numberSuffix: string diff --git a/web-app/src/api/transaction.ts b/web-app/src/api/transaction.ts index ff74b2f..072843f 100644 --- a/web-app/src/api/transaction.ts +++ b/web-app/src/api/transaction.ts @@ -1,8 +1,28 @@ +import type { SimpleAccountResponse } from './account' import type { Attachment } from './attachment' import { ApiClient } from './base' import type { Currency } from './data' import { type Page, type PageRequest } from './pagination' +export interface SimpleVendorResponse { + id: number + name: string +} + +export interface SimpleCategoryResponse { + id: number + name: string + color: string +} + +export interface TransactionLineItemResponse { + idx: number + valuePerItem: number + quantity: number + description: string + category: TransactionCategory | null +} + export interface TransactionVendor { id: number name: string @@ -47,10 +67,10 @@ export interface TransactionsListItem { currency: Currency description: string internalTransfer: boolean - vendor: TransactionsListItemVendor | null - category: TransactionsListItemCategory | null - creditedAccount: TransactionsListItemAccount | null - debitedAccount: TransactionsListItemAccount | null + vendor: SimpleVendorResponse | null + category: SimpleCategoryResponse | null + creditedAccount: SimpleAccountResponse | null + debitedAccount: SimpleAccountResponse | null tags: string[] } @@ -82,28 +102,13 @@ export interface TransactionDetail { internalTransfer: boolean vendor: TransactionVendor | null category: TransactionCategory | null - creditedAccount: TransactionDetailAccount | null - debitedAccount: TransactionDetailAccount | null + creditedAccount: SimpleAccountResponse | null + debitedAccount: SimpleAccountResponse | null tags: string[] - lineItems: TransactionDetailLineItem[] + lineItems: TransactionLineItemResponse[] attachments: Attachment[] } -export interface TransactionDetailAccount { - id: number - name: string - type: string - numberSuffix: string -} - -export interface TransactionDetailLineItem { - idx: number - valuePerItem: number - quantity: number - description: string - category: TransactionCategory | null -} - export interface AddTransactionPayload { timestamp: string amount: number @@ -144,6 +149,56 @@ export interface AggregateTransactionData { currencies: AggregateTransactionCurrencyData[] } +export interface TransactionDraftListItem { + id: number + addedAt: string + templateName: string | null + timestamp: string | null + amount: number | null + currency: Currency | null + description: string | null + internalTransfer: boolean | null + vendor: SimpleVendorResponse | null + category: SimpleCategoryResponse | null + creditedAccount: SimpleAccountResponse | null + debitedAccount: SimpleAccountResponse | null + tags: string[] +} + +export interface TransactionDraftResponse { + id: number + addedAt: string + templateName: string | null + timestamp: string | null + amount: number | null + currency: Currency | null + description: string | null + internalTransfer: boolean | null + vendor: SimpleVendorResponse | null + category: SimpleCategoryResponse | null + creditedAccount: SimpleAccountResponse | null + debitedAccount: SimpleAccountResponse | null + tags: string[] + lineItems: TransactionLineItemResponse[] + attachments: Attachment[] +} + +export interface TransactionDraftPayload { + templateName: string | null + timestamp: string | null + amount: number | null + currencyCode: string | null + description: string | null + internalTransfer: boolean | null + vendorId: number | null + categoryId: number | null + creditedAccountId: number | null + debitedAccountId: number | null + tags: string[] + lineItems: AddTransactionPayloadLineItem[] + attachmentIdsToRemove: number[] +} + export class TransactionApiClient extends ApiClient { readonly path: string @@ -277,4 +332,57 @@ export class TransactionApiClient extends ApiClient { getAllTags(): Promise { return super.getJson(this.path + '/transaction-tags') } + + // Drafts: + + getDrafts( + paginationOptions: PageRequest | undefined = undefined, + ): Promise> { + return super.getJsonPage(this.path + '/transaction-drafts', paginationOptions) + } + + getTemplateDrafts( + paginationOptions: PageRequest | undefined = undefined, + ): Promise> { + const params = new URLSearchParams() + params.append('template', 'true') + if (paginationOptions !== undefined) { + params.append('page', paginationOptions.page + '') + params.append('size', paginationOptions.size + '') + for (const sort of paginationOptions.sorts) { + params.append('sort', sort.attribute + ',' + sort.dir) + } + } + return super.getJson(this.path + '/transaction-drafts?' + params.toString()) + } + + getDraft(id: number): Promise { + return super.getJson(this.path + '/transaction-drafts/' + id) + } + + addDraft(data: TransactionDraftPayload, files: File[] = []): Promise { + const formData = new FormData() + formData.append('payload', JSON.stringify(data)) + for (const file of files) { + formData.append('file', file) + } + return super.postFormData(this.path + '/transaction-drafts', formData) + } + + updateDraft( + id: number, + data: TransactionDraftPayload, + files: File[] = [], + ): Promise { + const formData = new FormData() + formData.append('payload', JSON.stringify(data)) + for (const file of files) { + formData.append('file', file) + } + return super.putFormData(this.path + '/transaction-drafts/' + id, formData) + } + + deleteDraft(id: number): Promise { + return super.delete(this.path + '/transaction-drafts/' + id) + } } diff --git a/web-app/src/components/LineItemCard.vue b/web-app/src/components/LineItemCard.vue index 423f326..e700ce9 100644 --- a/web-app/src/components/LineItemCard.vue +++ b/web-app/src/components/LineItemCard.vue @@ -1,10 +1,10 @@ diff --git a/web-app/src/pages/home/DraftsModule.vue b/web-app/src/pages/home/DraftsModule.vue new file mode 100644 index 0000000..08a4663 --- /dev/null +++ b/web-app/src/pages/home/DraftsModule.vue @@ -0,0 +1,49 @@ + + -- 2.34.1 From 9ce0ffd3a494225b0b663f84e7d9bd7049cec194 Mon Sep 17 00:00:00 2001 From: andrewlalis Date: Sun, 28 Jun 2026 13:43:29 -0400 Subject: [PATCH 11/18] Refactored editor page locations, started on generic editor logic. --- .../src/pages/{forms => }/EditAccountPage.vue | 0 .../EditTransactionPage.vue | 0 web-app/src/pages/transaction-editor/util.ts | 32 +++++++++++++++++++ web-app/src/router/index.ts | 8 ++--- 4 files changed, 36 insertions(+), 4 deletions(-) rename web-app/src/pages/{forms => }/EditAccountPage.vue (100%) rename web-app/src/pages/{forms => transaction-editor}/EditTransactionPage.vue (100%) create mode 100644 web-app/src/pages/transaction-editor/util.ts diff --git a/web-app/src/pages/forms/EditAccountPage.vue b/web-app/src/pages/EditAccountPage.vue similarity index 100% rename from web-app/src/pages/forms/EditAccountPage.vue rename to web-app/src/pages/EditAccountPage.vue diff --git a/web-app/src/pages/forms/EditTransactionPage.vue b/web-app/src/pages/transaction-editor/EditTransactionPage.vue similarity index 100% rename from web-app/src/pages/forms/EditTransactionPage.vue rename to web-app/src/pages/transaction-editor/EditTransactionPage.vue diff --git a/web-app/src/pages/transaction-editor/util.ts b/web-app/src/pages/transaction-editor/util.ts new file mode 100644 index 0000000..f693f4b --- /dev/null +++ b/web-app/src/pages/transaction-editor/util.ts @@ -0,0 +1,32 @@ +import type { Currency } from '@/api/data' +import type { TransactionLineItemResponse, TransactionVendor } from '@/api/transaction' + +/** + * The set of all form fields on the transaction editor page. Note that some + * fields may only be used in certain contexts. + */ +export interface TransactionEditorFormFields { + timestamp: string | null + amount: number | null + templateName: string | null // Only for drafts, not transactions. + currency: Currency | null + description: string | null + internalTransfer: boolean | null + vendor: TransactionVendor | null + categoryId: number | null + creditedAccountId: number | null + debitedAccountId: number | null + lineItems: TransactionLineItemResponse[] + tags: string[] + attachmentsToUpload: File[] + removedAttachmentIds: number[] +} + +/** + * Base class for transaction editor contexts. + */ +export abstract class TransactionEditorContextBase {} + +export class TransactionEditorContext extends TransactionEditorContextBase {} + +export class DraftEditorContext extends TransactionEditorContextBase {} diff --git a/web-app/src/router/index.ts b/web-app/src/router/index.ts index 2e665a9..91989d0 100644 --- a/web-app/src/router/index.ts +++ b/web-app/src/router/index.ts @@ -44,7 +44,7 @@ const router = createRouter({ }, { path: 'accounts/:id/edit', - component: () => import('@/pages/forms/EditAccountPage.vue'), + component: () => import('@/pages/EditAccountPage.vue'), meta: { title: 'Edit Account' }, }, { @@ -54,7 +54,7 @@ const router = createRouter({ }, { path: 'add-account', - component: () => import('@/pages/forms/EditAccountPage.vue'), + component: () => import('@/pages/EditAccountPage.vue'), meta: { title: 'Add Account' }, }, { @@ -64,7 +64,7 @@ const router = createRouter({ }, { path: 'transactions/:id/edit', - component: () => import('@/pages/forms/EditTransactionPage.vue'), + component: () => import('@/pages/transaction-editor/EditTransactionPage.vue'), meta: { title: 'Edit Transaction' }, }, { @@ -74,7 +74,7 @@ const router = createRouter({ }, { path: 'add-transaction', - component: () => import('@/pages/forms/EditTransactionPage.vue'), + component: () => import('@/pages/transaction-editor/EditTransactionPage.vue'), meta: { title: 'Add Transaction' }, }, { -- 2.34.1 From c7994b8282aa13fce3f666fe8a176be7f22f8da5 Mon Sep 17 00:00:00 2001 From: andrewlalis Date: Sun, 28 Jun 2026 13:47:52 -0400 Subject: [PATCH 12/18] Removed old unused tag form properties from editor page. --- .../src/pages/transaction-editor/EditTransactionPage.vue | 6 ------ 1 file changed, 6 deletions(-) diff --git a/web-app/src/pages/transaction-editor/EditTransactionPage.vue b/web-app/src/pages/transaction-editor/EditTransactionPage.vue index 8678950..8b29697 100644 --- a/web-app/src/pages/transaction-editor/EditTransactionPage.vue +++ b/web-app/src/pages/transaction-editor/EditTransactionPage.vue @@ -146,15 +146,9 @@ const creditedAccountId: Ref = ref(null) const debitedAccountId: Ref = ref(null) const lineItems: Ref = ref([]) const tags: Ref = ref([]) -const customTagInput = ref('') -const customTagInputValid = ref(false) const attachmentsToUpload: Ref = ref([]) const removedAttachmentIds: Ref = ref([]) -watch(customTagInput, (newValue: string) => { - const result = newValue.match('^[a-z0-9-_]{3,32}$') - customTagInputValid.value = result !== null && result.length > 0 -}) watch(availableCurrencies, (newValue: Currency[]) => { if (newValue.length === 1) { currency.value = newValue[0] -- 2.34.1 From ed9b53ee79d3c0486d8eabc2256936e1cffd38fb Mon Sep 17 00:00:00 2001 From: andrewlalis Date: Sun, 28 Jun 2026 20:26:39 -0400 Subject: [PATCH 13/18] Added actions and most of the rest of the editor context implementations. --- .../source/transaction/data_impl_sqlite.d | 3 +- .../EditTransactionPage.vue | 297 ++---------- web-app/src/pages/transaction-editor/util.ts | 426 +++++++++++++++++- web-app/src/router/index.ts | 17 +- 4 files changed, 483 insertions(+), 260 deletions(-) diff --git a/finnow-api/source/transaction/data_impl_sqlite.d b/finnow-api/source/transaction/data_impl_sqlite.d index 5c7791f..8445f6a 100644 --- a/finnow-api/source/transaction/data_impl_sqlite.d +++ b/finnow-api/source/transaction/data_impl_sqlite.d @@ -703,7 +703,8 @@ class SqliteTransactionDraftRepository : TransactionDraftRepository { )); } return item; - } + }, + draft.id ); // Return the response, excluding attachments (they are fetched using the attachment repo). return Optional!TransactionDraftResponse.of(response); diff --git a/web-app/src/pages/transaction-editor/EditTransactionPage.vue b/web-app/src/pages/transaction-editor/EditTransactionPage.vue index 8b29697..502f6d7 100644 --- a/web-app/src/pages/transaction-editor/EditTransactionPage.vue +++ b/web-app/src/pages/transaction-editor/EditTransactionPage.vue @@ -12,115 +12,32 @@ The form consists of a few main sections: diff --git a/web-app/src/pages/transaction-editor/util.ts b/web-app/src/pages/transaction-editor/util.ts index f693f4b..085e377 100644 --- a/web-app/src/pages/transaction-editor/util.ts +++ b/web-app/src/pages/transaction-editor/util.ts @@ -1,5 +1,17 @@ -import type { Currency } from '@/api/data' -import type { TransactionLineItemResponse, TransactionVendor } from '@/api/transaction' +import type { Attachment } from '@/api/attachment' +import { floatMoneyToInteger, integerMoneyToFloat, type Currency } from '@/api/data' +import { getSelectedProfile } from '@/api/profile' +import { + TransactionApiClient, + type AddTransactionPayload, + type TransactionDetail, + type TransactionDraftPayload, + type TransactionDraftResponse, + type TransactionLineItemResponse, + type TransactionVendor, +} from '@/api/transaction' +import { getDatetimeLocalValueForNow } from '@/util/time' +import type { RouteLocation, Router } from 'vue-router' /** * The set of all form fields on the transaction editor page. Note that some @@ -20,13 +32,417 @@ export interface TransactionEditorFormFields { tags: string[] attachmentsToUpload: File[] removedAttachmentIds: number[] + existingAttachments: Attachment[] +} + +export function defaultEmptyFormFields(): TransactionEditorFormFields { + return { + timestamp: null, + amount: null, + templateName: null, + currency: null, + description: null, + internalTransfer: null, + vendor: null, + categoryId: null, + creditedAccountId: null, + debitedAccountId: null, + lineItems: [], + tags: [], + attachmentsToUpload: [], + removedAttachmentIds: [], + existingAttachments: [], + } +} + +export interface TransactionEditorAction { + name: string + disabled: boolean + callback: ( + formData: TransactionEditorFormFields, + route: RouteLocation, + router: Router, + ) => Promise } /** * Base class for transaction editor contexts. */ -export abstract class TransactionEditorContextBase {} +export interface TransactionEditorContextBase { + isFormDataValid(formData: TransactionEditorFormFields): boolean + areChangesPresent(formData: TransactionEditorFormFields): boolean + initializeFormFields( + queryParams: Record, + ): TransactionEditorFormFields + getActions(formData: TransactionEditorFormFields): TransactionEditorAction[] +} -export class TransactionEditorContext extends TransactionEditorContextBase {} +/** + * Editor context that's used when the user starts editing a new transaction, + * not related to any saved transaction or draft. + */ +export class NewTransactionEditorContext implements TransactionEditorContextBase { + isFormDataValid(formData: TransactionEditorFormFields): boolean { + return ( + formData.amount !== null && + formData.amount > 0 && + formData.timestamp !== null && + formData.currency !== null + ) + } -export class DraftEditorContext extends TransactionEditorContextBase {} + areChangesPresent(formData: TransactionEditorFormFields): boolean { + return ( + formData.timestamp !== null || + formData.amount !== null || + formData.templateName !== null || + formData.currency !== null || + formData.description !== null || + formData.internalTransfer !== null || + formData.vendor !== null || + formData.creditedAccountId !== null || + formData.debitedAccountId !== null || + formData.lineItems.length > 0 || + formData.tags.length > 0 || + formData.attachmentsToUpload.length > 0 + ) + } + + initializeFormFields( + queryParams: Record, + ): TransactionEditorFormFields { + const fields = defaultEmptyFormFields() + fields.timestamp = getDatetimeLocalValueForNow() + if ('credited-account' in queryParams) { + fields.creditedAccountId = parseInt(queryParams['credited-account'] as string) + } + if ('debited-account' in queryParams) { + fields.debitedAccountId = parseInt(queryParams['debited-account'] as string) + } + return fields + } + + getActions(formData: TransactionEditorFormFields): TransactionEditorAction[] { + return [ + { + name: 'Save', + disabled: !(this.areChangesPresent(formData) && this.isFormDataValid(formData)), + callback: async (formData, route, router) => { + const api = new TransactionApiClient(getSelectedProfile(route)) + // Assume that form data is valid! + const data = toTransactionPayload(formData) + const txn = await api.addTransaction(data, formData.attachmentsToUpload) + await router.replace(`/profiles/${getSelectedProfile(route)}/transactions/${txn.id}`) + }, + }, + { + name: 'Save Draft', + disabled: !this.areChangesPresent(formData), + callback: async (formData, route, router) => { + const api = new TransactionApiClient(getSelectedProfile(route)) + const data = toDraftPayload(formData) + const draft = await api.addDraft(data, formData.attachmentsToUpload) + await router.replace( + `/profiles/${getSelectedProfile(route)}/transaction-drafts/${draft.id}`, + ) + }, + }, + { + name: 'Cancel', + disabled: false, + callback: async (_formData, route, router) => { + await goBackOrHome(router, route) + }, + }, + ] + } +} + +/** + * Editor context for when the user is editing a transaction. + */ +export class TransactionEditorContext implements TransactionEditorContextBase { + private existingTransaction: TransactionDetail + + constructor(existingTransaction: TransactionDetail) { + this.existingTransaction = existingTransaction + } + + isFormDataValid(formData: TransactionEditorFormFields): boolean { + return ( + formData.timestamp !== null && + formData.timestamp.length > 0 && + formData.amount !== null && + formData.amount > 0 && + formData.currency !== null && + formData.description !== null && + formData.description.length > 0 && + (formData.creditedAccountId !== null || formData.debitedAccountId !== null) && + formData.creditedAccountId !== formData.debitedAccountId + ) + } + + areChangesPresent(formData: TransactionEditorFormFields): boolean { + const tx: TransactionDetail = this.existingTransaction + const tagsChanged = + formData.tags.every((t) => tx.tags.includes(t)) && + tx.tags.every((t) => formData.tags.includes(t)) + const lineItemsChanged = + JSON.stringify(formData.lineItems) !== JSON.stringify(formData.lineItems) + const attachmentsChanged = + formData.attachmentsToUpload.length > 0 || formData.removedAttachmentIds.length > 0 + + const timestampChanged = new Date(formData.timestamp ?? 0).toISOString() !== tx.timestamp + const amountChanged = + floatMoneyToInteger(formData.amount ?? 0, formData.currency ?? tx.currency) !== tx.amount + const currencyChanged = formData.currency?.code !== tx.currency.code + const descriptionChanged = formData.description !== tx.description + const internalTransferChanged = formData.internalTransfer !== tx.internalTransfer + const vendorChanged = formData.vendor?.id !== tx.vendor?.id + const categoryChanged = formData.categoryId !== (tx.category?.id ?? null) + const creditedAccountChanged = formData.creditedAccountId !== (tx.creditedAccount?.id ?? null) + const debitedAccountChanged = formData.debitedAccountId !== (tx.debitedAccount?.id ?? null) + + return ( + tagsChanged || + lineItemsChanged || + attachmentsChanged || + timestampChanged || + amountChanged || + currencyChanged || + descriptionChanged || + internalTransferChanged || + vendorChanged || + categoryChanged || + creditedAccountChanged || + debitedAccountChanged + ) + } + + initializeFormFields(): TransactionEditorFormFields { + const tx = this.existingTransaction + return { + timestamp: getLocalDateTimeStringFromUTCTimestamp(tx.timestamp), + amount: integerMoneyToFloat(tx.amount, tx.currency), + templateName: null, + currency: tx.currency, + description: tx.description, + internalTransfer: tx.internalTransfer, + vendor: tx.vendor ?? null, + categoryId: tx.category?.id ?? null, + creditedAccountId: tx.creditedAccount?.id ?? null, + debitedAccountId: tx.debitedAccount?.id ?? null, + lineItems: [...tx.lineItems], + tags: [...tx.tags], + attachmentsToUpload: [], + removedAttachmentIds: [], + existingAttachments: [...tx.attachments], + } + } + + getActions(formData: TransactionEditorFormFields): TransactionEditorAction[] { + return [ + { + name: 'Save', + disabled: !this.areChangesPresent(formData) || !this.isFormDataValid(formData), + callback: async (formData, route, router) => { + const api = new TransactionApiClient(getSelectedProfile(route)) + // Assume that form data is valid! + const data = toTransactionPayload(formData) + const txn = await api.updateTransaction( + this.existingTransaction.id, + data, + formData.attachmentsToUpload, + ) + await router.replace(`/profiles/${getSelectedProfile(route)}/transactions/${txn.id}`) + }, + }, + { + name: 'Cancel', + disabled: false, + callback: async (_formData, route, router) => { + await goBackOrHome(router, route) + }, + }, + ] + } +} + +/** + * Editor context for when the user is editing an existing draft. + */ +export class DraftEditorContext implements TransactionEditorContextBase { + private existingDraft: TransactionDraftResponse + + constructor(existingDraft: TransactionDraftResponse) { + this.existingDraft = existingDraft + } + + isFormDataValid(): boolean { + // TODO: What validation is needed client-side for draft data? + return true + } + + areChangesPresent(): boolean { + return true + } + + initializeFormFields(): TransactionEditorFormFields { + const d = this.existingDraft + const fields = defaultEmptyFormFields() + if (d.timestamp !== null) { + fields.timestamp = getLocalDateTimeStringFromUTCTimestamp(d.timestamp) + } + if (d.amount !== null && d.currency !== null) { + fields.currency = d.currency + fields.amount = integerMoneyToFloat(d.amount, d.currency) + } + fields.description = d.description + fields.internalTransfer = d.internalTransfer + if (d.vendor !== null) { + fields.vendor = { + id: d.vendor.id, + name: d.vendor.name, + description: '', + } + // TODO: Update TransactionDraftResponse format to include full vendor data. + } + if (d.category !== null) { + fields.categoryId = d.category.id + } + if (d.creditedAccount !== null) { + fields.creditedAccountId = d.creditedAccount.id + } + if (d.debitedAccount !== null) { + fields.debitedAccountId = d.debitedAccount.id + } + fields.lineItems = [...d.lineItems] + fields.tags = [...d.tags] + fields.existingAttachments = [...d.attachments] + return fields + } + + getActions(): TransactionEditorAction[] { + return [ + { + name: 'Save', + disabled: !this.areChangesPresent() || !this.isFormDataValid(), + callback: async (formData, route, router) => { + const api = new TransactionApiClient(getSelectedProfile(route)) + const data = toDraftPayload(formData) + const draft = await api.updateDraft( + this.existingDraft.id, + data, + formData.attachmentsToUpload, + ) + await router.replace( + `/profiles/${getSelectedProfile(route)}/transaction-drafts/${draft.id}`, + ) + }, + }, + { + name: 'Cancel', + disabled: false, + callback: async (_formData, route, router) => { + await goBackOrHome(router, route) + }, + }, + ] + } +} + +/** + * Obtains an editor context by determining what the user is doing based on the + * route they've navigated to. + * @param route The current route (which tells us what intent the user has). + * @returns A promise that resolves to an editor context. + */ +export async function loadEditorContextFromRoute( + route: RouteLocation, +): Promise { + const transactionApi = new TransactionApiClient(getSelectedProfile(route)) + if (route.name === 'edit-transaction') { + const transactionIdStr = route.params.id + if (transactionIdStr && typeof transactionIdStr === 'string') { + const transactionId = parseInt(transactionIdStr) + const existingTransaction = await transactionApi.getTransaction(transactionId) + return new TransactionEditorContext(existingTransaction) + } + } else if (route.name === 'edit-draft') { + const draftIdStr = route.params.id + if (draftIdStr && typeof draftIdStr === 'string') { + const draftId = parseInt(draftIdStr) + const existingDraft = await transactionApi.getDraft(draftId) + return new DraftEditorContext(existingDraft) + } + } + + return new NewTransactionEditorContext() +} + +function getLocalDateTimeStringFromUTCTimestamp(timestamp: string) { + const date = new Date(timestamp) + date.setMilliseconds(0) + const timezoneOffset = new Date().getTimezoneOffset() * 60_000 + return new Date(date.getTime() - timezoneOffset).toISOString().slice(0, -1) +} + +function toDraftPayload(formData: TransactionEditorFormFields): TransactionDraftPayload { + if (typeof formData.amount === 'string') { + formData.amount = null + } + let isoTimestamp = null + if (formData.timestamp !== null && formData.timestamp.length > 0) { + isoTimestamp = new Date(formData.timestamp).toISOString() + } + return { + templateName: formData.templateName, + timestamp: isoTimestamp, + amount: formData.amount, + currencyCode: formData.currency?.code ?? null, + description: formData.description, + internalTransfer: formData.internalTransfer, + vendorId: formData.vendor?.id ?? null, + categoryId: formData.categoryId, + creditedAccountId: formData.creditedAccountId, + debitedAccountId: formData.debitedAccountId, + tags: [...formData.tags], + lineItems: [...formData.lineItems].map((li) => { + return { + valuePerItem: li.valuePerItem, + quantity: li.quantity, + description: li.description, + categoryId: li.category?.id ?? null, + } + }), + attachmentIdsToRemove: [...formData.removedAttachmentIds], + } +} + +function toTransactionPayload(formData: TransactionEditorFormFields): AddTransactionPayload { + const payload: AddTransactionPayload = { + timestamp: new Date(formData.timestamp!).toISOString(), + amount: floatMoneyToInteger(formData.amount!, formData.currency!), + currencyCode: formData.currency!.code, + description: formData.description ?? '', + internalTransfer: formData.internalTransfer ?? false, + vendorId: formData.vendor?.id ?? null, + categoryId: formData.categoryId, + creditedAccountId: formData.creditedAccountId, + debitedAccountId: formData.debitedAccountId, + tags: formData.tags, + lineItems: formData.lineItems.map((li) => { + return { ...li, categoryId: li.category?.id ?? null } + }), + attachmentIdsToRemove: formData.removedAttachmentIds, + } + return payload +} + +async function goBackOrHome(router: Router, route: RouteLocation) { + if (window.history.length > 0) { + await router.back() + } else { + await router.replace(`/profiles/${getSelectedProfile(route)}`) + } +} diff --git a/web-app/src/router/index.ts b/web-app/src/router/index.ts index 91989d0..03ea512 100644 --- a/web-app/src/router/index.ts +++ b/web-app/src/router/index.ts @@ -63,20 +63,27 @@ const router = createRouter({ meta: { title: 'Transaction' }, }, { + name: 'edit-transaction', path: 'transactions/:id/edit', component: () => import('@/pages/transaction-editor/EditTransactionPage.vue'), meta: { title: 'Edit Transaction' }, }, - { - path: 'transactions/search', - component: () => import('@/pages/TransactionSearchPage.vue'), - meta: { title: 'Search Transactions' }, - }, { path: 'add-transaction', component: () => import('@/pages/transaction-editor/EditTransactionPage.vue'), meta: { title: 'Add Transaction' }, }, + { + name: 'edit-draft', + path: 'transaction-drafts/:id/edit', + component: () => import('@/pages/transaction-editor/EditTransactionPage.vue'), + meta: { title: 'Edit Draft' }, + }, + { + path: 'transactions/search', + component: () => import('@/pages/TransactionSearchPage.vue'), + meta: { title: 'Search Transactions' }, + }, { path: 'vendors', component: () => import('@/pages/VendorsPage.vue'), -- 2.34.1 From 23cfe0b1a976b270fe27325285284dfd3d2e6f96 Mon Sep 17 00:00:00 2001 From: andrewlalis Date: Sun, 28 Jun 2026 21:33:08 -0400 Subject: [PATCH 14/18] Added draft card and page components, added specialization for template drafts. --- .../source/transaction/data_impl_sqlite.d | 3 +- .../src/components/TransactionDraftCard.vue | 115 +++++++++++ web-app/src/pages/TransactionDraftPage.vue | 188 ++++++++++++++++++ web-app/src/pages/home/DraftsModule.vue | 9 +- .../EditTransactionPage.vue | 29 ++- web-app/src/pages/transaction-editor/util.ts | 9 + web-app/src/router/index.ts | 5 + 7 files changed, 352 insertions(+), 6 deletions(-) create mode 100644 web-app/src/components/TransactionDraftCard.vue create mode 100644 web-app/src/pages/TransactionDraftPage.vue diff --git a/finnow-api/source/transaction/data_impl_sqlite.d b/finnow-api/source/transaction/data_impl_sqlite.d index 8445f6a..bb2077f 100644 --- a/finnow-api/source/transaction/data_impl_sqlite.d +++ b/finnow-api/source/transaction/data_impl_sqlite.d @@ -682,6 +682,7 @@ class SqliteTransactionDraftRepository : TransactionDraftRepository { response.category = draft.category; response.creditedAccount = draft.creditedAccount; response.debitedAccount = draft.debitedAccount; + response.tags = li.value.tags; response.lineItems = util.sqlite.findAll( db, import("sql/query/get_line_items_draft.sql"), @@ -881,7 +882,7 @@ class SqliteTransactionDraftRepository : TransactionDraftRepository { row.peek!string(20) ).toOptional; } - string aggregateTags = row.peek!(string, PeekMode.slice)(21); + string aggregateTags = row.peek!string(21); if (aggregateTags !is null) { import std.string : split; item.tags = aggregateTags.split(","); diff --git a/web-app/src/components/TransactionDraftCard.vue b/web-app/src/components/TransactionDraftCard.vue new file mode 100644 index 0000000..a0a4bab --- /dev/null +++ b/web-app/src/components/TransactionDraftCard.vue @@ -0,0 +1,115 @@ + + + diff --git a/web-app/src/pages/TransactionDraftPage.vue b/web-app/src/pages/TransactionDraftPage.vue new file mode 100644 index 0000000..8098838 --- /dev/null +++ b/web-app/src/pages/TransactionDraftPage.vue @@ -0,0 +1,188 @@ + + diff --git a/web-app/src/pages/home/DraftsModule.vue b/web-app/src/pages/home/DraftsModule.vue index 08a4663..a48aa96 100644 --- a/web-app/src/pages/home/DraftsModule.vue +++ b/web-app/src/pages/home/DraftsModule.vue @@ -6,6 +6,7 @@ import HomeModule from '@/components/HomeModule.vue' import { useRoute } from 'vue-router' import { onMounted, ref, type Ref } from 'vue' import { getSelectedProfile } from '@/api/profile' +import TransactionDraftCard from '@/components/TransactionDraftCard.vue' const route = useRoute() const page: Ref> = ref({ @@ -38,12 +39,12 @@ async function fetchPage(pageRequest: PageRequest) { @update="(pr) => fetchPage(pr)" class="align-right" /> -
- Draft ID: {{ draft.id }} Template name: {{ draft.templateName }} -
+ :draft="draft" + /> +

There are no drafts.

diff --git a/web-app/src/pages/transaction-editor/EditTransactionPage.vue b/web-app/src/pages/transaction-editor/EditTransactionPage.vue index 502f6d7..cd555e4 100644 --- a/web-app/src/pages/transaction-editor/EditTransactionPage.vue +++ b/web-app/src/pages/transaction-editor/EditTransactionPage.vue @@ -25,8 +25,10 @@ import VendorSelect from '@/components/VendorSelect.vue' import TagsSelect from '@/components/TagsSelect.vue' import { defaultEmptyFormFields, + DraftEditorContext, loadEditorContextFromRoute, NewTransactionEditorContext, + TransactionEditorContext, type TransactionEditorContextBase, type TransactionEditorFormFields, } from './util' @@ -54,6 +56,16 @@ const availableAccounts = computed(() => { const loading = ref(false) const formData: Ref = ref(defaultEmptyFormFields()) const editorContext: Ref = ref(new NewTransactionEditorContext()) +const pageTitle = computed(() => { + if (editorContext.value instanceof NewTransactionEditorContext) { + return 'Add Transaction' + } else if (editorContext.value instanceof DraftEditorContext) { + return 'Edit Draft Transaction' + } else if (editorContext.value instanceof TransactionEditorContext) { + return 'Edit Transaction' + } + return 'Edit Transaction' +}) watch(availableCurrencies, (newValue: Currency[]) => { if (newValue.length === 1) { @@ -77,8 +89,23 @@ onMounted(async () => { })