270 lines
10 KiB
D
270 lines
10 KiB
D
module transaction.service;
|
|
|
|
import handy_http_primitives;
|
|
import std.datetime;
|
|
|
|
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 core.internal.container.common;
|
|
|
|
// 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) {
|
|
return ds.getTransactionRepository().findById(transactionId)
|
|
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
|
|
}
|
|
|
|
TransactionDetail addTransaction(ProfileDataSource ds, in AddTransactionPayload payload) {
|
|
TransactionVendorRepository vendorRepo = ds.getTransactionVendorRepository();
|
|
TransactionCategoryRepository categoryRepo = ds.getTransactionCategoryRepository();
|
|
AccountRepository accountRepo = ds.getAccountRepository();
|
|
|
|
validateTransactionPayload(vendorRepo, categoryRepo, accountRepo, payload);
|
|
SysTime timestamp = SysTime.fromISOExtString(payload.timestamp, UTC());
|
|
|
|
// Add the transaction:
|
|
ulong txnId;
|
|
ds.doTransaction(() {
|
|
TransactionRepository txRepo = ds.getTransactionRepository();
|
|
TransactionDetail txn = txRepo.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);
|
|
txnId = txn.id;
|
|
});
|
|
return ds.getTransactionRepository().findById(txnId).orElseThrow();
|
|
}
|
|
|
|
TransactionDetail updateTransaction(ProfileDataSource ds, ulong transactionId, in AddTransactionPayload payload) {
|
|
TransactionVendorRepository vendorRepo = ds.getTransactionVendorRepository();
|
|
TransactionCategoryRepository categoryRepo = ds.getTransactionCategoryRepository();
|
|
AccountRepository accountRepo = ds.getAccountRepository();
|
|
TransactionRepository transactionRepo = ds.getTransactionRepository();
|
|
AccountJournalEntryRepository jeRepo = ds.getAccountJournalEntryRepository();
|
|
TransactionTagRepository tagRepo = ds.getTransactionTagRepository();
|
|
|
|
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);
|
|
bool amountOrCurrencyChanged = prev.amount != curr.amount || prev.currency.code != curr.currency.code;
|
|
bool updateCreditEntry = amountOrCurrencyChanged || (
|
|
prev.creditedAccount != curr.creditedAccount
|
|
);
|
|
bool updateDebitEntry = amountOrCurrencyChanged || (
|
|
prev.debitedAccount != curr.debitedAccount
|
|
);
|
|
|
|
// Update journal entries if necessary:
|
|
if (updateCreditEntry && !prev.creditedAccount.isNull) {
|
|
jeRepo.deleteByAccountIdAndTransactionId(prev.creditedAccount.get.id, transactionId);
|
|
}
|
|
if (updateCreditEntry && !curr.creditedAccount.isNull) {
|
|
jeRepo.insert(
|
|
timestamp,
|
|
curr.creditedAccount.get.id,
|
|
transactionId,
|
|
curr.amount,
|
|
AccountJournalEntryType.CREDIT,
|
|
curr.currency
|
|
);
|
|
}
|
|
if (updateDebitEntry && !prev.debitedAccount.isNull) {
|
|
jeRepo.deleteByAccountIdAndTransactionId(prev.debitedAccount.get.id, transactionId);
|
|
}
|
|
if (updateDebitEntry && !curr.debitedAccount.isNull) {
|
|
jeRepo.insert(
|
|
timestamp,
|
|
curr.debitedAccount.get.id,
|
|
transactionId,
|
|
curr.amount,
|
|
AccountJournalEntryType.DEBIT,
|
|
curr.currency
|
|
);
|
|
}
|
|
|
|
// Update tags.
|
|
tagRepo.updateTags(transactionId, payload.tags);
|
|
});
|
|
return transactionRepo.findById(transactionId).orElseThrow();
|
|
}
|
|
|
|
void deleteTransaction(ProfileDataSource ds, ulong transactionId) {
|
|
TransactionRepository txnRepo = ds.getTransactionRepository();
|
|
TransactionDetail txn = txnRepo.findById(transactionId)
|
|
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
|
|
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."
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
}
|