finnow/finnow-api/source/transaction/service.d

401 lines
16 KiB
D

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!(TransactionCategoryResponse.of).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);
}