mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-02-21 23:30:32 +00:00
feat: Expenses resource.
This commit is contained in:
@@ -25,7 +25,7 @@ export default {
|
|||||||
'type': {
|
'type': {
|
||||||
column: 'account_type_id',
|
column: 'account_type_id',
|
||||||
relation: 'account_types.id',
|
relation: 'account_types.id',
|
||||||
relationColumn: 'account_types.name',
|
relationColumn: 'account_types.id',
|
||||||
},
|
},
|
||||||
'description': {
|
'description': {
|
||||||
column: 'description',
|
column: 'description',
|
||||||
@@ -38,6 +38,9 @@ export default {
|
|||||||
relation: 'account_types.id',
|
relation: 'account_types.id',
|
||||||
relationColumn: 'account_types.root_type',
|
relationColumn: 'account_types.root_type',
|
||||||
},
|
},
|
||||||
|
'created_at': {
|
||||||
|
column: 'created_at',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// Items
|
// Items
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ export default (tenantDb) => {
|
|||||||
const costAccount = await factory.create('account');
|
const costAccount = await factory.create('account');
|
||||||
const sellAccount = await factory.create('account');
|
const sellAccount = await factory.create('account');
|
||||||
const inventoryAccount = await factory.create('account');
|
const inventoryAccount = await factory.create('account');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: faker.lorem.word(),
|
name: faker.lorem.word(),
|
||||||
note: faker.lorem.paragraph(),
|
note: faker.lorem.paragraph(),
|
||||||
@@ -222,17 +223,30 @@ export default (tenantDb) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
factory.define('expense', 'expenses', async () => {
|
factory.define('expense', 'expenses_transactions', async () => {
|
||||||
const paymentAccount = await factory.create('account');
|
const paymentAccount = await factory.create('account');
|
||||||
const expenseAccount = await factory.create('account');
|
const expenseAccount = await factory.create('account');
|
||||||
const user = await factory.create('user');
|
const user = await factory.create('user');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
payment_account_id: paymentAccount.id,
|
total_amount: faker.random.number(),
|
||||||
expense_account_id: expenseAccount.id,
|
|
||||||
user_id: user.id,
|
|
||||||
amount: faker.random.number(),
|
|
||||||
currency_code: 'USD',
|
currency_code: 'USD',
|
||||||
|
description: '',
|
||||||
|
reference_no: faker.random.number(),
|
||||||
|
payment_account_id: paymentAccount.id,
|
||||||
|
published: true,
|
||||||
|
user_id: user.id,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
factory.define('expense_category', 'expense_transaction_categories', async () => {
|
||||||
|
const expense = await factory.create('expense');
|
||||||
|
|
||||||
|
return {
|
||||||
|
expense_account_id: expense.id,
|
||||||
|
description: '',
|
||||||
|
amount: faker.random.number(),
|
||||||
|
expense_id: expense.id,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
|
|
||||||
exports.up = function(knex) {
|
exports.up = function(knex) {
|
||||||
return knex.schema.createTable('expenses', (table) => {
|
return knex.schema.createTable('expenses_transactions', (table) => {
|
||||||
table.increments();
|
table.increments();
|
||||||
table.decimal('amount');
|
table.decimal('total_amount');
|
||||||
table.string('currency_code');
|
table.string('currency_code');
|
||||||
table.decimal('exchange_rate');
|
|
||||||
table.text('description');
|
table.text('description');
|
||||||
table.integer('expense_account_id').unsigned();
|
|
||||||
table.integer('payment_account_id').unsigned();
|
table.integer('payment_account_id').unsigned();
|
||||||
table.string('reference');
|
table.integer('payee_id').unsigned();
|
||||||
|
table.string('reference_no');
|
||||||
table.boolean('published').defaultTo(false);
|
table.boolean('published').defaultTo(false);
|
||||||
table.integer('user_id').unsigned();
|
table.integer('user_id').unsigned();
|
||||||
table.date('date');
|
table.date('payment_date');
|
||||||
// table.timestamps();
|
table.timestamps();
|
||||||
})
|
}).raw('ALTER TABLE `EXPENSES_TRANSACTIONS` AUTO_INCREMENT = 1000');
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.down = function(knex) {
|
exports.down = function(knex) {
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
|
||||||
|
exports.up = function(knex) {
|
||||||
|
return knex.schema.createTable('expense_transaction_categories', table => {
|
||||||
|
table.increments();
|
||||||
|
table.integer('expense_account_id').unsigned();
|
||||||
|
table.integer('index').unsigned();
|
||||||
|
table.text('description');
|
||||||
|
table.decimal('amount');
|
||||||
|
table.integer('expense_id').unsigned();
|
||||||
|
table.timestamps();
|
||||||
|
}).raw('ALTER TABLE `EXPENSE_TRANSACTION_CATEGORIES` AUTO_INCREMENT = 1000');;
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = function(knex) {
|
||||||
|
return knex.schema.dropTableIfExists('expense_transaction_categories');
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
|
||||||
|
exports.up = function(knex) {
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = function(knex) {
|
||||||
|
|
||||||
|
};
|
||||||
@@ -49,6 +49,15 @@ exports.seed = (knex) => {
|
|||||||
predefined: 1,
|
predefined: 1,
|
||||||
columnable: true,
|
columnable: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 16,
|
||||||
|
resource_id: 1,
|
||||||
|
label_name: 'Created at',
|
||||||
|
data_type: 'date',
|
||||||
|
key: 'created_at',
|
||||||
|
predefined: 1,
|
||||||
|
columnable: true,
|
||||||
|
},
|
||||||
|
|
||||||
// Expenses
|
// Expenses
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,16 +6,21 @@ import {
|
|||||||
validationResult,
|
validationResult,
|
||||||
} from 'express-validator';
|
} from 'express-validator';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import { difference, chain, omit } from 'lodash';
|
import { difference, sumBy, omit } from 'lodash';
|
||||||
import asyncMiddleware from '@/http/middleware/asyncMiddleware';
|
import asyncMiddleware from '@/http/middleware/asyncMiddleware';
|
||||||
import JournalPoster from '@/services/Accounting/JournalPoster';
|
import JournalPoster from '@/services/Accounting/JournalPoster';
|
||||||
import JournalEntry from '@/services/Accounting/JournalEntry';
|
import JournalEntry from '@/services/Accounting/JournalEntry';
|
||||||
import JWTAuth from '@/http/middleware/jwtAuth';
|
import JWTAuth from '@/http/middleware/jwtAuth';
|
||||||
import ResourceCustomFieldRepository from '@/services/CustomFields/ResourceCustomFieldRepository';
|
|
||||||
import {
|
import {
|
||||||
validateViewRoles,
|
|
||||||
mapViewRolesToConditionals,
|
mapViewRolesToConditionals,
|
||||||
} from '@/lib/ViewRolesBuilder';
|
} from '@/lib/ViewRolesBuilder';
|
||||||
|
import {
|
||||||
|
DynamicFilter,
|
||||||
|
DynamicFilterSortBy,
|
||||||
|
DynamicFilterViews,
|
||||||
|
DynamicFilterFilterRoles,
|
||||||
|
} from '@/lib/DynamicFilter';
|
||||||
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
/**
|
/**
|
||||||
@@ -37,10 +42,6 @@ export default {
|
|||||||
this.deleteExpense.validation,
|
this.deleteExpense.validation,
|
||||||
asyncMiddleware(this.deleteExpense.handler));
|
asyncMiddleware(this.deleteExpense.handler));
|
||||||
|
|
||||||
router.post('/bulk',
|
|
||||||
this.bulkAddExpenses.validation,
|
|
||||||
asyncMiddleware(this.bulkAddExpenses.handler));
|
|
||||||
|
|
||||||
router.post('/:id',
|
router.post('/:id',
|
||||||
this.updateExpense.validation,
|
this.updateExpense.validation,
|
||||||
asyncMiddleware(this.updateExpense.handler));
|
asyncMiddleware(this.updateExpense.handler));
|
||||||
@@ -49,9 +50,9 @@ export default {
|
|||||||
this.listExpenses.validation,
|
this.listExpenses.validation,
|
||||||
asyncMiddleware(this.listExpenses.handler));
|
asyncMiddleware(this.listExpenses.handler));
|
||||||
|
|
||||||
// router.get('/:id',
|
router.get('/:id',
|
||||||
// this.getExpense.validation,
|
this.getExpense.validation,
|
||||||
// asyncMiddleware(this.getExpense.handler));
|
asyncMiddleware(this.getExpense.handler));
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
},
|
},
|
||||||
@@ -61,15 +62,21 @@ export default {
|
|||||||
*/
|
*/
|
||||||
newExpense: {
|
newExpense: {
|
||||||
validation: [
|
validation: [
|
||||||
check('date').optional(),
|
check('reference_no').optional().trim().escape(),
|
||||||
|
check('payment_date').isISO8601().optional(),
|
||||||
check('payment_account_id').exists().isNumeric().toInt(),
|
check('payment_account_id').exists().isNumeric().toInt(),
|
||||||
check('expense_account_id').exists().isNumeric().toInt(),
|
|
||||||
check('description').optional(),
|
check('description').optional(),
|
||||||
check('amount').exists().isNumeric().toFloat(),
|
|
||||||
check('currency_code').optional(),
|
check('currency_code').optional(),
|
||||||
check('exchange_rate').optional().isNumeric().toFloat(),
|
check('exchange_rate').optional().isNumeric().toFloat(),
|
||||||
check('publish').optional().isBoolean().toBoolean(),
|
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.*.key').exists().trim().escape(),
|
||||||
check('custom_fields.*.value').exists(),
|
check('custom_fields.*.value').exists(),
|
||||||
],
|
],
|
||||||
@@ -81,170 +88,94 @@ export default {
|
|||||||
code: 'validation_error', ...validationErrors,
|
code: 'validation_error', ...validationErrors,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const { user } = req;
|
||||||
|
const { Expense, ExpenseCategory, Account } = req.models;
|
||||||
|
|
||||||
const form = {
|
const form = {
|
||||||
date: new Date(),
|
date: new Date(),
|
||||||
published: false,
|
published: false,
|
||||||
custom_fields: [],
|
custom_fields: [],
|
||||||
|
categories: [],
|
||||||
...req.body,
|
...req.body,
|
||||||
};
|
};
|
||||||
const { Account, Expense } = req.models;
|
const totalAmount = sumBy(form.categories, 'amount');
|
||||||
// Convert the date to the general format.
|
const expenseAccountsIds = form.categories.map((account) => account.expense_account_id)
|
||||||
form.date = moment(form.date).format('YYYY-MM-DD');
|
|
||||||
|
|
||||||
|
const storedExpenseAccounts = await Account.query().whereIn('id', expenseAccountsIds);
|
||||||
|
const storedExpenseAccountsIds = storedExpenseAccounts.map(a => a.id);
|
||||||
|
|
||||||
|
const notStoredExpensesAccountsIds = difference(expenseAccountsIds, storedExpenseAccountsIds);
|
||||||
const errorReasons = [];
|
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) {
|
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 (notStoredExpensesAccountsIds.length > 0) {
|
||||||
|
errorReasons.push({
|
||||||
if (!expenseAccount) {
|
type: 'EXPENSE.ACCOUNTS.IDS.NOT.STORED', code: 400, ids: notStoredExpensesAccountsIds,
|
||||||
errorReasons.push({ type: 'EXPENSE.ACCOUNT.NOT.FOUND', code: 200 });
|
});
|
||||||
|
}
|
||||||
|
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) {
|
if (errorReasons.length > 0) {
|
||||||
return res.status(400).send({ errors: errorReasons });
|
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();
|
form.categories.forEach((category) => {
|
||||||
const creditEntry = new JournalEntry({
|
const oper = ExpenseCategory.query().insert({
|
||||||
credit: form.amount,
|
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,
|
referenceId: expenseTransaction.id,
|
||||||
referenceType: Expense.referenceType,
|
userId: user.id,
|
||||||
date: form.date,
|
draft: !form.publish,
|
||||||
account: expenseAccount.id,
|
};
|
||||||
accountNormal: 'debit',
|
const paymentJournalEntry = new JournalEntry({
|
||||||
draft: !form.published,
|
credit: totalAmount,
|
||||||
});
|
|
||||||
const debitEntry = new JournalEntry({
|
|
||||||
debit: form.amount,
|
|
||||||
referenceId: expenseTransaction.id,
|
|
||||||
referenceType: Expense.referenceType,
|
|
||||||
date: form.date,
|
|
||||||
account: paymentAccount.id,
|
account: paymentAccount.id,
|
||||||
accountNormal: 'debit',
|
...mixinEntry,
|
||||||
draft: !form.published,
|
|
||||||
});
|
});
|
||||||
journalEntries.credit(creditEntry);
|
journalPoster.credit(paymentJournalEntry)
|
||||||
journalEntries.debit(debitEntry);
|
|
||||||
|
|
||||||
|
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([
|
await Promise.all([
|
||||||
// customFields.saveCustomFields(expenseTransaction.id),
|
...storeExpenseCategoriesOper,
|
||||||
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([
|
|
||||||
journalPoster.saveEntries(),
|
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,
|
code: 'validation_error', ...validationErrors,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { Expense, AccountTransaction } = req.models;
|
const { Expense, AccountTransaction } = req.models;
|
||||||
const errorReasons = [];
|
|
||||||
const expense = await Expense.query().findById(id);
|
const expense = await Expense.query().findById(id);
|
||||||
|
const errorReasons = [];
|
||||||
|
|
||||||
if (!expense) {
|
if (!expense) {
|
||||||
errorReasons.push({ type: 'EXPENSE.NOT.FOUND', code: 100 });
|
errorReasons.push({ type: 'EXPENSE.NOT.FOUND', code: 100 });
|
||||||
}
|
|
||||||
if (errorReasons.length > 0) {
|
|
||||||
return res.status(400).send({ errors: errorReasons });
|
return res.status(400).send({ errors: errorReasons });
|
||||||
}
|
}
|
||||||
if (expense.published) {
|
if (expense.published) {
|
||||||
@@ -281,18 +209,33 @@ export default {
|
|||||||
if (errorReasons.length > 0) {
|
if (errorReasons.length > 0) {
|
||||||
return res.status(400).send({ errors: errorReasons });
|
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_id', expense.id)
|
||||||
.where('reference_type', 'Expense')
|
.where('reference_type', 'Expense')
|
||||||
.patch({
|
.patch({
|
||||||
draft: false,
|
draft: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
await Expense.query()
|
const updateExpenseOper = Expense.query()
|
||||||
.where('id', expense.id)
|
.where('id', expense.id)
|
||||||
.update({ published: true });
|
.update({ published: true });
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
updateAccTransactionsOper,
|
||||||
|
updateExpenseOper,
|
||||||
|
journal.saveBalance(),
|
||||||
|
]);
|
||||||
return res.status(200).send();
|
return res.status(200).send();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -301,25 +244,15 @@ export default {
|
|||||||
* Retrieve paginated expenses list.
|
* Retrieve paginated expenses list.
|
||||||
*/
|
*/
|
||||||
listExpenses: {
|
listExpenses: {
|
||||||
validation: [
|
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']),
|
|
||||||
query('page').optional().isNumeric().toInt(),
|
query('page').optional().isNumeric().toInt(),
|
||||||
query('page_size').optional().isNumeric().toInt(),
|
query('page_size').optional().isNumeric().toInt(),
|
||||||
query('custom_view_id').optional().isNumeric().toInt(),
|
|
||||||
|
|
||||||
query('filter_roles').optional().isArray(),
|
query('custom_view_id').optional().isNumeric().toInt(),
|
||||||
query('filter_roles.*.field_key').exists().escape().trim(),
|
query('stringified_filter_roles').optional().isJSON(),
|
||||||
query('filter_roles.*.value').exists().escape().trim(),
|
|
||||||
query('filter_roles.*.comparator').exists().escape().trim(),
|
query('column_sort_by').optional(),
|
||||||
query('filter_roles.*.index').exists().isNumeric().toInt(),
|
query('sort_order').optional().isIn(['desc', 'asc']),
|
||||||
],
|
],
|
||||||
async handler(req, res) {
|
async handler(req, res) {
|
||||||
const validationErrors = validationResult(req);
|
const validationErrors = validationResult(req);
|
||||||
@@ -329,77 +262,99 @@ export default {
|
|||||||
code: 'validation_error', ...validationErrors,
|
code: 'validation_error', ...validationErrors,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const filter = {
|
const filter = {
|
||||||
page_size: 10,
|
sort_order: 'asc',
|
||||||
|
filter_roles: [],
|
||||||
|
page_size: 15,
|
||||||
page: 1,
|
page: 1,
|
||||||
...req.query,
|
...req.query,
|
||||||
};
|
};
|
||||||
const { Resource, View, Expense } = req.models;
|
|
||||||
const errorReasons = [];
|
const errorReasons = [];
|
||||||
const expenseResource = await Resource.query().where('name', 'expenses').first();
|
const { Resource, Expense, View } = req.models;
|
||||||
|
|
||||||
if (!expenseResource) {
|
const expensesResource = await Resource.query()
|
||||||
errorReasons.push({ type: 'EXPENSE_RESOURCE_NOT_FOUND', code: 300 });
|
.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) {
|
if (errorReasons.length > 0) {
|
||||||
return res.status(400).send({ errors: errorReasons });
|
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) => {
|
const expenses = await Expense.query().onBuild((builder) => {
|
||||||
builder.withGraphFetched('paymentAccount');
|
builder.withGraphFetched('paymentAccount');
|
||||||
builder.withGraphFetched('expenseAccount');
|
builder.withGraphFetched('categories');
|
||||||
builder.withGraphFetched('user');
|
builder.withGraphFetched('user');
|
||||||
|
dynamicFilter.buildQuery()(builder);
|
||||||
if (viewConditionals.length) {
|
}).pagination(filter.page - 1, filter.page_size);;
|
||||||
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);
|
|
||||||
|
|
||||||
return res.status(200).send({
|
return res.status(200).send({
|
||||||
...(view) ? {
|
|
||||||
customViewId: view.id,
|
|
||||||
viewColumns: view.columns,
|
|
||||||
viewConditionals,
|
|
||||||
} : {},
|
|
||||||
expenses,
|
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: {
|
deleteExpense: {
|
||||||
validation: [
|
validation: [
|
||||||
@@ -414,26 +369,37 @@ export default {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { Expense, AccountTransaction } = req.models;
|
const {
|
||||||
const expenseTransaction = await Expense.query().findById(id);
|
Expense,
|
||||||
|
ExpenseCategory,
|
||||||
|
AccountTransaction,
|
||||||
|
Account,
|
||||||
|
} = req.models;
|
||||||
|
|
||||||
if (!expenseTransaction) {
|
const expense = await Expense.query().where('id', id).first();
|
||||||
return res.status(404).send({
|
|
||||||
errors: [{ type: 'EXPENSE.TRANSACTION.NOT.FOUND', code: 100 }],
|
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_type', 'Expense')
|
||||||
.where('reference_id', expenseTransaction.id);
|
.where('reference_id', expense.id);
|
||||||
|
|
||||||
const expenseEntriesCollect = new JournalPoster();
|
const accountsDepGraph = await Account.depGraph().query().remember();
|
||||||
expenseEntriesCollect.loadEntries(expenseEntries);
|
const journalEntries = new JournalPoster(accountsDepGraph);
|
||||||
expenseEntriesCollect.reverseEntries();
|
|
||||||
|
journalEntries.loadEntries(expenseTransactions);
|
||||||
|
journalEntries.removeEntries();
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
Expense.query().findById(expenseTransaction.id).delete(),
|
deleteExpenseOper,
|
||||||
expenseEntriesCollect.deleteEntries(),
|
journalEntries.deleteEntries(),
|
||||||
expenseEntriesCollect.saveBalance(),
|
journalEntries.saveBalance(),
|
||||||
]);
|
]);
|
||||||
return res.status(200).send();
|
return res.status(200).send();
|
||||||
},
|
},
|
||||||
@@ -445,13 +411,20 @@ export default {
|
|||||||
updateExpense: {
|
updateExpense: {
|
||||||
validation: [
|
validation: [
|
||||||
param('id').isNumeric().toInt(),
|
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('payment_account_id').exists().isNumeric().toInt(),
|
||||||
check('expense_account_id').exists().isNumeric().toInt(),
|
|
||||||
check('description').optional(),
|
check('description').optional(),
|
||||||
check('amount').exists().isNumeric().toFloat(),
|
|
||||||
check('currency_code').optional(),
|
check('currency_code').optional(),
|
||||||
check('exchange_rate').optional().isNumeric().toFloat(),
|
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) {
|
async handler(req, res) {
|
||||||
const validationErrors = validationResult(req);
|
const validationErrors = validationResult(req);
|
||||||
@@ -462,14 +435,149 @@ export default {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { Expense } = req.models;
|
const { user } = req;
|
||||||
const expenseTransaction = await Expense.query().findById(id);
|
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({
|
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 { id } = req.params;
|
||||||
const { Expense } = req.models;
|
const { Expense, AccountTransaction } = req.models;
|
||||||
const expenseTransaction = await Expense.query().findById(id);
|
|
||||||
|
|
||||||
if (!expenseTransaction) {
|
const expense = await Expense.query()
|
||||||
|
.where('id', id)
|
||||||
|
.withGraphFetched('categories')
|
||||||
|
.withGraphFetched('paymentAccount')
|
||||||
|
.withGraphFetched('user')
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!expense) {
|
||||||
return res.status(404).send({
|
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);
|
const journalEntries = await AccountTransaction.query()
|
||||||
await expenseCFMetadataRepo.load();
|
.where('reference_id', expense.id)
|
||||||
await expenseCFMetadataRepo.fetchCustomFieldsMetadata(expenseTransaction.id);
|
.where('reference_type', 'Expense');
|
||||||
|
|
||||||
const expenseCusFieldsMetadata = expenseCFMetadataRepo.getMetadata(expenseTransaction.id);
|
|
||||||
|
|
||||||
return res.status(200).send({
|
return res.status(200).send({
|
||||||
...expenseTransaction,
|
expense: {
|
||||||
custom_fields: [
|
...expense,
|
||||||
...expenseCusFieldsMetadata.toArray(),
|
journalEntries,
|
||||||
],
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ export default {
|
|||||||
router() {
|
router() {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get('/:resource_slug/data',
|
||||||
|
this.resourceData.validation,
|
||||||
|
asyncMiddleware(this.resourceData.handler));
|
||||||
|
|
||||||
router.get('/:resource_slug/columns',
|
router.get('/:resource_slug/columns',
|
||||||
this.resourceColumns.validation,
|
this.resourceColumns.validation,
|
||||||
asyncMiddleware(this.resourceColumns.handler));
|
asyncMiddleware(this.resourceColumns.handler));
|
||||||
@@ -23,6 +27,26 @@ export default {
|
|||||||
return router;
|
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.
|
* Retrieve resource columns of the given resource.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -13,12 +13,12 @@ import Views from '@/http/controllers/Views';
|
|||||||
// import CustomFields from '@/http/controllers/Fields';
|
// import CustomFields from '@/http/controllers/Fields';
|
||||||
import Accounting from '@/http/controllers/Accounting';
|
import Accounting from '@/http/controllers/Accounting';
|
||||||
import FinancialStatements from '@/http/controllers/FinancialStatements';
|
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 Options from '@/http/controllers/Options';
|
||||||
// import Budget from '@/http/controllers/Budget';
|
// import Budget from '@/http/controllers/Budget';
|
||||||
// import BudgetReports from '@/http/controllers/BudgetReports';
|
// import BudgetReports from '@/http/controllers/BudgetReports';
|
||||||
import Currencies from '@/http/controllers/Currencies';
|
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 Suppliers from '@/http/controllers/Suppliers';
|
||||||
// import Bills from '@/http/controllers/Bills';
|
// import Bills from '@/http/controllers/Bills';
|
||||||
// import CurrencyAdjustment from './controllers/CurrencyAdjustment';
|
// import CurrencyAdjustment from './controllers/CurrencyAdjustment';
|
||||||
@@ -52,11 +52,11 @@ export default (app) => {
|
|||||||
// app.use('/api/fields', CustomFields.router());
|
// app.use('/api/fields', CustomFields.router());
|
||||||
dashboard.use('/api/items', Items.router());
|
dashboard.use('/api/items', Items.router());
|
||||||
dashboard.use('/api/item_categories', ItemCategories.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/financial_statements', FinancialStatements.router());
|
||||||
dashboard.use('/api/options', Options.router());
|
dashboard.use('/api/options', Options.router());
|
||||||
// app.use('/api/budget_reports', BudgetReports.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/suppliers', Suppliers.router());
|
||||||
// app.use('/api/bills', Bills.router());
|
// app.use('/api/bills', Bills.router());
|
||||||
// app.use('/api/budget', Budget.router());
|
// app.use('/api/budget', Budget.router());
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export default class Expense extends TenantModel {
|
|||||||
* Table name
|
* Table name
|
||||||
*/
|
*/
|
||||||
static get tableName() {
|
static get tableName() {
|
||||||
return 'expenses';
|
return 'expenses_transactions';
|
||||||
}
|
}
|
||||||
|
|
||||||
static get referenceType() {
|
static get referenceType() {
|
||||||
@@ -62,31 +62,30 @@ export default class Expense extends TenantModel {
|
|||||||
static get relationMappings() {
|
static get relationMappings() {
|
||||||
const Account = require('@/models/Account');
|
const Account = require('@/models/Account');
|
||||||
const User = require('@/models/TenantUser');
|
const User = require('@/models/TenantUser');
|
||||||
|
const ExpenseCategory = require('@/models/ExpenseCategory');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
paymentAccount: {
|
paymentAccount: {
|
||||||
relation: Model.BelongsToOneRelation,
|
relation: Model.BelongsToOneRelation,
|
||||||
modelClass: this.relationBindKnex(Account.default),
|
modelClass: this.relationBindKnex(Account.default),
|
||||||
join: {
|
join: {
|
||||||
from: 'expenses.paymentAccountId',
|
from: 'expenses_transactions.paymentAccountId',
|
||||||
to: 'accounts.id',
|
to: 'accounts.id',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
categories: {
|
||||||
expenseAccount: {
|
relation: Model.HasManyRelation,
|
||||||
relation: Model.BelongsToOneRelation,
|
modelClass: this.relationBindKnex(ExpenseCategory.default),
|
||||||
modelClass: this.relationBindKnex(Account.default),
|
|
||||||
join: {
|
join: {
|
||||||
from: 'expenses.expenseAccountId',
|
from: 'expenses_transactions.id',
|
||||||
to: 'accounts.id',
|
to: 'expense_transaction_categories.expenseId',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
user: {
|
user: {
|
||||||
relation: Model.BelongsToOneRelation,
|
relation: Model.BelongsToOneRelation,
|
||||||
modelClass: this.relationBindKnex(User.default),
|
modelClass: this.relationBindKnex(User.default),
|
||||||
join: {
|
join: {
|
||||||
from: 'expenses.userId',
|
from: 'expenses_transactions.userId',
|
||||||
to: 'users.id',
|
to: 'users.id',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
29
server/src/models/ExpenseCategory.js
Normal file
29
server/src/models/ExpenseCategory.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { Model } from 'objection';
|
||||||
|
import TenantModel from '@/models/TenantModel';
|
||||||
|
|
||||||
|
export default class ExpenseCategory extends TenantModel {
|
||||||
|
/**
|
||||||
|
* Table name
|
||||||
|
*/
|
||||||
|
static get tableName() {
|
||||||
|
return 'expense_transaction_categories';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Relationship mapping.
|
||||||
|
*/
|
||||||
|
static get relationMappings() {
|
||||||
|
const Account = require('@/models/Account');
|
||||||
|
|
||||||
|
return {
|
||||||
|
expenseAccount: {
|
||||||
|
relation: Model.BelongsToOneRelation,
|
||||||
|
modelClass: this.relationBindKnex(Account.default),
|
||||||
|
join: {
|
||||||
|
from: 'expense_transaction_categories.expenseAccountId',
|
||||||
|
to: 'accounts.id',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
39
server/tests/models/Expense.test.js
Normal file
39
server/tests/models/Expense.test.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { create, expect } from '~/testInit';
|
||||||
|
import Expense from '@/models/Expense';
|
||||||
|
import ExpenseCategory from '@/models/ExpenseCategory';
|
||||||
|
import {
|
||||||
|
tenantFactory,
|
||||||
|
tenantWebsite
|
||||||
|
} from '~/dbInit';
|
||||||
|
|
||||||
|
describe('Model: Expense', () => {
|
||||||
|
describe('relations', () => {
|
||||||
|
it('Expense model may belongs to associated payment account.', async () => {
|
||||||
|
const expense = await tenantFactory.create('expense');
|
||||||
|
|
||||||
|
const expenseModel = await Expense.tenant().query().findById(expense.id);
|
||||||
|
const paymentAccountModel = await expenseModel.$relatedQuery('paymentAccount');
|
||||||
|
|
||||||
|
expect(paymentAccountModel.id).equals(expense.paymentAccountId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Expense model may has many associated expense categories.', async () => {
|
||||||
|
const expenseCategory = await tenantFactory.create('expense_category');
|
||||||
|
|
||||||
|
const expenseModel = await Expense.tenant().query().findById(expenseCategory.expenseId);
|
||||||
|
const expenseCategories = await expenseModel.$relatedQuery('categories');
|
||||||
|
|
||||||
|
expect(expenseCategories.length).equals(1);
|
||||||
|
expect(expenseCategories[0].expenseId).equals(expenseModel.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Expense model may belongs to associated user model.', async () => {
|
||||||
|
const expense = await tenantFactory.create('expense');
|
||||||
|
|
||||||
|
const expenseModel = await Expense.tenant().query().findById(expense.id);
|
||||||
|
const expenseUserModel = await expenseModel.$relatedQuery('user');
|
||||||
|
|
||||||
|
expect(expenseUserModel.id).equals(expense.userId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
5
server/tests/models/ExpenseCategory.test.js
Normal file
5
server/tests/models/ExpenseCategory.test.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
|
||||||
|
|
||||||
|
describe('ExpenseCategory', () => {
|
||||||
|
|
||||||
|
});
|
||||||
681
server/tests/routes/expenses.test.js
Normal file
681
server/tests/routes/expenses.test.js
Normal file
@@ -0,0 +1,681 @@
|
|||||||
|
import moment from 'moment';
|
||||||
|
import { pick } from 'lodash';
|
||||||
|
import {
|
||||||
|
request,
|
||||||
|
expect,
|
||||||
|
} from '~/testInit';
|
||||||
|
import Expense from '@/models/Expense';
|
||||||
|
import ExpenseCategory from '@/models/ExpenseCategory';
|
||||||
|
import AccountTransaction from '@/models/AccountTransaction';
|
||||||
|
import {
|
||||||
|
tenantWebsite,
|
||||||
|
tenantFactory,
|
||||||
|
loginRes,
|
||||||
|
} from '~/dbInit';
|
||||||
|
|
||||||
|
describe('routes: /expenses/', () => {
|
||||||
|
describe('POST `/expenses`', () => {
|
||||||
|
it('Should retrieve unauthorized access if the user was not authorized.', async () => {
|
||||||
|
const res = await request()
|
||||||
|
.post('/api/expenses')
|
||||||
|
.set('organization-id', tenantWebsite.organizationId)
|
||||||
|
.send();
|
||||||
|
|
||||||
|
expect(res.status).equals(401);
|
||||||
|
expect(res.body.message).equals('Unauthorized');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should categories total not be equals zero.', async () => {
|
||||||
|
const res = await request()
|
||||||
|
.post('/api/expenses')
|
||||||
|
.set('x-access-token', loginRes.body.token)
|
||||||
|
.set('organization-id', tenantWebsite.organizationId)
|
||||||
|
.send({
|
||||||
|
payment_date: moment().format('YYYY-MM-DD'),
|
||||||
|
reference_no: '',
|
||||||
|
payment_account_id: 0,
|
||||||
|
description: '',
|
||||||
|
publish: 1,
|
||||||
|
categories: [
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
expense_account_id: 33,
|
||||||
|
amount: 1000,
|
||||||
|
description: '',
|
||||||
|
}
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).equals(400);
|
||||||
|
expect(res.body.errors).include.something.deep.equals({
|
||||||
|
type: 'EXPENSE.ACCOUNTS.IDS.NOT.STORED', code: 400, ids: [33]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should expense accounts ids be stored in the storage.', async () => {
|
||||||
|
const res = await request()
|
||||||
|
.post('/api/expenses')
|
||||||
|
.set('x-access-token', loginRes.body.token)
|
||||||
|
.set('organization-id', tenantWebsite.organizationId)
|
||||||
|
.send({
|
||||||
|
payment_date: moment().format('YYYY-MM-DD'),
|
||||||
|
reference_no: '',
|
||||||
|
payment_account_id: 0,
|
||||||
|
description: '',
|
||||||
|
publish: 1,
|
||||||
|
categories: [
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
expense_account_id: 22,
|
||||||
|
amount: 1000,
|
||||||
|
description: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).equals(400);
|
||||||
|
expect(res.body.errors).include.something.deep.equals({
|
||||||
|
type: 'EXPENSE.ACCOUNTS.IDS.NOT.STORED', code: 400, ids: [22],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should `payment_account_id` be in the storage.', async () => {
|
||||||
|
const res = await request()
|
||||||
|
.post('/api/expenses')
|
||||||
|
.set('x-access-token', loginRes.body.token)
|
||||||
|
.set('organization-id', tenantWebsite.organizationId)
|
||||||
|
.send({
|
||||||
|
payment_date: moment().format('YYYY-MM-DD'),
|
||||||
|
reference_no: '',
|
||||||
|
payment_account_id: 22,
|
||||||
|
description: '',
|
||||||
|
publish: 1,
|
||||||
|
categories: [
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
expense_account_id: 22,
|
||||||
|
amount: 1000,
|
||||||
|
description: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).equals(400);
|
||||||
|
expect(res.body.errors).include.something.deep.equals({
|
||||||
|
type: 'PAYMENT.ACCOUNT.NOT.FOUND', code: 500,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should payment_account be required.', async () => {
|
||||||
|
const res = await request()
|
||||||
|
.post('/api/expenses')
|
||||||
|
.set('x-access-token', loginRes.body.token)
|
||||||
|
.set('organization-id', tenantWebsite.organizationId)
|
||||||
|
.send();
|
||||||
|
|
||||||
|
expect(res.status).equals(422);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should `categories.*.expense_account_id` be required.', async () => {
|
||||||
|
const res = await request()
|
||||||
|
.post('/api/expenses')
|
||||||
|
.set('x-access-token', loginRes.body.token)
|
||||||
|
.set('organization-id', tenantWebsite.organizationId)
|
||||||
|
.send({
|
||||||
|
|
||||||
|
});
|
||||||
|
expect(res.body.errors).include.something.deep.equals({
|
||||||
|
msg: 'Invalid value', param: 'payment_account_id', location: 'body'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should expense transactions be stored on the storage.', async () => {
|
||||||
|
const paymentAccount = await tenantFactory.create('account');
|
||||||
|
const expenseAccount = await tenantFactory.create('account');
|
||||||
|
|
||||||
|
const res = await request()
|
||||||
|
.post('/api/expenses')
|
||||||
|
.set('x-access-token', loginRes.body.token)
|
||||||
|
.set('organization-id', tenantWebsite.organizationId)
|
||||||
|
.send({
|
||||||
|
payment_date: moment().format('YYYY-MM-DD'),
|
||||||
|
reference_no: 'ABC',
|
||||||
|
payment_account_id: paymentAccount.id,
|
||||||
|
description: 'desc',
|
||||||
|
publish: 1,
|
||||||
|
categories: [
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
expense_account_id: expenseAccount.id,
|
||||||
|
amount: 1000,
|
||||||
|
description: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const foundExpense = await Expense.tenant().query().where('id', res.body.id);
|
||||||
|
|
||||||
|
expect(foundExpense.length).equals(1);
|
||||||
|
expect(foundExpense[0].referenceNo).equals('ABC');
|
||||||
|
expect(foundExpense[0].paymentAccountId).equals(paymentAccount.id);
|
||||||
|
expect(foundExpense[0].description).equals('desc');
|
||||||
|
expect(foundExpense[0].totalAmount).equals(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should expense categories transactions be stored on the storage.', async () => {
|
||||||
|
const paymentAccount = await tenantFactory.create('account');
|
||||||
|
const expenseAccount = await tenantFactory.create('account');
|
||||||
|
|
||||||
|
const res = await request()
|
||||||
|
.post('/api/expenses')
|
||||||
|
.set('x-access-token', loginRes.body.token)
|
||||||
|
.set('organization-id', tenantWebsite.organizationId)
|
||||||
|
.send({
|
||||||
|
payment_date: moment().format('YYYY-MM-DD'),
|
||||||
|
reference_no: 'ABC',
|
||||||
|
payment_account_id: paymentAccount.id,
|
||||||
|
description: 'desc',
|
||||||
|
publish: 1,
|
||||||
|
categories: [
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
expense_account_id: expenseAccount.id,
|
||||||
|
amount: 1000,
|
||||||
|
description: 'category desc',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const foundCategories = await ExpenseCategory.tenant().query().where('id', res.body.id);
|
||||||
|
|
||||||
|
expect(foundCategories.length).equals(1);
|
||||||
|
expect(foundCategories[0].index).equals(1);
|
||||||
|
expect(foundCategories[0].expenseAccountId).equals(expenseAccount.id);
|
||||||
|
expect(foundCategories[0].amount).equals(1000);
|
||||||
|
expect(foundCategories[0].description).equals('category desc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should save journal entries that associate to the expense transaction.', async () => {
|
||||||
|
const paymentAccount = await tenantFactory.create('account');
|
||||||
|
const expenseAccount = await tenantFactory.create('account');
|
||||||
|
|
||||||
|
const res = await request()
|
||||||
|
.post('/api/expenses')
|
||||||
|
.set('x-access-token', loginRes.body.token)
|
||||||
|
.set('organization-id', tenantWebsite.organizationId)
|
||||||
|
.send({
|
||||||
|
payment_date: moment().format('YYYY-MM-DD'),
|
||||||
|
reference_no: 'ABC',
|
||||||
|
payment_account_id: paymentAccount.id,
|
||||||
|
description: 'desc',
|
||||||
|
publish: 1,
|
||||||
|
categories: [
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
expense_account_id: expenseAccount.id,
|
||||||
|
amount: 1000,
|
||||||
|
description: 'category desc',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const transactions = await AccountTransaction.tenant().query()
|
||||||
|
.where('reference_id', res.body.id)
|
||||||
|
.where('reference_type', 'Expense');
|
||||||
|
|
||||||
|
const mappedTransactions = transactions.map(tr => ({
|
||||||
|
...pick(tr, ['credit', 'debit', 'referenceId', 'referenceType']),
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(mappedTransactions[0]).deep.equals({
|
||||||
|
credit: 1000,
|
||||||
|
debit: 0,
|
||||||
|
referenceType: 'Expense',
|
||||||
|
referenceId: res.body.id,
|
||||||
|
});
|
||||||
|
expect(mappedTransactions[1]).deep.equals({
|
||||||
|
credit: 0,
|
||||||
|
debit: 1000,
|
||||||
|
referenceType: 'Expense',
|
||||||
|
referenceId: res.body.id,
|
||||||
|
});
|
||||||
|
expect(transactions.length).equals(2);
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET: `/expenses`', () => {
|
||||||
|
it('Should response unauthorized if the user was not logged in.', async () => {
|
||||||
|
const res = await request()
|
||||||
|
.post('/api/expenses')
|
||||||
|
.set('organization-id', tenantWebsite.organizationId)
|
||||||
|
.send();
|
||||||
|
|
||||||
|
expect(res.status).equals(401);
|
||||||
|
expect(res.body.message).equals('Unauthorized');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should retrieve expenses with pagination meta.', async () => {
|
||||||
|
await tenantFactory.create('expense');
|
||||||
|
await tenantFactory.create('expense');
|
||||||
|
|
||||||
|
const res = await request()
|
||||||
|
.get('/api/expenses')
|
||||||
|
.set('x-access-token', loginRes.body.token)
|
||||||
|
.set('organization-id', tenantWebsite.organizationId)
|
||||||
|
.send();
|
||||||
|
|
||||||
|
expect(res.body.expenses).that.is.an('object');
|
||||||
|
expect(res.body.expenses.results).that.is.an('array');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should retrieve expenses based on view roles conditions of the custom view.', () => {
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should sort expenses based on the given `column_sort_order` column on ASC direction.', () => {
|
||||||
|
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.only('DELETE: `/expenses/:id`', () => {
|
||||||
|
it('Should response unauthorized if the user was not logged in.', async () => {
|
||||||
|
const res = await request()
|
||||||
|
.delete('/api/expenses')
|
||||||
|
.set('organization-id', tenantWebsite.organizationId)
|
||||||
|
.send();
|
||||||
|
|
||||||
|
expect(res.status).equals(401);
|
||||||
|
expect(res.body.message).equals('Unauthorized');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should response not found in case expense id was not exists on the storage.', async () => {
|
||||||
|
const res = await request()
|
||||||
|
.delete('/api/expenses/123321')
|
||||||
|
.set('organization-id', tenantWebsite.organizationId)
|
||||||
|
.set('x-access-token', loginRes.body.token)
|
||||||
|
.send();
|
||||||
|
|
||||||
|
expect(res.status).equals(404);
|
||||||
|
expect(res.body.errors).include.something.deep.equals({
|
||||||
|
type: 'EXPENSE.NOT.FOUND', code: 200,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should delete the given expense transactions with associated categories.', async () => {
|
||||||
|
const expense = await tenantFactory.create('expense');
|
||||||
|
|
||||||
|
const res = await request()
|
||||||
|
.delete(`/api/expenses/${expense.id}`)
|
||||||
|
.set('organization-id', tenantWebsite.organizationId)
|
||||||
|
.set('x-access-token', loginRes.body.token)
|
||||||
|
.send();
|
||||||
|
|
||||||
|
expect(res.status).equals(200);
|
||||||
|
|
||||||
|
const storedExpense = await Expense.tenant().query().where('id', expense.id);
|
||||||
|
const storedExpenseCategories = await ExpenseCategory.tenant().query().where('expense_id', expense.id);
|
||||||
|
|
||||||
|
expect(storedExpense.length).equals(0);
|
||||||
|
expect(storedExpenseCategories.length).equals(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.only('Should delete all journal entries that associated to the given expense.', async () => {
|
||||||
|
const expense = await tenantFactory.create('expense');
|
||||||
|
|
||||||
|
const trans = { reference_id: expense.id, reference_type: 'Expense' };
|
||||||
|
await tenantFactory.create('account_transaction', trans);
|
||||||
|
await tenantFactory.create('account_transaction', trans);
|
||||||
|
|
||||||
|
const res = await request()
|
||||||
|
.delete(`/api/expenses/${expense.id}`)
|
||||||
|
.set('organization-id', tenantWebsite.organizationId)
|
||||||
|
.set('x-access-token', loginRes.body.token)
|
||||||
|
.send();
|
||||||
|
|
||||||
|
const foundTransactions = await AccountTransaction.tenant().query()
|
||||||
|
.where('reference_type', 'Expense')
|
||||||
|
.where('reference_id', expense.id);
|
||||||
|
|
||||||
|
expect(foundTransactions.length).equals(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET: `/expenses/:id`', () => {
|
||||||
|
it('Should response unauthorized if the user was not logged in.', async () => {
|
||||||
|
const res = await request()
|
||||||
|
.get('/api/expenses/123')
|
||||||
|
.set('organization-id', tenantWebsite.organizationId)
|
||||||
|
.send();
|
||||||
|
|
||||||
|
expect(res.status).equals(401);
|
||||||
|
expect(res.body.message).equals('Unauthorized');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should response not found in case the given expense id was not exists in the storage.', async () => {
|
||||||
|
const res = await request()
|
||||||
|
.get(`/api/expenses/321`)
|
||||||
|
.set('organization-id', tenantWebsite.organizationId)
|
||||||
|
.set('x-access-token', loginRes.body.token)
|
||||||
|
.send();
|
||||||
|
|
||||||
|
expect(res.status).equals(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should retrieve expense metadata and associated expense categories.', async () => {
|
||||||
|
const expense = await tenantFactory.create('expense');
|
||||||
|
const expenseCategory = await tenantFactory.create('expense_category', {
|
||||||
|
expense_id: expense.id,
|
||||||
|
})
|
||||||
|
const res = await request()
|
||||||
|
.get(`/api/expenses/${expense.id}`)
|
||||||
|
.set('organization-id', tenantWebsite.organizationId)
|
||||||
|
.set('x-access-token', loginRes.body.token)
|
||||||
|
.send();
|
||||||
|
|
||||||
|
expect(res.status).equals(200);
|
||||||
|
|
||||||
|
expect(res.body.expense.id).is.a('number');
|
||||||
|
expect(res.body.expense.paymentAccountId).is.a('number');
|
||||||
|
expect(res.body.expense.totalAmount).is.a('number');
|
||||||
|
expect(res.body.expense.userId).is.a('number');
|
||||||
|
expect(res.body.expense.referenceNo).is.a('string');
|
||||||
|
expect(res.body.expense.description).is.a('string');
|
||||||
|
expect(res.body.expense.categories).is.a('array');
|
||||||
|
|
||||||
|
expect(res.body.expense.categories[0].id).is.a('number');
|
||||||
|
expect(res.body.expense.categories[0].description).is.a('string');
|
||||||
|
expect(res.body.expense.categories[0].expenseAccountId).is.a('number');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should retrieve journal entries with expense metadata.', async () => {
|
||||||
|
const expense = await tenantFactory.create('expense');
|
||||||
|
const expenseCategory = await tenantFactory.create('expense_category', {
|
||||||
|
expense_id: expense.id,
|
||||||
|
});
|
||||||
|
const trans = { reference_id: expense.id, reference_type: 'Expense' };
|
||||||
|
await tenantFactory.create('account_transaction', trans);
|
||||||
|
await tenantFactory.create('account_transaction', trans);
|
||||||
|
|
||||||
|
const res = await request()
|
||||||
|
.get(`/api/expenses/${expense.id}`)
|
||||||
|
.set('organization-id', tenantWebsite.organizationId)
|
||||||
|
.set('x-access-token', loginRes.body.token)
|
||||||
|
.send();
|
||||||
|
|
||||||
|
expect(res.body.expense.journalEntries).is.an('array');
|
||||||
|
expect(res.body.expense.journalEntries.length).equals(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST: `expenses/:id`', () => {
|
||||||
|
it('Should response unauthorized in case the user was not logged in.', async () => {
|
||||||
|
const expense = await tenantFactory.create('expense');
|
||||||
|
const res = await request()
|
||||||
|
.post(`/api/expenses/${expense.id}`)
|
||||||
|
.set('organization-id', tenantWebsite.organizationId)
|
||||||
|
.send();
|
||||||
|
|
||||||
|
expect(res.status).equals(401);
|
||||||
|
expect(res.body.message).equals('Unauthorized');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should response the given expense id not exists on the storage.', async () => {
|
||||||
|
const expenseAccount = await tenantFactory.create('account');
|
||||||
|
|
||||||
|
const res = await request()
|
||||||
|
.post('/api/expenses/1233')
|
||||||
|
.set('x-access-token', loginRes.body.token)
|
||||||
|
.set('organization-id', tenantWebsite.organizationId)
|
||||||
|
.send({
|
||||||
|
reference_no: '123',
|
||||||
|
payment_date: moment().format('YYYY-MM-DD'),
|
||||||
|
payment_account_id: 321,
|
||||||
|
publish: true,
|
||||||
|
categories: [
|
||||||
|
{
|
||||||
|
expense_account_id: expenseAccount.id,
|
||||||
|
index: 1,
|
||||||
|
amount: 1000,
|
||||||
|
description: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(res.status).equals(404);
|
||||||
|
expect(res.body.errors).include.something.deep.equals({
|
||||||
|
type: 'EXPENSE.NOT.FOUND', code: 200,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should response the given `payment_account_id` not exists.', async () => {
|
||||||
|
const expense = await tenantFactory.create('expense');
|
||||||
|
const expenseAccount = await tenantFactory.create('account');
|
||||||
|
|
||||||
|
const res = await request()
|
||||||
|
.post(`/api/expenses/${expense.id}`)
|
||||||
|
.set('x-access-token', loginRes.body.token)
|
||||||
|
.set('organization-id', tenantWebsite.organizationId)
|
||||||
|
.send({
|
||||||
|
reference_no: '123',
|
||||||
|
payment_date: moment().format('YYYY-MM-DD'),
|
||||||
|
payment_account_id: 321,
|
||||||
|
publish: true,
|
||||||
|
categories: [
|
||||||
|
{
|
||||||
|
expense_account_id: expenseAccount.id,
|
||||||
|
index: 1,
|
||||||
|
amount: 1000,
|
||||||
|
description: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).equals(400);
|
||||||
|
expect(res.body.errors).include.something.deep.equals({
|
||||||
|
type: 'PAYMENT.ACCOUNT.NOT.FOUND', code: 400,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should response the given `categories.*.expense_account_id` not exists.', async () => {
|
||||||
|
const paymentAccount = await tenantFactory.create('account');
|
||||||
|
const expense = await tenantFactory.create('expense');
|
||||||
|
|
||||||
|
const res = await request()
|
||||||
|
.post(`/api/expenses/${expense.id}`)
|
||||||
|
.set('x-access-token', loginRes.body.token)
|
||||||
|
.set('organization-id', tenantWebsite.organizationId)
|
||||||
|
.send({
|
||||||
|
reference_no: '123',
|
||||||
|
payment_date: moment().format('YYYY-MM-DD'),
|
||||||
|
payment_account_id: paymentAccount.id,
|
||||||
|
publish: true,
|
||||||
|
categories: [
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
expense_account_id: 100,
|
||||||
|
amount: 1000,
|
||||||
|
description: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).equals(400);
|
||||||
|
expect(res.body.errors).include.something.deep.equals({
|
||||||
|
type: 'EXPENSE.ACCOUNTS.IDS.NOT.FOUND', code: 600, ids: [100],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should response the total amount equals zero.', async () => {
|
||||||
|
const expense = await tenantFactory.create('expense');
|
||||||
|
const expenseAccount = await tenantFactory.create('account');
|
||||||
|
const paymentAccount = await tenantFactory.create('account');
|
||||||
|
|
||||||
|
const res = await request()
|
||||||
|
.post(`/api/expenses/${expense.id}`)
|
||||||
|
.set('x-access-token', loginRes.body.token)
|
||||||
|
.set('organization-id', tenantWebsite.organizationId)
|
||||||
|
.send({
|
||||||
|
reference_no: '123',
|
||||||
|
payment_date: moment().format('YYYY-MM-DD'),
|
||||||
|
payment_account_id: paymentAccount.id,
|
||||||
|
publish: true,
|
||||||
|
categories: [
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
expense_account_id: expenseAccount.id,
|
||||||
|
amount: 0,
|
||||||
|
description: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.status).equals(400);
|
||||||
|
expect(res.body.errors).include.something.deep.equals({
|
||||||
|
type: 'TOTAL.AMOUNT.EQUALS.ZERO', code: 500,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should update the expense transaction.', async () => {
|
||||||
|
const expense = await tenantFactory.create('expense');
|
||||||
|
const paymentAccount = await tenantFactory.create('account');
|
||||||
|
const expenseAccount = await tenantFactory.create('account');
|
||||||
|
|
||||||
|
const res = await request()
|
||||||
|
.post(`/api/expenses/${expense.id}`)
|
||||||
|
.set('x-access-token', loginRes.body.token)
|
||||||
|
.set('organization-id', tenantWebsite.organizationId)
|
||||||
|
.send({
|
||||||
|
reference_no: '123',
|
||||||
|
payment_date: moment('2009-01-02').format('YYYY-MM-DD'),
|
||||||
|
payment_account_id: paymentAccount.id,
|
||||||
|
publish: true,
|
||||||
|
description: 'Updated description',
|
||||||
|
categories: [
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
expense_account_id: expenseAccount.id,
|
||||||
|
amount: 3000,
|
||||||
|
description: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(res.status).equals(200);
|
||||||
|
|
||||||
|
const updatedExpense = await Expense.tenant().query()
|
||||||
|
.where('id', expense.id).first();
|
||||||
|
|
||||||
|
expect(updatedExpense.id).equals(expense.id);
|
||||||
|
expect(updatedExpense.referenceNo).equals('123');
|
||||||
|
expect(updatedExpense.description).equals('Updated description');
|
||||||
|
expect(updatedExpense.totalAmount).equals(3000);
|
||||||
|
expect(updatedExpense.paymentAccountId).equals(paymentAccount.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should delete the expense categories that associated to the expense transaction.', async () => {
|
||||||
|
const expense = await tenantFactory.create('expense');
|
||||||
|
const expenseCategory = await tenantFactory.create('expense_category', {
|
||||||
|
expense_id: expense.id,
|
||||||
|
});
|
||||||
|
const paymentAccount = await tenantFactory.create('account');
|
||||||
|
const expenseAccount = await tenantFactory.create('account');
|
||||||
|
|
||||||
|
const res = await request()
|
||||||
|
.post(`/api/expenses/${expense.id}`)
|
||||||
|
.set('x-access-token', loginRes.body.token)
|
||||||
|
.set('organization-id', tenantWebsite.organizationId)
|
||||||
|
.send({
|
||||||
|
reference_no: '123',
|
||||||
|
payment_date: moment('2009-01-02').format('YYYY-MM-DD'),
|
||||||
|
payment_account_id: paymentAccount.id,
|
||||||
|
publish: true,
|
||||||
|
description: 'Updated description',
|
||||||
|
categories: [
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
expense_account_id: expenseAccount.id,
|
||||||
|
amount: 3000,
|
||||||
|
description: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const foundExpenseCategories = await ExpenseCategory.tenant()
|
||||||
|
.query().where('id', expenseCategory.id)
|
||||||
|
|
||||||
|
expect(foundExpenseCategories.length).equals(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should insert the expense categories to associated to the expense transaction.', async () => {
|
||||||
|
const expense = await tenantFactory.create('expense');
|
||||||
|
const expenseCategory = await tenantFactory.create('expense_category', {
|
||||||
|
expense_id: expense.id,
|
||||||
|
});
|
||||||
|
const paymentAccount = await tenantFactory.create('account');
|
||||||
|
const expenseAccount = await tenantFactory.create('account');
|
||||||
|
|
||||||
|
const res = await request()
|
||||||
|
.post(`/api/expenses/${expense.id}`)
|
||||||
|
.set('x-access-token', loginRes.body.token)
|
||||||
|
.set('organization-id', tenantWebsite.organizationId)
|
||||||
|
.send({
|
||||||
|
reference_no: '123',
|
||||||
|
payment_date: moment('2009-01-02').format('YYYY-MM-DD'),
|
||||||
|
payment_account_id: paymentAccount.id,
|
||||||
|
publish: true,
|
||||||
|
description: 'Updated description',
|
||||||
|
categories: [
|
||||||
|
{
|
||||||
|
index: 1,
|
||||||
|
expense_account_id: expenseAccount.id,
|
||||||
|
amount: 3000,
|
||||||
|
description: '__desc__',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const foundExpenseCategories = await ExpenseCategory.tenant()
|
||||||
|
.query()
|
||||||
|
.where('expense_id', expense.id)
|
||||||
|
|
||||||
|
expect(foundExpenseCategories.length).equals(1);
|
||||||
|
expect(foundExpenseCategories[0].id).not.equals(expenseCategory.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Should update the expense categories that associated to the expense transactions.', async () => {
|
||||||
|
const expense = await tenantFactory.create('expense');
|
||||||
|
const expenseCategory = await tenantFactory.create('expense_category', {
|
||||||
|
expense_id: expense.id,
|
||||||
|
});
|
||||||
|
const paymentAccount = await tenantFactory.create('account');
|
||||||
|
const expenseAccount = await tenantFactory.create('account');
|
||||||
|
|
||||||
|
const res = await request()
|
||||||
|
.post(`/api/expenses/${expense.id}`)
|
||||||
|
.set('x-access-token', loginRes.body.token)
|
||||||
|
.set('organization-id', tenantWebsite.organizationId)
|
||||||
|
.send({
|
||||||
|
reference_no: '123',
|
||||||
|
payment_date: moment('2009-01-02').format('YYYY-MM-DD'),
|
||||||
|
payment_account_id: paymentAccount.id,
|
||||||
|
publish: true,
|
||||||
|
description: 'Updated description',
|
||||||
|
categories: [
|
||||||
|
{
|
||||||
|
id: expenseCategory.id,
|
||||||
|
index: 1,
|
||||||
|
expense_account_id: expenseAccount.id,
|
||||||
|
amount: 3000,
|
||||||
|
description: '__desc__',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const foundExpenseCategory = await ExpenseCategory.tenant().query()
|
||||||
|
.where('id', expenseCategory.id);
|
||||||
|
|
||||||
|
expect(foundExpenseCategory.length).equals(1);
|
||||||
|
expect(foundExpenseCategory[0].expenseAccountId).equals(expenseAccount.id);
|
||||||
|
expect(foundExpenseCategory[0].description).equals('__desc__');
|
||||||
|
expect(foundExpenseCategory[0].amount).equals(3000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user