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