module transaction.service; import handy_http_primitives; import std.datetime; import slf4d; import transaction.api; import transaction.model; import transaction.data; import transaction.dto; import profile.data; import account.model; import account.data; import util.money; import util.pagination; import util.data; import attachment.data; import attachment.dto; // Transactions Services Page!TransactionsListItem getTransactions(ProfileDataSource ds, in PageRequest pageRequest) { Page!TransactionsListItem page = ds.getTransactionRepository() .findAll(pageRequest); return page; } TransactionDetail getTransaction(ProfileDataSource ds, ulong transactionId) { import std.algorithm : map; import std.array : array; TransactionDetail txn = ds.getTransactionRepository().findById(transactionId) .orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND)); txn.attachments = ds.getAttachmentRepository().findAllByTransactionId(txn.id) .map!(AttachmentResponse.of) .array; return txn; } TransactionDetail addTransaction(ProfileDataSource ds, in AddTransactionPayload payload, in MultipartFile[] files) { TransactionRepository txnRepo = ds.getTransactionRepository(); TransactionVendorRepository vendorRepo = ds.getTransactionVendorRepository(); TransactionCategoryRepository categoryRepo = ds.getTransactionCategoryRepository(); AccountRepository accountRepo = ds.getAccountRepository(); AttachmentRepository attachmentRepo = ds.getAttachmentRepository(); validateTransactionPayload(vendorRepo, categoryRepo, accountRepo, payload); SysTime timestamp = SysTime.fromISOExtString(payload.timestamp, UTC()); // Add the transaction: ulong txnId; ds.doTransaction(() { TransactionDetail txn = txnRepo.insert(payload); AccountJournalEntryRepository jeRepo = ds.getAccountJournalEntryRepository(); if (!payload.creditedAccountId.isNull) { jeRepo.insert( timestamp, payload.creditedAccountId.get, txn.id, txn.amount, AccountJournalEntryType.CREDIT, txn.currency ); } if (!payload.debitedAccountId.isNull) { jeRepo.insert( timestamp, payload.debitedAccountId.get, txn.id, txn.amount, AccountJournalEntryType.DEBIT, txn.currency ); } TransactionTagRepository tagRepo = ds.getTransactionTagRepository(); tagRepo.updateTags(txn.id, payload.tags); updateAttachments(txn.id, timestamp, payload.attachmentIdsToRemove, files, attachmentRepo, txnRepo); txnId = txn.id; }); return getTransaction(ds, txnId); } TransactionDetail updateTransaction( ProfileDataSource ds, ulong transactionId, in AddTransactionPayload payload, in MultipartFile[] files ) { TransactionVendorRepository vendorRepo = ds.getTransactionVendorRepository(); TransactionCategoryRepository categoryRepo = ds.getTransactionCategoryRepository(); AccountRepository accountRepo = ds.getAccountRepository(); TransactionRepository transactionRepo = ds.getTransactionRepository(); TransactionTagRepository tagRepo = ds.getTransactionTagRepository(); AttachmentRepository attachmentRepo = ds.getAttachmentRepository(); validateTransactionPayload(vendorRepo, categoryRepo, accountRepo, payload); SysTime timestamp = SysTime.fromISOExtString(payload.timestamp, UTC()); const TransactionDetail prev = transactionRepo.findById(transactionId) .orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND)); // Update the transaction: ds.doTransaction(() { TransactionDetail curr = transactionRepo.update(transactionId, payload); updateLinkedAccountJournalEntries(prev, curr, payload, ds, timestamp); tagRepo.updateTags(transactionId, payload.tags); updateAttachments(curr.id, timestamp, payload.attachmentIdsToRemove, files, attachmentRepo, transactionRepo); }); return getTransaction(ds, transactionId); } private void updateLinkedAccountJournalEntries( in TransactionDetail prev, in TransactionDetail curr, in AddTransactionPayload payload, ProfileDataSource ds, in SysTime timestamp ) { 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.isNull && !payload.creditedAccountId.isNull && prev.creditedAccount.get.id != payload.creditedAccountId.get ) ); const bool updateDebitEntry = amountOrCurrencyChanged || ( (prev.debitedAccount.isNull && !payload.creditedAccountId.isNull) || (!prev.debitedAccount.isNull && payload.debitedAccountId.isNull) || ( !prev.debitedAccount.isNull && !payload.debitedAccountId.isNull && prev.debitedAccount.get.id != payload.debitedAccountId.get ) ); // Update journal entries if necessary: if (updateCreditEntry && !prev.creditedAccount.isNull) { jeRepo.deleteByAccountIdAndTransactionId(prev.creditedAccount.get.id, prev.id); } if (updateCreditEntry && !payload.creditedAccountId.isNull) { jeRepo.insert( timestamp, payload.creditedAccountId.get, curr.id, curr.amount, AccountJournalEntryType.CREDIT, curr.currency ); } if (updateDebitEntry && !prev.debitedAccount.isNull) { jeRepo.deleteByAccountIdAndTransactionId(prev.debitedAccount.get.id, prev.id); } if (updateDebitEntry && !payload.debitedAccountId.isNull) { jeRepo.insert( timestamp, payload.debitedAccountId.get, curr.id, curr.amount, AccountJournalEntryType.DEBIT, curr.currency ); } } void deleteTransaction(ProfileDataSource ds, ulong transactionId) { TransactionRepository txnRepo = ds.getTransactionRepository(); AttachmentRepository attachmentRepo = ds.getAttachmentRepository(); TransactionDetail txn = txnRepo.findById(transactionId) .orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND)); ds.doTransaction(() { // First delete all attachments. foreach (a; attachmentRepo.findAllByTransactionId(txn.id)) { attachmentRepo.remove(a.id); } txnRepo.deleteById(txn.id); }); } private void validateTransactionPayload( TransactionVendorRepository vendorRepo, TransactionCategoryRepository categoryRepo, AccountRepository accountRepo, in AddTransactionPayload payload ) { if (payload.creditedAccountId.isNull && payload.debitedAccountId.isNull) { throw new HttpStatusException(HttpStatus.BAD_REQUEST, "At least one account must be linked."); } if ( !payload.creditedAccountId.isNull && !payload.debitedAccountId.isNull && payload.creditedAccountId.get == payload.debitedAccountId.get ) { throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Cannot link the same account as both credit and debit."); } if (payload.amount == 0) { throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Amount should be greater than 0."); } SysTime now = Clock.currTime(UTC()); SysTime timestamp; try { timestamp = SysTime.fromISOExtString(payload.timestamp, UTC()); } catch (TimeException e) { throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid timestamp format. Expected ISO-8601 datetime."); } if (timestamp > now) { throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Cannot create transaction in the future."); } if (!payload.vendorId.isNull && !vendorRepo.existsById(payload.vendorId.get)) { throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Vendor doesn't exist."); } if (!payload.categoryId.isNull && !categoryRepo.existsById(payload.categoryId.get)) { throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Category doesn't exist."); } if (!payload.creditedAccountId.isNull && !accountRepo.existsById(payload.creditedAccountId.get)) { throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Credited account doesn't exist."); } if (!payload.debitedAccountId.isNull && !accountRepo.existsById(payload.debitedAccountId.get)) { throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Debited account doesn't exist."); } foreach (tag; payload.tags) { import std.regex; auto r = ctRegex!(`^[a-z0-9-_]{3,32}$`); if (!matchFirst(tag, r)) { throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid tag: \"" ~ tag ~ "\"."); } } if (payload.lineItems.length > 0) { long lineItemsTotal = 0; foreach (lineItem; payload.lineItems) { if (!lineItem.categoryId.isNull && !categoryRepo.existsById(lineItem.categoryId.get)) { throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Line item's category doesn't exist."); } if (lineItem.quantity == 0) { throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Line item's quantity should greater than zero."); } for (ulong i = 0; i < lineItem.quantity; i++) { lineItemsTotal += lineItem.valuePerItem; } } if (lineItemsTotal != payload.amount) { throw new HttpStatusException( HttpStatus.BAD_REQUEST, "Total of all line items doesn't equal the transaction's total." ); } } } /** * Helper function to add / remove attachments for a transaction. */ void updateAttachments( ulong transactionId, SysTime timestamp, in ulong[] attachmentIdsToRemove, in MultipartFile[] attachmentsToAdd, AttachmentRepository attachmentRepo, TransactionRepository txnRepo ) { // Save & link attachment files: foreach (file; attachmentsToAdd) { ulong attachmentId = attachmentRepo.save(timestamp, file.name, file.contentType, file.content); txnRepo.linkAttachment(transactionId, attachmentId); } // Delete attachments (this cascades to delete the link record in transaction_attachment). foreach (idToRemove; attachmentIdsToRemove) { attachmentRepo.remove(idToRemove); } } // Vendors Services TransactionVendor[] getAllVendors(ProfileDataSource ds) { return ds.getTransactionVendorRepository().findAll(); } TransactionVendor getVendor(ProfileDataSource ds, ulong vendorId) { return ds.getTransactionVendorRepository().findById(vendorId) .orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND)); } TransactionVendor createVendor(ProfileDataSource ds, in VendorPayload payload) { auto vendorRepo = ds.getTransactionVendorRepository(); if (vendorRepo.existsByName(payload.name)) { throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Vendor name is already in use."); } return vendorRepo.insert(payload.name, payload.description); } TransactionVendor updateVendor(ProfileDataSource ds, ulong vendorId, in VendorPayload payload) { TransactionVendorRepository repo = ds.getTransactionVendorRepository(); TransactionVendor existingVendor = repo.findById(vendorId) .orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND)); if (payload.name != existingVendor.name && repo.existsByName(payload.name)) { throw new HttpStatusException( HttpStatus.BAD_REQUEST, "Vendor name is already in use." ); } return repo.updateById(vendorId, payload.name, payload.description); } void deleteVendor(ProfileDataSource ds, ulong vendorId) { ds.getTransactionVendorRepository().deleteById(vendorId); } // Categories Services TransactionCategoryTree[] getCategories(ProfileDataSource ds) { TransactionCategoryRepository repo = ds.getTransactionCategoryRepository(); return getCategoriesRecursive(repo, Optional!ulong.empty, 0); } private TransactionCategoryTree[] getCategoriesRecursive( TransactionCategoryRepository repo, Optional!ulong parentId, uint depth ) { import util.data : toNullable; TransactionCategoryTree[] nodes; foreach (category; repo.findAllByParentId(parentId)) { nodes ~= TransactionCategoryTree( category.id, parentId, category.name, category.description, category.color, getCategoriesRecursive(repo, Optional!ulong.of(category.id), depth + 1), depth ); } return nodes; } TransactionCategoryResponse getCategory(ProfileDataSource ds, ulong categoryId) { auto category = ds.getTransactionCategoryRepository().findById(categoryId) .orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND)); return TransactionCategoryResponse.of(category); } TransactionCategoryResponse[] getChildCategories(ProfileDataSource ds, ulong categoryId) { import std.algorithm : map; import std.array : array; auto categories = ds.getTransactionCategoryRepository().findAllByParentId(Optional!ulong.of(categoryId)); return categories.map!(c => TransactionCategoryResponse.of(c)).array; } TransactionCategoryResponse createCategory(ProfileDataSource ds, in CategoryPayload payload) { TransactionCategoryRepository repo = ds.getTransactionCategoryRepository(); if (payload.name is null || payload.name.length == 0) { throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Missing name."); } if (repo.existsByName(payload.name)) { throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Name already in use."); } if (!payload.parentId.isNull && !repo.existsById(payload.parentId.get)) { throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid parent id."); } import std.regex; const colorHexRegex = ctRegex!`^(([0-9a-fA-F]{2}){3}|([0-9a-fA-F]){3})$`; if (payload.color is null || matchFirst(payload.color, colorHexRegex).empty) { throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid color hex string."); } auto category = repo.insert( toOptional(payload.parentId), payload.name, payload.description, payload.color ); return TransactionCategoryResponse.of(category); } TransactionCategoryResponse updateCategory(ProfileDataSource ds, ulong categoryId, in CategoryPayload payload) { TransactionCategoryRepository repo = ds.getTransactionCategoryRepository(); if (payload.name is null || payload.name.length == 0) { throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Missing name."); } TransactionCategory prev = repo.findById(categoryId) .orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND)); 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)) { throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid parent id."); } TransactionCategory curr = repo.updateById( categoryId, payload.name, payload.description, payload.color ); return TransactionCategoryResponse.of(curr); } void deleteCategory(ProfileDataSource ds, ulong categoryId) { ds.getTransactionCategoryRepository().deleteById(categoryId); }