From b5f94e9a8b584685f1f520b2899a444c7be17b98 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sat, 21 Mar 2020 23:32:04 +0200 Subject: [PATCH] WIP --- client/src/config/sidebarMenu.js | 4 + client/src/connectors/Journal.connect.js | 19 + .../BalanceSheet/BalanceSheetTable.js | 9 +- .../FinancialStatements/Journal/Journal.js | 72 ++++ .../Journal/JournalHeader.js | 120 +++++++ .../Journal/JournalTable.js | 89 +++++ client/src/routes/dashboard.js | 9 +- .../financialStatements.actions.js | 19 +- .../financialStatements.reducer.js | 23 +- .../financialStatements.selectors.js | 43 +++ .../financialStatements.types.js | 1 + server/src/http/controllers/Accounts.js | 6 + .../http/controllers/FinancialStatements.js | 150 +++++--- server/src/http/controllers/Items.js | 21 +- server/tests/routes/accounts.test.js | 19 + .../tests/routes/financial_statements.test.js | 330 ++++++++++++------ server/tests/testInit.js | 34 +- 17 files changed, 780 insertions(+), 188 deletions(-) create mode 100644 client/src/connectors/Journal.connect.js create mode 100644 client/src/containers/Dashboard/FinancialStatements/Journal/Journal.js create mode 100644 client/src/containers/Dashboard/FinancialStatements/Journal/JournalHeader.js create mode 100644 client/src/containers/Dashboard/FinancialStatements/Journal/JournalTable.js diff --git a/client/src/config/sidebarMenu.js b/client/src/config/sidebarMenu.js index 8e215cc65..6ea6987ea 100644 --- a/client/src/config/sidebarMenu.js +++ b/client/src/config/sidebarMenu.js @@ -120,6 +120,10 @@ export default [ text: 'Trial Balance Sheet', href: '/dashboard/accounting/trial-balance-sheet', }, + { + text: 'Journal', + href: '/dashboard/accounting/journal-sheet', + }, { divider: true, }, diff --git a/client/src/connectors/Journal.connect.js b/client/src/connectors/Journal.connect.js new file mode 100644 index 000000000..3e556f18d --- /dev/null +++ b/client/src/connectors/Journal.connect.js @@ -0,0 +1,19 @@ +import {connect} from 'react-redux'; +import { + fetchJournalSheet +} from 'store/financialStatement/financialStatements.actions'; +import { + getFinancialSheetIndexByQuery, + getFinancialSheet, +} from 'store/financialStatement/financialStatements.selectors'; + +export const mapStateToProps = (state, props) => ({ + getJournalSheetIndex: (query) => getFinancialSheetIndexByQuery(state.financialStatements.journalSheets, query), + getJournalSheet: (index) => getFinancialSheet(state.financialStatements.journalSheets, index), +}); + +export const mapDispatchToProps = (dispatch) => ({ + fetchJournalSheet: (query) => dispatch(fetchJournalSheet({ query })), +}); + +export default connect(mapStateToProps, mapDispatchToProps); \ No newline at end of file diff --git a/client/src/containers/Dashboard/FinancialStatements/BalanceSheet/BalanceSheetTable.js b/client/src/containers/Dashboard/FinancialStatements/BalanceSheet/BalanceSheetTable.js index a3c64291e..392bcc0b1 100644 --- a/client/src/containers/Dashboard/FinancialStatements/BalanceSheet/BalanceSheetTable.js +++ b/client/src/containers/Dashboard/FinancialStatements/BalanceSheet/BalanceSheetTable.js @@ -78,16 +78,12 @@ function BalanceSheetTable({ } }, }))), - - - - ], [balanceSheetColumns]); + ], [balanceSheetColumns, balanceSheetQuery]); const [data, setData] = useState([]); useEffect(() => { if (!balanceSheet) { return; } - setData([ { name: 'Assets', @@ -106,9 +102,6 @@ function BalanceSheetTable({ ]) }, []) - // if (balanceSheets.length > 0) { - // setData(balanceSheets[0].balance_sheet); - // } return ( { + await Promise.all([ + fetchJournalSheet(filter), + ]); + setReload(false); + }); + + useEffect(() => { + changePageTitle('Journal'); + }, []); + + useEffect(() => { + if (reload) { + fetchHook.execute(); + } + }, [reload, fetchHook]); + + const journalSheetIndex = useMemo(() => { + return getJournalSheetIndex(filter); + }, [filter, getJournalSheetIndex]); + + const handleFilterSubmit = (filter) => { + setFilter({ + ...filter, + from_date: moment(filter.from_date).format('YYYY-MM-DD'), + to_date: moment(filter.to_date).format('YYYY-MM-DD'), + }); + setReload(true); + }; + return ( +
+ + +
+ + + +
+
+ ) +} + +export default compose( + JournalConnect, + DashboardConnect, +)(Journal); \ No newline at end of file diff --git a/client/src/containers/Dashboard/FinancialStatements/Journal/JournalHeader.js b/client/src/containers/Dashboard/FinancialStatements/Journal/JournalHeader.js new file mode 100644 index 000000000..27f83df4b --- /dev/null +++ b/client/src/containers/Dashboard/FinancialStatements/Journal/JournalHeader.js @@ -0,0 +1,120 @@ +import React, {useState, useMemo, useEffect} from 'react'; +import FinancialStatementHeader from 'containers/Dashboard/FinancialStatements/FinancialStatementHeader'; +import {Row, Col} from 'react-grid-system'; +import { + Button, + FormGroup, + Position, + HTMLSelect, + Intent, +} from '@blueprintjs/core'; +import {DateInput} from '@blueprintjs/datetime'; +import moment from 'moment'; +import { + momentFormatter, + parseDateRangeQuery, +} from 'utils'; +import {useIntl} from 'react-intl'; + +export default function JournalHeader({ + pageFilter, + onSubmitFilter, +}) { + const intl = useIntl(); + + const [filter, setFilter] = useState({ + ...pageFilter, + from_date: moment(pageFilter.from_date).toDate(), + to_date: moment(pageFilter.to_date).toDate() + }); + + const setFilterByKey = (name, value) => { + setFilter({ ...filter, [name]: value }); + }; + const [reportDateRange, setReportDateRange] = useState('this_year'); + const dateRangeOptions = [ + {value: 'today', label: 'Today', }, + {value: 'this_week', label: 'This Week'}, + {value: 'this_month', label: 'This Month'}, + {value: 'this_quarter', label: 'This Quarter'}, + {value: 'this_year', label: 'This Year'}, + {value: 'custom', label: 'Custom Range'}, + ]; + const handleDateChange = (name) => (date) => { + setReportDateRange('custom'); + setFilterByKey(name, date); + }; + + useEffect(() => { + if (reportDateRange === 'custom') { return; } + const dateRange = parseDateRangeQuery(reportDateRange); + + if (dateRange) { + setFilter((filter) => ({ ...filter, ...dateRange })); + } + }, [reportDateRange]); + + const handleSubmitClick = () => { onSubmitFilter(filter); }; + + return ( + + + + + + setReportDateRange(event.target.value)} /> + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} \ No newline at end of file diff --git a/client/src/containers/Dashboard/FinancialStatements/Journal/JournalTable.js b/client/src/containers/Dashboard/FinancialStatements/Journal/JournalTable.js new file mode 100644 index 000000000..dfb914245 --- /dev/null +++ b/client/src/containers/Dashboard/FinancialStatements/Journal/JournalTable.js @@ -0,0 +1,89 @@ +import React, {useState, useEffect, useMemo} from 'react'; +import FinancialSheet from 'components/FinancialSheet'; +import DataTable from 'components/DataTable'; +import {compose} from 'utils'; +import moment from 'moment'; +import JournalConnect from 'connectors/Journal.connect'; +import { + getFinancialSheet, +} from 'store/financialStatement/financialStatements.selectors'; +import {connect} from 'react-redux'; + +function JournalSheetTable({ + journalIndex, + journalTableData, +}) { + const columns = useMemo(() => [ + { + Header: 'Date', + accessor: r => moment(r.date).format('YYYY/MM/DD'), + className: 'date', + }, + { + Header: 'Account Name', + accessor: 'account.name', + }, + { + Header: 'Transaction Type', + accessor: 'transaction_type', + className: "transaction_type", + }, + { + Header: 'Num.', + accessor: 'reference_id', + className: 'reference_id', + }, + { + Header: 'Note', + accessor: 'note', + }, + { + Header: 'Credit', + accessor: 'credit', + }, + { + Header: 'Debit', + accessor: 'debit', + }, + ], []); + + return ( + + + + + + ); +} + +const mapStateToProps = (state, props) => { + const journalTableData = []; + const journalSheet = getFinancialSheet(state.financialStatements.journalSheets, props.journalIndex); + + if (journalSheet && journalSheet.journal) { + journalSheet.journal.forEach((journal) => { + journal.entries.forEach((entry, index) => { + journalTableData.push({ ...entry, index }); + }); + journalTableData.push({ + credit: journal.credit, + debit: journal.debit, + total: true, + }) + }) + } + return { + journalSheet, + journalTableData, + } +} + +export default compose( + connect(mapStateToProps), + JournalConnect, +)(JournalSheetTable); \ No newline at end of file diff --git a/client/src/routes/dashboard.js b/client/src/routes/dashboard.js index 67c08d661..7b37e355e 100644 --- a/client/src/routes/dashboard.js +++ b/client/src/routes/dashboard.js @@ -95,5 +95,12 @@ export default [ component: LazyLoader({ loader: () => import('containers/Dashboard/FinancialStatements/ProfitLossSheet/ProfitLossSheet') }), - } + }, + { + path: `${BASE_URL}/accounting/journal-sheet`, + name: 'dashboard.accounting.journal.sheet', + component: LazyLoader({ + loader: () => import('containers/Dashboard/FinancialStatements/Journal/Journal') + }), + }, ]; \ No newline at end of file diff --git a/client/src/store/financialStatement/financialStatements.actions.js b/client/src/store/financialStatement/financialStatements.actions.js index 32673c09e..6dc64cbc5 100644 --- a/client/src/store/financialStatement/financialStatements.actions.js +++ b/client/src/store/financialStatement/financialStatements.actions.js @@ -3,7 +3,7 @@ import t from 'store/types'; export const fetchGeneralLedger = ({ query }) => { return (dispatch) => new Promise((resolve, reject) => { - ApiService.get('/financial_statements/general_ledger').then((response) => { + ApiService.get('/financial_statements/general_ledger', { params: query }).then((response) => { dispatch({ type: t.GENERAL_LEDGER_STATEMENT_SET, data: response.data, @@ -28,7 +28,7 @@ export const fetchBalanceSheet = ({ query }) => { export const fetchTrialBalanceSheet = ({ query }) => { return (dispatch) => new Promise((resolve, reject) => { - ApiService.get('/financial_statements/trial_balance_sheet').then((response) => { + ApiService.get('/financial_statements/trial_balance_sheet', { params: query }).then((response) => { dispatch({ type: t.TRAIL_BALANCE_STATEMENT_SET, data: response.data, @@ -40,7 +40,7 @@ export const fetchTrialBalanceSheet = ({ query }) => { export const fetchProfitLossSheet = ({ query }) => { return (dispatch) => new Promise((resolve, reject) => { - ApiService.get('/financial_statements/profit_loss_sheet').then((response) => { + ApiService.get('/financial_statements/profit_loss_sheet', { params: query }).then((response) => { dispatch({ type: t.PROFIT_LOSS_STATEMENT_SET, data: response.data, @@ -48,4 +48,17 @@ export const fetchProfitLossSheet = ({ query }) => { resolve(response.data); }).catch((error) => { reject(error); }); }) +}; + +export const fetchJournalSheet = ({ query }) => { + return (dispatch) => new Promise((resolve, reject) => { + ApiService.get('/financial_statements/journal', { params: query }).then((response) => { + dispatch({ + type: t.JOURNAL_SHEET_SET, + data: response.data, + query: response.data.query, + }); + resolve(response.data); + }).catch(error => { reject(error); }); + }); }; \ No newline at end of file diff --git a/client/src/store/financialStatement/financialStatements.reducer.js b/client/src/store/financialStatement/financialStatements.reducer.js index fbbb64744..a2b14cf08 100644 --- a/client/src/store/financialStatement/financialStatements.reducer.js +++ b/client/src/store/financialStatement/financialStatements.reducer.js @@ -1,12 +1,16 @@ import { createReducer } from '@reduxjs/toolkit'; import t from 'store/types'; -import {getBalanceSheetIndexByQuery, getTrialBalanceSheetIndex} from './financialStatements.selectors'; -import { actionComplete } from '@syncfusion/ej2-react-grids'; +import { + getBalanceSheetIndexByQuery, + getTrialBalanceSheetIndex, + getFinancialSheetIndexByQuery, +} from './financialStatements.selectors'; const initialState = { balanceSheets: [], trialBalanceSheets: [], generalLedger: [], + journalSheets: [], }; export default createReducer(initialState, { @@ -37,5 +41,20 @@ export default createReducer(initialState, { } else { state.trailBalanceSheet.push(trailBalanceSheet); } + }, + + [t.JOURNAL_SHEET_SET]: (state, action) => { + const index = getFinancialSheetIndexByQuery(state.journalSheets, action.query); + console.log(index, 'INDEX'); + + const journal = { + query: action.data.query, + journal: action.data.journal, + }; + if (index !== -1) { + state.journalSheets[index] = journal; + } else { + state.journalSheets.push(journal); + } } }); \ No newline at end of file diff --git a/client/src/store/financialStatement/financialStatements.selectors.js b/client/src/store/financialStatement/financialStatements.selectors.js index d0ba0ec59..f458a5a53 100644 --- a/client/src/store/financialStatement/financialStatements.selectors.js +++ b/client/src/store/financialStatement/financialStatements.selectors.js @@ -1,6 +1,49 @@ import {getObjectDiff} from 'utils'; +// Financial Statements selectors. + +/** + * Retrieve financial statement sheet by the given query. + * @param {array} sheets + * @param {object} query + */ +export const getFinancialSheetIndexByQuery = (sheets, query) => { + return sheets.findIndex(balanceSheet => ( + getObjectDiff(query, balanceSheet.query).length === 0 + )); +}; + +/** + * Retrieve financial statement sheet by the given sheet index. + * @param {array} sheets + * @param {number} index + */ +export const getFinancialSheet = (sheets, index) => { + return (typeof sheets[index] !== 'undefined') ? sheets[index] : null; +}; + +/** + * Retrieve financial statement columns by the given sheet index. + * @param {array} sheets + * @param {number} index + */ +export const getFinancialSheetColumns = (sheets, index) => { + const sheet = getFinancialSheet(sheets, index); + return (sheet && sheet.columns) ? sheet.columns : []; +}; + +/** + * Retrieve financial statement query by the given sheet index. + * @param {array} sheets + * @param {number} index + */ +export const getFinancialSheetsQuery = (sheets, index) => { + const sheet = getFinancialSheet(sheets, index); + return (sheet && sheet.query) ? sheet.columns : {}; +}; + + // Balance Sheet. export const getBalanceSheetByQuery = (balanceSheets, query) => { return balanceSheets.find(balanceSheet => { diff --git a/client/src/store/financialStatement/financialStatements.types.js b/client/src/store/financialStatement/financialStatements.types.js index 8cd91f95c..321330d07 100644 --- a/client/src/store/financialStatement/financialStatements.types.js +++ b/client/src/store/financialStatement/financialStatements.types.js @@ -4,4 +4,5 @@ export default { GENERAL_LEDGER_STATEMENT_SET: 'GENERAL_LEDGER_STATEMENT_SET', BALANCE_SHEET_STATEMENT_SET: 'BALANCE_SHEET_STATEMENT_SET', TRAIL_BALANCE_STATEMENT_SET: 'TRAIL_BALANCE_STATEMENT_SET', + JOURNAL_SHEET_SET: 'JOURNAL_SHEET_SET', } \ No newline at end of file diff --git a/server/src/http/controllers/Accounts.js b/server/src/http/controllers/Accounts.js index e12feb9fc..cc0801184 100644 --- a/server/src/http/controllers/Accounts.js +++ b/server/src/http/controllers/Accounts.js @@ -214,6 +214,12 @@ export default { query('account_types').optional().isArray(), query('account_types.*').optional().isNumeric().toInt(), query('custom_view_id').optional().isNumeric().toInt(), + + query('roles').optional().isArray({ min: 1 }), + query('roles.*.field_key').exists().escape().trim(), + query('roles.*.comparator').exists(), + query('roles.*.value').exists(), + query('roles.*.index').exists().isNumeric().toInt(), ], async handler(req, res) { const validationErrors = validationResult(req); diff --git a/server/src/http/controllers/FinancialStatements.js b/server/src/http/controllers/FinancialStatements.js index d8abec9d9..3444e5339 100644 --- a/server/src/http/controllers/FinancialStatements.js +++ b/server/src/http/controllers/FinancialStatements.js @@ -1,7 +1,7 @@ import express from 'express'; -import { query, validationResult } from 'express-validator'; +import { query, oneOf, validationResult } from 'express-validator'; import moment from 'moment'; -import { pick } from 'lodash'; +import { pick, difference, groupBy } from 'lodash'; import asyncMiddleware from '@/http/middleware/asyncMiddleware'; import AccountTransaction from '@/models/AccountTransaction'; import jwtAuth from '@/http/middleware/jwtAuth'; @@ -30,9 +30,9 @@ export default { const router = express.Router(); router.use(jwtAuth); - router.get('/ledger', - this.ledger.validation, - asyncMiddleware(this.ledger.handler)); + router.get('/journal', + this.journal.validation, + asyncMiddleware(this.journal.handler)); router.get('/general_ledger', this.generalLedger.validation, @@ -60,13 +60,22 @@ export default { /** * Retrieve the ledger report of the given account. */ - ledger: { + journal: { validation: [ query('from_date').optional().isISO8601(), query('to_date').optional().isISO8601(), - query('transaction_types').optional().isArray({ min: 1 }), - query('account_ids').optional().isArray({ min: 1 }), - query('account_ids.*').optional().isNumeric().toInt(), + oneOf([ + query('transaction_types').optional().isArray({ min: 1 }), + query('transaction_types.*').optional().isNumeric().toInt(), + ], [ + query('transaction_types').optional().trim().escape(), + ]), + oneOf([ + query('account_ids').optional().isArray({ min: 1 }), + query('account_ids.*').optional().isNumeric().toInt(), + ], [ + query('account_ids').optional().isNumeric().toInt(), + ]), query('from_range').optional().isNumeric().toInt(), query('to_range').optional().isNumeric().toInt(), query('number_format.no_cents').optional().isBoolean().toBoolean(), @@ -81,6 +90,8 @@ export default { }); } const filter = { + from_date: moment().startOf('year').format('YYYY-MM-DD'), + to_date: moment().endOf('year').format('YYYY-MM-DD'), from_range: null, to_range: null, account_ids: [], @@ -91,22 +102,45 @@ export default { }, ...req.query, }; + if (!Array.isArray(filter.transaction_types)) { + filter.transaction_types = [filter.transaction_types]; + } + if (!Array.isArray(filter.account_ids)) { + filter.account_ids = [filter.account_ids]; + } + filter.account_ids = filter.account_ids.map((id) => parseInt(id, 10)); + const accountsJournalEntries = await AccountTransaction.query() .modify('filterDateRange', filter.from_date, filter.to_date) .modify('filterAccounts', filter.account_ids) .modify('filterTransactionTypes', filter.transaction_types) .modify('filterAmountRange', filter.from_range, filter.to_range) - .withGraphFetched('account'); + .withGraphFetched('account.type'); const formatNumber = formatNumberClosure(filter.number_format); + const journalGrouped = groupBy(accountsJournalEntries, (entry) => { + return `${entry.id}-${entry.referenceType}`; + }); + const journal = Object.keys(journalGrouped).map((key) => { + const transactionsGroup = journalGrouped[key]; + + const journalPoster = new JournalPoster(); + journalPoster.loadEntries(transactionsGroup); + + const trialBalance = journalPoster.getTrialBalance(); + + return { + id: key, + entries: transactionsGroup, + credit: formatNumber(trialBalance.credit), + debit: formatNumber(trialBalance.debit), + }; + }); + return res.status(200).send({ - meta: { ...filter }, - items: accountsJournalEntries.map((entry) => ({ - ...entry, - credit: formatNumber(entry.credit), - debit: formatNumber(entry.debit), - })), + query: { ...filter }, + journal, }); }, }, @@ -122,7 +156,10 @@ export default { query('number_format.no_cents').optional().isBoolean().toBoolean(), query('number_format.divide_1000').optional().isBoolean().toBoolean(), query('none_zero').optional().isBoolean().toBoolean(), - query('accounts_ids').optional().trim().escape(), + query('accounts_ids').optional(), + query('accounts_ids.*').isNumeric().toInt(), + query('orderBy').optional().isIn(['created_at', 'name', 'code']), + query('order').optional().isIn(['desc', 'asc']), ], async handler(req, res) { const validationErrors = validationResult(req); @@ -144,12 +181,29 @@ export default { accounts_ids: [], ...req.query, }; + if (!Array.isArray(filter.accounts_ids)) { + filter.accounts_ids = [filter.accounts_ids]; + } + filter.accounts_ids = filter.accounts_ids.map((id) => parseInt(id, 10)); + const errorReasons = []; + + if (filter.accounts_ids.length > 0) { + const accounts = await Account.query().whereIn('id', filter.accounts_ids); + const accountsIds = accounts.map((a) => a.id); + + if (difference(filter.accounts_ids, accountsIds).length > 0) { + errorReasons.push({ type: 'FILTER.ACCOUNTS.IDS.NOT.FOUND', code: 200 }); + } + } + if (errorReasons.length > 0) { + return res.status(400).send({ error: errorReasons }); + } const accounts = await Account.query() .orderBy('index', 'DESC') .modify('filterAccounts', filter.accounts_ids) - .withGraphFetched('transactions') .withGraphFetched('type') + .withGraphFetched('transactions') .modifyGraph('transactions', (builder) => { builder.modify('filterDateRange', filter.from_date, filter.to_date); }); @@ -175,7 +229,7 @@ export default { const items = accounts .filter((account) => ( - account.transactions.length > 0 || !filter.none_zero + account.transactions.length > 0 || filter.none_zero )) .map((account) => ({ ...pick(account, ['id', 'name', 'code', 'index']), @@ -184,12 +238,13 @@ export default { let amount = 0; if (account.type.normal === 'credit') { - amount += transaction.credit - transaction.credit; + amount += transaction.credit - transaction.debit; } else if (account.type.normal === 'debit') { amount += transaction.debit - transaction.credit; } return { - ...transaction, + ...pick(transaction, ['id', 'note', 'transactionType', 'referenceType', + 'referenceId', 'date', 'createdAt']), amount: formatNumber(amount), }; }), @@ -271,7 +326,6 @@ export default { filter.to_date, filterDateType, ); - // Retrieve the asset balance sheet. const assets = accounts .filter((account) => ( @@ -320,7 +374,6 @@ export default { ...(type !== 'total') ? { periods_balance: dateRangeSet.map((date) => { const balance = journalEntries.getClosingBalance(account.id, date, filterDateType); - return { date, formatted_amount: balanceFormatter(balance), @@ -329,7 +382,7 @@ export default { }), } : {}, balance: { - formattedAmount: balanceFormatter(closingBalance), + formatted_amount: balanceFormatter(closingBalance), amount: closingBalance, date: filter.to_date, }, @@ -460,7 +513,7 @@ export default { basis: 'accural', none_zero: false, display_columns_type: 'total', - display_columns_by: 'total', + display_columns_by: 'month', ...req.query, }; const incomeStatementTypes = await AccountType.query().where('income_sheet', true); @@ -481,12 +534,14 @@ export default { // Account balance formmatter based on the given query. const numberFormatter = formatNumberClosure(filter.number_format); + const comparatorDateType = filter.display_columns_type === 'total' + ? 'day' : filter.display_columns_by; // Gets the date range set from start to end date. const dateRangeSet = dateRangeCollection( filter.from_date, filter.to_date, - filter.display_columns_by, + comparatorDateType, ); const accountsMapper = (incomeExpenseAccounts) => ( @@ -503,7 +558,7 @@ export default { // Date periods when display columns type `periods`. ...(filter.display_columns_type === 'date_periods') && { periods: dateRangeSet.map((date) => { - const type = filter.display_columns_by; + const type = comparatorDateType; const amount = journalEntries.getClosingBalance(account.id, date, type); return { date, amount, formatted_amount: numberFormatter(amount) }; @@ -551,23 +606,22 @@ export default { // @return {Object} const netIncomeTotal = (totalIncome, totalExpenses) => { const netIncomeAmount = totalIncome.amount - totalExpenses.amount; - return { amount: netIncomeAmount, formatted_amount: netIncomeAmount }; + return { amount: netIncomeAmount, formatted_amount: netIncomeAmount, date: filter.to_date }; }; - const totalIncomeAccounts = totalAccountsReducer(accountsIncome); - const totalExpensesAccounts = totalAccountsReducer(accountsExpenses); - const incomeResponse = { entry_normal: 'credit', accounts: accountsIncome, - - ...(filter.display_columns_type === 'total') && { - total: { - amount: totalIncomeAccounts, - date: filter.to_date, - formatted_amount: numberFormatter(totalIncomeAccounts), - }, - }, + ...(filter.display_columns_type === 'total') && (() => { + const totalIncomeAccounts = totalAccountsReducer(accountsIncome); + return { + total: { + amount: totalIncomeAccounts, + date: filter.to_date, + formatted_amount: numberFormatter(totalIncomeAccounts), + }, + }; + })(), ...(filter.display_columns_type === 'date_periods') && { total_periods: [ ...totalPeriodsMapper(accountsIncome), @@ -577,14 +631,16 @@ export default { const expenseResponse = { entry_normal: 'debit', accounts: accountsExpenses, - - ...(filter.display_columns_type === 'total') && { - total: { - amount: totalExpensesAccounts, - date: filter.to_date, - formatted_amount: numberFormatter(totalExpensesAccounts), - }, - }, + ...(filter.display_columns_type === 'total') && (() => { + const totalExpensesAccounts = totalAccountsReducer(accountsExpenses); + return { + total: { + amount: totalExpensesAccounts, + date: filter.to_date, + formatted_amount: numberFormatter(totalExpensesAccounts), + }, + }; + })(), ...(filter.display_columns_type === 'date_periods') && { total_periods: [ ...totalPeriodsMapper(accountsExpenses), diff --git a/server/src/http/controllers/Items.js b/server/src/http/controllers/Items.js index 6c03e724a..7fc20ca85 100644 --- a/server/src/http/controllers/Items.js +++ b/server/src/http/controllers/Items.js @@ -49,11 +49,14 @@ export default { newItem: { validation: [ check('name').exists(), - check('type').exists().trim().escape().isIn(['service', 'product']), + check('type').exists().trim().escape() + .isIn(['service', 'non-inventory', 'inventory']), + check('sku').optional().trim().escape(), check('cost_price').exists().isNumeric(), check('sell_price').exists().isNumeric(), check('cost_account_id').exists().isInt().toInt(), check('sell_account_id').exists().isInt().toInt(), + check('inventory_account_id').exists().isInt().toInt(), check('category_id').optional().isInt().toInt(), check('custom_fields').optional().isArray({ min: 1 }), @@ -78,6 +81,9 @@ export default { const costAccountPromise = Account.query().findById(form.cost_account_id); const sellAccountPromise = Account.query().findById(form.sell_account_id); + const inventoryAccountPromise = (form.type === 'inventory') ? + Account.query().findByid(form.inventory_account_id) : null; + const itemCategoryPromise = (form.category_id) ? ItemCategory.query().findById(form.category_id) : null; @@ -101,8 +107,14 @@ export default { errorReasons.push({ type: 'FIELD_KEY_NOT_FOUND', code: 150, fields: notFoundFields }); } } - const [costAccount, sellAccount, itemCategory] = await Promise.all([ - costAccountPromise, sellAccountPromise, itemCategoryPromise, + const [ + costAccount, + sellAccount, + itemCategory, + inventoryAccount, + ] = await Promise.all([ + costAccountPromise, sellAccountPromise, + itemCategoryPromise, inventoryAccountPromise, ]); if (!costAccount) { errorReasons.push({ type: 'COST_ACCOUNT_NOT_FOUND', code: 100 }); @@ -113,6 +125,9 @@ export default { if (!itemCategory && form.category_id) { errorReasons.push({ type: 'ITEM_CATEGORY_NOT_FOUND', code: 140 }); } + if (!inventoryAccount && form.type === 'inventory') { + errorReasons.push({ type: 'INVENTORY_ACCOUNT_NOT_FOUND', code: 150 }); + } if (errorReasons.length > 0) { return res.boom.badRequest(null, { errors: errorReasons }); } diff --git a/server/tests/routes/accounts.test.js b/server/tests/routes/accounts.test.js index 3ff3f1faf..0a4b65680 100644 --- a/server/tests/routes/accounts.test.js +++ b/server/tests/routes/accounts.test.js @@ -300,6 +300,25 @@ describe('routes: /accounts/', () => { expect(res.body.accounts[2].id).equals(account3.id); expect(res.body.accounts[2].name).equals(`${account1.name} ― ${account2.name} ― ${account3.name}`); }); + + it('Should retrieve filtered accounts according to the given filter roles.', async () => { + const account1 = await create('account', { name: 'ahmed' }); + const account2 = await create('account'); + const account3 = await create('account'); + + const res = await request() + .get('/api/accounts') + .set('x-access-token', loginRes.body.token) + .query({ + filter_roles: [{ + field_key: 'name', + comparator: 'equals', + value: 'ahmed', + }], + }); + + expect(res.body.accounts.length).equals(1); + }); }); describe('DELETE: `/accounts`', () => { diff --git a/server/tests/routes/financial_statements.test.js b/server/tests/routes/financial_statements.test.js index 5fb167d68..b86b593e3 100644 --- a/server/tests/routes/financial_statements.test.js +++ b/server/tests/routes/financial_statements.test.js @@ -1,10 +1,10 @@ +import moment from 'moment'; import { expect, request, login, create, } from '~/testInit'; -import moment from 'moment'; let loginRes; let creditAccount; @@ -52,35 +52,36 @@ describe('routes: `/financial_statements`', () => { afterEach(() => { loginRes = null; }); - describe('routes: `/financial_statements/ledger`', () => { + describe('routes: `/financial_statements/journal`', () => { it('Should response unauthorized in case the user was not authorized.', async () => { const res = await request() - .get('/api/financial_statements/ledger') + .get('/api/financial_statements/journal') .send(); - expect(res.status).equals(400); + expect(res.status).equals(401); }); - it('Should retrieve ledger transactions grouped by accounts.', async () => { + it('Should retrieve ledger transactions grouped by reference type and id.', async () => { const res = await request() - .get('/api/financial_statements/ledger') + .get('/api/financial_statements/journal') .set('x-access-token', loginRes.body.token) .send(); expect(res.status).equals(200); - expect(res.body.items.length).to.be.at.least(1); + expect(res.body.journal.length).to.be.at.least(1); - expect(res.body.items[0]).to.have.property('id'); - expect(res.body.items[0]).to.have.property('referenceType'); - expect(res.body.items[0]).to.have.property('referenceId'); - expect(res.body.items[0]).to.have.property('date'); - expect(res.body.items[0]).to.have.property('account'); - expect(res.body.items[0]).to.have.property('note'); + expect(res.body.journal[0].credit).to.be.a('number'); + expect(res.body.journal[0].debit).to.be.a('number'); + expect(res.body.journal[0].entries).to.be.a('array'); + expect(res.body.journal[0].id).to.be.a('string'); + + expect(res.body.journal[0].entries[0].credit).to.be.a('number'); + expect(res.body.journal[0].entries[0].debit).to.be.a('number'); }); it('Should retrieve transactions between date range.', async () => { const res = await request() - .get('/api/financial_statements/ledger') + .get('/api/financial_statements/journal') .set('x-access-token', loginRes.body.token) .query({ from_date: '2018-01-01', @@ -88,40 +89,47 @@ describe('routes: `/financial_statements`', () => { }) .send(); - expect(res.body.items.length).equals(0); + expect(res.body.journal.length).equals(0); }); it('Should retrieve transactions that associated to the queried accounts.', async () => { const res = await request() - .get('/api/financial_statements/ledger') + .get('/api/financial_statements/journal') .set('x-access-token', loginRes.body.token) .query({ - account_ids: [creditAccount.id, debitAccount.id], + account_ids: [creditAccount.id], }) .send(); - expect(res.body.items.length).equals(4); + expect(res.body.journal.length).equals(2); }); it('Should retrieve tranasactions with the given types.', async () => { const res = await request() - .get('/api/financial_statements/ledger') + .get('/api/financial_statements/journal') .set('x-access-token', loginRes.body.token) .query({ transaction_types: ['Expense'], }); - expect(res.body.items.length).equals(1); + expect(res.body.journal.length).equals(1); }); it('Should retrieve transactions with range amount.', async () => { const res = await request() - .get('/api/financial_statements/ledger') + .get('/api/financial_statements/journal') .set('x-access-token', loginRes.body.token) .query({ - from_range: 1000, + from_range: 2000, to_range: 2000, }); + + expect(res.body.journal[0].credit).satisfy((credit) => { + return credit === 0 || credit === 2000; + }); + expect(res.body.journal[0].debit).satisfy((debit) => { + return debit === 0 || debit === 2000; + }); }); it('Should format credit and debit to no cents of retrieved transactions.', async () => { @@ -130,7 +138,7 @@ describe('routes: `/financial_statements`', () => { it('Should divide credit/debit amount on 1000', async () => { const res = await request() - .get('/api/financial_statements/ledger') + .get('/api/financial_statements/journal') .set('x-access-token', loginRes.body.token) .query({ number_format: { @@ -139,14 +147,14 @@ describe('routes: `/financial_statements`', () => { }) .send(); - res.body.items.forEach((item) => { - expect(item.credit).to.be.at.most(100); - expect(item.debit).to.be.at.most(100); - }); + const journal = res.body.journal.find((j) => j.id === '1-Expense'); + + expect(journal.credit).equals(1); + expect(journal.debit).equals(0); }); }); - describe.only('routes: `/financial_statements/general_ledger`', () => { + describe('routes: `/financial_statements/general_ledger`', () => { it('Should response unauthorized in case the user was not authorized.', async () => { const res = await request() .get('/api/financial_statements/general_ledger') @@ -206,36 +214,152 @@ describe('routes: `/financial_statements`', () => { }); }); - it('Should retrieve opening and closing balance between the given date range.', () => { + it('Should retrieve opening and closing balance between the given date range.', async () => { + const res = await request() + .get('/api/financial_statements/general_ledger') + .set('x-access-token', loginRes.body.token) + .query({ + from_date: '2020-01-20', + to_date: '2020-03-30', + none_zero: true, + }) + .send(); + const targetAccount = res.body.accounts.find((a) => a.id === creditAccount.id); + + expect(targetAccount).to.be.an('object'); + expect(targetAccount.opening).to.deep.equal({ + balance: 2000, date: '2020-01-20', + }); + expect(targetAccount.closing).to.deep.equal({ + balance: 2000, date: '2020-03-30', + }); }); - it('Should retrieve transactions of accounts that has transactions between date range.', () => { + it('Should retrieve accounts with associated transactions.', async () => { + const res = await request() + .get('/api/financial_statements/general_ledger') + .set('x-access-token', loginRes.body.token) + .query({ + none_zero: true, + }) + .send(); + const targetAccount = res.body.accounts.find((a) => a.id === creditAccount.id); + + expect(targetAccount.transactions[0].amount).equals(1000); + expect(targetAccount.transactions[1].amount).equals(1000); + + expect(targetAccount.transactions[1].id).to.be.an('number'); + // expect(targetAccount.transactions[1].note).to.be.an('string'); + // expect(targetAccount.transactions[1].transactionType).to.be.an('string'); + // expect(targetAccount.transactions[1].referenceType).to.be.an('string'); + // expect(targetAccount.transactions[1].referenceId).to.be.an('number'); + expect(targetAccount.transactions[1].date).to.be.an('string'); + }) + + it('Should retrieve accounts transactions only that between date range.', async () => { + const res = await request() + .get('/api/financial_statements/general_ledger') + .set('x-access-token', loginRes.body.token) + .query({ + from_date: '2020-01-20', + to_date: '2020-03-30', + none_zero: true, + }) + .send(); + + const targetAccount = res.body.accounts.find((a) => a.id === creditAccount.id); + expect(targetAccount.transactions.length).equals(0); }); - it('Should retrieve accounts transactions only that between date range.', () => { + it('Should not retrieve all accounts that have no transactions in the given date range when `none_zero` is `false`.', async () => { + const res = await request() + .get('/api/financial_statements/general_ledger') + .set('x-access-token', loginRes.body.token) + .query({ + from_date: '2020-01-20', + to_date: '2020-03-30', + none_zero: false, + }) + .send(); + res.body.accounts.forEach((account) => { + expect(account.transactions.length).not.equals(0); + }); }); - it('Should not retrieve all accounts that have no transactions in the given date range when `none_zero` is `false`.', () => { + it('Should retrieve all accounts even it have no transactions in the given date range when `none_zero` is `true`', async () => { + const res = await request() + .get('/api/financial_statements/general_ledger') + .set('x-access-token', loginRes.body.token) + .query({ + from_date: '2020-01-01', + to_date: '2020-03-30', + none_zero: true, + }) + .send(); + const accountsNoTransactions = res.body.accounts.filter(a => a.transactions.length === 0); + const accountsWithTransactions = res.body.accounts.filter(a => a.transactions.length > 0); + + expect(accountsNoTransactions.length).not.equals(0); + expect(accountsWithTransactions.length).not.equals(0); }); - it('Should retrieve all accounts even it have no transactions in the given date range when `none_zero` is `true`', () => { - + it('Should amount transactions divided on `1000` when `number_format.none_zero` is `true`.', async () => { + const res = await request() + .get('/api/financial_statements/general_ledger') + .set('x-access-token', loginRes.body.token) + .query({ + from_date: '2020-01-01', + to_date: '2020-03-30', + number_format: { + divide_1000: true, + }, + }) + .send(); + + const targetAccount = res.body.accounts.find((a) => a.id === creditAccount.id); + expect(targetAccount.transactions[0].amount).equals(1); + expect(targetAccount.transactions[1].amount).equals(1); }); - it('Should amount transactions divided on 1000 when `number_format.none_zero` is `true`.', () => { + it('Should amount transactions rounded with no decimals when `number_format.no_cents` is `true`.', async () => { + await create('account_transaction', { + debit: 0.25, credit: 0, account_id: debitAccount.id, date: '2020-1-10', + }); + const res = await request() + .get('/api/financial_statements/general_ledger') + .set('x-access-token', loginRes.body.token) + .query({ + from_date: '2020-01-01', + to_date: '2020-03-30', + number_format: { + divide_1000: true, + no_cents: true, + }, + accounts_ids: [debitAccount.id] + }) + .send(); + + expect(res.body.accounts[0].transactions[2].amount).equal(0); }); - it('Should amount transactions rounded with no decimals when `number_format.no_cents` is `true`.', () => { - - }); - - it('Should retrieve only accounts that given in the query.', () => { + it('Should retrieve only accounts that given in the query.', async () => { + const res = await request() + .get('/api/financial_statements/general_ledger') + .set('x-access-token', loginRes.body.token) + .query({ + from_date: '2020-01-01', + to_date: '2020-03-30', + none_zero: true, + accounts_ids: [creditAccount.id], + }) + .send(); + expect(res.body.accounts.length).equals(1); }); }); @@ -294,10 +418,10 @@ describe('routes: `/financial_statements`', () => { .send(); expect(res.body.balance_sheet.assets.accounts[0].balance).deep.equals({ - amount: 4000, formattedAmount: 4000, date: '2032-02-02', + amount: 4000, formatted_amount: 4000, date: '2032-02-02', }); expect(res.body.balance_sheet.liabilities_equity.accounts[0].balance).deep.equals({ - amount: 2000, formattedAmount: 2000, date: '2032-02-02', + amount: 2000, formatted_amount: 2000, date: '2032-02-02', }); }); @@ -633,11 +757,11 @@ describe('routes: `/financial_statements`', () => { from_date: moment().startOf('year').format('YYYY-MM-DD'), to_date: moment().endOf('year').format('YYYY-MM-DD'), display_columns_type: 'date_periods', - display_columns_by: 'month', + display_columns_by: 'quarter', }) .send(); - expect(res.body.columns.length).equals(12); + expect(res.body.columns.length).equals(4); expect(res.body.columns).deep.equals([ '2020-03', '2020-06', '2020-09', '2020-12', ]); @@ -679,31 +803,33 @@ describe('routes: `/financial_statements`', () => { from_date: moment('2020-01-01').startOf('month').format('YYYY-MM-DD'), to_date: moment('2020-01-01').endOf('month').format('YYYY-MM-DD'), display_columns_type: 'total', - display_columns_by: 'day', + display_columns_by: 'month', }) .send(); - console.log(res.body); - - // expect(res.body.income.accounts.length).equals(2); - // expect(res.body.income.accounts[0].name).to.be.an('string'); - // expect(res.body.income.accounts[0].code).to.be.an('string'); - // expect(res.body.income.accounts[0].periods).to.be.an('array'); - // expect(res.body.income.accounts[0].periods.length).equals(31); + const zeroAccount = res.body.income.accounts.filter((a) => a.total.amount === 0); + expect(zeroAccount.length).not.equals(0); }); it('Should retrieve total of each income account when display columns by `total`.', async () => { + const toDate = moment('2020-01-01').endOf('month').format('YYYY-MM-DD'); const res = await request() .get('/api/financial_statements/profit_loss_sheet') .set('x-access-token', loginRes.body.token) .query({ from_date: moment('2020-01-01').startOf('month').format('YYYY-MM-DD'), - to_date: moment('2020-01-01').endOf('month').format('YYYY-MM-DD'), - display_columns_by: 'day', + to_date: toDate, }) .send(); - expect(res.body.income).deep.equals(); + expect(res.body.income.accounts).to.be.an('array'); + expect(res.body.income.accounts.length).not.equals(0); + expect(res.body.income.accounts[0].id).to.be.an('number'); + expect(res.body.income.accounts[0].name).to.be.an('string'); + expect(res.body.income.accounts[0].total).to.be.an('object'); + expect(res.body.income.accounts[0].total.amount).to.be.an('number'); + expect(res.body.income.accounts[0].total.formatted_amount).to.be.an('number'); + expect(res.body.income.accounts[0].total.date).equals(toDate); }); it('Should retrieve credit sumation of income accounts.', async () => { @@ -713,17 +839,13 @@ describe('routes: `/financial_statements`', () => { .query({ from_date: '2020-01-01', to_date: '2021-01-01', - number_format: { - divide_1000: true, - }, }) .send(); - console.log(res.body); - - res.body.income.accounts[0].dates.forEach((item) => { - expect(item.rawAmount).equals(2000); - }); + expect(res.body.income.total).to.be.an('object'); + expect(res.body.income.total.amount).equals(2000); + expect(res.body.income.total.formatted_amount).equals(2000); + expect(res.body.income.total.date).equals('2021-01-01'); }); it('Should retrieve debit sumation of expenses accounts.', async () => { @@ -733,91 +855,85 @@ describe('routes: `/financial_statements`', () => { .query({ from_date: '2020-01-01', to_date: '2021-01-01', - number_format: { - divide_1000: true, - }, }) .send(); - res.body.expenses.accounts[0].dates.forEach((item) => { - expect(item.rawAmount).equals(4000); - }); + expect(res.body.expenses.total).to.be.an('object'); + expect(res.body.expenses.total.amount).equals(6000); + expect(res.body.expenses.total.formatted_amount).equals(6000); + expect(res.body.expenses.total.date).equals('2021-01-01'); }); - it('Should retrieve credit sumation of income accounts between the given date range.', async () => { + it('Should retrieve credit total of income accounts with `date_periods` columns between the given date range.', async () => { const res = await request() .get('/api/financial_statements/profit_loss_sheet') .set('x-access-token', loginRes.body.token) .query({ - from_date: '2020-01-01', - to_date: '2021-01-01', + from_date: '2019-12-01', + to_date: '2020-12-01', + display_columns_type: 'date_periods', display_columns_by: 'month', - number_format: { - divide_1000: true, - }, }) .send(); - expect(res.body.income.accounts[0].dates.length).equals(12); + expect(res.body.income.total_periods[0].amount).equals(0); + expect(res.body.income.total_periods[1].amount).equals(2000); + expect(res.body.income.total_periods[2].amount).equals(2000); }); - it('Should retrieve debit sumation of expenses accounts between the given date range.', async () => { + it('Should retrieve debit total of expenses accounts with `date_periods` columns between the given date range.', async () => { const res = await request() .get('/api/financial_statements/profit_loss_sheet') .set('x-access-token', loginRes.body.token) .query({ - from_date: '2020-01-01', - to_date: '2021-01-01', + from_date: '2019-12-01', + to_date: '2020-12-01', + display_columns_type: 'date_periods', display_columns_by: 'month', - number_format: { - divide_1000: true, - }, }) .send(); - expect(res.body.expenses.accounts[0].dates.length).equals(12); + expect(res.body.expenses.total_periods[0].amount).equals(0); + expect(res.body.expenses.total_periods[1].amount).equals(6000); + expect(res.body.expenses.total_periods[2].amount).equals(6000); }); - it('Should retrieve total income of income accounts between the given date range.', async () => { + it('Should retrieve total net income with `total column display between the given date range.', async () => { const res = await request() .get('/api/financial_statements/profit_loss_sheet') .set('x-access-token', loginRes.body.token) .query({ - from_date: '2020-01-01', - to_date: '2021-01-01', - display_columns_by: 'month', + from_date: '2019-12-01', + to_date: '2020-12-01', + display_columns_type: 'total', }) .send(); - expect(res.body.total_income[0].rawAmount).equals(2000); + expect(res.body.net_income.total.amount).equals(-4000); + expect(res.body.net_income.total.formatted_amount).equals(-4000); + expect(res.body.net_income.total.date).equals('2020-12-01'); }); - it('Should retrieve total expenses of expenses accounts between the given date range.', async () => { + it('Should retrieve total net income with `date_periods` columns between the given date range.', async () => { const res = await request() .get('/api/financial_statements/profit_loss_sheet') .set('x-access-token', loginRes.body.token) .query({ - from_date: '2020-01-01', - to_date: '2021-01-01', - display_columns_by: 'month', + from_date: '2019-12-01', + to_date: '2020-12-01', + display_columns_type: 'date_periods', + display_columns_by: 'quarter', }) .send(); - expect(res.body.total_expenses[0].rawAmount).equals(6000); - }); + expect(res.body.net_income.total_periods.length).equals(5); + expect(res.body.net_income.total_periods[0].amount).equals(0); + expect(res.body.net_income.total_periods[0].formatted_amount).equal(0); + expect(res.body.net_income.total_periods[0].date).equals('2019-12'); - it('Should retrieve total net income between the given date range.', async () => { - const res = await request() - .get('/api/financial_statements/profit_loss_sheet') - .set('x-access-token', loginRes.body.token) - .query({ - from_date: '2020-01-01', - to_date: '2021-01-01', - display_columns_by: 'month', - }) - .send(); - - expect(res.body.total_net_income[0].rawAmount).equals(-4000); + expect(res.body.net_income.total_periods[1].amount).equals(-4000); + expect(res.body.net_income.total_periods[1].formatted_amount).equal(-4000); + expect(res.body.net_income.total_periods[1].date).equals('2020-03'); }); it('Should not retrieve income or expenses accounts that has no transactions between the given date range in case none_zero equals true.', async () => { @@ -828,7 +944,7 @@ describe('routes: `/financial_statements`', () => { from_date: '2020-01-01', to_date: '2021-01-01', display_columns_by: 'month', - none_zero: true + none_zero: true, }) .send(); diff --git a/server/tests/testInit.js b/server/tests/testInit.js index 86667ee09..a34a0e557 100644 --- a/server/tests/testInit.js +++ b/server/tests/testInit.js @@ -6,16 +6,29 @@ import knex from '@/database/knex'; import '@/models'; import app from '@/app'; import factory from '@/database/factories'; -import knexConfig from '@/../knexfile'; import dbManager from '@/database/manager'; // import { hashPassword } from '@/utils'; const request = () => chai.request(app); const { expect } = chai; +const login = async (givenUser) => { + const user = !givenUser ? await factory.create('user') : givenUser; + + const response = request() + .post('/api/auth/login') + .send({ + crediential: user.email, + password: 'admin', + }); + return response; +}; + before(async () => { - await dbManager.dropDb(); - await dbManager.createDb('ratteb'); + await dbManager.closeKnex(); + await dbManager.close(); + // await dbManager.dropDb(); + // await dbManager.createDb(); }); beforeEach(async () => { @@ -23,25 +36,12 @@ beforeEach(async () => { await knex.migrate.latest(); }); -afterEach(async () => { +after(async () => { }); chai.use(chaiHttp); chai.use(chaiThings); -const login = async (givenUser) => { - const user = !givenUser ? await factory.create('user') : givenUser; - - const response = await request() - .post('/api/auth/login') - .send({ - crediential: user.email, - password: 'admin', - }); - - return response; -}; - const create = async (name, data) => factory.create(name, data); const make = async (name, data) => factory.build(name, data);