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

499 lines
19 KiB
D

module transaction.service;
import handy_http_primitives;
import streams : isByteOutputStream;
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);
}
}
/**
* Exports transaction data to a file for download.
* Params:
* ds = The profile datasource to use.
* request = The request to read filter parameters from.
* response = The response to write the file to.
*/
void exportTransactionsToFile(
ProfileDataSource ds,
in ServerHttpRequest request,
ref ServerHttpResponse response
) {
Page!TransactionsListItem data = ds.getTransactionRepository()
.search(PageRequest.unpaged(), request);
ExportFileFormat fileFormat = getPreferredExportFileFormat(request);
if (fileFormat == ExportFileFormat.JSON) {
import handy_http_data : writeJsonBody;
addFileExportHeaders(response, "transactions.json", ContentTypes.APPLICATION_JSON);
writeJsonBody(response, data.items);
} else if (fileFormat == ExportFileFormat.CSV) {
addFileExportHeaders(response, "transactions.csv", ContentTypes.TEXT_CSV);
writeTransactionsCsvExport(&response.outputStream, data.items);
} else {
throw new HttpStatusException(
HttpStatus.BAD_REQUEST,
"Invalid file export format requested. JSON or CSV are permitted."
);
}
}
private void writeTransactionsCsvExport(S)(
S outputStream,
in TransactionsListItem[] items
) if (isByteOutputStream!S) {
import util.csv;
import std.format : format;
import std.conv : to;
import std.string : join;
CsvStreamWriter!S csv = CsvStreamWriter!(S)(outputStream);
csv
.append("ID")
.append("Timestamp")
.append("Added to Finnow")
.append("Amount")
.append("Currency")
.append("Description")
.append("Internal Transfer")
.append("Vendor")
.append("Category")
.append("Credited Account")
.append("Credited Account Type")
.append("Credited Account Number")
.append("Debited Account")
.append("Debited Account Type")
.append("Debited Account Number")
.append("tags")
.newLine();
foreach (item; items) {
string tagsStr = join(item.tags, ",");
if (tagsStr.length > 0) {
tagsStr = "\"" ~ tagsStr ~ "\"";
}
csv
.append(item.id)
.append(item.timestamp)
.append(item.addedAt)
.append(format(
"%." ~ item.currency.fractionalDigits.to!string ~ "f",
MoneyValue(item.currency, item.amount).toFloatingPoint()
))
.append(item.currency.code)
.append(item.description)
.append(item.internalTransfer)
.append(item.vendor.mapIfPresent!(v => v.name).orElse(null))
.append(item.category.mapIfPresent!(c => c.name).orElse(null))
.append(item.creditedAccount.mapIfPresent!(a => a.name).orElse(null))
.append(item.creditedAccount.mapIfPresent!(a => a.type).orElse(null))
.append(item.creditedAccount.mapIfPresent!(a => "#" ~ a.numberSuffix).orElse(null))
.append(item.debitedAccount.mapIfPresent!(a => a.name).orElse(null))
.append(item.debitedAccount.mapIfPresent!(a => a.type).orElse(null))
.append(item.debitedAccount.mapIfPresent!(a => "#" ~ a.numberSuffix).orElse(null))
.append(tagsStr)
.newLine();
}
}
// 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;
}
TransactionCategoryBalance[] getCategoryBalances(ProfileDataSource ds, ulong categoryId, bool includeChildren) {
return ds.getTransactionCategoryRepository().getBalance(
categoryId,
includeChildren,
Optional!SysTime.empty(),
Optional!SysTime.empty()
);
}
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,
toOptional!ulong(payload.parentId)
);
return TransactionCategoryResponse.of(curr);
}
void deleteCategory(ProfileDataSource ds, ulong categoryId) {
ds.getTransactionCategoryRepository().deleteById(categoryId);
}