mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-02-18 13:50:31 +00:00
feat: Expenses resource.
This commit is contained in:
@@ -6,16 +6,21 @@ import {
|
||||
validationResult,
|
||||
} from 'express-validator';
|
||||
import moment from 'moment';
|
||||
import { difference, chain, omit } from 'lodash';
|
||||
import { difference, sumBy, omit } from 'lodash';
|
||||
import asyncMiddleware from '@/http/middleware/asyncMiddleware';
|
||||
import JournalPoster from '@/services/Accounting/JournalPoster';
|
||||
import JournalEntry from '@/services/Accounting/JournalEntry';
|
||||
import JWTAuth from '@/http/middleware/jwtAuth';
|
||||
import ResourceCustomFieldRepository from '@/services/CustomFields/ResourceCustomFieldRepository';
|
||||
import {
|
||||
validateViewRoles,
|
||||
mapViewRolesToConditionals,
|
||||
} from '@/lib/ViewRolesBuilder';
|
||||
import {
|
||||
DynamicFilter,
|
||||
DynamicFilterSortBy,
|
||||
DynamicFilterViews,
|
||||
DynamicFilterFilterRoles,
|
||||
} from '@/lib/DynamicFilter';
|
||||
|
||||
|
||||
export default {
|
||||
/**
|
||||
@@ -37,10 +42,6 @@ export default {
|
||||
this.deleteExpense.validation,
|
||||
asyncMiddleware(this.deleteExpense.handler));
|
||||
|
||||
router.post('/bulk',
|
||||
this.bulkAddExpenses.validation,
|
||||
asyncMiddleware(this.bulkAddExpenses.handler));
|
||||
|
||||
router.post('/:id',
|
||||
this.updateExpense.validation,
|
||||
asyncMiddleware(this.updateExpense.handler));
|
||||
@@ -49,9 +50,9 @@ export default {
|
||||
this.listExpenses.validation,
|
||||
asyncMiddleware(this.listExpenses.handler));
|
||||
|
||||
// router.get('/:id',
|
||||
// this.getExpense.validation,
|
||||
// asyncMiddleware(this.getExpense.handler));
|
||||
router.get('/:id',
|
||||
this.getExpense.validation,
|
||||
asyncMiddleware(this.getExpense.handler));
|
||||
|
||||
return router;
|
||||
},
|
||||
@@ -61,15 +62,21 @@ export default {
|
||||
*/
|
||||
newExpense: {
|
||||
validation: [
|
||||
check('date').optional(),
|
||||
check('reference_no').optional().trim().escape(),
|
||||
check('payment_date').isISO8601().optional(),
|
||||
check('payment_account_id').exists().isNumeric().toInt(),
|
||||
check('expense_account_id').exists().isNumeric().toInt(),
|
||||
check('description').optional(),
|
||||
check('amount').exists().isNumeric().toFloat(),
|
||||
check('currency_code').optional(),
|
||||
check('exchange_rate').optional().isNumeric().toFloat(),
|
||||
check('publish').optional().isBoolean().toBoolean(),
|
||||
check('custom_fields').optional().isArray({ min: 1 }),
|
||||
|
||||
check('categories').exists().isArray({ min: 1 }),
|
||||
check('categories.*.index').exists().isNumeric().toInt(),
|
||||
check('categories.*.expense_account_id').exists().isNumeric().toInt(),
|
||||
check('categories.*.amount').optional().isNumeric().toFloat(),
|
||||
check('categories.*.description').optional().trim().escape(),
|
||||
|
||||
check('custom_fields').optional().isArray({ min: 0 }),
|
||||
check('custom_fields.*.key').exists().trim().escape(),
|
||||
check('custom_fields.*.value').exists(),
|
||||
],
|
||||
@@ -81,170 +88,94 @@ export default {
|
||||
code: 'validation_error', ...validationErrors,
|
||||
});
|
||||
}
|
||||
const { user } = req;
|
||||
const { Expense, ExpenseCategory, Account } = req.models;
|
||||
|
||||
const form = {
|
||||
date: new Date(),
|
||||
published: false,
|
||||
custom_fields: [],
|
||||
categories: [],
|
||||
...req.body,
|
||||
};
|
||||
const { Account, Expense } = req.models;
|
||||
// Convert the date to the general format.
|
||||
form.date = moment(form.date).format('YYYY-MM-DD');
|
||||
const totalAmount = sumBy(form.categories, 'amount');
|
||||
const expenseAccountsIds = form.categories.map((account) => account.expense_account_id)
|
||||
|
||||
const storedExpenseAccounts = await Account.query().whereIn('id', expenseAccountsIds);
|
||||
const storedExpenseAccountsIds = storedExpenseAccounts.map(a => a.id);
|
||||
|
||||
const notStoredExpensesAccountsIds = difference(expenseAccountsIds, storedExpenseAccountsIds);
|
||||
const errorReasons = [];
|
||||
const paymentAccount = await Account.query()
|
||||
.findById(form.payment_account_id).first();
|
||||
|
||||
const paymentAccount = await Account.query().where('id', form.payment_account_id).first();
|
||||
|
||||
if (!paymentAccount) {
|
||||
errorReasons.push({ type: 'PAYMENT.ACCOUNT.NOT.FOUND', code: 100 });
|
||||
errorReasons.push({
|
||||
type: 'PAYMENT.ACCOUNT.NOT.FOUND', code: 500,
|
||||
});
|
||||
}
|
||||
const expenseAccount = await Account.query().findById(form.expense_account_id).first();
|
||||
|
||||
if (!expenseAccount) {
|
||||
errorReasons.push({ type: 'EXPENSE.ACCOUNT.NOT.FOUND', code: 200 });
|
||||
if (notStoredExpensesAccountsIds.length > 0) {
|
||||
errorReasons.push({
|
||||
type: 'EXPENSE.ACCOUNTS.IDS.NOT.STORED', code: 400, ids: notStoredExpensesAccountsIds,
|
||||
});
|
||||
}
|
||||
if (totalAmount <= 0) {
|
||||
errorReasons.push({ type: 'TOTAL.AMOUNT.EQUALS.ZERO', code: 300 });
|
||||
}
|
||||
// const customFields = new ResourceCustomFieldRepository(Expense);
|
||||
// await customFields.load();
|
||||
|
||||
// if (customFields.validateExistCustomFields()) {
|
||||
// errorReasons.push({ type: 'CUSTOM.FIELDS.SLUGS.NOT.EXISTS', code: 400 });
|
||||
// }
|
||||
if (errorReasons.length > 0) {
|
||||
return res.status(400).send({ errors: errorReasons });
|
||||
}
|
||||
const expenseTransaction = await Expense.query().insertAndFetch({
|
||||
...omit(form, ['custom_fields']),
|
||||
|
||||
const expenseTransaction = await Expense.query().insert({
|
||||
total_amount: totalAmount,
|
||||
payment_account_id: form.payment_account_id,
|
||||
reference_no: form.reference_no,
|
||||
description: form.description,
|
||||
payment_date: moment(form.payment_date).format('YYYY-MM-DD'),
|
||||
user_id: user.id,
|
||||
});
|
||||
// customFields.fillCustomFields(expenseTransaction.id, form.custom_fields);
|
||||
const storeExpenseCategoriesOper = [];
|
||||
|
||||
const journalEntries = new JournalPoster();
|
||||
const creditEntry = new JournalEntry({
|
||||
credit: form.amount,
|
||||
form.categories.forEach((category) => {
|
||||
const oper = ExpenseCategory.query().insert({
|
||||
expense_id: expenseTransaction.id,
|
||||
...category,
|
||||
});
|
||||
storeExpenseCategoriesOper.push(oper);
|
||||
});
|
||||
|
||||
const accountsDepGraph = await Account.depGraph().query();
|
||||
const journalPoster = new JournalPoster(accountsDepGraph);
|
||||
|
||||
const mixinEntry = {
|
||||
referenceType: 'Expense',
|
||||
referenceId: expenseTransaction.id,
|
||||
referenceType: Expense.referenceType,
|
||||
date: form.date,
|
||||
account: expenseAccount.id,
|
||||
accountNormal: 'debit',
|
||||
draft: !form.published,
|
||||
});
|
||||
const debitEntry = new JournalEntry({
|
||||
debit: form.amount,
|
||||
referenceId: expenseTransaction.id,
|
||||
referenceType: Expense.referenceType,
|
||||
date: form.date,
|
||||
userId: user.id,
|
||||
draft: !form.publish,
|
||||
};
|
||||
const paymentJournalEntry = new JournalEntry({
|
||||
credit: totalAmount,
|
||||
account: paymentAccount.id,
|
||||
accountNormal: 'debit',
|
||||
draft: !form.published,
|
||||
...mixinEntry,
|
||||
});
|
||||
journalEntries.credit(creditEntry);
|
||||
journalEntries.debit(debitEntry);
|
||||
journalPoster.credit(paymentJournalEntry)
|
||||
|
||||
form.categories.forEach((category) => {
|
||||
const expenseJournalEntry = new JournalEntry({
|
||||
account: category.expense_account_id,
|
||||
debit: category.amount,
|
||||
note: category.description,
|
||||
...mixinEntry,
|
||||
});
|
||||
journalPoster.debit(expenseJournalEntry);
|
||||
});
|
||||
await Promise.all([
|
||||
// customFields.saveCustomFields(expenseTransaction.id),
|
||||
journalEntries.saveEntries(),
|
||||
journalEntries.saveBalance(),
|
||||
]);
|
||||
return res.status(200).send({ id: expenseTransaction.id });
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Bulk add expneses to the given accounts.
|
||||
*/
|
||||
bulkAddExpenses: {
|
||||
validation: [
|
||||
check('expenses').exists().isArray({ min: 1 }),
|
||||
check('expenses.*.date').optional().isISO8601(),
|
||||
check('expenses.*.payment_account_id').exists().isNumeric().toInt(),
|
||||
check('expenses.*.expense_account_id').exists().isNumeric().toInt(),
|
||||
check('expenses.*.description').optional(),
|
||||
check('expenses.*.amount').exists().isNumeric().toFloat(),
|
||||
check('expenses.*.currency_code').optional(),
|
||||
check('expenses.*.exchange_rate').optional().isNumeric().toFloat(),
|
||||
],
|
||||
async handler(req, res) {
|
||||
const validationErrors = validationResult(req);
|
||||
|
||||
if (!validationErrors.isEmpty()) {
|
||||
return res.boom.badData(null, {
|
||||
code: 'validation_error', ...validationErrors,
|
||||
});
|
||||
}
|
||||
const { Account, Expense } = req.models;
|
||||
const form = { ...req.body };
|
||||
const errorReasons = [];
|
||||
|
||||
const paymentAccountsIds = chain(form.expenses)
|
||||
.map((e) => e.payment_account_id).uniq().value();
|
||||
const expenseAccountsIds = chain(form.expenses)
|
||||
.map((e) => e.expense_account_id).uniq().value();
|
||||
|
||||
const [expensesAccounts, paymentAccounts] = await Promise.all([
|
||||
Account.query().whereIn('id', expenseAccountsIds),
|
||||
Account.query().whereIn('id', paymentAccountsIds),
|
||||
]);
|
||||
const storedExpensesAccountsIds = expensesAccounts.map((a) => a.id);
|
||||
const storedPaymentAccountsIds = paymentAccounts.map((a) => a.id);
|
||||
|
||||
const notFoundPaymentAccountsIds = difference(expenseAccountsIds, storedExpensesAccountsIds);
|
||||
const notFoundExpenseAccountsIds = difference(paymentAccountsIds, storedPaymentAccountsIds);
|
||||
|
||||
if (notFoundPaymentAccountsIds.length > 0) {
|
||||
errorReasons.push({
|
||||
type: 'PAYMENY.ACCOUNTS.NOT.FOUND',
|
||||
code: 100,
|
||||
accounts: notFoundPaymentAccountsIds,
|
||||
});
|
||||
}
|
||||
if (notFoundExpenseAccountsIds.length > 0) {
|
||||
errorReasons.push({
|
||||
type: 'EXPENSE.ACCOUNTS.NOT.FOUND',
|
||||
code: 200,
|
||||
accounts: notFoundExpenseAccountsIds,
|
||||
});
|
||||
}
|
||||
if (errorReasons.length > 0) {
|
||||
return res.boom.badRequest(null, { reasons: errorReasons });
|
||||
}
|
||||
const expenseSaveOpers = [];
|
||||
const journalPoster = new JournalPoster();
|
||||
|
||||
form.expenses.forEach(async (expense) => {
|
||||
const expenseSaveOper = Expense.query().insert({ ...expense });
|
||||
expenseSaveOpers.push(expenseSaveOper);
|
||||
});
|
||||
// Wait unit save all expense transactions.
|
||||
const savedExpenseTransactions = await Promise.all(expenseSaveOpers);
|
||||
|
||||
savedExpenseTransactions.forEach((expense) => {
|
||||
const date = moment(expense.date).format('YYYY-DD-MM');
|
||||
|
||||
const debit = new JournalEntry({
|
||||
debit: expense.amount,
|
||||
referenceId: expense.id,
|
||||
referenceType: Expense.referenceType,
|
||||
account: expense.payment_account_id,
|
||||
accountNormal: 'debit',
|
||||
date,
|
||||
});
|
||||
const credit = new JournalEntry({
|
||||
credit: expense.amount,
|
||||
referenceId: expense.id,
|
||||
referenceType: Expense.referenceId,
|
||||
account: expense.expense_account_id,
|
||||
accountNormal: 'debit',
|
||||
date,
|
||||
});
|
||||
journalPoster.credit(credit);
|
||||
journalPoster.debit(debit);
|
||||
});
|
||||
|
||||
// Save expense journal entries and balance change.
|
||||
await Promise.all([
|
||||
...storeExpenseCategoriesOper,
|
||||
journalPoster.saveEntries(),
|
||||
journalPoster.saveBalance(),
|
||||
(form.status) && journalPoster.saveBalance(),
|
||||
]);
|
||||
return res.status(200).send();
|
||||
|
||||
return res.status(200).send({ id: expenseTransaction.id });
|
||||
},
|
||||
},
|
||||
|
||||
@@ -263,16 +194,13 @@ export default {
|
||||
code: 'validation_error', ...validationErrors,
|
||||
});
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const { Expense, AccountTransaction } = req.models;
|
||||
const errorReasons = [];
|
||||
const expense = await Expense.query().findById(id);
|
||||
const errorReasons = [];
|
||||
|
||||
if (!expense) {
|
||||
errorReasons.push({ type: 'EXPENSE.NOT.FOUND', code: 100 });
|
||||
}
|
||||
if (errorReasons.length > 0) {
|
||||
return res.status(400).send({ errors: errorReasons });
|
||||
}
|
||||
if (expense.published) {
|
||||
@@ -281,18 +209,33 @@ export default {
|
||||
if (errorReasons.length > 0) {
|
||||
return res.status(400).send({ errors: errorReasons });
|
||||
}
|
||||
const transactions = await AccountTransaction.query()
|
||||
.whereIn('reference_type', ['Expense'])
|
||||
.where('reference_id', expense.id)
|
||||
.withGraphFetched('account.type');
|
||||
|
||||
await AccountTransaction.query()
|
||||
const accountsDepGraph = await Account.depGraph().query().remember();
|
||||
const journal = new JournalPoster(accountsDepGraph);
|
||||
|
||||
journal.loadEntries(transactions);
|
||||
journal.calculateEntriesBalanceChange();
|
||||
|
||||
const updateAccTransactionsOper = AccountTransaction.query()
|
||||
.where('reference_id', expense.id)
|
||||
.where('reference_type', 'Expense')
|
||||
.patch({
|
||||
draft: false,
|
||||
});
|
||||
|
||||
await Expense.query()
|
||||
const updateExpenseOper = Expense.query()
|
||||
.where('id', expense.id)
|
||||
.update({ published: true });
|
||||
|
||||
await Promise.all([
|
||||
updateAccTransactionsOper,
|
||||
updateExpenseOper,
|
||||
journal.saveBalance(),
|
||||
]);
|
||||
return res.status(200).send();
|
||||
},
|
||||
},
|
||||
@@ -301,25 +244,15 @@ export default {
|
||||
* Retrieve paginated expenses list.
|
||||
*/
|
||||
listExpenses: {
|
||||
validation: [
|
||||
query('expense_account_id').optional().isNumeric().toInt(),
|
||||
query('payment_account_id').optional().isNumeric().toInt(),
|
||||
query('note').optional(),
|
||||
query('range_from').optional().isNumeric().toFloat(),
|
||||
query('range_to').optional().isNumeric().toFloat(),
|
||||
query('date_from').optional().isISO8601(),
|
||||
query('date_to').optional().isISO8601(),
|
||||
query('column_sort_order').optional().isIn(['created_at', 'date', 'amount']),
|
||||
query('sort_order').optional().isIn(['desc', 'asc']),
|
||||
validation: [
|
||||
query('page').optional().isNumeric().toInt(),
|
||||
query('page_size').optional().isNumeric().toInt(),
|
||||
query('custom_view_id').optional().isNumeric().toInt(),
|
||||
|
||||
query('filter_roles').optional().isArray(),
|
||||
query('filter_roles.*.field_key').exists().escape().trim(),
|
||||
query('filter_roles.*.value').exists().escape().trim(),
|
||||
query('filter_roles.*.comparator').exists().escape().trim(),
|
||||
query('filter_roles.*.index').exists().isNumeric().toInt(),
|
||||
query('custom_view_id').optional().isNumeric().toInt(),
|
||||
query('stringified_filter_roles').optional().isJSON(),
|
||||
|
||||
query('column_sort_by').optional(),
|
||||
query('sort_order').optional().isIn(['desc', 'asc']),
|
||||
],
|
||||
async handler(req, res) {
|
||||
const validationErrors = validationResult(req);
|
||||
@@ -329,77 +262,99 @@ export default {
|
||||
code: 'validation_error', ...validationErrors,
|
||||
});
|
||||
}
|
||||
|
||||
const filter = {
|
||||
page_size: 10,
|
||||
sort_order: 'asc',
|
||||
filter_roles: [],
|
||||
page_size: 15,
|
||||
page: 1,
|
||||
...req.query,
|
||||
};
|
||||
const { Resource, View, Expense } = req.models;
|
||||
const errorReasons = [];
|
||||
const expenseResource = await Resource.query().where('name', 'expenses').first();
|
||||
const { Resource, Expense, View } = req.models;
|
||||
|
||||
if (!expenseResource) {
|
||||
errorReasons.push({ type: 'EXPENSE_RESOURCE_NOT_FOUND', code: 300 });
|
||||
const expensesResource = await Resource.query()
|
||||
.remember()
|
||||
.where('name', 'expenses')
|
||||
.withGraphFetched('fields')
|
||||
.first();
|
||||
|
||||
const expensesResourceFields = expensesResource.fields.map(f => f.key);
|
||||
|
||||
if (!expensesResource) {
|
||||
return res.status(400).send({
|
||||
errors: [{ type: 'EXPENSES.RESOURCE.NOT.FOUND', code: 200 }],
|
||||
});
|
||||
}
|
||||
const view = await View.query().onBuild((builder) => {
|
||||
if (filter.csutom_view_id) {
|
||||
builder.where('id', filter.csutom_view_id);
|
||||
} else {
|
||||
builder.where('favourite', true);
|
||||
}
|
||||
builder.withGraphFetched('roles.field');
|
||||
builder.withGraphFetched('columns');
|
||||
builder.first();
|
||||
});
|
||||
const dynamicFilter = new DynamicFilter(Expense.tableName);
|
||||
|
||||
// Column sorting.
|
||||
if (filter.column_sort_by) {
|
||||
if (expensesResourceFields.indexOf(filter.column_sort_by) === -1) {
|
||||
errorReasons.push({ type: 'COLUMN.SORT.ORDER.NOT.FOUND', code: 300 });
|
||||
}
|
||||
const sortByFilter = new DynamicFilterSortBy(
|
||||
filter.column_sort_by,
|
||||
filter.sort_order,
|
||||
);
|
||||
dynamicFilter.setFilter(sortByFilter);
|
||||
}
|
||||
// Custom view roles.
|
||||
if (view && view.roles.length > 0) {
|
||||
const viewFilter = new DynamicFilterViews(
|
||||
mapViewRolesToConditionals(view.roles),
|
||||
view.rolesLogicExpression,
|
||||
);
|
||||
if (viewFilter.validateFilterRoles()) {
|
||||
errorReasons.push({ type: 'VIEW.LOGIC.EXPRESSION.INVALID', code: 400 });
|
||||
}
|
||||
dynamicFilter.setFilter(viewFilter);
|
||||
}
|
||||
// Filter roles.
|
||||
if (filter.filter_roles.length > 0) {
|
||||
const filterRoles = new DynamicFilterFilterRoles(
|
||||
mapFilterRolesToDynamicFilter(filter.filter_roles),
|
||||
expensesResource.fields,
|
||||
);
|
||||
if (filterRoles.validateFilterRoles().length > 0) {
|
||||
errorReasons.push({ type: 'ACCOUNTS.RESOURCE.HAS.NO.GIVEN.FIELDS', code: 500 });
|
||||
}
|
||||
dynamicFilter.setFilter(filterRoles);
|
||||
}
|
||||
if (errorReasons.length > 0) {
|
||||
return res.status(400).send({ errors: errorReasons });
|
||||
}
|
||||
const view = await View.query().onBuild((builder) => {
|
||||
if (filter.custom_view_id) {
|
||||
builder.where('id', filter.custom_view_id);
|
||||
} else {
|
||||
builder.where('favourite', true);
|
||||
}
|
||||
builder.where('resource_id', expenseResource.id);
|
||||
builder.withGraphFetched('viewRoles.field');
|
||||
builder.withGraphFetched('columns');
|
||||
|
||||
builder.first();
|
||||
});
|
||||
let viewConditionals = [];
|
||||
|
||||
if (view && view.viewRoles.length > 0) {
|
||||
viewConditionals = mapViewRolesToConditionals(view.viewRoles);
|
||||
|
||||
if (!validateViewRoles(viewConditionals, view.rolesLogicExpression)) {
|
||||
errorReasons.push({ type: 'VIEW.LOGIC.EXPRESSION.INVALID', code: 400 });
|
||||
}
|
||||
}
|
||||
if (!view && filter.custom_view_id) {
|
||||
errorReasons.push({ type: 'VIEW_NOT_FOUND', code: 100 });
|
||||
}
|
||||
if (errorReasons.length > 0) {
|
||||
return res.boom.badRequest(null, { errors: errorReasons });
|
||||
}
|
||||
|
||||
const expenses = await Expense.query().onBuild((builder) => {
|
||||
builder.withGraphFetched('paymentAccount');
|
||||
builder.withGraphFetched('expenseAccount');
|
||||
builder.withGraphFetched('categories');
|
||||
builder.withGraphFetched('user');
|
||||
|
||||
if (viewConditionals.length) {
|
||||
builder.modify('viewRolesBuilder', viewConditionals, view.rolesLogicExpression);
|
||||
}
|
||||
builder.modify('filterByAmountRange', filter.range_from, filter.to_range);
|
||||
builder.modify('filterByDateRange', filter.date_from, filter.date_to);
|
||||
builder.modify('filterByExpenseAccount', filter.expense_account_id);
|
||||
builder.modify('filterByPaymentAccount', filter.payment_account_id);
|
||||
builder.modify('orderBy', filter.column_sort_order, filter.sort_order);
|
||||
}).page(filter.page - 1, filter.page_size);
|
||||
dynamicFilter.buildQuery()(builder);
|
||||
}).pagination(filter.page - 1, filter.page_size);;
|
||||
|
||||
return res.status(200).send({
|
||||
...(view) ? {
|
||||
customViewId: view.id,
|
||||
viewColumns: view.columns,
|
||||
viewConditionals,
|
||||
} : {},
|
||||
expenses,
|
||||
page_size: filter.page_size,
|
||||
page: filter.page,
|
||||
...(view) ? {
|
||||
viewColumns: view.columns,
|
||||
customViewId: view.id,
|
||||
} : {},
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete the given account.
|
||||
* Delete the given expense transaction.
|
||||
*/
|
||||
deleteExpense: {
|
||||
validation: [
|
||||
@@ -414,26 +369,37 @@ export default {
|
||||
});
|
||||
}
|
||||
const { id } = req.params;
|
||||
const { Expense, AccountTransaction } = req.models;
|
||||
const expenseTransaction = await Expense.query().findById(id);
|
||||
const {
|
||||
Expense,
|
||||
ExpenseCategory,
|
||||
AccountTransaction,
|
||||
Account,
|
||||
} = req.models;
|
||||
|
||||
if (!expenseTransaction) {
|
||||
return res.status(404).send({
|
||||
errors: [{ type: 'EXPENSE.TRANSACTION.NOT.FOUND', code: 100 }],
|
||||
});
|
||||
const expense = await Expense.query().where('id', id).first();
|
||||
|
||||
if (!expense) {
|
||||
return res.status(404).send({ errors: [{
|
||||
type: 'EXPENSE.NOT.FOUND', code: 200,
|
||||
}] });
|
||||
}
|
||||
const expenseEntries = await AccountTransaction.query()
|
||||
await ExpenseCategory.query().where('expense_id', id).delete();
|
||||
|
||||
const deleteExpenseOper = Expense.query().where('id', id).delete();
|
||||
const expenseTransactions = await AccountTransaction.query()
|
||||
.where('reference_type', 'Expense')
|
||||
.where('reference_id', expenseTransaction.id);
|
||||
.where('reference_id', expense.id);
|
||||
|
||||
const expenseEntriesCollect = new JournalPoster();
|
||||
expenseEntriesCollect.loadEntries(expenseEntries);
|
||||
expenseEntriesCollect.reverseEntries();
|
||||
const accountsDepGraph = await Account.depGraph().query().remember();
|
||||
const journalEntries = new JournalPoster(accountsDepGraph);
|
||||
|
||||
journalEntries.loadEntries(expenseTransactions);
|
||||
journalEntries.removeEntries();
|
||||
|
||||
await Promise.all([
|
||||
Expense.query().findById(expenseTransaction.id).delete(),
|
||||
expenseEntriesCollect.deleteEntries(),
|
||||
expenseEntriesCollect.saveBalance(),
|
||||
deleteExpenseOper,
|
||||
journalEntries.deleteEntries(),
|
||||
journalEntries.saveBalance(),
|
||||
]);
|
||||
return res.status(200).send();
|
||||
},
|
||||
@@ -445,13 +411,20 @@ export default {
|
||||
updateExpense: {
|
||||
validation: [
|
||||
param('id').isNumeric().toInt(),
|
||||
check('date').optional().isISO8601(),
|
||||
check('reference_no').optional().trim().escape(),
|
||||
check('payment_date').isISO8601().optional(),
|
||||
check('payment_account_id').exists().isNumeric().toInt(),
|
||||
check('expense_account_id').exists().isNumeric().toInt(),
|
||||
check('description').optional(),
|
||||
check('amount').exists().isNumeric().toFloat(),
|
||||
check('currency_code').optional(),
|
||||
check('exchange_rate').optional().isNumeric().toFloat(),
|
||||
check('publish').optional().isBoolean().toBoolean(),
|
||||
|
||||
check('categories').exists().isArray({ min: 1 }),
|
||||
check('categories.*.id').optional().isNumeric().toInt(),
|
||||
check('categories.*.index').exists().isNumeric().toInt(),
|
||||
check('categories.*.expense_account_id').exists().isNumeric().toInt(),
|
||||
check('categories.*.amount').optional().isNumeric().toFloat(),
|
||||
check('categories.*.description').optional().trim().escape(),
|
||||
],
|
||||
async handler(req, res) {
|
||||
const validationErrors = validationResult(req);
|
||||
@@ -462,14 +435,149 @@ export default {
|
||||
});
|
||||
}
|
||||
const { id } = req.params;
|
||||
const { Expense } = req.models;
|
||||
const expenseTransaction = await Expense.query().findById(id);
|
||||
const { user } = req;
|
||||
const { Account, Expense, ExpenseCategory, AccountTransaction } = req.models;
|
||||
|
||||
if (!expenseTransaction) {
|
||||
const form = {
|
||||
categories: [],
|
||||
...req.body,
|
||||
};
|
||||
if (!Array.isArray(form.categories)) {
|
||||
form.categories = [form.categories];
|
||||
}
|
||||
const expense = await Expense.query()
|
||||
.where('id', id)
|
||||
.withGraphFetched('categories')
|
||||
.first();
|
||||
|
||||
if (!expense) {
|
||||
return res.status(404).send({
|
||||
errors: [{ type: 'EXPENSE.TRANSACTION.NOT.FOUND', code: 100 }],
|
||||
errors: [{ type: 'EXPENSE.NOT.FOUND', code: 200 }],
|
||||
});
|
||||
}
|
||||
const errorReasons = [];
|
||||
const paymentAccount = await Account.query()
|
||||
.where('id', form.payment_account_id).first();
|
||||
|
||||
if (!paymentAccount) {
|
||||
errorReasons.push({ type: 'PAYMENT.ACCOUNT.NOT.FOUND', code: 400 });
|
||||
}
|
||||
const categoriesHasNoId = form.categories.filter(c => !c.id);
|
||||
const categoriesHasId = form.categories.filter(c => c.id);
|
||||
|
||||
const expenseCategoriesIds = expense.categories.map((c) => c.id);
|
||||
const formExpenseCategoriesIds = categoriesHasId.map(c => c.id);
|
||||
|
||||
const categoriesIdsDeleted = difference(
|
||||
formExpenseCategoriesIds, expenseCategoriesIds,
|
||||
);
|
||||
const categoriesShouldDelete = difference(
|
||||
expenseCategoriesIds, formExpenseCategoriesIds,
|
||||
);
|
||||
|
||||
const formExpensesAccountsIds = form.categories.map(c => c.expense_account_id);
|
||||
const storedExpenseAccounts = await Account.query().whereIn('id', formExpensesAccountsIds);
|
||||
const storedExpenseAccountsIds = storedExpenseAccounts.map(a => a.id);
|
||||
|
||||
const expenseAccountsIdsNotFound = difference(
|
||||
formExpensesAccountsIds, storedExpenseAccountsIds,
|
||||
);
|
||||
const totalAmount = sumBy(form.categories, 'amount');
|
||||
|
||||
if (expenseAccountsIdsNotFound.length > 0) {
|
||||
errorReasons.push({ type: 'EXPENSE.ACCOUNTS.IDS.NOT.FOUND', code: 600, ids: expenseAccountsIdsNotFound })
|
||||
}
|
||||
|
||||
if (categoriesIdsDeleted.length > 0) {
|
||||
errorReasons.push({ type: 'EXPENSE.CATEGORIES.IDS.NOT.FOUND', code: 300 });
|
||||
}
|
||||
if (totalAmount <= 0) {
|
||||
errorReasons.push({ type: 'TOTAL.AMOUNT.EQUALS.ZERO', code: 500 });
|
||||
}
|
||||
// Handle all error reasons.
|
||||
if (errorReasons.length > 0) {
|
||||
return res.status(400).send({ errors: errorReasons });
|
||||
}
|
||||
const expenseCategoriesMap = new Map(expense.categories
|
||||
.map(category => [category.id, category]));
|
||||
|
||||
const categoriesInsertOpers = [];
|
||||
const categoriesUpdateOpers = [];
|
||||
|
||||
categoriesHasNoId.forEach((category) => {
|
||||
const oper = ExpenseCategory.query().insert({
|
||||
...category,
|
||||
expense_id: expense.id,
|
||||
});
|
||||
categoriesInsertOpers.push(oper);
|
||||
});
|
||||
|
||||
categoriesHasId.forEach((category) => {
|
||||
const oper = ExpenseCategory.query().where('id', category.id)
|
||||
.patch({
|
||||
...omit(category, ['id']),
|
||||
});
|
||||
categoriesUpdateOpers.push(oper);
|
||||
});
|
||||
|
||||
const updateExpenseOper = Expense.query().where('id', id)
|
||||
.update({
|
||||
payment_date: moment(form.payment_date).format('YYYY-MM-DD'),
|
||||
total_amount: totalAmount,
|
||||
description: form.description,
|
||||
payment_account_id: form.payment_account_id,
|
||||
reference_no: form.reference_no,
|
||||
})
|
||||
|
||||
const deleteCategoriesOper = (categoriesShouldDelete.length > 0) ?
|
||||
ExpenseCategory.query().whereIn('id', categoriesShouldDelete).delete() :
|
||||
Promise.resolve();
|
||||
|
||||
// Update the journal entries.
|
||||
const transactions = await AccountTransaction.query()
|
||||
.whereIn('reference_type', ['Expense'])
|
||||
.where('reference_id', expense.id)
|
||||
.withGraphFetched('account.type');
|
||||
|
||||
const accountsDepGraph = await Account.depGraph().query().remember();
|
||||
const journal = new JournalPoster(accountsDepGraph);
|
||||
|
||||
journal.loadEntries(transactions);
|
||||
journal.removeEntries();
|
||||
|
||||
const mixinEntry = {
|
||||
referenceType: 'Expense',
|
||||
referenceId: expense.id,
|
||||
userId: user.id,
|
||||
draft: !form.publish,
|
||||
};
|
||||
const paymentJournalEntry = new JournalEntry({
|
||||
credit: totalAmount,
|
||||
account: paymentAccount.id,
|
||||
...mixinEntry,
|
||||
});
|
||||
journal.credit(paymentJournalEntry);
|
||||
|
||||
form.categories.forEach((category) => {
|
||||
const entry = new JournalEntry({
|
||||
account: category.expense_account_id,
|
||||
debit: category.amount,
|
||||
note: category.description,
|
||||
...mixinEntry,
|
||||
});
|
||||
journal.debit(entry);
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
...categoriesInsertOpers,
|
||||
...categoriesUpdateOpers,
|
||||
updateExpenseOper,
|
||||
deleteCategoriesOper,
|
||||
|
||||
journal.saveEntries(),
|
||||
(form.status) && journal.saveBalance(),
|
||||
]);
|
||||
return res.status(200).send({ id });
|
||||
},
|
||||
},
|
||||
|
||||
@@ -489,26 +597,30 @@ export default {
|
||||
});
|
||||
}
|
||||
const { id } = req.params;
|
||||
const { Expense } = req.models;
|
||||
const expenseTransaction = await Expense.query().findById(id);
|
||||
const { Expense, AccountTransaction } = req.models;
|
||||
|
||||
if (!expenseTransaction) {
|
||||
const expense = await Expense.query()
|
||||
.where('id', id)
|
||||
.withGraphFetched('categories')
|
||||
.withGraphFetched('paymentAccount')
|
||||
.withGraphFetched('user')
|
||||
.first();
|
||||
|
||||
if (!expense) {
|
||||
return res.status(404).send({
|
||||
errors: [{ type: 'EXPENSE.TRANSACTION.NOT.FOUND', code: 100 }],
|
||||
errors: [{ type: 'EXPENSE.NOT.FOUND', code: 200 }],
|
||||
});
|
||||
}
|
||||
|
||||
const expenseCFMetadataRepo = new ResourceCustomFieldRepository(Expense);
|
||||
await expenseCFMetadataRepo.load();
|
||||
await expenseCFMetadataRepo.fetchCustomFieldsMetadata(expenseTransaction.id);
|
||||
|
||||
const expenseCusFieldsMetadata = expenseCFMetadataRepo.getMetadata(expenseTransaction.id);
|
||||
const journalEntries = await AccountTransaction.query()
|
||||
.where('reference_id', expense.id)
|
||||
.where('reference_type', 'Expense');
|
||||
|
||||
return res.status(200).send({
|
||||
...expenseTransaction,
|
||||
custom_fields: [
|
||||
...expenseCusFieldsMetadata.toArray(),
|
||||
],
|
||||
expense: {
|
||||
...expense,
|
||||
journalEntries,
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
@@ -12,6 +12,10 @@ export default {
|
||||
router() {
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/:resource_slug/data',
|
||||
this.resourceData.validation,
|
||||
asyncMiddleware(this.resourceData.handler));
|
||||
|
||||
router.get('/:resource_slug/columns',
|
||||
this.resourceColumns.validation,
|
||||
asyncMiddleware(this.resourceColumns.handler));
|
||||
@@ -23,6 +27,26 @@ export default {
|
||||
return router;
|
||||
},
|
||||
|
||||
/**
|
||||
* Retrieve resource data of the given resource key/slug.
|
||||
*/
|
||||
resourceData: {
|
||||
validation: [
|
||||
param('resource_slug').trim().escape().exists(),
|
||||
],
|
||||
async handler(req, res) {
|
||||
const { AccountType } = req.models;
|
||||
const { resource_slug: resourceSlug } = req.params;
|
||||
|
||||
const data = await AccountType.query();
|
||||
|
||||
return res.status(200).send({
|
||||
data,
|
||||
resource_slug: resourceSlug,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Retrieve resource columns of the given resource.
|
||||
*/
|
||||
|
||||
@@ -13,12 +13,12 @@ import Views from '@/http/controllers/Views';
|
||||
// import CustomFields from '@/http/controllers/Fields';
|
||||
import Accounting from '@/http/controllers/Accounting';
|
||||
import FinancialStatements from '@/http/controllers/FinancialStatements';
|
||||
// import Expenses from '@/http/controllers/Expenses';
|
||||
import Expenses from '@/http/controllers/Expenses';
|
||||
import Options from '@/http/controllers/Options';
|
||||
// import Budget from '@/http/controllers/Budget';
|
||||
// import BudgetReports from '@/http/controllers/BudgetReports';
|
||||
import Currencies from '@/http/controllers/Currencies';
|
||||
// import Customers from '@/http/controllers/Customers';
|
||||
import Customers from '@/http/controllers/Customers';
|
||||
// import Suppliers from '@/http/controllers/Suppliers';
|
||||
// import Bills from '@/http/controllers/Bills';
|
||||
// import CurrencyAdjustment from './controllers/CurrencyAdjustment';
|
||||
@@ -52,11 +52,11 @@ export default (app) => {
|
||||
// app.use('/api/fields', CustomFields.router());
|
||||
dashboard.use('/api/items', Items.router());
|
||||
dashboard.use('/api/item_categories', ItemCategories.router());
|
||||
// app.use('/api/expenses', Expenses.router());
|
||||
dashboard.use('/api/expenses', Expenses.router());
|
||||
dashboard.use('/api/financial_statements', FinancialStatements.router());
|
||||
dashboard.use('/api/options', Options.router());
|
||||
// app.use('/api/budget_reports', BudgetReports.router());
|
||||
// app.use('/api/customers', Customers.router());
|
||||
// dashboard.use('/api/customers', Customers.router());
|
||||
// app.use('/api/suppliers', Suppliers.router());
|
||||
// app.use('/api/bills', Bills.router());
|
||||
// app.use('/api/budget', Budget.router());
|
||||
|
||||
Reference in New Issue
Block a user