533 lines
20 KiB
D
533 lines
20 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 transaction.search_filters : extractSearchParams;
|
|
import profile.data;
|
|
import account.model;
|
|
import account.data;
|
|
import util.money;
|
|
import util.pagination;
|
|
import util.data;
|
|
import util.validation.transaction;
|
|
import util.validation.draft;
|
|
import attachment.data;
|
|
import attachment.dto;
|
|
|
|
// Transactions Services
|
|
|
|
Page!TransactionsListItem getTransactions(ProfileDataSource ds, in PageRequest pageRequest) {
|
|
return ds.getTransactionRepository()
|
|
.findAll(pageRequest);
|
|
}
|
|
|
|
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 now = Clock.currTime(UTC());
|
|
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) {
|
|
jeRepo.insert(
|
|
timestamp,
|
|
payload.creditedAccountId.value,
|
|
txn.id,
|
|
txn.amount,
|
|
AccountJournalEntryType.CREDIT,
|
|
txn.currency
|
|
);
|
|
}
|
|
if (payload.debitedAccountId) {
|
|
jeRepo.insert(
|
|
timestamp,
|
|
payload.debitedAccountId.value,
|
|
txn.id,
|
|
txn.amount,
|
|
AccountJournalEntryType.DEBIT,
|
|
txn.currency
|
|
);
|
|
}
|
|
TransactionTagRepository tagRepo = ds.getTransactionTagRepository();
|
|
tagRepo.updateTags(txn.id, payload.tags);
|
|
updateAttachments(txn.id, now, 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());
|
|
SysTime now = Clock.currTime(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, now, 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 && payload.creditedAccountId) ||
|
|
(prev.creditedAccount && !payload.creditedAccountId) ||
|
|
(
|
|
prev.creditedAccount &&
|
|
payload.creditedAccountId &&
|
|
prev.creditedAccount.value.id != payload.creditedAccountId.value
|
|
)
|
|
);
|
|
const bool updateDebitEntry = amountOrCurrencyChanged || (
|
|
(!prev.debitedAccount && payload.creditedAccountId) ||
|
|
(prev.debitedAccount && !payload.debitedAccountId) ||
|
|
(
|
|
prev.debitedAccount &&
|
|
payload.debitedAccountId &&
|
|
prev.debitedAccount.value.id != payload.debitedAccountId.value
|
|
)
|
|
);
|
|
|
|
// Update journal entries if necessary:
|
|
if (updateCreditEntry && prev.creditedAccount) {
|
|
jeRepo.deleteByAccountIdAndTransactionId(prev.creditedAccount.value.id, prev.id);
|
|
}
|
|
if (updateCreditEntry && payload.creditedAccountId) {
|
|
jeRepo.insert(
|
|
timestamp,
|
|
payload.creditedAccountId.value,
|
|
curr.id,
|
|
curr.amount,
|
|
AccountJournalEntryType.CREDIT,
|
|
curr.currency
|
|
);
|
|
}
|
|
if (updateDebitEntry && prev.debitedAccount) {
|
|
jeRepo.deleteByAccountIdAndTransactionId(prev.debitedAccount.value.id, prev.id);
|
|
}
|
|
if (updateDebitEntry && payload.debitedAccountId) {
|
|
jeRepo.insert(
|
|
timestamp,
|
|
payload.debitedAccountId.value,
|
|
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);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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(), extractSearchParams(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 && !repo.existsById(payload.parentId.value)) {
|
|
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(
|
|
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 && !repo.existsById(payload.parentId.value)) {
|
|
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid parent id.");
|
|
}
|
|
TransactionCategory curr = repo.updateById(
|
|
categoryId,
|
|
payload.name,
|
|
payload.description,
|
|
payload.color,
|
|
payload.parentId
|
|
);
|
|
return TransactionCategoryResponse.of(curr);
|
|
}
|
|
|
|
void deleteCategory(ProfileDataSource ds, ulong categoryId) {
|
|
ds.getTransactionCategoryRepository().deleteById(categoryId);
|
|
}
|
|
|
|
// Draft services
|
|
|
|
Page!TransactionDraftListItem getDrafts(ProfileDataSource ds, in PageRequest pr) {
|
|
return ds.getTransactionDraftRepository().findAll(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(
|
|
ds.getTransactionVendorRepository(),
|
|
ds.getTransactionCategoryRepository(),
|
|
ds.getAccountRepository(),
|
|
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(
|
|
ds.getTransactionVendorRepository(),
|
|
ds.getTransactionCategoryRepository(),
|
|
ds.getAccountRepository(),
|
|
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 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);
|
|
}
|
|
}
|