diff --git a/server/src/data/ResourceFieldsKeys.js b/server/src/data/ResourceFieldsKeys.js index 0ee1c6942..4772146b7 100644 --- a/server/src/data/ResourceFieldsKeys.js +++ b/server/src/data/ResourceFieldsKeys.js @@ -25,7 +25,7 @@ export default { 'type': { column: 'account_type_id', relation: 'account_types.id', - relationColumn: 'account_types.name', + relationColumn: 'account_types.id', }, 'description': { column: 'description', @@ -38,6 +38,9 @@ export default { relation: 'account_types.id', relationColumn: 'account_types.root_type', }, + 'created_at': { + column: 'created_at', + }, }, // Items diff --git a/server/src/database/factories/index.js b/server/src/database/factories/index.js index e7ceb8525..7c2079634 100644 --- a/server/src/database/factories/index.js +++ b/server/src/database/factories/index.js @@ -97,6 +97,7 @@ export default (tenantDb) => { const costAccount = await factory.create('account'); const sellAccount = await factory.create('account'); const inventoryAccount = await factory.create('account'); + return { name: faker.lorem.word(), 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 expenseAccount = await factory.create('account'); const user = await factory.create('user'); return { - payment_account_id: paymentAccount.id, - expense_account_id: expenseAccount.id, - user_id: user.id, - amount: faker.random.number(), + total_amount: faker.random.number(), 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, }; }); diff --git a/server/src/database/migrations/20200105014405_create_expenses_table.js b/server/src/database/migrations/20200105014405_create_expenses_table.js index f6f8e383d..7f0250f5f 100644 --- a/server/src/database/migrations/20200105014405_create_expenses_table.js +++ b/server/src/database/migrations/20200105014405_create_expenses_table.js @@ -1,19 +1,18 @@ exports.up = function(knex) { - return knex.schema.createTable('expenses', (table) => { + return knex.schema.createTable('expenses_transactions', (table) => { table.increments(); - table.decimal('amount'); + table.decimal('total_amount'); table.string('currency_code'); - table.decimal('exchange_rate'); table.text('description'); - table.integer('expense_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.integer('user_id').unsigned(); - table.date('date'); - // table.timestamps(); - }) + table.date('payment_date'); + table.timestamps(); + }).raw('ALTER TABLE `EXPENSES_TRANSACTIONS` AUTO_INCREMENT = 1000'); }; exports.down = function(knex) { diff --git a/server/src/database/migrations/20200606113848_create_expense_transactions_categories_table.js b/server/src/database/migrations/20200606113848_create_expense_transactions_categories_table.js new file mode 100644 index 000000000..4d466d80e --- /dev/null +++ b/server/src/database/migrations/20200606113848_create_expense_transactions_categories_table.js @@ -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'); +}; diff --git a/server/src/database/migrations/20200607212203_create_customers_table.js b/server/src/database/migrations/20200607212203_create_customers_table.js new file mode 100644 index 000000000..99dc14fcb --- /dev/null +++ b/server/src/database/migrations/20200607212203_create_customers_table.js @@ -0,0 +1,8 @@ + +exports.up = function(knex) { + +}; + +exports.down = function(knex) { + +}; diff --git a/server/src/database/seeds/seed_resources_fields.js b/server/src/database/seeds/seed_resources_fields.js index e667b301a..a473d48fd 100644 --- a/server/src/database/seeds/seed_resources_fields.js +++ b/server/src/database/seeds/seed_resources_fields.js @@ -49,6 +49,15 @@ exports.seed = (knex) => { predefined: 1, columnable: true, }, + { + id: 16, + resource_id: 1, + label_name: 'Created at', + data_type: 'date', + key: 'created_at', + predefined: 1, + columnable: true, + }, // Expenses { diff --git a/server/src/http/controllers/Expenses.js b/server/src/http/controllers/Expenses.js index ae86c0475..7906e85a6 100644 --- a/server/src/http/controllers/Expenses.js +++ b/server/src/http/controllers/Expenses.js @@ -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, + } }); }, }, diff --git a/server/src/http/controllers/Resources.js b/server/src/http/controllers/Resources.js index f3b7da54e..1826091c8 100644 --- a/server/src/http/controllers/Resources.js +++ b/server/src/http/controllers/Resources.js @@ -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. */ diff --git a/server/src/http/index.js b/server/src/http/index.js index a1a6ad0e6..253ab77c6 100644 --- a/server/src/http/index.js +++ b/server/src/http/index.js @@ -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()); diff --git a/server/src/models/Expense.js b/server/src/models/Expense.js index 41262b4b5..567a3c924 100644 --- a/server/src/models/Expense.js +++ b/server/src/models/Expense.js @@ -7,7 +7,7 @@ export default class Expense extends TenantModel { * Table name */ static get tableName() { - return 'expenses'; + return 'expenses_transactions'; } static get referenceType() { @@ -62,31 +62,30 @@ export default class Expense extends TenantModel { static get relationMappings() { const Account = require('@/models/Account'); const User = require('@/models/TenantUser'); + const ExpenseCategory = require('@/models/ExpenseCategory'); return { paymentAccount: { relation: Model.BelongsToOneRelation, modelClass: this.relationBindKnex(Account.default), join: { - from: 'expenses.paymentAccountId', + from: 'expenses_transactions.paymentAccountId', to: 'accounts.id', }, }, - - expenseAccount: { - relation: Model.BelongsToOneRelation, - modelClass: this.relationBindKnex(Account.default), + categories: { + relation: Model.HasManyRelation, + modelClass: this.relationBindKnex(ExpenseCategory.default), join: { - from: 'expenses.expenseAccountId', - to: 'accounts.id', + from: 'expenses_transactions.id', + to: 'expense_transaction_categories.expenseId', }, }, - user: { relation: Model.BelongsToOneRelation, modelClass: this.relationBindKnex(User.default), join: { - from: 'expenses.userId', + from: 'expenses_transactions.userId', to: 'users.id', }, }, diff --git a/server/src/models/ExpenseCategory.js b/server/src/models/ExpenseCategory.js new file mode 100644 index 000000000..382c300af --- /dev/null +++ b/server/src/models/ExpenseCategory.js @@ -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', + }, + }, + }; + } +} diff --git a/server/tests/models/Expense.test.js b/server/tests/models/Expense.test.js new file mode 100644 index 000000000..1adec914f --- /dev/null +++ b/server/tests/models/Expense.test.js @@ -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); + }); + }); +}); diff --git a/server/tests/models/ExpenseCategory.test.js b/server/tests/models/ExpenseCategory.test.js new file mode 100644 index 000000000..1eca93c07 --- /dev/null +++ b/server/tests/models/ExpenseCategory.test.js @@ -0,0 +1,5 @@ + + +describe('ExpenseCategory', () => { + +}); \ No newline at end of file diff --git a/server/tests/routes/expenses.test.js b/server/tests/routes/expenses.test.js new file mode 100644 index 000000000..2562b2fcf --- /dev/null +++ b/server/tests/routes/expenses.test.js @@ -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); + }); + }); +});