feat: Receivable and payable aging summary financial statement.

This commit is contained in:
Ahmed Bouhuolia
2020-06-11 22:05:34 +02:00
parent 55a4319827
commit 4d1dd14f8d
36 changed files with 1435 additions and 195 deletions

View File

@@ -1,11 +1,12 @@
import React, { useEffect, useState, useMemo, useCallback } from 'react'; import React, { useEffect, useState, useMemo, useCallback } from 'react';
import { import { Route, Switch } from 'react-router-dom';
Route,
Switch,
} from 'react-router-dom';
import { Alert, Intent } from '@blueprintjs/core'; import { Alert, Intent } from '@blueprintjs/core';
import { useQuery } from 'react-query' import { useQuery } from 'react-query';
import { FormattedMessage as T, FormattedHTMLMessage, useIntl } from 'react-intl'; import {
FormattedMessage as T,
FormattedHTMLMessage,
useIntl,
} from 'react-intl';
import AppToaster from 'components/AppToaster'; import AppToaster from 'components/AppToaster';
@@ -24,7 +25,6 @@ import withAccounts from 'containers/Accounts/withAccounts';
import { compose } from 'utils'; import { compose } from 'utils';
function AccountsChart({ function AccountsChart({
// #withDashboardActions // #withDashboardActions
changePageTitle, changePageTitle,
@@ -57,8 +57,8 @@ function AccountsChart({
const [activateAccount, setActivateAccount] = useState(false); const [activateAccount, setActivateAccount] = useState(false);
const [bulkDelete, setBulkDelete] = useState(false); const [bulkDelete, setBulkDelete] = useState(false);
const [selectedRows, setSelectedRows] = useState([]); const [selectedRows, setSelectedRows] = useState([]);
const [bulkActivate,setBulkActivate] =useState(false); const [bulkActivate, setBulkActivate] = useState(false);
const [bulkInactiveAccounts,setBulkInactiveAccounts] =useState(false) const [bulkInactiveAccounts, setBulkInactiveAccounts] = useState(false);
const [tableLoading, setTableLoading] = useState(false); const [tableLoading, setTableLoading] = useState(false);
// Fetch accounts resource views and fields. // Fetch accounts resource views and fields.
@@ -77,7 +77,7 @@ function AccountsChart({
useEffect(() => { useEffect(() => {
changePageTitle(formatMessage({ id: 'chart_of_accounts' })); changePageTitle(formatMessage({ id: 'chart_of_accounts' }));
}, [changePageTitle,formatMessage]); }, [changePageTitle, formatMessage]);
// Handle click and cancel/confirm account delete // Handle click and cancel/confirm account delete
const handleDeleteAccount = (account) => { const handleDeleteAccount = (account) => {
@@ -85,7 +85,9 @@ function AccountsChart({
}; };
// handle cancel delete account alert. // handle cancel delete account alert.
const handleCancelAccountDelete = useCallback(() => { setDeleteAccount(false); }, []); const handleCancelAccountDelete = useCallback(() => {
setDeleteAccount(false);
}, []);
const handleDeleteErrors = (errors) => { const handleDeleteErrors = (errors) => {
if (errors.find((e) => e.type === 'ACCOUNT.PREDEFINED')) { if (errors.find((e) => e.type === 'ACCOUNT.PREDEFINED')) {
@@ -98,24 +100,30 @@ function AccountsChart({
} }
if (errors.find((e) => e.type === 'ACCOUNT.HAS.ASSOCIATED.TRANSACTIONS')) { if (errors.find((e) => e.type === 'ACCOUNT.HAS.ASSOCIATED.TRANSACTIONS')) {
AppToaster.show({ AppToaster.show({
message: formatMessage({id:'cannot_delete_account_has_associated_transactions'}) message: formatMessage({
id: 'cannot_delete_account_has_associated_transactions',
}),
}); });
} }
} };
// Handle confirm account delete // Handle confirm account delete
const handleConfirmAccountDelete = useCallback(() => { const handleConfirmAccountDelete = useCallback(() => {
requestDeleteAccount(deleteAccount.id).then(() => { requestDeleteAccount(deleteAccount.id)
setDeleteAccount(false); .then(() => {
AppToaster.show({ setDeleteAccount(false);
message: formatMessage({ id: 'the_account_has_been_successfully_deleted' }), AppToaster.show({
intent: Intent.SUCCESS, message: formatMessage({
id: 'the_account_has_been_successfully_deleted',
}),
intent: Intent.SUCCESS,
});
})
.catch((errors) => {
setDeleteAccount(false);
handleDeleteErrors(errors);
}); });
}).catch(errors => { }, [deleteAccount, requestDeleteAccount, formatMessage]);
setDeleteAccount(false);
handleDeleteErrors(errors);
});
}, [deleteAccount, requestDeleteAccount,formatMessage]);
// Handle cancel/confirm account inactive. // Handle cancel/confirm account inactive.
const handleInactiveAccount = useCallback((account) => { const handleInactiveAccount = useCallback((account) => {
@@ -138,7 +146,7 @@ function AccountsChart({
intent: Intent.SUCCESS, intent: Intent.SUCCESS,
}); });
}); });
}, [inactiveAccount, requestInactiveAccount,formatMessage]); }, [inactiveAccount, requestInactiveAccount, formatMessage]);
// Handle activate account click. // Handle activate account click.
const handleActivateAccount = useCallback((account) => { const handleActivateAccount = useCallback((account) => {
@@ -166,23 +174,30 @@ function AccountsChart({
const handleRestoreAccount = (account) => {}; const handleRestoreAccount = (account) => {};
// Handle accounts bulk delete button click., // Handle accounts bulk delete button click.,
const handleBulkDelete = useCallback((accountsIds) => { const handleBulkDelete = useCallback(
setBulkDelete(accountsIds); (accountsIds) => {
}, [setBulkDelete]); setBulkDelete(accountsIds);
},
[setBulkDelete],
);
// Handle confirm accounts bulk delete. // Handle confirm accounts bulk delete.
const handleConfirmBulkDelete = useCallback(() => { const handleConfirmBulkDelete = useCallback(() => {
requestDeleteBulkAccounts(bulkDelete).then(() => { requestDeleteBulkAccounts(bulkDelete)
setBulkDelete(false); .then(() => {
AppToaster.show({ setBulkDelete(false);
message: formatMessage({ id: 'the_accounts_has_been_successfully_deleted' }), AppToaster.show({
intent: Intent.SUCCESS, message: formatMessage({
id: 'the_accounts_has_been_successfully_deleted',
}),
intent: Intent.SUCCESS,
});
})
.catch((errors) => {
setBulkDelete(false);
handleDeleteErrors(errors);
}); });
}).catch((errors) => { }, [requestDeleteBulkAccounts, bulkDelete, formatMessage]);
setBulkDelete(false);
handleDeleteErrors(errors);
});
}, [requestDeleteBulkAccounts, bulkDelete,formatMessage]);
// Handle cancel accounts bulk delete. // Handle cancel accounts bulk delete.
const handleCancelBulkDelete = useCallback(() => { const handleCancelBulkDelete = useCallback(() => {
@@ -191,16 +206,14 @@ function AccountsChart({
const handleBulkArchive = useCallback((accounts) => {}, []); const handleBulkArchive = useCallback((accounts) => {}, []);
const handleEditAccount = useCallback(() => { const handleEditAccount = useCallback(() => {}, []);
}, []);
// Handle selected rows change. // Handle selected rows change.
const handleSelectedRowsChange = useCallback( const handleSelectedRowsChange = useCallback(
(accounts) => { (accounts) => {
setSelectedRows(accounts); setSelectedRows(accounts);
}, },
[setSelectedRows] [setSelectedRows],
); );
// Refetches accounts data table when current custom view changed. // Refetches accounts data table when current custom view changed.
@@ -232,70 +245,73 @@ function AccountsChart({
}); });
fetchAccountsHook.refetch(); fetchAccountsHook.refetch();
}, },
[fetchAccountsHook, addAccountsTableQueries] [fetchAccountsHook, addAccountsTableQueries],
); );
// Calculates the data table selected rows count. // Calculates the data table selected rows count.
const selectedRowsCount = useMemo(() => Object.values(selectedRows).length, [selectedRows]); const selectedRowsCount = useMemo(() => Object.values(selectedRows).length, [
selectedRows,
]);
// Handle bulk Activate accounts button click.,
const handleBulkActivate = useCallback(
(bulkActivateIds) => {
setBulkActivate(bulkActivateIds);
},
[setBulkActivate],
);
// Handle bulk Activate accounts button click., // Handle cancel Bulk Activate accounts bulk delete.
const handleBulkActivate = useCallback((bulkActivateIds) => {
setBulkActivate(bulkActivateIds);
}, [setBulkActivate]);
// Handle cancel Bulk Activate accounts bulk delete.
const handleCancelBulkActivate = useCallback(() => { const handleCancelBulkActivate = useCallback(() => {
setBulkActivate(false);
}, []);
// Handle Bulk activate account confirm.
const handleConfirmBulkActivate = useCallback(() => {
requestBulkActivateAccounts(bulkActivate).then(() => {
setBulkActivate(false); setBulkActivate(false);
AppToaster.show({ }, []);
message: formatMessage({ id: 'the_accounts_has_been_successfully_activated' }),
intent: Intent.SUCCESS,
});
}).catch((errors) => {
setBulkActivate(false);
});
}, [requestBulkActivateAccounts, bulkActivate,formatMessage]);
// Handle Bulk activate account confirm.
const handleConfirmBulkActivate = useCallback(() => {
requestBulkActivateAccounts(bulkActivate)
.then(() => {
setBulkActivate(false);
AppToaster.show({
message: formatMessage({
id: 'the_accounts_has_been_successfully_activated',
}),
intent: Intent.SUCCESS,
});
})
.catch((errors) => {
setBulkActivate(false);
});
}, [requestBulkActivateAccounts, bulkActivate, formatMessage]);
// Handle bulk Inactive accounts button click.,
// Handle bulk Inactive accounts button click., const handleBulkInactive = useCallback(
const handleBulkInactive = useCallback((bulkInactiveIds) => { (bulkInactiveIds) => {
setBulkInactiveAccounts(bulkInactiveIds); setBulkInactiveAccounts(bulkInactiveIds);
}, [setBulkInactiveAccounts]); },
[setBulkInactiveAccounts],
);
// Handle cancel Bulk Inactive accounts bulk delete. // Handle cancel Bulk Inactive accounts bulk delete.
const handleCancelBulkInactive = useCallback(() => { const handleCancelBulkInactive = useCallback(() => {
setBulkInactiveAccounts(false); setBulkInactiveAccounts(false);
}, []); }, []);
// Handle Bulk Inactive accounts confirm. // Handle Bulk Inactive accounts confirm.
const handleConfirmBulkInactive = useCallback(() => { const handleConfirmBulkInactive = useCallback(() => {
requestBulkInactiveAccounts(bulkInactiveAccounts).then(() => { requestBulkInactiveAccounts(bulkInactiveAccounts)
setBulkInactiveAccounts(false); .then(() => {
AppToaster.show({ setBulkInactiveAccounts(false);
message: formatMessage({ id: 'the_accounts_has_been_successfully_inactivated' }), AppToaster.show({
intent: Intent.SUCCESS, message: formatMessage({
}); id: 'the_accounts_has_been_successfully_inactivated',
}).catch((errors) => { }),
setBulkInactiveAccounts(false); intent: Intent.SUCCESS,
});
}); })
}, [requestBulkInactiveAccounts, bulkInactiveAccounts]); .catch((errors) => {
setBulkInactiveAccounts(false);
});
}, [requestBulkInactiveAccounts, bulkInactiveAccounts]);
return ( return (
<DashboardInsider loading={fetchHook.isFetching} name={'accounts-chart'}> <DashboardInsider loading={fetchHook.isFetching} name={'accounts-chart'}>
@@ -312,10 +328,7 @@ const handleConfirmBulkActivate = useCallback(() => {
<Switch> <Switch>
<Route <Route
exact={true} exact={true}
path={[ path={['/accounts/:custom_view_id/custom_view', '/accounts']}
'/accounts/:custom_view_id/custom_view',
'/accounts',
]}
> >
<AccountsViewsTabs onViewChanged={handleViewChanged} /> <AccountsViewsTabs onViewChanged={handleViewChanged} />
@@ -343,7 +356,8 @@ const handleConfirmBulkActivate = useCallback(() => {
> >
<p> <p>
<FormattedHTMLMessage <FormattedHTMLMessage
id={'once_delete_this_account_you_will_able_to_restore_it'} /> id={'once_delete_this_account_you_will_able_to_restore_it'}
/>
</p> </p>
</Alert> </Alert>
@@ -366,15 +380,18 @@ const handleConfirmBulkActivate = useCallback(() => {
intent={Intent.WARNING} intent={Intent.WARNING}
isOpen={activateAccount} isOpen={activateAccount}
onCancel={handleCancelActivateAccount} onCancel={handleCancelActivateAccount}
onConfirm={handleConfirmAccountActivate}> onConfirm={handleConfirmAccountActivate}
>
<p> <p>
<T id={'are_sure_to_activate_this_account'} /> <T id={'are_sure_to_activate_this_account'} />
</p> </p>
</Alert> </Alert>
<Alert <Alert
cancelButtonText={<T id={'cancel'}/>} cancelButtonText={<T id={'cancel'} />}
confirmButtonText={`${formatMessage({id:'delete'})} (${selectedRowsCount})`} confirmButtonText={`${formatMessage({
id: 'delete',
})} (${selectedRowsCount})`}
icon="trash" icon="trash"
intent={Intent.DANGER} intent={Intent.DANGER}
isOpen={bulkDelete} isOpen={bulkDelete}
@@ -382,28 +399,36 @@ const handleConfirmBulkActivate = useCallback(() => {
onConfirm={handleConfirmBulkDelete} onConfirm={handleConfirmBulkDelete}
> >
<p> <p>
<T id={'once_delete_these_accounts_you_will_not_able_restore_them'} /> <T
id={'once_delete_these_accounts_you_will_not_able_restore_them'}
/>
</p> </p>
</Alert> </Alert>
<Alert <Alert
cancelButtonText={<T id={'cancel'} />} cancelButtonText={<T id={'cancel'} />}
confirmButtonText={`${formatMessage({id:'activate'})} (${selectedRowsCount})`} confirmButtonText={`${formatMessage({
id: 'activate',
})} (${selectedRowsCount})`}
intent={Intent.WARNING} intent={Intent.WARNING}
isOpen={bulkActivate} isOpen={bulkActivate}
onCancel={handleCancelBulkActivate} onCancel={handleCancelBulkActivate}
onConfirm={handleConfirmBulkActivate}> onConfirm={handleConfirmBulkActivate}
>
<p> <p>
<T id={'are_sure_to_activate_this_accounts'} /> <T id={'are_sure_to_activate_this_accounts'} />
</p> </p>
</Alert> </Alert>
<Alert <Alert
cancelButtonText={<T id={'cancel'} />} cancelButtonText={<T id={'cancel'} />}
confirmButtonText={`${formatMessage({id:'inactivate'})} (${selectedRowsCount})`} confirmButtonText={`${formatMessage({
id: 'inactivate',
})} (${selectedRowsCount})`}
intent={Intent.WARNING} intent={Intent.WARNING}
isOpen={bulkInactiveAccounts} isOpen={bulkInactiveAccounts}
onCancel={handleCancelBulkInactive} onCancel={handleCancelBulkInactive}
onConfirm={handleConfirmBulkInactive}> onConfirm={handleConfirmBulkInactive}
>
<p> <p>
<T id={'are_sure_to_inactive_this_accounts'} /> <T id={'are_sure_to_inactive_this_accounts'} />
</p> </p>

View File

@@ -3,6 +3,7 @@ exports.up = (knex) => {
return knex.schema.createTable('account_types', (table) => { return knex.schema.createTable('account_types', (table) => {
table.increments(); table.increments();
table.string('name'); table.string('name');
table.string('key');
table.string('normal'); table.string('normal');
table.string('root_type'); table.string('root_type');
table.boolean('balance_sheet'); table.boolean('balance_sheet');

View File

@@ -7,6 +7,7 @@ exports.up = function (knex) {
table.integer('resource_id').unsigned().references('id').inTable('resources'); table.integer('resource_id').unsigned().references('id').inTable('resources');
table.boolean('favourite'); table.boolean('favourite');
table.string('roles_logic_expression'); table.string('roles_logic_expression');
table.timestamps();
}).raw('ALTER TABLE `VIEWS` AUTO_INCREMENT = 1000').then(() => { }).raw('ALTER TABLE `VIEWS` AUTO_INCREMENT = 1000').then(() => {
return knex.seed.run({ return knex.seed.run({
specific: 'seed_views.js', specific: 'seed_views.js',

View File

@@ -8,6 +8,8 @@ exports.up = function(knex) {
table.string('reference_type'); table.string('reference_type');
table.integer('reference_id'); table.integer('reference_id');
table.integer('account_id').unsigned(); table.integer('account_id').unsigned();
table.string('contact_type').nullable();
table.integer('contact_id').unsigned().nullable();
table.string('note'); table.string('note');
table.boolean('draft').defaultTo(false); table.boolean('draft').defaultTo(false);
table.integer('user_id').unsigned(); table.integer('user_id').unsigned();

View File

@@ -4,6 +4,7 @@ exports.up = function(knex) {
table.increments(); table.increments();
table.string('currency_name'); table.string('currency_name');
table.string('currency_code', 4); table.string('currency_code', 4);
table.timestamps();
}).raw('ALTER TABLE `CURRENCIES` AUTO_INCREMENT = 1000'); }).raw('ALTER TABLE `CURRENCIES` AUTO_INCREMENT = 1000');
}; };

View File

@@ -5,6 +5,7 @@ exports.up = function(knex) {
table.string('currency_code', 4); table.string('currency_code', 4);
table.decimal('exchange_rate'); table.decimal('exchange_rate');
table.date('date'); table.date('date');
table.timestamps();
}).raw('ALTER TABLE `EXCHANGE_RATES` AUTO_INCREMENT = 1000'); }).raw('ALTER TABLE `EXCHANGE_RATES` AUTO_INCREMENT = 1000');
}; };

View File

@@ -34,6 +34,7 @@ exports.up = function(knex) {
table.text('note'); table.text('note');
table.boolean('active').defaultTo(true); table.boolean('active').defaultTo(true);
table.timestamps();
}); });
}; };

View File

@@ -34,6 +34,8 @@ exports.up = function(knex) {
table.text('note'); table.text('note');
table.boolean('active').defaultTo(true); table.boolean('active').defaultTo(true);
table.timestamps();
}); });
}; };

View File

@@ -8,6 +8,7 @@ exports.seed = (knex) => {
{ {
id: 1, id: 1,
name: 'Fixed Asset', name: 'Fixed Asset',
key: 'fixed_asset',
normal: 'debit', normal: 'debit',
root_type: 'asset', root_type: 'asset',
balance_sheet: true, balance_sheet: true,
@@ -16,6 +17,7 @@ exports.seed = (knex) => {
{ {
id: 2, id: 2,
name: 'Current Asset', name: 'Current Asset',
key: 'current_asset',
normal: 'debit', normal: 'debit',
root_type: 'asset', root_type: 'asset',
balance_sheet: true, balance_sheet: true,
@@ -24,6 +26,7 @@ exports.seed = (knex) => {
{ {
id: 3, id: 3,
name: 'Long Term Liability', name: 'Long Term Liability',
key: 'long_term_liability',
normal: 'credit', normal: 'credit',
root_type: 'liability', root_type: 'liability',
balance_sheet: false, balance_sheet: false,
@@ -32,6 +35,7 @@ exports.seed = (knex) => {
{ {
id: 4, id: 4,
name: 'Current Liability', name: 'Current Liability',
key: 'current_liability',
normal: 'credit', normal: 'credit',
root_type: 'liability', root_type: 'liability',
balance_sheet: false, balance_sheet: false,
@@ -40,6 +44,7 @@ exports.seed = (knex) => {
{ {
id: 5, id: 5,
name: 'Equity', name: 'Equity',
key: 'equity',
normal: 'credit', normal: 'credit',
root_type: 'equity', root_type: 'equity',
balance_sheet: true, balance_sheet: true,
@@ -48,6 +53,7 @@ exports.seed = (knex) => {
{ {
id: 6, id: 6,
name: 'Expense', name: 'Expense',
key: 'expense',
normal: 'debit', normal: 'debit',
root_type: 'expense', root_type: 'expense',
balance_sheet: false, balance_sheet: false,
@@ -56,6 +62,7 @@ exports.seed = (knex) => {
{ {
id: 7, id: 7,
name: 'Income', name: 'Income',
key: 'income',
normal: 'credit', normal: 'credit',
root_type: 'income', root_type: 'income',
balance_sheet: false, balance_sheet: false,
@@ -64,6 +71,7 @@ exports.seed = (knex) => {
{ {
id: 8, id: 8,
name: 'Accounts Receivable', name: 'Accounts Receivable',
key: 'accounts_receivable',
normal: 'debit', normal: 'debit',
root_type: 'asset', root_type: 'asset',
balance_sheet: true, balance_sheet: true,
@@ -72,6 +80,7 @@ exports.seed = (knex) => {
{ {
id: 9, id: 9,
name: 'Accounts Payable', name: 'Accounts Payable',
key: 'accounts_payable',
normal: 'credit', normal: 'credit',
root_type: 'liability', root_type: 'liability',
balance_sheet: true, balance_sheet: true,

View File

@@ -103,7 +103,29 @@ exports.seed = (knex) => {
active: 1, active: 1,
index: 1, index: 1,
predefined: 1, predefined: 1,
} },
{
id: 10,
name: 'Accounts Receivable',
account_type_id: 8,
parent_account_id: null,
code: '1000',
description: '',
active: 1,
index: 1,
predefined: 1,
},
{
id: 11,
name: 'Accounts Payable',
account_type_id: 9,
parent_account_id: null,
code: '1000',
description: '',
active: 1,
index: 1,
predefined: 1,
},
]); ]);
}); });
}; };

View File

@@ -33,6 +33,8 @@ export default {
asyncMiddleware(this.manualJournals.handler)); asyncMiddleware(this.manualJournals.handler));
router.post('/make-journal-entries', router.post('/make-journal-entries',
this.validateMediaIds,
this.validateContactEntries,
this.makeJournalEntries.validation, this.makeJournalEntries.validation,
asyncMiddleware(this.makeJournalEntries.handler)); asyncMiddleware(this.makeJournalEntries.handler));
@@ -41,6 +43,8 @@ export default {
asyncMiddleware(this.publishManualJournal.handler)); asyncMiddleware(this.publishManualJournal.handler));
router.post('/manual-journals/:id', router.post('/manual-journals/:id',
this.validateMediaIds,
this.validateContactEntries,
this.editManualJournal.validation, this.editManualJournal.validation,
asyncMiddleware(this.editManualJournal.handler)); asyncMiddleware(this.editManualJournal.handler));
@@ -168,6 +172,114 @@ export default {
}, },
}, },
/**
* Validate media ids.
* @param {Request} req -
* @param {Response} res -
* @param {Function} next -
*/
async validateMediaIds(req, res, next) {
const form = { media_ids: [], ...req.body };
const { Media } = req.models;
const errorReasons = [];
// Validate if media ids was not already exists on the storage.
if (form.media_ids.length > 0) {
const storedMedia = await Media.query().whereIn('id', form.media_ids);
const notFoundMedia = difference(form.media_ids, storedMedia.map((m) => m.id));
if (notFoundMedia.length > 0) {
errorReasons.push({ type: 'MEDIA.IDS.NOT.FOUND', code: 400, ids: notFoundMedia });
}
}
req.errorReasons = Array.isArray(req.errorReasons) && req.errorReasons.length
? req.errorReasons.push(...errorReasons) : errorReasons;
next();
},
/**
* Validate form entries with contact customers and vendors.
* @param {Request} req
* @param {Response} res
* @param {Function} next
*/
async validateContactEntries(req, res, next) {
const form = { entries: [], ...req.body };
const { AccountType, Vendor, Customer } = req.models;
const errorReasons = [];
// Validate the entries contact type and ids.
const customersContacts = form.entries.filter(e => e.contact_type === 'customer');
const vendorsContacts = form.entries.filter(e => e.contact_type === 'vendor');
const accountsTypes = await AccountType.query();
const payableAccountsType = accountsTypes.find(t => t.key === 'accounts_payable');;
const receivableAccountsType = accountsTypes.find(t => t.key === 'accounts_receivable');
// Validate customers contacts.
if (customersContacts.length > 0) {
const customersContactsIds = customersContacts.map(c => c.contact_id);
const storedContacts = await Customer.query().whereIn('id', customersContactsIds);
const storedContactsIds = storedContacts.map(c => c.id);
const formEntriesCustomersIds = form.entries.filter(e => e.contact_type === 'customer');
const notFoundContactsIds = difference(
formEntriesCustomersIds.map(c => c.contact_id),
storedContactsIds,
);
if (notFoundContactsIds.length > 0) {
errorReasons.push({ type: 'CUSTOMERS.CONTACTS.NOT.FOUND', code: 500, ids: notFoundContactsIds });
}
const notReceivableAccounts = formEntriesCustomersIds.filter(
c => receivableAccountsType && c.contact_id !== receivableAccountsType.id);
if (notReceivableAccounts.length > 0) {
errorReasons.push({
type: 'CUSTOMERS.ACCOUNTS.NOT.RECEIVABLE.TYPE',
code: 700,
indexes: notReceivableAccounts.map(a => a.index),
});
}
}
// Validate vendors contacts.
if (vendorsContacts.length > 0) {
const vendorsContactsIds = vendorsContacts.map(c => c.contact_id);
const storedContacts = await Vendor.query().where('id', vendorsContactsIds);
const storedContactsIds = storedContacts.map(c => c.id);
const formEntriesVendorsIds = form.entries.filter(e => e.contact_type === 'vendor');
const notFoundContactsIds = difference(
formEntriesVendorsIds.map(v => v.contact_id),
storedContactsIds,
);
if (notFoundContactsIds.length > 0) {
errorReasons.push({
type: 'VENDORS.CONTACTS.NOT.FOUND', code: 600, ids: notFoundContactsIds,
});
}
const notPayableAccounts = formEntriesVendorsIds.filter(
v => payableAccountsType && v.contact_id === payableAccountsType.id
);
if (notPayableAccounts.length > 0) {
errorReasons.push({
type: 'VENDORS.ACCOUNTS.NOT.PAYABLE.TYPE',
code: 800,
indexes: notPayableAccounts.map(a => a.index),
});
}
}
req.errorReasons = Array.isArray(req.errorReasons) && req.errorReasons.length
? req.errorReasons.push(...errorReasons) : errorReasons;
next();
},
/** /**
* Make journal entrires. * Make journal entrires.
*/ */
@@ -180,10 +292,13 @@ export default {
check('description').optional().trim().escape(), check('description').optional().trim().escape(),
check('status').optional().isBoolean().toBoolean(), check('status').optional().isBoolean().toBoolean(),
check('entries').isArray({ min: 2 }), check('entries').isArray({ min: 2 }),
check('entries.*.index').exists().isNumeric().toInt(),
check('entries.*.credit').optional({ nullable: true }).isNumeric().toInt(), check('entries.*.credit').optional({ nullable: true }).isNumeric().toInt(),
check('entries.*.debit').optional({ nullable: true }).isNumeric().toInt(), check('entries.*.debit').optional({ nullable: true }).isNumeric().toInt(),
check('entries.*.account_id').isNumeric().toInt(), check('entries.*.account_id').isNumeric().toInt(),
check('entries.*.note').optional(), check('entries.*.note').optional(),
check('entries.*.contact_id').optional().isNumeric().toInt(),
check('entries.*.contact_type').optional().isIn(['vendor', 'customer']),
check('media_ids').optional().isArray(), check('media_ids').optional().isArray(),
check('media_ids.*').exists().isNumeric().toInt(), check('media_ids.*').exists().isNumeric().toInt(),
], ],
@@ -202,13 +317,17 @@ export default {
media_ids: [], media_ids: [],
...req.body, ...req.body,
}; };
const { ManualJournal, Account, Media, MediaLink } = req.models; const {
ManualJournal,
Account,
MediaLink,
} = req.models;
let totalCredit = 0; let totalCredit = 0;
let totalDebit = 0; let totalDebit = 0;
const { user } = req; const { user } = req;
const errorReasons = []; const errorReasons = [...(req.errorReasons || [])];
const entries = form.entries.filter((entry) => (entry.credit || entry.debit)); const entries = form.entries.filter((entry) => (entry.credit || entry.debit));
const formattedDate = moment(form.date).format('YYYY-MM-DD'); const formattedDate = moment(form.date).format('YYYY-MM-DD');
@@ -229,23 +348,18 @@ export default {
if (totalCredit !== totalDebit) { if (totalCredit !== totalDebit) {
errorReasons.push({ type: 'CREDIT.DEBIT.NOT.EQUALS', code: 100 }); errorReasons.push({ type: 'CREDIT.DEBIT.NOT.EQUALS', code: 100 });
} }
const accountsIds = entries.map((entry) => entry.account_id); const formEntriesAccountsIds = entries.map((entry) => entry.account_id);
const formEntriesContactsIds = entries.map((entry) => entry.contact_id);
const accounts = await Account.query() const accounts = await Account.query()
.whereIn('id', accountsIds) .whereIn('id', formEntriesAccountsIds)
.withGraphFetched('type') .withGraphFetched('type')
.remember(); .remember();
const storedAccountsIds = accounts.map((account) => account.id); const storedAccountsIds = accounts.map((account) => account.id);
if (form.media_ids.length > 0) {
const storedMedia = await Media.query().whereIn('id', form.media_ids); if (difference(formEntriesAccountsIds, storedAccountsIds).length > 0) {
const notFoundMedia = difference(form.media_ids, storedMedia.map((m) => m.id));
if (notFoundMedia.length > 0) {
errorReasons.push({ type: 'MEDIA.IDS.NOT.FOUND', code: 400, ids: notFoundMedia });
}
}
if (difference(accountsIds, storedAccountsIds).length > 0) {
errorReasons.push({ type: 'ACCOUNTS.IDS.NOT.FOUND', code: 200 }); errorReasons.push({ type: 'ACCOUNTS.IDS.NOT.FOUND', code: 200 });
} }
@@ -255,10 +369,11 @@ export default {
if (journalNumber.length > 0) { if (journalNumber.length > 0) {
errorReasons.push({ type: 'JOURNAL.NUMBER.ALREADY.EXISTS', code: 300 }); errorReasons.push({ type: 'JOURNAL.NUMBER.ALREADY.EXISTS', code: 300 });
} }
if (errorReasons.length > 0) { if (errorReasons.length > 0) {
return res.status(400).send({ errors: errorReasons }); return res.status(400).send({ errors: errorReasons });
} }
// Save manual journal transaction. // Save manual journal tansaction.
const manualJournal = await ManualJournal.query().insert({ const manualJournal = await ManualJournal.query().insert({
reference: form.reference, reference: form.reference,
transaction_type: 'Journal', transaction_type: 'Journal',
@@ -282,6 +397,8 @@ export default {
referenceType: 'Journal', referenceType: 'Journal',
referenceId: manualJournal.id, referenceId: manualJournal.id,
accountNormal: account.type.normal, accountNormal: account.type.normal,
contactType: entry.contact_type,
contactId: entry.contact_id,
note: entry.note, note: entry.note,
date: formattedDate, date: formattedDate,
userId: user.id, userId: user.id,
@@ -356,6 +473,8 @@ export default {
check('entries.*.credit').optional({ nullable: true }).isNumeric().toInt(), check('entries.*.credit').optional({ nullable: true }).isNumeric().toInt(),
check('entries.*.debit').optional({ nullable: true }).isNumeric().toInt(), check('entries.*.debit').optional({ nullable: true }).isNumeric().toInt(),
check('entries.*.account_id').isNumeric().toInt(), check('entries.*.account_id').isNumeric().toInt(),
check('entries.*.contact_id').optional().isNumeric().toInt(),
check('entries.*.contact_type').optional().isIn(['vendor', 'customer']).isNumeric().toInt(),
check('entries.*.note').optional(), check('entries.*.note').optional(),
check('media_ids').optional().isArray(), check('media_ids').optional().isArray(),
check('media_ids.*').isNumeric().toInt(), check('media_ids.*').isNumeric().toInt(),
@@ -393,7 +512,7 @@ export default {
let totalDebit = 0; let totalDebit = 0;
const { user } = req; const { user } = req;
const errorReasons = []; const errorReasons = [...(req.errorReasons || [])];
const entries = form.entries.filter((entry) => (entry.credit || entry.debit)); const entries = form.entries.filter((entry) => (entry.credit || entry.debit));
const formattedDate = moment(form.date).format('YYYY-MM-DD'); const formattedDate = moment(form.date).format('YYYY-MM-DD');
@@ -431,16 +550,6 @@ export default {
if (difference(accountsIds, storedAccountsIds).length > 0) { if (difference(accountsIds, storedAccountsIds).length > 0) {
errorReasons.push({ type: 'ACCOUNTS.IDS.NOT.FOUND', code: 200 }); errorReasons.push({ type: 'ACCOUNTS.IDS.NOT.FOUND', code: 200 });
} }
// Validate if media ids was not already exists on the storage.
if (form.media_ids.length > 0) {
const storedMedia = await Media.query().whereIn('id', form.media_ids);
const notFoundMedia = difference(form.media_ids, storedMedia.map((m) => m.id));
if (notFoundMedia.length > 0) {
errorReasons.push({ type: 'MEDIA.IDS.NOT.FOUND', code: 400, ids: notFoundMedia });
}
}
if (errorReasons.length > 0) { if (errorReasons.length > 0) {
return res.status(400).send({ errors: errorReasons }); return res.status(400).send({ errors: errorReasons });
} }

View File

@@ -0,0 +1,6 @@
export default class BaseController {
}

View File

@@ -5,6 +5,8 @@ import TrialBalanceSheetController from './FinancialStatements/TrialBalanceSheet
import GeneralLedgerController from './FinancialStatements/generalLedger'; import GeneralLedgerController from './FinancialStatements/generalLedger';
import JournalSheetController from './FinancialStatements/JournalSheet'; import JournalSheetController from './FinancialStatements/JournalSheet';
import ProfitLossController from './FinancialStatements/ProfitLossSheet'; import ProfitLossController from './FinancialStatements/ProfitLossSheet';
import ReceivableAgingSummary from './FinancialStatements/ReceivableAgingSummary';
import PayableAgingSummary from './FinancialStatements/PayableAgingSummary';
export default { export default {
/** /**
@@ -18,6 +20,8 @@ export default {
router.use('/general_ledger', GeneralLedgerController.router()); router.use('/general_ledger', GeneralLedgerController.router());
router.use('/trial_balance_sheet', TrialBalanceSheetController.router()); router.use('/trial_balance_sheet', TrialBalanceSheetController.router());
router.use('/journal', JournalSheetController.router()); router.use('/journal', JournalSheetController.router());
router.use('/receivable_aging_summary', ReceivableAgingSummary.router());
router.use('/payable_aging_summary', PayableAgingSummary.router());
return router; return router;
}, },

View File

@@ -0,0 +1,106 @@
import moment from 'moment';
import { validationResult } from 'express-validator';
import { omit, reverse } from 'lodash';
import BaseController from '@/http/controllers/BaseController';
export default class AgingReport extends BaseController{
/**
* Express validator middleware.
* @param {Request} req
* @param {Response} res
* @param {Function} next
*/
static validateResults(req, res, next) {
const validationErrors = validationResult(req);
if (!validationErrors.isEmpty()) {
return res.boom.badData(null, {
code: 'validation_error', ...validationErrors,
});
}
next();
}
/**
*
* @param {Array} agingPeriods
* @param {Numeric} customerBalance
*/
static contactAgingBalance(agingPeriods, receivableTotalCredit) {
let prevAging = 0;
let receivableCredit = receivableTotalCredit;
let diff = receivableCredit;
const periods = reverse(agingPeriods).map((agingPeriod) => {
const agingAmount = (agingPeriod.closingBalance - prevAging);
const subtract = Math.min(diff, agingAmount);
diff -= Math.min(agingAmount, diff);
const total = Math.max(agingAmount - subtract, 0);
const output = {
...omit(agingPeriod, ['closingBalance']),
total,
};
prevAging = agingPeriod.closingBalance;
return output;
});
return reverse(periods);
}
/**
*
* @param {*} asDay
* @param {*} agingDaysBefore
* @param {*} agingPeriodsFreq
*/
static agingRangePeriods(asDay, agingDaysBefore, agingPeriodsFreq) {
const totalAgingDays = agingDaysBefore * agingPeriodsFreq;
const startAging = moment(asDay).startOf('day');
const endAging = startAging.clone().subtract('days', totalAgingDays).endOf('day');
const agingPeriods = [];
const startingAging = startAging.clone();
let beforeDays = 1;
let toDays = 0;
while (startingAging > endAging) {
const currentAging = startingAging.clone();
startingAging.subtract('days', agingDaysBefore).endOf('day');
toDays += agingDaysBefore;
agingPeriods.push({
from_period: moment(currentAging).toDate(),
to_period: moment(startingAging).toDate(),
before_days: beforeDays === 1 ? 0 : beforeDays,
to_days: toDays,
...(startingAging.valueOf() === endAging.valueOf()) ? {
to_period: null,
to_days: null,
} : {},
});
beforeDays += agingDaysBefore;
}
return agingPeriods;
}
/**
*
* @param {*} filter
*/
static formatNumberClosure(filter) {
return (balance) => {
let formattedBalance = parseFloat(balance);
if (filter.no_cents) {
formattedBalance = parseInt(formattedBalance, 10);
}
if (filter.divide_1000) {
formattedBalance /= 1000;
}
return formattedBalance;
};
}
}

View File

@@ -0,0 +1,188 @@
import express from 'express';
import { query } from 'express-validator';
import { difference } from 'lodash';
import JournalPoster from '@/services/Accounting/JournalPoster';
import asyncMiddleware from '@/http/middleware/asyncMiddleware';
import AgingReport from '@/http/controllers/FinancialStatements/AgingReport';
import moment from 'moment';
export default class PayableAgingSummary extends AgingReport {
/**
* Router constructor.
*/
static router() {
const router = express.Router();
router.get(
'/',
this.payableAgingSummaryRoles(),
this.validateResults,
asyncMiddleware(this.validateVendorsIds.bind(this)),
asyncMiddleware(this.payableAgingSummary.bind(this))
);
return router;
}
/**
* Validates the report vendors ids query.
*/
static async validateVendorsIds(req, res, next) {
const { Vendor } = req.models;
const filter = {
vendors_ids: [],
...req.query,
};
if (!Array.isArray(filter.vendors_ids)) {
filter.vendors_ids = [filter.vendors_ids];
}
if (filter.vendors_ids.length > 0) {
const storedCustomers = await Vendor.query().whereIn(
'id',
filter.vendors_ids
);
const storedCustomersIds = storedCustomers.map((c) => c.id);
const notStoredCustomersIds = difference(
storedCustomersIds,
filter,
vendors_ids
);
if (notStoredCustomersIds.length) {
return res.status(400).send({
errors: [{ type: 'VENDORS.IDS.NOT.FOUND', code: 300 }],
});
}
}
next();
}
/**
* Receivable aging summary validation roles.
*/
static payableAgingSummaryRoles() {
return [
query('as_date').optional().isISO8601(),
query('aging_days_before').optional().isNumeric().toInt(),
query('aging_periods').optional().isNumeric().toInt(),
query('number_format.no_cents').optional().isBoolean().toBoolean(),
query('number_format.1000_divide').optional().isBoolean().toBoolean(),
query('vendors_ids.*').isNumeric().toInt(),
query('none_zero').optional().isBoolean().toBoolean(),
];
}
/**
* Retrieve payable aging summary report.
*/
static async payableAgingSummary(req, res) {
const { Customer, Account, AccountTransaction, AccountType } = req.models;
const storedVendors = await Customer.query();
const filter = {
as_date: moment().format('YYYY-MM-DD'),
aging_days_before: 30,
aging_periods: 3,
number_format: {
no_cents: false,
divide_1000: false,
},
...req.query,
};
const accountsReceivableType = await AccountType.query()
.where('key', 'accounts_payable')
.first();
const accountsReceivable = await Account.query()
.where('account_type_id', accountsReceivableType.id)
.remember()
.first();
const transactions = await AccountTransaction.query()
.modify('filterDateRange', null, filter.as_date)
.where('account_id', accountsReceivable.id)
.remember();
const journalPoster = new JournalPoster();
journalPoster.loadEntries(transactions);
const agingPeriods = this.agingRangePeriods(
filter.as_date,
filter.aging_days_before,
filter.aging_periods
);
// Total amount formmatter based on the given query.
const totalFormatter = formatNumberClosure(filter.number_format);
const vendors = storedVendors.map((vendor) => {
// Calculate the trial balance total of the given vendor.
const vendorBalance = journalPoster.getContactTrialBalance(
accountsReceivable.id,
vendor.id,
'vendor'
);
const agingClosingBalance = agingPeriods.map((agingPeriod) => {
// Calculate the trial balance between the given date period.
const agingTrialBalance = journalPoster.getContactTrialBalance(
accountsReceivable.id,
vendor.id,
'vendor',
agingPeriod.from_period
);
return {
...agingPeriod,
closingBalance: agingTrialBalance.debit,
};
});
const aging = this.contactAgingBalance(
agingClosingBalance,
vendorBalance.credit
);
return {
vendor_name: vendor.displayName,
aging: aging.map((item) => ({
...item,
formatted_total: totalFormatter(item.total),
})),
total: vendorBalance.balance,
formatted_total: totalFormatted(vendorBalance.balance),
};
});
const agingClosingBalance = agingPeriods.map((agingPeriod) => {
const closingTrialBalance = journalPoster.getContactTrialBalance(
accountsReceivable.id,
null,
'vendor',
agingPeriod.from_period
);
return {
...agingPeriod,
closingBalance: closingTrialBalance.balance,
};
});
const totalClosingBalance = journalPoster.getContactTrialBalance(
accountsReceivable.id,
null,
'vendor'
);
const agingTotal = this.contactAgingBalance(
agingClosingBalance,
totalClosingBalance.credit
);
return res.status(200).send({
columns: [ ...agingPeriods ],
aging: {
vendors,
total: [
...agingTotal.map((item) => ({
...item,
formatted_total: totalFormatter(item.total),
})),
],
},
});
}
}

View File

@@ -0,0 +1,218 @@
import express from 'express';
import { query, oneOf } from 'express-validator';
import { difference } from 'lodash';
import JournalPoster from '@/services/Accounting/JournalPoster';
import asyncMiddleware from '@/http/middleware/asyncMiddleware';
import AgingReport from '@/http/controllers/FinancialStatements/AgingReport';
import moment from 'moment';
export default class ReceivableAgingSummary extends AgingReport {
/**
* Router constructor.
*/
static router() {
const router = express.Router();
router.get(
'/',
this.receivableAgingSummaryRoles,
this.validateResults,
asyncMiddleware(this.validateCustomersIds.bind(this)),
asyncMiddleware(this.receivableAgingSummary.bind(this))
);
return router;
}
/**
* Validates the report customers ids query.
*/
static async validateCustomersIds(req, res, next) {
const { Customer } = req.models;
console.log(req.query);
const filter = {
customer_ids: [],
...req.query,
};
if (!Array.isArray(filter.customer_ids)) {
filter.customer_ids = [filter.customer_ids];
}
if (filter.customer_ids.length > 0) {
const storedCustomers = await Customer.query().whereIn(
'id',
filter.customer_ids
);
const storedCustomersIds = storedCustomers.map((c) => parseInt(c.id, 10));
const notStoredCustomersIds = difference(
filter.customer_ids.map(a => parseInt(a, 10)),
storedCustomersIds
);
if (notStoredCustomersIds.length) {
return res.status(400).send({
errors: [
{
type: 'CUSTOMERS.IDS.NOT.FOUND',
code: 300,
ids: notStoredCustomersIds,
},
],
});
}
}
next();
}
/**
* Receivable aging summary validation roles.
*/
static get receivableAgingSummaryRoles() {
return [
query('as_date').optional().isISO8601(),
query('aging_days_before').optional().isNumeric().toInt(),
query('aging_periods').optional().isNumeric().toInt(),
query('number_format.no_cents').optional().isBoolean().toBoolean(),
query('number_format.1000_divide').optional().isBoolean().toBoolean(),
oneOf(
[
query('customer_ids').optional().isArray({ min: 1 }),
query('customer_ids.*').isNumeric().toInt(),
],
[query('customer_ids').optional().isNumeric().toInt()]
),
query('none_zero').optional().isBoolean().toBoolean(),
];
}
/**
* Retrieve receivable aging summary report.
*/
static async receivableAgingSummary(req, res) {
const { Customer, Account, AccountTransaction, AccountType } = req.models;
const filter = {
as_date: moment().format('YYYY-MM-DD'),
aging_days_before: 30,
aging_periods: 3,
number_format: {
no_cents: false,
divide_1000: false,
},
customer_ids: [],
...req.query,
};
if (!Array.isArray(filter.customer_ids)) {
filter.customer_ids = [filter.customer_ids];
}
const storedCustomers = await Customer.query().onBuild((builder) => {
if (filter.customer_ids) {
builder.modify('filterCustomerIds', filter.customer_ids);
}
return builder;
});
const accountsReceivableType = await AccountType.query()
.where('key', 'accounts_receivable')
.first();
const accountsReceivable = await Account.query()
.where('account_type_id', accountsReceivableType.id)
.remember()
.first();
const transactions = await AccountTransaction.query().onBuild((query) => {
query.modify('filterDateRange', null, filter.as_date)
query.where('account_id', accountsReceivable.id)
query.modify('filterContactType', 'customer');
if (filter.customer_ids.length> 0) {
query.modify('filterContactIds', filter.customer_ids)
}
query.remember();
return query;
});
const journalPoster = new JournalPoster();
journalPoster.loadEntries(transactions);
const agingPeriods = this.agingRangePeriods(
filter.as_date,
filter.aging_days_before,
filter.aging_periods
);
// Total amount formmatter based on the given query.
const totalFormatter = this.formatNumberClosure(filter.number_format);
const customers = storedCustomers.map((customer) => {
// Calculate the trial balance total of the given customer.
const customerBalance = journalPoster.getContactTrialBalance(
accountsReceivable.id,
customer.id,
'customer'
);
const agingClosingBalance = agingPeriods.map((agingPeriod) => {
// Calculate the trial balance between the given date period.
const agingTrialBalance = journalPoster.getContactTrialBalance(
accountsReceivable.id,
customer.id,
'customer',
agingPeriod.from_period
);
return {
...agingPeriod,
closingBalance: agingTrialBalance.debit,
};
});
const aging = this.contactAgingBalance(
agingClosingBalance,
customerBalance.credit
);
return {
customer_name: customer.displayName,
aging: aging.map((item) => ({
...item,
formatted_total: totalFormatter(item.total),
})),
total: customerBalance.balance,
formatted_total: totalFormatter(customerBalance.balance),
};
});
const agingClosingBalance = agingPeriods.map((agingPeriod) => {
const closingTrialBalance = journalPoster.getContactTrialBalance(
accountsReceivable.id,
null,
'customer',
agingPeriod.from_period
);
return {
...agingPeriod,
closingBalance: closingTrialBalance.balance,
};
});
const totalClosingBalance = journalPoster.getContactTrialBalance(
accountsReceivable.id,
null,
'customer'
);
const agingTotal = this.contactAgingBalance(
agingClosingBalance,
totalClosingBalance.credit
);
return res.status(200).send({
columns: [...agingPeriods],
aging: {
customers,
total: [
...agingTotal.map((item) => ({
...item,
formatted_total: totalFormatter(item.total),
})),
],
},
});
}
}

View File

@@ -8,11 +8,10 @@ import {
} from '@/lib/ViewRolesBuilder'; } from '@/lib/ViewRolesBuilder';
import CachableQueryBuilder from '@/lib/Cachable/CachableQueryBuilder'; import CachableQueryBuilder from '@/lib/Cachable/CachableQueryBuilder';
import CachableModel from '@/lib/Cachable/CachableModel'; import CachableModel from '@/lib/Cachable/CachableModel';
import DateSession from '@/models/DateSession';
import { flatToNestedArray } from '@/utils'; import { flatToNestedArray } from '@/utils';
import DependencyGraph from '@/lib/DependencyGraph'; import DependencyGraph from '@/lib/DependencyGraph';
export default class Account extends mixin(TenantModel, [CachableModel, DateSession]) { export default class Account extends mixin(TenantModel, [CachableModel]) {
/** /**
* Table name * Table name
*/ */
@@ -20,6 +19,13 @@ export default class Account extends mixin(TenantModel, [CachableModel, DateSess
return 'accounts'; return 'accounts';
} }
/**
* Timestamps columns.
*/
static get timestamps() {
return ['createdAt', 'updatedAt'];
}
/** /**
* Extend query builder model. * Extend query builder model.
*/ */

View File

@@ -3,10 +3,9 @@ import moment from 'moment';
import TenantModel from '@/models/TenantModel'; import TenantModel from '@/models/TenantModel';
import CachableQueryBuilder from '@/lib/Cachable/CachableQueryBuilder'; import CachableQueryBuilder from '@/lib/Cachable/CachableQueryBuilder';
import CachableModel from '@/lib/Cachable/CachableModel'; import CachableModel from '@/lib/Cachable/CachableModel';
import DateSession from '@/models/DateSession';
export default class AccountTransaction extends mixin(TenantModel, [CachableModel, DateSession]) { export default class AccountTransaction extends mixin(TenantModel, [CachableModel]) {
/** /**
* Table name * Table name
*/ */
@@ -14,6 +13,13 @@ export default class AccountTransaction extends mixin(TenantModel, [CachableMode
return 'accounts_transactions'; return 'accounts_transactions';
} }
/**
* Timestamps columns.
*/
static get timestamps() {
return ['createdAt'];
}
/** /**
* Extend query builder model. * Extend query builder model.
*/ */
@@ -69,6 +75,12 @@ export default class AccountTransaction extends mixin(TenantModel, [CachableMode
query.sum('debit as debit'); query.sum('debit as debit');
query.groupBy('account_id'); query.groupBy('account_id');
}, },
filterContactType(query, contactType) {
query.where('contact_type', contactType);
},
filterContactIds(query, contactIds) {
query.whereIn('contact_id', contactIds);
},
}; };
} }

View File

@@ -1,8 +1,9 @@
// import path from 'path'; // import path from 'path';
import { Model } from 'objection'; import { Model, mixin } from 'objection';
import TenantModel from '@/models/TenantModel'; import TenantModel from '@/models/TenantModel';
import CachableModel from '@/lib/Cachable/CachableModel';
export default class AccountType extends TenantModel { export default class AccountType extends mixin(TenantModel, [CachableModel]) {
/** /**
* Table name * Table name
*/ */

View File

@@ -7,4 +7,11 @@ export default class Currency extends TenantModel {
static get tableName() { static get tableName() {
return 'currencies'; return 'currencies';
} }
/**
* Timestamps columns.
*/
static get timestamps() {
return ['createdAt', 'updatedAt'];
}
} }

View File

@@ -8,4 +8,22 @@ export default class Customer extends TenantModel {
static get tableName() { static get tableName() {
return 'customers'; return 'customers';
} }
/**
* Model timestamps.
*/
static get timestamps() {
return ['createdAt', 'updatedAt'];
}
/**
* Model modifiers.
*/
static get modifiers() {
return {
filterCustomerIds(query, customerIds) {
query.whereIn('id', customerIds);
},
};
}
} }

View File

@@ -9,4 +9,11 @@ export default class ExchangeRate extends TenantModel {
static get tableName() { static get tableName() {
return 'exchange_rates'; return 'exchange_rates';
} }
/**
* Timestamps columns.
*/
static get timestamps() {
return ['createdAt', 'updatedAt'];
}
} }

View File

@@ -10,10 +10,20 @@ export default class Expense extends TenantModel {
return 'expenses_transactions'; return 'expenses_transactions';
} }
/**
* Account transaction reference type.
*/
static get referenceType() { static get referenceType() {
return 'Expense'; return 'Expense';
} }
/**
* Model timestamps.
*/
static get timestamps() {
return ['createdAt', 'updatedAt'];
}
/** /**
* Model modifiers. * Model modifiers.
*/ */

View File

@@ -12,6 +12,13 @@ export default class Item extends TenantModel {
return 'items'; return 'items';
} }
/**
* Model timestamps.
*/
static get timestamps() {
return ['createdAt', 'updatedAt'];
}
/** /**
* Model modifiers. * Model modifiers.
*/ */

View File

@@ -9,6 +9,13 @@ export default class ManualJournal extends TenantModel {
return 'manual_journals'; return 'manual_journals';
} }
/**
* Model timestamps.
*/
static get timestamps() {
return ['createdAt', 'updatedAt'];
}
/** /**
* Relationship mapping. * Relationship mapping.
*/ */

View File

@@ -1,9 +1,14 @@
import { Model } from 'objection'; import { Model, mixin } from 'objection';
import { snakeCase } from 'lodash'; import { snakeCase } from 'lodash';
import { mapKeysDeep } from '@/utils'; import { mapKeysDeep } from '@/utils';
import PaginationQueryBuilder from '@/models/Pagination'; import PaginationQueryBuilder from '@/models/Pagination';
import DateSession from '@/models/DateSession';
export default class ModelBase extends Model { export default class ModelBase extends mixin(Model, [DateSession]) {
static get timestamps() {
return [];
}
static get knexBinded() { static get knexBinded() {
return this.knexBindInstance; return this.knexBindInstance;

View File

@@ -1,10 +1,9 @@
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { Model, mixin } from 'objection'; import { Model } from 'objection';
import TenantModel from '@/models/TenantModel'; import TenantModel from '@/models/TenantModel';
import DateSession from '@/models/DateSession';
// import PermissionsService from '@/services/PermissionsService'; // import PermissionsService from '@/services/PermissionsService';
export default class TenantUser extends mixin(TenantModel, [DateSession]) { export default class TenantUser extends TenantModel {
/** /**
* Virtual attributes. * Virtual attributes.
*/ */
@@ -19,6 +18,13 @@ export default class TenantUser extends mixin(TenantModel, [DateSession]) {
return 'users'; return 'users';
} }
/**
* Timestamps columns.
*/
static get timestamps() {
return ['createdAt', 'updatedAt'];
}
/** /**
* Relationship mapping. * Relationship mapping.
*/ */

View File

@@ -8,4 +8,11 @@ export default class Vendor extends TenantModel {
static get tableName() { static get tableName() {
return 'vendors'; return 'vendors';
} }
/**
* Model timestamps.
*/
static get timestamps() {
return ['createdAt', 'updatedAt'];
}
} }

View File

@@ -11,6 +11,13 @@ export default class View extends mixin(TenantModel, [CachableModel]) {
return 'views'; return 'views';
} }
/**
* Model timestamps.
*/
static get timestamps() {
return ['createdAt', 'updatedAt'];
}
/** /**
* Extend query builder model. * Extend query builder model.
*/ */

View File

@@ -3,11 +3,10 @@ import moment from 'moment';
import JournalEntry from '@/services/Accounting/JournalEntry'; import JournalEntry from '@/services/Accounting/JournalEntry';
import AccountTransaction from '@/models/AccountTransaction'; import AccountTransaction from '@/models/AccountTransaction';
import AccountBalance from '@/models/AccountBalance'; import AccountBalance from '@/models/AccountBalance';
import {promiseSerial} from '@/utils'; import { promiseSerial } from '@/utils';
import Account from '@/models/Account'; import Account from '@/models/Account';
import NestedSet from '../../collection/NestedSet'; import NestedSet from '../../collection/NestedSet';
export default class JournalPoster { export default class JournalPoster {
/** /**
* Journal poster constructor. * Journal poster constructor.
@@ -34,7 +33,7 @@ export default class JournalPoster {
} }
/** /**
* Writes the debit entr y for the given account. * Writes the debit entry for the given account.
* @param {JournalEntry} entry - * @param {JournalEntry} entry -
*/ */
debit(entryModel) { debit(entryModel) {
@@ -78,7 +77,11 @@ export default class JournalPoster {
* @private * @private
*/ */
_setAccountBalanceChange({ _setAccountBalanceChange({
accountId, accountNormal, debit, credit, entryType accountId,
accountNormal,
debit,
credit,
entryType,
}) { }) {
if (!this.balancesChange[accountId]) { if (!this.balancesChange[accountId]) {
this.balancesChange[accountId] = 0; this.balancesChange[accountId] = 0;
@@ -86,9 +89,9 @@ export default class JournalPoster {
let change = 0; let change = 0;
if (accountNormal === 'credit') { if (accountNormal === 'credit') {
change = (entryType === 'credit') ? credit : -1 * debit; change = entryType === 'credit' ? credit : -1 * debit;
} else if (accountNormal === 'debit') { } else if (accountNormal === 'debit') {
change = (entryType === 'debit') ? debit : -1 * credit; change = entryType === 'debit' ? debit : -1 * credit;
} }
this.balancesChange[accountId] += change; this.balancesChange[accountId] += change;
} }
@@ -132,9 +135,9 @@ export default class JournalPoster {
const method = balance.amount < 0 ? 'decrement' : 'increment'; const method = balance.amount < 0 ? 'decrement' : 'increment';
// Detarmine if the account balance is already exists or not. // Detarmine if the account balance is already exists or not.
const foundAccBalance = balanceAccounts.some((account) => ( const foundAccBalance = balanceAccounts.some(
account && account.account_id === balance.account_id (account) => account && account.account_id === balance.account_id
)); );
if (foundAccBalance) { if (foundAccBalance) {
const query = AccountBalance.tenant() const query = AccountBalance.tenant()
@@ -152,9 +155,7 @@ export default class JournalPoster {
balanceInsertOpers.push(query); balanceInsertOpers.push(query);
} }
}); });
await Promise.all([ await Promise.all([...balanceUpdateOpers, ...balanceInsertOpers]);
...balanceUpdateOpers, ...balanceInsertOpers,
]);
} }
/** /**
@@ -164,11 +165,23 @@ export default class JournalPoster {
const saveOperations = []; const saveOperations = [];
this.entries.forEach((entry) => { this.entries.forEach((entry) => {
const oper = AccountTransaction.tenant().query().insert({ const oper = AccountTransaction.tenant()
accountId: entry.account, .query()
...pick(entry, ['credit', 'debit', 'transactionType', 'date', 'userId', .insert({
'referenceType', 'referenceId', 'note']), accountId: entry.account,
}); ...pick(entry, [
'credit',
'debit',
'transactionType',
'date',
'userId',
'referenceType',
'referenceId',
'note',
'contactId',
'contactType',
]),
});
saveOperations.push(() => oper); saveOperations.push(() => oper);
}); });
await promiseSerial(saveOperations); await promiseSerial(saveOperations);
@@ -195,15 +208,16 @@ export default class JournalPoster {
} }
/** /**
* *
* @param {Array} ids - * @param {Array} ids -
*/ */
removeEntries(ids = []) { removeEntries(ids = []) {
const targetIds = (ids.length <= 0) ? this.entries.map(e => e.id) : ids; const targetIds = ids.length <= 0 ? this.entries.map((e) => e.id) : ids;
const removeEntries = this.entries.filter((e) => targetIds.indexOf(e.id) !== -1); const removeEntries = this.entries.filter(
(e) => targetIds.indexOf(e.id) !== -1
);
this.entries = this.entries this.entries = this.entries.filter((e) => targetIds.indexOf(e.id) === -1);
.filter(e => targetIds.indexOf(e.id) === -1)
removeEntries.forEach((entry) => { removeEntries.forEach((entry) => {
entry.credit = -1 * entry.credit; entry.credit = -1 * entry.credit;
@@ -211,9 +225,7 @@ export default class JournalPoster {
this.setAccountBalanceChange(entry, entry.accountNormal); this.setAccountBalanceChange(entry, entry.accountNormal);
}); });
this.deletedEntriesIds.push( this.deletedEntriesIds.push(...removeEntries.map((entry) => entry.id));
...removeEntries.map(entry => entry.id),
);
} }
/** /**
@@ -221,7 +233,8 @@ export default class JournalPoster {
*/ */
async deleteEntries() { async deleteEntries() {
if (this.deletedEntriesIds.length > 0) { if (this.deletedEntriesIds.length > 0) {
await AccountTransaction.tenant().query() await AccountTransaction.tenant()
.query()
.whereIn('id', this.deletedEntriesIds) .whereIn('id', this.deletedEntriesIds)
.delete(); .delete();
} }
@@ -238,15 +251,17 @@ export default class JournalPoster {
this.entries.forEach((entry) => { this.entries.forEach((entry) => {
// Can not continue if not before or event same closing date. // Can not continue if not before or event same closing date.
if ((!momentClosingDate.isAfter(entry.date, dateType) if (
&& !momentClosingDate.isSame(entry.date, dateType)) (!momentClosingDate.isAfter(entry.date, dateType) &&
|| (entry.account !== accountId && accountId)) { !momentClosingDate.isSame(entry.date, dateType)) ||
(entry.account !== accountId && accountId)
) {
return; return;
} }
if (entry.accountNormal === 'credit') { if (entry.accountNormal === 'credit') {
closingBalance += (entry.credit) ? entry.credit : -1 * entry.debit; closingBalance += entry.credit ? entry.credit : -1 * entry.debit;
} else if (entry.accountNormal === 'debit') { } else if (entry.accountNormal === 'debit') {
closingBalance += (entry.debit) ? entry.debit : -1 * entry.credit; closingBalance += entry.debit ? entry.debit : -1 * entry.credit;
} }
}); });
return closingBalance; return closingBalance;
@@ -254,21 +269,27 @@ export default class JournalPoster {
/** /**
* Retrieve the given account balance with dependencies accounts. * Retrieve the given account balance with dependencies accounts.
* @param {Number} accountId * @param {Number} accountId
* @param {Date} closingDate * @param {Date} closingDate
* @param {String} dateType * @param {String} dateType
* @return {Number} * @return {Number}
*/ */
getAccountBalance(accountId, closingDate, dateType) { getAccountBalance(accountId, closingDate, dateType) {
const accountNode = this.accountsGraph.getNodeData(accountId); const accountNode = this.accountsGraph.getNodeData(accountId);
const depAccountsIds = this.accountsGraph.dependenciesOf(accountId); const depAccountsIds = this.accountsGraph.dependenciesOf(accountId);
const depAccounts = depAccountsIds.map((id) => this.accountsGraph.getNodeData(id)); const depAccounts = depAccountsIds.map((id) =>
this.accountsGraph.getNodeData(id)
);
let balance = 0; let balance = 0;
[...depAccounts, accountNode].forEach((account) => { [...depAccounts, accountNode].forEach((account) => {
// if (!this.accountsBalanceTable[account.id]) { // if (!this.accountsBalanceTable[account.id]) {
const closingBalance = this.getClosingBalance(account.id, closingDate, dateType); const closingBalance = this.getClosingBalance(
this.accountsBalanceTable[account.id] = closingBalance; account.id,
closingDate,
dateType
);
this.accountsBalanceTable[account.id] = closingBalance;
// } // }
balance += this.accountsBalanceTable[account.id]; balance += this.accountsBalanceTable[account.id];
}); });
@@ -288,9 +309,11 @@ export default class JournalPoster {
balance: 0, balance: 0,
}; };
this.entries.forEach((entry) => { this.entries.forEach((entry) => {
if ((!momentClosingDate.isAfter(entry.date, dateType) if (
&& !momentClosingDate.isSame(entry.date, dateType)) (!momentClosingDate.isAfter(entry.date, dateType) &&
|| (entry.account !== accountId && accountId)) { !momentClosingDate.isSame(entry.date, dateType)) ||
(entry.account !== accountId && accountId)
) {
return; return;
} }
result.credit += entry.credit; result.credit += entry.credit;
@@ -307,20 +330,27 @@ export default class JournalPoster {
/** /**
* Retrieve trial balance of the given account with depends. * Retrieve trial balance of the given account with depends.
* @param {Number} accountId * @param {Number} accountId
* @param {Date} closingDate * @param {Date} closingDate
* @param {String} dateType * @param {String} dateType
* @return {Number} * @return {Number}
*/ */
getTrialBalanceWithDepands(accountId, closingDate, dateType) { getTrialBalanceWithDepands(accountId, closingDate, dateType) {
const accountNode = this.accountsGraph.getNodeData(accountId); const accountNode = this.accountsGraph.getNodeData(accountId);
const depAccountsIds = this.accountsGraph.dependenciesOf(accountId); const depAccountsIds = this.accountsGraph.dependenciesOf(accountId);
const depAccounts = depAccountsIds.map((id) => this.accountsGraph.getNodeData(id)); const depAccounts = depAccountsIds.map((id) =>
this.accountsGraph.getNodeData(id)
);
const trialBalance = { credit: 0, debit: 0, balance: 0 }; const trialBalance = { credit: 0, debit: 0, balance: 0 };
[...depAccounts, accountNode].forEach((account) => { [...depAccounts, accountNode].forEach((account) => {
const _trialBalance = this.getTrialBalance(account.id, closingDate, dateType); const _trialBalance = this.getTrialBalance(
account.id,
closingDate,
dateType
);
trialBalance.credit += _trialBalance.credit; trialBalance.credit += _trialBalance.credit;
trialBalance.debit += _trialBalance.debit; trialBalance.debit += _trialBalance.debit;
@@ -329,6 +359,85 @@ export default class JournalPoster {
return trialBalance; return trialBalance;
} }
getContactTrialBalance(
accountId,
contactId,
contactType,
closingDate,
openingDate
) {
const momentClosingDate = moment(closingDate);
const momentOpeningDate = moment(openingDate);
const trial = {
credit: 0,
debit: 0,
balance: 0,
};
this.entries.forEach((entry) => {
if (
(closingDate &&
!momentClosingDate.isAfter(entry.date, 'day') &&
!momentClosingDate.isSame(entry.date, 'day')) ||
(openingDate &&
!momentOpeningDate.isBefore(entry.date, 'day') &&
!momentOpeningDate.isSame(entry.date)) ||
(accountId && entry.account !== accountId) ||
(contactId && entry.contactId !== contactId) ||
entry.contactType !== contactType
) {
return;
}
if (entry.credit) {
trial.balance -= entry.credit;
trial.credit += entry.credit;
}
if (entry.debit) {
trial.balance += entry.debit;
trial.debit += entry.debit;
}
});
return trial;
}
/**
* Retrieve total balnace of the given customer/vendor contact.
* @param {Number} accountId
* @param {Number} contactId
* @param {String} contactType
* @param {Date} closingDate
*/
getContactBalance(
accountId,
contactId,
contactType,
closingDate,
openingDate
) {
const momentClosingDate = moment(closingDate);
let balance = 0;
this.entries.forEach((entry) => {
if (
(closingDate &&
!momentClosingDate.isAfter(entry.date, 'day') &&
!momentClosingDate.isSame(entry.date, 'day')) ||
(entry.account !== accountId && accountId) ||
(contactId && entry.contactId !== contactId) ||
entry.contactType !== contactType
) {
return;
}
if (entry.credit) {
balance -= entry.credit;
}
if (entry.debit) {
balance += entry.debit;
}
});
return balance;
}
/** /**
* Load fetched accounts journal entries. * Load fetched accounts journal entries.
* @param {Array} entries - * @param {Array} entries -
@@ -338,8 +447,10 @@ export default class JournalPoster {
this.entries.push({ this.entries.push({
...entry, ...entry,
account: entry.account ? entry.account.id : entry.accountId, account: entry.account ? entry.account.id : entry.accountId,
accountNormal: (entry.account && entry.account.type) accountNormal:
? entry.account.type.normal : entry.accountNormal, entry.account && entry.account.type
? entry.account.type.normal
: entry.accountNormal,
}); });
}); });
} }

View File

@@ -1,11 +1,10 @@
import { Model, mixin } from 'objection'; import { Model, mixin } from 'objection';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import SystemModel from '@/system/models/SystemModel'; import SystemModel from '@/system/models/SystemModel';
import DateSession from '@/models/DateSession';
import UserSubscription from '@/services/Subscription/UserSubscription'; import UserSubscription from '@/services/Subscription/UserSubscription';
export default class SystemUser extends mixin(SystemModel, [DateSession, UserSubscription]) { export default class SystemUser extends mixin(SystemModel, [UserSubscription]) {
/** /**
* Table name. * Table name.
*/ */
@@ -13,6 +12,13 @@ export default class SystemUser extends mixin(SystemModel, [DateSession, UserSub
return 'users'; return 'users';
} }
/**
* Timestamps columns.
*/
static get timestamps() {
return ['createdAt', 'updatedAt'];
}
/** /**
* Relationship mapping. * Relationship mapping.
*/ */

View File

@@ -27,11 +27,13 @@ describe('routes: `/accounting`', () => {
reference: 'ASC', reference: 'ASC',
entries: [ entries: [
{ {
index: 1,
credit: 0, credit: 0,
debit: 0, debit: 0,
account_id: account.id, account_id: account.id,
}, },
{ {
index: 2,
credit: 0, credit: 0,
debit: 0, debit: 0,
account_id: account.id, account_id: account.id,
@@ -56,11 +58,13 @@ describe('routes: `/accounting`', () => {
journal_number: '123', journal_number: '123',
entries: [ entries: [
{ {
index: 1,
credit: 1000, credit: 1000,
debit: 0, debit: 0,
account_id: account.id, account_id: account.id,
}, },
{ {
index: 2,
credit: 0, credit: 0,
debit: 500, debit: 500,
account_id: account.id, account_id: account.id,
@@ -88,11 +92,13 @@ describe('routes: `/accounting`', () => {
journal_number: manualJournal.journalNumber, journal_number: manualJournal.journalNumber,
entries: [ entries: [
{ {
index: 1,
credit: 1000, credit: 1000,
debit: 0, debit: 0,
account_id: account.id, account_id: account.id,
}, },
{ {
index: 2,
credit: 0, credit: 0,
debit: 1000, debit: 1000,
account_id: account.id, account_id: account.id,
@@ -117,11 +123,13 @@ describe('routes: `/accounting`', () => {
journal_number: '123', journal_number: '123',
entries: [ entries: [
{ {
index: 1,
credit: 1000, credit: 1000,
debit: 0, debit: 0,
account_id: 12, account_id: 12,
}, },
{ {
index: 2,
credit: 0, credit: 0,
debit: 1000, debit: 1000,
account_id: 12, account_id: 12,
@@ -149,11 +157,13 @@ describe('routes: `/accounting`', () => {
journal_number: '1000', journal_number: '1000',
entries: [ entries: [
{ {
index: 1,
credit: null, credit: null,
debit: 0, debit: 0,
account_id: account1.id, account_id: account1.id,
}, },
{ {
index: 2,
credit: null, credit: null,
debit: 0, debit: 0,
account_id: account2.id, account_id: account2.id,
@@ -164,10 +174,88 @@ describe('routes: `/accounting`', () => {
expect(res.status).equals(400); expect(res.status).equals(400);
expect(res.body.errors).include.something.that.deep.equal({ expect(res.body.errors).include.something.that.deep.equal({
type: 'CREDIT.DEBIT.SUMATION.SHOULD.NOT.EQUAL.ZERO', type: 'CREDIT.DEBIT.SUMATION.SHOULD.NOT.EQUAL.ZERO',
code: 400, code: 400,
}); });
}); });
it('Should validate the customers and vendors contact if were not found on the storage.', async () => {
const account1 = await tenantFactory.create('account');
const account2 = await tenantFactory.create('account');
const res = await request()
.post('/api/accounting/make-journal-entries')
.set('x-access-token', loginRes.body.token)
.set('organization-id', tenantWebsite.organizationId)
.send({
date: new Date().toISOString(),
journal_number: '1000',
entries: [
{
index: 1,
credit: null,
debit: 1000,
account_id: account1.id,
contact_type: 'customer',
contact_id: 100,
},
{
index: 2,
credit: 1000,
debit: 0,
account_id: account1.id,
contact_type: 'vendor',
contact_id: 300,
},
],
});
expect(res.body.errors).include.something.deep.equals({
type: 'CUSTOMERS.CONTACTS.NOT.FOUND', code: 500, ids: [100],
});
expect(res.body.errors).include.something.deep.equals({
type: 'VENDORS.CONTACTS.NOT.FOUND', code: 600, ids: [300],
})
});
it('Should customer contact_type with receivable accounts type.', async () => {
const account1 = await tenantFactory.create('account');
const account2 = await tenantFactory.create('account');
const customer = await tenantFactory.create('customer');
const res = await request()
.post('/api/accounting/make-journal-entries')
.set('x-access-token', loginRes.body.token)
.set('organization-id', tenantWebsite.organizationId)
.send({
date: new Date().toISOString(),
journal_number: '1000',
entries: [
{
index: 1,
credit: null,
debit: 1000,
account_id: account1.id,
contact_type: 'customer',
contact_id: 100,
},
{
index: 2,
credit: 1000,
debit: 0,
account_id: account1.id,
},
],
});
expect(res.status).equals(400);
expect(res.body.errors).include.something.deep.equals({
type: 'CUSTOMERS.ACCOUNTS.NOT.RECEIVABLE.TYPE',
code: 700,
indexes: [1]
});
});
it('Should store manual journal transaction to the storage.', async () => { it('Should store manual journal transaction to the storage.', async () => {
const account1 = await tenantFactory.create('account'); const account1 = await tenantFactory.create('account');
const account2 = await tenantFactory.create('account'); const account2 = await tenantFactory.create('account');
@@ -183,10 +271,12 @@ describe('routes: `/accounting`', () => {
description: 'Description here.', description: 'Description here.',
entries: [ entries: [
{ {
index: 1,
credit: 1000, credit: 1000,
account_id: account1.id, account_id: account1.id,
}, },
{ {
index: 2,
debit: 1000, debit: 1000,
account_id: account2.id, account_id: account2.id,
}, },
@@ -194,7 +284,6 @@ describe('routes: `/accounting`', () => {
}); });
const foundManualJournal = await ManualJournal.tenant().query(); const foundManualJournal = await ManualJournal.tenant().query();
expect(foundManualJournal.length).equals(1); expect(foundManualJournal.length).equals(1);
expect(foundManualJournal[0].reference).equals('2000'); expect(foundManualJournal[0].reference).equals('2000');
@@ -221,11 +310,13 @@ describe('routes: `/accounting`', () => {
memo: 'Description here.', memo: 'Description here.',
entries: [ entries: [
{ {
index: 1,
credit: 1000, credit: 1000,
account_id: account1.id, account_id: account1.id,
note: 'First note', note: 'First note',
}, },
{ {
index: 2,
debit: 1000, debit: 1000,
account_id: account2.id, account_id: account2.id,
note: 'Second note', note: 'Second note',

View File

@@ -10,7 +10,7 @@ import {
} from '~/dbInit'; } from '~/dbInit';
describe('routes: /accounts/', () => { describe.only('routes: /accounts/', () => {
describe('POST `/accounts`', () => { describe('POST `/accounts`', () => {
it('Should `name` be required.', async () => { it('Should `name` be required.', async () => {
const res = await request() const res = await request()
@@ -190,7 +190,7 @@ describe('routes: /accounts/', () => {
}); });
}); });
it('Should response success with correct data form.', async () => { it.only('Should response success with correct data form.', async () => {
const account = await tenantFactory.create('account'); const account = await tenantFactory.create('account');
const res = await request() const res = await request()
.post('/api/accounts') .post('/api/accounts')
@@ -204,6 +204,8 @@ describe('routes: /accounts/', () => {
code: '123', code: '123',
}); });
console.log(res.body);
expect(res.status).equals(200); expect(res.status).equals(200);
}); });
}); });

View File

@@ -0,0 +1,234 @@
import {
request,
expect,
} from '~/testInit';
import Item from '@/models/Item';
import {
tenantWebsite,
tenantFactory,
loginRes
} from '~/dbInit';
describe('routes: `/financial_statements/receivable_aging_summary`', () => {
it('Should retrieve customers list.', async () => {
const customer1 = await tenantFactory.create('customer', { display_name: 'Ahmed' });
const customer2 = await tenantFactory.create('customer', { display_name: 'Mohamed' });
const res = await request()
.get('/api/financial_statements/receivable_aging_summary')
.set('x-access-token', loginRes.body.token)
.set('organization-id', tenantWebsite.organizationId)
.send();
expect(res.status).equals(200);
expect(res.body.aging.customers).is.an('array');
expect(res.body.aging.customers.length).equals(2);
expect(res.body.aging.customers[0].customer_name).equals('Ahmed');
expect(res.body.aging.customers[1].customer_name).equals('Mohamed');
});
it('Should respon se the customers ids not found.', async () => {
const res = await request()
.get('/api/financial_statements/receivable_aging_summary')
.set('x-access-token', loginRes.body.token)
.set('organization-id', tenantWebsite.organizationId)
.query({
customer_ids: [3213, 3322],
})
.send();
expect(res.status).equals(400);
expect(res.body.errors).include.something.deep.equals({
type: 'CUSTOMERS.IDS.NOT.FOUND', code: 300, ids: [3213, 3322]
})
});
it('Should retrieve aging report columns.', async () => {
const customer1 = await tenantFactory.create('customer', { display_name: 'Ahmed' });
const customer2 = await tenantFactory.create('customer', { display_name: 'Mohamed' });
const res = await request()
.get('/api/financial_statements/receivable_aging_summary')
.set('x-access-token', loginRes.body.token)
.set('organization-id', tenantWebsite.organizationId)
.query({
as_date: '2020-06-01',
aging_days_before: 30,
aging_periods: 6,
})
.send();
expect(res.body.columns).length(6);
expect(res.body.columns[0].before_days).equals(0);
expect(res.body.columns[0].to_days).equals(30);
expect(res.body.columns[1].before_days).equals(31);
expect(res.body.columns[1].to_days).equals(60);
expect(res.body.columns[2].before_days).equals(61);
expect(res.body.columns[2].to_days).equals(90);
expect(res.body.columns[3].before_days).equals(91);
expect(res.body.columns[3].to_days).equals(120);
expect(res.body.columns[4].before_days).equals(121);
expect(res.body.columns[4].to_days).equals(150);
expect(res.body.columns[5].before_days).equals(151);
expect(res.body.columns[5].to_days).equals(null);
});
it('Should retrieve receivable total of the customers.', async () => {
const customer1 = await tenantFactory.create('customer', { display_name: 'Ahmed' });
const customer2 = await tenantFactory.create('customer', { display_name: 'Mohamed' });
await tenantFactory.create('account_transaction', {
contact_id: customer1.id,
contact_type: 'customer',
debit: 10000,
credit: 0,
account_id: 10,
date: '2020-01-01',
});
await tenantFactory.create('account_transaction', {
contact_id: customer1.id,
contact_type: 'customer',
debit: 1000,
credit: 0,
account_id: 10,
date: '2020-03-15',
});
// Receive
await tenantFactory.create('account_transaction', {
contact_id: customer1.id,
contact_type: 'customer',
debit: 0,
credit: 8000,
account_id: 10,
date: '2020-06-01',
});
const res = await request()
.get('/api/financial_statements/receivable_aging_summary')
.set('x-access-token', loginRes.body.token)
.set('organization-id', tenantWebsite.organizationId)
.query({
as_date: '2020-06-01',
aging_days_before: 30,
aging_periods: 6,
})
.send();
expect(res.body.aging.total[0].total).equals(0);
expect(res.body.aging.total[1].total).equals(0);
expect(res.body.aging.total[2].total).equals(1000);
expect(res.body.aging.total[3].total).equals(0);
expect(res.body.aging.total[4].total).equals(0);
expect(res.body.aging.total[5].total).equals(2000);
});
it('Should retrieve customer aging.', async () => {
const customer1 = await tenantFactory.create('customer', { display_name: 'Ahmed' });
const customer2 = await tenantFactory.create('customer', { display_name: 'Mohamed' });
await tenantFactory.create('account_transaction', {
contact_id: customer1.id,
contact_type: 'customer',
debit: 10000,
credit: 0,
account_id: 10,
date: '2020-01-14',
});
await tenantFactory.create('account_transaction', {
contact_id: customer1.id,
contact_type: 'customer',
debit: 1000,
credit: 0,
account_id: 10,
date: '2020-03-15',
});
// Receive
await tenantFactory.create('account_transaction', {
contact_id: customer1.id,
contact_type: 'customer',
debit: 0,
credit: 8000,
account_id: 10,
date: '2020-06-01',
});
const res = await request()
.get('/api/financial_statements/receivable_aging_summary')
.set('x-access-token', loginRes.body.token)
.set('organization-id', tenantWebsite.organizationId)
.query({
as_date: '2020-06-01',
aging_days_before: 30,
aging_periods: 6,
})
.send();
expect(res.body.aging.customers[0].aging[0].total).equals(0);
expect(res.body.aging.customers[0].aging[1].total).equals(0);
expect(res.body.aging.customers[0].aging[2].total).equals(1000);
expect(res.body.aging.customers[0].aging[3].total).equals(0);
expect(res.body.aging.customers[0].aging[4].total).equals(2000);
expect(res.body.aging.customers[0].aging[5].total).equals(0);
});
it('Should retrieve the queried customers ids only.', async () => {
const customer1 = await tenantFactory.create('customer', { display_name: 'Ahmed' });
const customer2 = await tenantFactory.create('customer', { display_name: 'Mohamed' });
await tenantFactory.create('account_transaction', {
contact_id: customer1.id,
contact_type: 'customer',
debit: 10000,
credit: 0,
account_id: 10,
date: '2020-01-14',
});
await tenantFactory.create('account_transaction', {
contact_id: customer1.id,
contact_type: 'customer',
debit: 1000,
credit: 0,
account_id: 10,
date: '2020-03-15',
});
// Receive
await tenantFactory.create('account_transaction', {
contact_id: customer1.id,
contact_type: 'customer',
debit: 0,
credit: 8000,
account_id: 10,
date: '2020-06-01',
});
const res = await request()
.get('/api/financial_statements/receivable_aging_summary')
.set('x-access-token', loginRes.body.token)
.set('organization-id', tenantWebsite.organizationId)
.query({
as_date: '2020-06-01',
aging_days_before: 30,
aging_periods: 6,
customer_ids: [customer1.id],
})
.send();
expect(res.body.aging.customers.length).equals(1);
})
});

View File

@@ -10,7 +10,7 @@ import {
} from '~/dbInit'; } from '~/dbInit';
import Vendor from '@/models/Vendor'; import Vendor from '@/models/Vendor';
describe.only('route: `/vendors`', () => { describe('route: `/vendors`', () => {
describe('POST: `/vendors`', () => { describe('POST: `/vendors`', () => {
it('Should response unauthorized in case the user was not logged in.', async () => { it('Should response unauthorized in case the user was not logged in.', async () => {
const res = await request() const res = await request()