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