From 4d1dd14f8d38c5d7a43c40da00b10f0cb0760035 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Thu, 11 Jun 2020 22:05:34 +0200 Subject: [PATCH] feat: Receivable and payable aging summary financial statement. --- .../src/containers/Accounts/AccountsChart.js | 233 +++++++++-------- ...190822214904_create_account_types_table.js | 1 + .../20190822214905_create_views_table.js | 1 + ...2647_create_accounts_transactions_table.js | 2 + .../20200419171451_create_currencies_table.js | 1 + ...00419191832_create_exchange_rates_table.js | 1 + .../20200607212203_create_customers_table.js | 1 + .../20200608192614_create_vendors_table.js | 2 + .../src/database/seeds/seed_account_types.js | 9 + server/src/database/seeds/seed_accounts.js | 24 +- server/src/http/controllers/Accounting.js | 159 ++++++++++-- server/src/http/controllers/BaseController.js | 6 + .../http/controllers/FinancialStatements.js | 4 + .../FinancialStatements/AgingReport.js | 106 ++++++++ .../PayableAgingSummary.js | 188 ++++++++++++++ .../ReceivableAgingSummary.js | 218 ++++++++++++++++ server/src/models/Account.js | 10 +- server/src/models/AccountTransaction.js | 16 +- server/src/models/AccountType.js | 5 +- server/src/models/Currency.js | 7 + server/src/models/Customer.js | 18 ++ server/src/models/ExchangeRate.js | 7 + server/src/models/Expense.js | 10 + server/src/models/Item.js | 7 + server/src/models/ManualJournal.js | 7 + server/src/models/Model.js | 9 +- server/src/models/TenantUser.js | 12 +- server/src/models/Vendor.js | 7 + server/src/models/View.js | 7 + .../src/services/Accounting/JournalPoster.js | 207 ++++++++++++---- server/src/system/models/SystemUser.js | 10 +- server/tests/routes/accounting.test.js | 93 ++++++- server/tests/routes/accounts.test.js | 6 +- server/tests/routes/payable_aging.test.js | 0 server/tests/routes/receivable_aging.test.js | 234 ++++++++++++++++++ server/tests/routes/vendors.test.js | 2 +- 36 files changed, 1435 insertions(+), 195 deletions(-) create mode 100644 server/src/http/controllers/BaseController.js create mode 100644 server/src/http/controllers/FinancialStatements/AgingReport.js create mode 100644 server/src/http/controllers/FinancialStatements/PayableAgingSummary.js create mode 100644 server/src/http/controllers/FinancialStatements/ReceivableAgingSummary.js create mode 100644 server/tests/routes/payable_aging.test.js create mode 100644 server/tests/routes/receivable_aging.test.js diff --git a/client/src/containers/Accounts/AccountsChart.js b/client/src/containers/Accounts/AccountsChart.js index 6d118a161..c7a70a282 100644 --- a/client/src/containers/Accounts/AccountsChart.js +++ b/client/src/containers/Accounts/AccountsChart.js @@ -1,11 +1,12 @@ import React, { useEffect, useState, useMemo, useCallback } from 'react'; -import { - Route, - Switch, -} from 'react-router-dom'; +import { Route, Switch } from 'react-router-dom'; import { Alert, Intent } from '@blueprintjs/core'; -import { useQuery } from 'react-query' -import { FormattedMessage as T, FormattedHTMLMessage, useIntl } from 'react-intl'; +import { useQuery } from 'react-query'; +import { + FormattedMessage as T, + FormattedHTMLMessage, + useIntl, +} from 'react-intl'; import AppToaster from 'components/AppToaster'; @@ -24,7 +25,6 @@ import withAccounts from 'containers/Accounts/withAccounts'; import { compose } from 'utils'; - function AccountsChart({ // #withDashboardActions changePageTitle, @@ -57,8 +57,8 @@ function AccountsChart({ const [activateAccount, setActivateAccount] = useState(false); const [bulkDelete, setBulkDelete] = useState(false); const [selectedRows, setSelectedRows] = useState([]); - const [bulkActivate,setBulkActivate] =useState(false); - const [bulkInactiveAccounts,setBulkInactiveAccounts] =useState(false) + const [bulkActivate, setBulkActivate] = useState(false); + const [bulkInactiveAccounts, setBulkInactiveAccounts] = useState(false); const [tableLoading, setTableLoading] = useState(false); // Fetch accounts resource views and fields. @@ -77,7 +77,7 @@ function AccountsChart({ useEffect(() => { changePageTitle(formatMessage({ id: 'chart_of_accounts' })); - }, [changePageTitle,formatMessage]); + }, [changePageTitle, formatMessage]); // Handle click and cancel/confirm account delete const handleDeleteAccount = (account) => { @@ -85,7 +85,9 @@ function AccountsChart({ }; // handle cancel delete account alert. - const handleCancelAccountDelete = useCallback(() => { setDeleteAccount(false); }, []); + const handleCancelAccountDelete = useCallback(() => { + setDeleteAccount(false); + }, []); const handleDeleteErrors = (errors) => { if (errors.find((e) => e.type === 'ACCOUNT.PREDEFINED')) { @@ -98,24 +100,30 @@ function AccountsChart({ } if (errors.find((e) => e.type === 'ACCOUNT.HAS.ASSOCIATED.TRANSACTIONS')) { AppToaster.show({ - message: formatMessage({id:'cannot_delete_account_has_associated_transactions'}) + message: formatMessage({ + id: 'cannot_delete_account_has_associated_transactions', + }), }); } - } - + }; + // Handle confirm account delete const handleConfirmAccountDelete = useCallback(() => { - requestDeleteAccount(deleteAccount.id).then(() => { - setDeleteAccount(false); - AppToaster.show({ - message: formatMessage({ id: 'the_account_has_been_successfully_deleted' }), - intent: Intent.SUCCESS, + requestDeleteAccount(deleteAccount.id) + .then(() => { + setDeleteAccount(false); + AppToaster.show({ + message: formatMessage({ + id: 'the_account_has_been_successfully_deleted', + }), + intent: Intent.SUCCESS, + }); + }) + .catch((errors) => { + setDeleteAccount(false); + handleDeleteErrors(errors); }); - }).catch(errors => { - setDeleteAccount(false); - handleDeleteErrors(errors); - }); - }, [deleteAccount, requestDeleteAccount,formatMessage]); + }, [deleteAccount, requestDeleteAccount, formatMessage]); // Handle cancel/confirm account inactive. const handleInactiveAccount = useCallback((account) => { @@ -138,7 +146,7 @@ function AccountsChart({ intent: Intent.SUCCESS, }); }); - }, [inactiveAccount, requestInactiveAccount,formatMessage]); + }, [inactiveAccount, requestInactiveAccount, formatMessage]); // Handle activate account click. const handleActivateAccount = useCallback((account) => { @@ -166,23 +174,30 @@ function AccountsChart({ const handleRestoreAccount = (account) => {}; // Handle accounts bulk delete button click., - const handleBulkDelete = useCallback((accountsIds) => { - setBulkDelete(accountsIds); - }, [setBulkDelete]); + const handleBulkDelete = useCallback( + (accountsIds) => { + setBulkDelete(accountsIds); + }, + [setBulkDelete], + ); // Handle confirm accounts bulk delete. const handleConfirmBulkDelete = useCallback(() => { - requestDeleteBulkAccounts(bulkDelete).then(() => { - setBulkDelete(false); - AppToaster.show({ - message: formatMessage({ id: 'the_accounts_has_been_successfully_deleted' }), - intent: Intent.SUCCESS, + requestDeleteBulkAccounts(bulkDelete) + .then(() => { + setBulkDelete(false); + AppToaster.show({ + message: formatMessage({ + id: 'the_accounts_has_been_successfully_deleted', + }), + intent: Intent.SUCCESS, + }); + }) + .catch((errors) => { + setBulkDelete(false); + handleDeleteErrors(errors); }); - }).catch((errors) => { - setBulkDelete(false); - handleDeleteErrors(errors); - }); - }, [requestDeleteBulkAccounts, bulkDelete,formatMessage]); + }, [requestDeleteBulkAccounts, bulkDelete, formatMessage]); // Handle cancel accounts bulk delete. const handleCancelBulkDelete = useCallback(() => { @@ -191,16 +206,14 @@ function AccountsChart({ const handleBulkArchive = useCallback((accounts) => {}, []); - const handleEditAccount = useCallback(() => { - - }, []); + const handleEditAccount = useCallback(() => {}, []); // Handle selected rows change. const handleSelectedRowsChange = useCallback( (accounts) => { setSelectedRows(accounts); }, - [setSelectedRows] + [setSelectedRows], ); // Refetches accounts data table when current custom view changed. @@ -232,70 +245,73 @@ function AccountsChart({ }); fetchAccountsHook.refetch(); }, - [fetchAccountsHook, addAccountsTableQueries] + [fetchAccountsHook, addAccountsTableQueries], ); // 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., - const handleBulkActivate = useCallback((bulkActivateIds) => { - setBulkActivate(bulkActivateIds); -}, [setBulkActivate]); - - - // Handle cancel Bulk Activate accounts bulk delete. + // Handle cancel Bulk Activate accounts bulk delete. const handleCancelBulkActivate = useCallback(() => { - setBulkActivate(false); -}, []); - - // 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 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., - const handleBulkInactive = useCallback((bulkInactiveIds) => { - setBulkInactiveAccounts(bulkInactiveIds); -}, [setBulkInactiveAccounts]); - + // Handle bulk Inactive accounts button click., + const handleBulkInactive = useCallback( + (bulkInactiveIds) => { + setBulkInactiveAccounts(bulkInactiveIds); + }, + [setBulkInactiveAccounts], + ); // Handle cancel Bulk Inactive accounts bulk delete. const handleCancelBulkInactive = useCallback(() => { setBulkInactiveAccounts(false); }, []); - // Handle Bulk Inactive accounts confirm. - const handleConfirmBulkInactive = useCallback(() => { - requestBulkInactiveAccounts(bulkInactiveAccounts).then(() => { - setBulkInactiveAccounts(false); - AppToaster.show({ - message: formatMessage({ id: 'the_accounts_has_been_successfully_inactivated' }), - intent: Intent.SUCCESS, - }); - }).catch((errors) => { - setBulkInactiveAccounts(false); - - }); -}, [requestBulkInactiveAccounts, bulkInactiveAccounts]); - - - - - + // Handle Bulk Inactive accounts confirm. + const handleConfirmBulkInactive = useCallback(() => { + requestBulkInactiveAccounts(bulkInactiveAccounts) + .then(() => { + setBulkInactiveAccounts(false); + AppToaster.show({ + message: formatMessage({ + id: 'the_accounts_has_been_successfully_inactivated', + }), + intent: Intent.SUCCESS, + }); + }) + .catch((errors) => { + setBulkInactiveAccounts(false); + }); + }, [requestBulkInactiveAccounts, bulkInactiveAccounts]); return ( @@ -312,10 +328,7 @@ const handleConfirmBulkActivate = useCallback(() => { @@ -343,7 +356,8 @@ const handleConfirmBulkActivate = useCallback(() => { >

+ id={'once_delete_this_account_you_will_able_to_restore_it'} + />

@@ -366,15 +380,18 @@ const handleConfirmBulkActivate = useCallback(() => { intent={Intent.WARNING} isOpen={activateAccount} onCancel={handleCancelActivateAccount} - onConfirm={handleConfirmAccountActivate}> + onConfirm={handleConfirmAccountActivate} + >

} - confirmButtonText={`${formatMessage({id:'delete'})} (${selectedRowsCount})`} + cancelButtonText={} + confirmButtonText={`${formatMessage({ + id: 'delete', + })} (${selectedRowsCount})`} icon="trash" intent={Intent.DANGER} isOpen={bulkDelete} @@ -382,28 +399,36 @@ const handleConfirmBulkActivate = useCallback(() => { onConfirm={handleConfirmBulkDelete} >

- +

} - confirmButtonText={`${formatMessage({id:'activate'})} (${selectedRowsCount})`} + confirmButtonText={`${formatMessage({ + id: 'activate', + })} (${selectedRowsCount})`} intent={Intent.WARNING} isOpen={bulkActivate} onCancel={handleCancelBulkActivate} - onConfirm={handleConfirmBulkActivate}> + onConfirm={handleConfirmBulkActivate} + >

} - confirmButtonText={`${formatMessage({id:'inactivate'})} (${selectedRowsCount})`} + confirmButtonText={`${formatMessage({ + id: 'inactivate', + })} (${selectedRowsCount})`} intent={Intent.WARNING} isOpen={bulkInactiveAccounts} onCancel={handleCancelBulkInactive} - onConfirm={handleConfirmBulkInactive}> + onConfirm={handleConfirmBulkInactive} + >

diff --git a/server/src/database/migrations/20190822214904_create_account_types_table.js b/server/src/database/migrations/20190822214904_create_account_types_table.js index d5a4ae25e..0dd47c741 100644 --- a/server/src/database/migrations/20190822214904_create_account_types_table.js +++ b/server/src/database/migrations/20190822214904_create_account_types_table.js @@ -3,6 +3,7 @@ exports.up = (knex) => { return knex.schema.createTable('account_types', (table) => { table.increments(); table.string('name'); + table.string('key'); table.string('normal'); table.string('root_type'); table.boolean('balance_sheet'); diff --git a/server/src/database/migrations/20190822214905_create_views_table.js b/server/src/database/migrations/20190822214905_create_views_table.js index 5c524ac72..c4e796c90 100644 --- a/server/src/database/migrations/20190822214905_create_views_table.js +++ b/server/src/database/migrations/20190822214905_create_views_table.js @@ -7,6 +7,7 @@ exports.up = function (knex) { table.integer('resource_id').unsigned().references('id').inTable('resources'); table.boolean('favourite'); table.string('roles_logic_expression'); + table.timestamps(); }).raw('ALTER TABLE `VIEWS` AUTO_INCREMENT = 1000').then(() => { return knex.seed.run({ specific: 'seed_views.js', diff --git a/server/src/database/migrations/20200104232647_create_accounts_transactions_table.js b/server/src/database/migrations/20200104232647_create_accounts_transactions_table.js index c85eb42bb..f1d94cc49 100644 --- a/server/src/database/migrations/20200104232647_create_accounts_transactions_table.js +++ b/server/src/database/migrations/20200104232647_create_accounts_transactions_table.js @@ -8,6 +8,8 @@ exports.up = function(knex) { table.string('reference_type'); table.integer('reference_id'); table.integer('account_id').unsigned(); + table.string('contact_type').nullable(); + table.integer('contact_id').unsigned().nullable(); table.string('note'); table.boolean('draft').defaultTo(false); table.integer('user_id').unsigned(); diff --git a/server/src/database/migrations/20200419171451_create_currencies_table.js b/server/src/database/migrations/20200419171451_create_currencies_table.js index 8c18ac5bc..de6201bb7 100644 --- a/server/src/database/migrations/20200419171451_create_currencies_table.js +++ b/server/src/database/migrations/20200419171451_create_currencies_table.js @@ -4,6 +4,7 @@ exports.up = function(knex) { table.increments(); table.string('currency_name'); table.string('currency_code', 4); + table.timestamps(); }).raw('ALTER TABLE `CURRENCIES` AUTO_INCREMENT = 1000'); }; diff --git a/server/src/database/migrations/20200419191832_create_exchange_rates_table.js b/server/src/database/migrations/20200419191832_create_exchange_rates_table.js index 50225d970..e347c42f7 100644 --- a/server/src/database/migrations/20200419191832_create_exchange_rates_table.js +++ b/server/src/database/migrations/20200419191832_create_exchange_rates_table.js @@ -5,6 +5,7 @@ exports.up = function(knex) { table.string('currency_code', 4); table.decimal('exchange_rate'); table.date('date'); + table.timestamps(); }).raw('ALTER TABLE `EXCHANGE_RATES` AUTO_INCREMENT = 1000'); }; diff --git a/server/src/database/migrations/20200607212203_create_customers_table.js b/server/src/database/migrations/20200607212203_create_customers_table.js index 7815f7eea..9f265c6e0 100644 --- a/server/src/database/migrations/20200607212203_create_customers_table.js +++ b/server/src/database/migrations/20200607212203_create_customers_table.js @@ -34,6 +34,7 @@ exports.up = function(knex) { table.text('note'); table.boolean('active').defaultTo(true); + table.timestamps(); }); }; diff --git a/server/src/database/migrations/20200608192614_create_vendors_table.js b/server/src/database/migrations/20200608192614_create_vendors_table.js index 0cdf8541d..63d871261 100644 --- a/server/src/database/migrations/20200608192614_create_vendors_table.js +++ b/server/src/database/migrations/20200608192614_create_vendors_table.js @@ -34,6 +34,8 @@ exports.up = function(knex) { table.text('note'); table.boolean('active').defaultTo(true); + + table.timestamps(); }); }; diff --git a/server/src/database/seeds/seed_account_types.js b/server/src/database/seeds/seed_account_types.js index e9e2ea2b0..a81cc5d0a 100644 --- a/server/src/database/seeds/seed_account_types.js +++ b/server/src/database/seeds/seed_account_types.js @@ -8,6 +8,7 @@ exports.seed = (knex) => { { id: 1, name: 'Fixed Asset', + key: 'fixed_asset', normal: 'debit', root_type: 'asset', balance_sheet: true, @@ -16,6 +17,7 @@ exports.seed = (knex) => { { id: 2, name: 'Current Asset', + key: 'current_asset', normal: 'debit', root_type: 'asset', balance_sheet: true, @@ -24,6 +26,7 @@ exports.seed = (knex) => { { id: 3, name: 'Long Term Liability', + key: 'long_term_liability', normal: 'credit', root_type: 'liability', balance_sheet: false, @@ -32,6 +35,7 @@ exports.seed = (knex) => { { id: 4, name: 'Current Liability', + key: 'current_liability', normal: 'credit', root_type: 'liability', balance_sheet: false, @@ -40,6 +44,7 @@ exports.seed = (knex) => { { id: 5, name: 'Equity', + key: 'equity', normal: 'credit', root_type: 'equity', balance_sheet: true, @@ -48,6 +53,7 @@ exports.seed = (knex) => { { id: 6, name: 'Expense', + key: 'expense', normal: 'debit', root_type: 'expense', balance_sheet: false, @@ -56,6 +62,7 @@ exports.seed = (knex) => { { id: 7, name: 'Income', + key: 'income', normal: 'credit', root_type: 'income', balance_sheet: false, @@ -64,6 +71,7 @@ exports.seed = (knex) => { { id: 8, name: 'Accounts Receivable', + key: 'accounts_receivable', normal: 'debit', root_type: 'asset', balance_sheet: true, @@ -72,6 +80,7 @@ exports.seed = (knex) => { { id: 9, name: 'Accounts Payable', + key: 'accounts_payable', normal: 'credit', root_type: 'liability', balance_sheet: true, diff --git a/server/src/database/seeds/seed_accounts.js b/server/src/database/seeds/seed_accounts.js index fba366c65..881b6215a 100644 --- a/server/src/database/seeds/seed_accounts.js +++ b/server/src/database/seeds/seed_accounts.js @@ -103,7 +103,29 @@ exports.seed = (knex) => { active: 1, index: 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, + }, ]); }); }; diff --git a/server/src/http/controllers/Accounting.js b/server/src/http/controllers/Accounting.js index db349cb14..0b92f13fa 100644 --- a/server/src/http/controllers/Accounting.js +++ b/server/src/http/controllers/Accounting.js @@ -33,6 +33,8 @@ export default { asyncMiddleware(this.manualJournals.handler)); router.post('/make-journal-entries', + this.validateMediaIds, + this.validateContactEntries, this.makeJournalEntries.validation, asyncMiddleware(this.makeJournalEntries.handler)); @@ -41,6 +43,8 @@ export default { asyncMiddleware(this.publishManualJournal.handler)); router.post('/manual-journals/:id', + this.validateMediaIds, + this.validateContactEntries, this.editManualJournal.validation, 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. */ @@ -180,10 +292,13 @@ export default { check('description').optional().trim().escape(), check('status').optional().isBoolean().toBoolean(), check('entries').isArray({ min: 2 }), + check('entries.*.index').exists().isNumeric().toInt(), check('entries.*.credit').optional({ nullable: true }).isNumeric().toInt(), check('entries.*.debit').optional({ nullable: true }).isNumeric().toInt(), check('entries.*.account_id').isNumeric().toInt(), 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.*').exists().isNumeric().toInt(), ], @@ -202,13 +317,17 @@ export default { media_ids: [], ...req.body, }; - const { ManualJournal, Account, Media, MediaLink } = req.models; + const { + ManualJournal, + Account, + MediaLink, + } = req.models; let totalCredit = 0; let totalDebit = 0; const { user } = req; - const errorReasons = []; + const errorReasons = [...(req.errorReasons || [])]; const entries = form.entries.filter((entry) => (entry.credit || entry.debit)); const formattedDate = moment(form.date).format('YYYY-MM-DD'); @@ -229,23 +348,18 @@ export default { if (totalCredit !== totalDebit) { 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() - .whereIn('id', accountsIds) + .whereIn('id', formEntriesAccountsIds) .withGraphFetched('type') .remember(); const storedAccountsIds = accounts.map((account) => account.id); - 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 (difference(accountsIds, storedAccountsIds).length > 0) { + + if (difference(formEntriesAccountsIds, storedAccountsIds).length > 0) { errorReasons.push({ type: 'ACCOUNTS.IDS.NOT.FOUND', code: 200 }); } @@ -255,10 +369,11 @@ export default { if (journalNumber.length > 0) { errorReasons.push({ type: 'JOURNAL.NUMBER.ALREADY.EXISTS', code: 300 }); } + if (errorReasons.length > 0) { return res.status(400).send({ errors: errorReasons }); } - // Save manual journal transaction. + // Save manual journal tansaction. const manualJournal = await ManualJournal.query().insert({ reference: form.reference, transaction_type: 'Journal', @@ -282,6 +397,8 @@ export default { referenceType: 'Journal', referenceId: manualJournal.id, accountNormal: account.type.normal, + contactType: entry.contact_type, + contactId: entry.contact_id, note: entry.note, date: formattedDate, userId: user.id, @@ -356,6 +473,8 @@ export default { check('entries.*.credit').optional({ nullable: true }).isNumeric().toInt(), check('entries.*.debit').optional({ nullable: true }).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('media_ids').optional().isArray(), check('media_ids.*').isNumeric().toInt(), @@ -393,7 +512,7 @@ export default { let totalDebit = 0; const { user } = req; - const errorReasons = []; + const errorReasons = [...(req.errorReasons || [])]; const entries = form.entries.filter((entry) => (entry.credit || entry.debit)); const formattedDate = moment(form.date).format('YYYY-MM-DD'); @@ -431,16 +550,6 @@ export default { if (difference(accountsIds, storedAccountsIds).length > 0) { 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) { return res.status(400).send({ errors: errorReasons }); } diff --git a/server/src/http/controllers/BaseController.js b/server/src/http/controllers/BaseController.js new file mode 100644 index 000000000..17a2ade45 --- /dev/null +++ b/server/src/http/controllers/BaseController.js @@ -0,0 +1,6 @@ + + + +export default class BaseController { + +} \ No newline at end of file diff --git a/server/src/http/controllers/FinancialStatements.js b/server/src/http/controllers/FinancialStatements.js index 4366d9a74..d050ce6f0 100644 --- a/server/src/http/controllers/FinancialStatements.js +++ b/server/src/http/controllers/FinancialStatements.js @@ -5,6 +5,8 @@ import TrialBalanceSheetController from './FinancialStatements/TrialBalanceSheet import GeneralLedgerController from './FinancialStatements/generalLedger'; import JournalSheetController from './FinancialStatements/JournalSheet'; import ProfitLossController from './FinancialStatements/ProfitLossSheet'; +import ReceivableAgingSummary from './FinancialStatements/ReceivableAgingSummary'; +import PayableAgingSummary from './FinancialStatements/PayableAgingSummary'; export default { /** @@ -18,6 +20,8 @@ export default { router.use('/general_ledger', GeneralLedgerController.router()); router.use('/trial_balance_sheet', TrialBalanceSheetController.router()); router.use('/journal', JournalSheetController.router()); + router.use('/receivable_aging_summary', ReceivableAgingSummary.router()); + router.use('/payable_aging_summary', PayableAgingSummary.router()); return router; }, diff --git a/server/src/http/controllers/FinancialStatements/AgingReport.js b/server/src/http/controllers/FinancialStatements/AgingReport.js new file mode 100644 index 000000000..fa31d2275 --- /dev/null +++ b/server/src/http/controllers/FinancialStatements/AgingReport.js @@ -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; + }; + } +} \ No newline at end of file diff --git a/server/src/http/controllers/FinancialStatements/PayableAgingSummary.js b/server/src/http/controllers/FinancialStatements/PayableAgingSummary.js new file mode 100644 index 000000000..4efa6f5ec --- /dev/null +++ b/server/src/http/controllers/FinancialStatements/PayableAgingSummary.js @@ -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), + })), + ], + }, + }); + } +} diff --git a/server/src/http/controllers/FinancialStatements/ReceivableAgingSummary.js b/server/src/http/controllers/FinancialStatements/ReceivableAgingSummary.js new file mode 100644 index 000000000..9bc649be8 --- /dev/null +++ b/server/src/http/controllers/FinancialStatements/ReceivableAgingSummary.js @@ -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), + })), + ], + }, + }); + } +} diff --git a/server/src/models/Account.js b/server/src/models/Account.js index 6d7ac44f3..382a4e0ee 100644 --- a/server/src/models/Account.js +++ b/server/src/models/Account.js @@ -8,11 +8,10 @@ import { } from '@/lib/ViewRolesBuilder'; import CachableQueryBuilder from '@/lib/Cachable/CachableQueryBuilder'; import CachableModel from '@/lib/Cachable/CachableModel'; -import DateSession from '@/models/DateSession'; import { flatToNestedArray } from '@/utils'; import DependencyGraph from '@/lib/DependencyGraph'; -export default class Account extends mixin(TenantModel, [CachableModel, DateSession]) { +export default class Account extends mixin(TenantModel, [CachableModel]) { /** * Table name */ @@ -20,6 +19,13 @@ export default class Account extends mixin(TenantModel, [CachableModel, DateSess return 'accounts'; } + /** + * Timestamps columns. + */ + static get timestamps() { + return ['createdAt', 'updatedAt']; + } + /** * Extend query builder model. */ diff --git a/server/src/models/AccountTransaction.js b/server/src/models/AccountTransaction.js index 42b396fe0..f2802a014 100644 --- a/server/src/models/AccountTransaction.js +++ b/server/src/models/AccountTransaction.js @@ -3,10 +3,9 @@ import moment from 'moment'; import TenantModel from '@/models/TenantModel'; import CachableQueryBuilder from '@/lib/Cachable/CachableQueryBuilder'; 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 */ @@ -14,6 +13,13 @@ export default class AccountTransaction extends mixin(TenantModel, [CachableMode return 'accounts_transactions'; } + /** + * Timestamps columns. + */ + static get timestamps() { + return ['createdAt']; + } + /** * Extend query builder model. */ @@ -69,6 +75,12 @@ export default class AccountTransaction extends mixin(TenantModel, [CachableMode query.sum('debit as debit'); query.groupBy('account_id'); }, + filterContactType(query, contactType) { + query.where('contact_type', contactType); + }, + filterContactIds(query, contactIds) { + query.whereIn('contact_id', contactIds); + }, }; } diff --git a/server/src/models/AccountType.js b/server/src/models/AccountType.js index a4a0eca42..06b9a0690 100644 --- a/server/src/models/AccountType.js +++ b/server/src/models/AccountType.js @@ -1,8 +1,9 @@ // import path from 'path'; -import { Model } from 'objection'; +import { Model, mixin } from 'objection'; 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 */ diff --git a/server/src/models/Currency.js b/server/src/models/Currency.js index 33f534216..7a05a03b2 100644 --- a/server/src/models/Currency.js +++ b/server/src/models/Currency.js @@ -7,4 +7,11 @@ export default class Currency extends TenantModel { static get tableName() { return 'currencies'; } + + /** + * Timestamps columns. + */ + static get timestamps() { + return ['createdAt', 'updatedAt']; + } } diff --git a/server/src/models/Customer.js b/server/src/models/Customer.js index 3c5d88365..12b11d272 100644 --- a/server/src/models/Customer.js +++ b/server/src/models/Customer.js @@ -8,4 +8,22 @@ export default class Customer extends TenantModel { static get tableName() { return 'customers'; } + + /** + * Model timestamps. + */ + static get timestamps() { + return ['createdAt', 'updatedAt']; + } + + /** + * Model modifiers. + */ + static get modifiers() { + return { + filterCustomerIds(query, customerIds) { + query.whereIn('id', customerIds); + }, + }; + } } diff --git a/server/src/models/ExchangeRate.js b/server/src/models/ExchangeRate.js index 55487e4aa..08820091f 100644 --- a/server/src/models/ExchangeRate.js +++ b/server/src/models/ExchangeRate.js @@ -9,4 +9,11 @@ export default class ExchangeRate extends TenantModel { static get tableName() { return 'exchange_rates'; } + + /** + * Timestamps columns. + */ + static get timestamps() { + return ['createdAt', 'updatedAt']; + } } \ No newline at end of file diff --git a/server/src/models/Expense.js b/server/src/models/Expense.js index 567a3c924..b731e221d 100644 --- a/server/src/models/Expense.js +++ b/server/src/models/Expense.js @@ -10,10 +10,20 @@ export default class Expense extends TenantModel { return 'expenses_transactions'; } + /** + * Account transaction reference type. + */ static get referenceType() { return 'Expense'; } + /** + * Model timestamps. + */ + static get timestamps() { + return ['createdAt', 'updatedAt']; + } + /** * Model modifiers. */ diff --git a/server/src/models/Item.js b/server/src/models/Item.js index ee05ab169..a6dd638af 100644 --- a/server/src/models/Item.js +++ b/server/src/models/Item.js @@ -12,6 +12,13 @@ export default class Item extends TenantModel { return 'items'; } + /** + * Model timestamps. + */ + static get timestamps() { + return ['createdAt', 'updatedAt']; + } + /** * Model modifiers. */ diff --git a/server/src/models/ManualJournal.js b/server/src/models/ManualJournal.js index 2ec212a2c..8c24f08a3 100644 --- a/server/src/models/ManualJournal.js +++ b/server/src/models/ManualJournal.js @@ -9,6 +9,13 @@ export default class ManualJournal extends TenantModel { return 'manual_journals'; } + /** + * Model timestamps. + */ + static get timestamps() { + return ['createdAt', 'updatedAt']; + } + /** * Relationship mapping. */ diff --git a/server/src/models/Model.js b/server/src/models/Model.js index 482d97b73..4beb45239 100644 --- a/server/src/models/Model.js +++ b/server/src/models/Model.js @@ -1,9 +1,14 @@ -import { Model } from 'objection'; +import { Model, mixin } from 'objection'; import { snakeCase } from 'lodash'; import { mapKeysDeep } from '@/utils'; 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() { return this.knexBindInstance; diff --git a/server/src/models/TenantUser.js b/server/src/models/TenantUser.js index 48bdc3be7..b39011e41 100644 --- a/server/src/models/TenantUser.js +++ b/server/src/models/TenantUser.js @@ -1,10 +1,9 @@ import bcrypt from 'bcryptjs'; -import { Model, mixin } from 'objection'; +import { Model } from 'objection'; import TenantModel from '@/models/TenantModel'; -import DateSession from '@/models/DateSession'; // import PermissionsService from '@/services/PermissionsService'; -export default class TenantUser extends mixin(TenantModel, [DateSession]) { +export default class TenantUser extends TenantModel { /** * Virtual attributes. */ @@ -19,6 +18,13 @@ export default class TenantUser extends mixin(TenantModel, [DateSession]) { return 'users'; } + /** + * Timestamps columns. + */ + static get timestamps() { + return ['createdAt', 'updatedAt']; + } + /** * Relationship mapping. */ diff --git a/server/src/models/Vendor.js b/server/src/models/Vendor.js index 9be8756ac..e7bf4c815 100644 --- a/server/src/models/Vendor.js +++ b/server/src/models/Vendor.js @@ -8,4 +8,11 @@ export default class Vendor extends TenantModel { static get tableName() { return 'vendors'; } + + /** + * Model timestamps. + */ + static get timestamps() { + return ['createdAt', 'updatedAt']; + } } diff --git a/server/src/models/View.js b/server/src/models/View.js index f920017fd..a834d2599 100644 --- a/server/src/models/View.js +++ b/server/src/models/View.js @@ -11,6 +11,13 @@ export default class View extends mixin(TenantModel, [CachableModel]) { return 'views'; } + /** + * Model timestamps. + */ + static get timestamps() { + return ['createdAt', 'updatedAt']; + } + /** * Extend query builder model. */ diff --git a/server/src/services/Accounting/JournalPoster.js b/server/src/services/Accounting/JournalPoster.js index 3f5077f9b..380ba4e33 100644 --- a/server/src/services/Accounting/JournalPoster.js +++ b/server/src/services/Accounting/JournalPoster.js @@ -3,11 +3,10 @@ import moment from 'moment'; import JournalEntry from '@/services/Accounting/JournalEntry'; import AccountTransaction from '@/models/AccountTransaction'; import AccountBalance from '@/models/AccountBalance'; -import {promiseSerial} from '@/utils'; +import { promiseSerial } from '@/utils'; import Account from '@/models/Account'; import NestedSet from '../../collection/NestedSet'; - export default class JournalPoster { /** * 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 - */ debit(entryModel) { @@ -78,7 +77,11 @@ export default class JournalPoster { * @private */ _setAccountBalanceChange({ - accountId, accountNormal, debit, credit, entryType + accountId, + accountNormal, + debit, + credit, + entryType, }) { if (!this.balancesChange[accountId]) { this.balancesChange[accountId] = 0; @@ -86,9 +89,9 @@ export default class JournalPoster { let change = 0; if (accountNormal === 'credit') { - change = (entryType === 'credit') ? credit : -1 * debit; + change = entryType === 'credit' ? credit : -1 * debit; } else if (accountNormal === 'debit') { - change = (entryType === 'debit') ? debit : -1 * credit; + change = entryType === 'debit' ? debit : -1 * credit; } this.balancesChange[accountId] += change; } @@ -132,9 +135,9 @@ export default class JournalPoster { const method = balance.amount < 0 ? 'decrement' : 'increment'; // Detarmine if the account balance is already exists or not. - const foundAccBalance = balanceAccounts.some((account) => ( - account && account.account_id === balance.account_id - )); + const foundAccBalance = balanceAccounts.some( + (account) => account && account.account_id === balance.account_id + ); if (foundAccBalance) { const query = AccountBalance.tenant() @@ -152,9 +155,7 @@ export default class JournalPoster { balanceInsertOpers.push(query); } }); - await Promise.all([ - ...balanceUpdateOpers, ...balanceInsertOpers, - ]); + await Promise.all([...balanceUpdateOpers, ...balanceInsertOpers]); } /** @@ -164,11 +165,23 @@ export default class JournalPoster { const saveOperations = []; this.entries.forEach((entry) => { - const oper = AccountTransaction.tenant().query().insert({ - accountId: entry.account, - ...pick(entry, ['credit', 'debit', 'transactionType', 'date', 'userId', - 'referenceType', 'referenceId', 'note']), - }); + const oper = AccountTransaction.tenant() + .query() + .insert({ + accountId: entry.account, + ...pick(entry, [ + 'credit', + 'debit', + 'transactionType', + 'date', + 'userId', + 'referenceType', + 'referenceId', + 'note', + 'contactId', + 'contactType', + ]), + }); saveOperations.push(() => oper); }); await promiseSerial(saveOperations); @@ -195,15 +208,16 @@ export default class JournalPoster { } /** - * + * * @param {Array} ids - */ removeEntries(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 targetIds = ids.length <= 0 ? this.entries.map((e) => e.id) : ids; + const removeEntries = this.entries.filter( + (e) => targetIds.indexOf(e.id) !== -1 + ); - this.entries = this.entries - .filter(e => targetIds.indexOf(e.id) === -1) + this.entries = this.entries.filter((e) => targetIds.indexOf(e.id) === -1); removeEntries.forEach((entry) => { entry.credit = -1 * entry.credit; @@ -211,9 +225,7 @@ export default class JournalPoster { this.setAccountBalanceChange(entry, entry.accountNormal); }); - this.deletedEntriesIds.push( - ...removeEntries.map(entry => entry.id), - ); + this.deletedEntriesIds.push(...removeEntries.map((entry) => entry.id)); } /** @@ -221,7 +233,8 @@ export default class JournalPoster { */ async deleteEntries() { if (this.deletedEntriesIds.length > 0) { - await AccountTransaction.tenant().query() + await AccountTransaction.tenant() + .query() .whereIn('id', this.deletedEntriesIds) .delete(); } @@ -238,15 +251,17 @@ export default class JournalPoster { this.entries.forEach((entry) => { // Can not continue if not before or event same closing date. - if ((!momentClosingDate.isAfter(entry.date, dateType) - && !momentClosingDate.isSame(entry.date, dateType)) - || (entry.account !== accountId && accountId)) { + if ( + (!momentClosingDate.isAfter(entry.date, dateType) && + !momentClosingDate.isSame(entry.date, dateType)) || + (entry.account !== accountId && accountId) + ) { return; } 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') { - closingBalance += (entry.debit) ? entry.debit : -1 * entry.credit; + closingBalance += entry.debit ? entry.debit : -1 * entry.credit; } }); return closingBalance; @@ -254,21 +269,27 @@ export default class JournalPoster { /** * Retrieve the given account balance with dependencies accounts. - * @param {Number} accountId - * @param {Date} closingDate - * @param {String} dateType + * @param {Number} accountId + * @param {Date} closingDate + * @param {String} dateType * @return {Number} */ getAccountBalance(accountId, closingDate, dateType) { const accountNode = this.accountsGraph.getNodeData(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; [...depAccounts, accountNode].forEach((account) => { // if (!this.accountsBalanceTable[account.id]) { - const closingBalance = this.getClosingBalance(account.id, closingDate, dateType); - this.accountsBalanceTable[account.id] = closingBalance; + const closingBalance = this.getClosingBalance( + account.id, + closingDate, + dateType + ); + this.accountsBalanceTable[account.id] = closingBalance; // } balance += this.accountsBalanceTable[account.id]; }); @@ -288,9 +309,11 @@ export default class JournalPoster { balance: 0, }; this.entries.forEach((entry) => { - if ((!momentClosingDate.isAfter(entry.date, dateType) - && !momentClosingDate.isSame(entry.date, dateType)) - || (entry.account !== accountId && accountId)) { + if ( + (!momentClosingDate.isAfter(entry.date, dateType) && + !momentClosingDate.isSame(entry.date, dateType)) || + (entry.account !== accountId && accountId) + ) { return; } result.credit += entry.credit; @@ -307,20 +330,27 @@ export default class JournalPoster { /** * Retrieve trial balance of the given account with depends. - * @param {Number} accountId - * @param {Date} closingDate - * @param {String} dateType + * @param {Number} accountId + * @param {Date} closingDate + * @param {String} dateType * @return {Number} - */ + */ + getTrialBalanceWithDepands(accountId, closingDate, dateType) { const accountNode = this.accountsGraph.getNodeData(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 }; [...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.debit += _trialBalance.debit; @@ -329,6 +359,85 @@ export default class JournalPoster { 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. * @param {Array} entries - @@ -338,8 +447,10 @@ export default class JournalPoster { this.entries.push({ ...entry, account: entry.account ? entry.account.id : entry.accountId, - accountNormal: (entry.account && entry.account.type) - ? entry.account.type.normal : entry.accountNormal, + accountNormal: + entry.account && entry.account.type + ? entry.account.type.normal + : entry.accountNormal, }); }); } diff --git a/server/src/system/models/SystemUser.js b/server/src/system/models/SystemUser.js index b2b393418..38fef312b 100644 --- a/server/src/system/models/SystemUser.js +++ b/server/src/system/models/SystemUser.js @@ -1,11 +1,10 @@ import { Model, mixin } from 'objection'; import bcrypt from 'bcryptjs'; import SystemModel from '@/system/models/SystemModel'; -import DateSession from '@/models/DateSession'; 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. */ @@ -13,6 +12,13 @@ export default class SystemUser extends mixin(SystemModel, [DateSession, UserSub return 'users'; } + /** + * Timestamps columns. + */ + static get timestamps() { + return ['createdAt', 'updatedAt']; + } + /** * Relationship mapping. */ diff --git a/server/tests/routes/accounting.test.js b/server/tests/routes/accounting.test.js index 34734a75b..fa1697ff0 100644 --- a/server/tests/routes/accounting.test.js +++ b/server/tests/routes/accounting.test.js @@ -27,11 +27,13 @@ describe('routes: `/accounting`', () => { reference: 'ASC', entries: [ { + index: 1, credit: 0, debit: 0, account_id: account.id, }, { + index: 2, credit: 0, debit: 0, account_id: account.id, @@ -56,11 +58,13 @@ describe('routes: `/accounting`', () => { journal_number: '123', entries: [ { + index: 1, credit: 1000, debit: 0, account_id: account.id, }, { + index: 2, credit: 0, debit: 500, account_id: account.id, @@ -88,11 +92,13 @@ describe('routes: `/accounting`', () => { journal_number: manualJournal.journalNumber, entries: [ { + index: 1, credit: 1000, debit: 0, account_id: account.id, }, { + index: 2, credit: 0, debit: 1000, account_id: account.id, @@ -117,11 +123,13 @@ describe('routes: `/accounting`', () => { journal_number: '123', entries: [ { + index: 1, credit: 1000, debit: 0, account_id: 12, }, { + index: 2, credit: 0, debit: 1000, account_id: 12, @@ -149,11 +157,13 @@ describe('routes: `/accounting`', () => { journal_number: '1000', entries: [ { + index: 1, credit: null, debit: 0, account_id: account1.id, }, { + index: 2, credit: null, debit: 0, account_id: account2.id, @@ -164,10 +174,88 @@ describe('routes: `/accounting`', () => { expect(res.status).equals(400); expect(res.body.errors).include.something.that.deep.equal({ type: 'CREDIT.DEBIT.SUMATION.SHOULD.NOT.EQUAL.ZERO', + 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 () => { const account1 = await tenantFactory.create('account'); const account2 = await tenantFactory.create('account'); @@ -183,10 +271,12 @@ describe('routes: `/accounting`', () => { description: 'Description here.', entries: [ { + index: 1, credit: 1000, account_id: account1.id, }, { + index: 2, debit: 1000, account_id: account2.id, }, @@ -194,7 +284,6 @@ describe('routes: `/accounting`', () => { }); const foundManualJournal = await ManualJournal.tenant().query(); - expect(foundManualJournal.length).equals(1); expect(foundManualJournal[0].reference).equals('2000'); @@ -221,11 +310,13 @@ describe('routes: `/accounting`', () => { memo: 'Description here.', entries: [ { + index: 1, credit: 1000, account_id: account1.id, note: 'First note', }, { + index: 2, debit: 1000, account_id: account2.id, note: 'Second note', diff --git a/server/tests/routes/accounts.test.js b/server/tests/routes/accounts.test.js index 6c9a9418c..3c45399ce 100644 --- a/server/tests/routes/accounts.test.js +++ b/server/tests/routes/accounts.test.js @@ -10,7 +10,7 @@ import { } from '~/dbInit'; -describe('routes: /accounts/', () => { +describe.only('routes: /accounts/', () => { describe('POST `/accounts`', () => { it('Should `name` be required.', async () => { 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 res = await request() .post('/api/accounts') @@ -204,6 +204,8 @@ describe('routes: /accounts/', () => { code: '123', }); + console.log(res.body); + expect(res.status).equals(200); }); }); diff --git a/server/tests/routes/payable_aging.test.js b/server/tests/routes/payable_aging.test.js new file mode 100644 index 000000000..e69de29bb diff --git a/server/tests/routes/receivable_aging.test.js b/server/tests/routes/receivable_aging.test.js new file mode 100644 index 000000000..a42037548 --- /dev/null +++ b/server/tests/routes/receivable_aging.test.js @@ -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); + }) +}); diff --git a/server/tests/routes/vendors.test.js b/server/tests/routes/vendors.test.js index 0a69a3e1d..6f1ff96dc 100644 --- a/server/tests/routes/vendors.test.js +++ b/server/tests/routes/vendors.test.js @@ -10,7 +10,7 @@ import { } from '~/dbInit'; import Vendor from '@/models/Vendor'; -describe.only('route: `/vendors`', () => { +describe('route: `/vendors`', () => { describe('POST: `/vendors`', () => { it('Should response unauthorized in case the user was not logged in.', async () => { const res = await request()