From d47633b8eac6d33a73f0b30fd0c90f2cd5acf8a9 Mon Sep 17 00:00:00 2001 From: "a.bouhuolia" Date: Mon, 31 May 2021 13:17:02 +0200 Subject: [PATCH] feat: Inventory item details report. feat: Cash flow statement report. --- client/package.json | 1 + .../NumberFormatFields.js | 2 +- client/src/config/financialReportsMenu.js | 48 +- .../CashFlowStatement/CashFlowStatement.js | 88 ++ .../CashFlowStatementActionsBar.js | 134 +++ .../CashFlowStatementGeneralPanel.js | 20 + .../CashFlowStatementHeader.js | 102 +++ .../CashFlowStatementProvider.js | 45 ++ .../CashFlowStatementTable.js | 63 ++ .../CashFlowStatement/components.js | 29 + .../CashFlowStatement/utils.js | 68 ++ .../withCashFlowStatement.js | 12 + .../withCashFlowStatementActions.js | 9 + .../CustomersTransactions/components.js | 1 + .../InventoryItemDetails.js | 83 ++ .../InventoryItemDetailsActionsBar.js | 131 +++ .../InventoryItemDetailsHeader.js | 99 +++ .../InventoryItemDetailsHeaderGeneralPanel.js | 13 + .../InventoryItemDetailsProvider.js | 43 + .../InventoryItemDetailsTable.js | 59 ++ .../InventoryItemDetails/components.js | 29 + .../InventoryItemDetails/utils.js | 42 + .../withInventoryItemDetails.js | 15 + .../withInventoryItemDetailsActions.js | 9 + client/src/hooks/query/financialReports.js | 60 ++ client/src/hooks/query/types.js | 4 +- client/src/lang/en/index.js | 3 + client/src/routes/dashboard.js | 59 +- .../financialStatements.actions.js | 26 + .../financialStatements.reducer.js | 11 + .../financialStatements.selectors.js | 28 + .../financialStatements.types.js | 2 + .../CashFlowStatement.scss | 50 ++ .../InventoryItemDetails.scss | 77 ++ server/package.json | 3 + server/src/api/controllers/BaseController.ts | 20 +- .../api/controllers/FinancialStatements.ts | 10 + .../FinancialStatements/CashFlow/CashFlow.ts | 113 +++ .../InventoryDetails/index.ts | 120 +++ server/src/data/AccountTypes.ts | 6 +- ...create_inventory_transaction_meta_table.js | 11 + ...create_inventory_cost_lot_tracker_table.js | 1 + server/src/interfaces/Account.ts | 8 +- server/src/interfaces/CashFlow.ts | 190 +++++ server/src/interfaces/InventoryDetails.ts | 76 ++ server/src/interfaces/InventoryTransaction.ts | 71 +- server/src/interfaces/Ledger.ts | 5 +- server/src/interfaces/Table.ts | 12 +- .../src/interfaces/TransactionsByContacts.ts | 4 +- server/src/interfaces/index.ts | 4 +- server/src/models/AccountTransaction.js | 17 +- server/src/models/InventoryTransaction.js | 66 ++ server/src/models/InventoryTransactionMeta.js | 29 + server/src/services/Accounting/Ledger.ts | 77 +- .../BalanceSheet/BalanceSheet.ts | 3 +- .../BalanceSheet/BalanceSheetService.ts | 4 +- .../FinancialStatements/CashFlow/CashFlow.ts | 761 ++++++++++++++++++ .../CashFlow/CashFlowDatePeriods.ts | 410 ++++++++++ .../CashFlow/CashFlowRepository.ts | 149 ++++ .../CashFlow/CashFlowService.ts | 144 ++++ .../CashFlow/CashFlowTable.ts | 365 +++++++++ .../FinancialStatements/CashFlow/schema.ts | 75 ++ .../CustomerBalanceSummaryRepository.ts | 69 ++ .../CustomerBalanceSummaryService.ts | 82 +- .../FinancialStatements/FinancialSheet.ts | 65 +- .../InventoryDetails/InventoryDetails.ts | 407 ++++++++++ .../InventoryDetailsRepository.ts | 87 ++ .../InventoryDetailsService.ts | 81 ++ .../InventoryDetails/InventoryDetailsTable.ts | 183 +++++ .../TransactionsByCustomersRepository.ts | 92 +++ .../TransactionsByCustomersService.ts | 101 +-- .../TransactionsByVendorRepository.ts | 92 +++ .../TransactionsByVendorService.ts | 111 +-- .../VendorBalanceSummaryRepository.ts | 69 ++ .../VendorBalanceSummaryService.ts | 83 +- server/src/services/Inventory/Inventory.ts | 7 +- .../Inventory/InventoryAverageCost.ts | 2 +- server/src/services/Sales/SalesInvoices.ts | 1 + server/src/utils/deepdash.ts | 52 ++ server/src/utils/index.ts | 47 ++ 80 files changed, 5474 insertions(+), 376 deletions(-) create mode 100644 client/src/containers/FinancialStatements/CashFlowStatement/CashFlowStatement.js create mode 100644 client/src/containers/FinancialStatements/CashFlowStatement/CashFlowStatementActionsBar.js create mode 100644 client/src/containers/FinancialStatements/CashFlowStatement/CashFlowStatementGeneralPanel.js create mode 100644 client/src/containers/FinancialStatements/CashFlowStatement/CashFlowStatementHeader.js create mode 100644 client/src/containers/FinancialStatements/CashFlowStatement/CashFlowStatementProvider.js create mode 100644 client/src/containers/FinancialStatements/CashFlowStatement/CashFlowStatementTable.js create mode 100644 client/src/containers/FinancialStatements/CashFlowStatement/components.js create mode 100644 client/src/containers/FinancialStatements/CashFlowStatement/utils.js create mode 100644 client/src/containers/FinancialStatements/CashFlowStatement/withCashFlowStatement.js create mode 100644 client/src/containers/FinancialStatements/CashFlowStatement/withCashFlowStatementActions.js create mode 100644 client/src/containers/FinancialStatements/InventoryItemDetails/InventoryItemDetails.js create mode 100644 client/src/containers/FinancialStatements/InventoryItemDetails/InventoryItemDetailsActionsBar.js create mode 100644 client/src/containers/FinancialStatements/InventoryItemDetails/InventoryItemDetailsHeader.js create mode 100644 client/src/containers/FinancialStatements/InventoryItemDetails/InventoryItemDetailsHeaderGeneralPanel.js create mode 100644 client/src/containers/FinancialStatements/InventoryItemDetails/InventoryItemDetailsProvider.js create mode 100644 client/src/containers/FinancialStatements/InventoryItemDetails/InventoryItemDetailsTable.js create mode 100644 client/src/containers/FinancialStatements/InventoryItemDetails/components.js create mode 100644 client/src/containers/FinancialStatements/InventoryItemDetails/utils.js create mode 100644 client/src/containers/FinancialStatements/InventoryItemDetails/withInventoryItemDetails.js create mode 100644 client/src/containers/FinancialStatements/InventoryItemDetails/withInventoryItemDetailsActions.js create mode 100644 client/src/style/pages/FinancialStatements/CashFlowStatement.scss create mode 100644 client/src/style/pages/FinancialStatements/InventoryItemDetails.scss create mode 100644 server/src/api/controllers/FinancialStatements/CashFlow/CashFlow.ts create mode 100644 server/src/api/controllers/FinancialStatements/InventoryDetails/index.ts create mode 100644 server/src/database/migrations/20200722164255_create_inventory_transaction_meta_table.js create mode 100644 server/src/interfaces/CashFlow.ts create mode 100644 server/src/interfaces/InventoryDetails.ts create mode 100644 server/src/models/InventoryTransactionMeta.js create mode 100644 server/src/services/FinancialStatements/CashFlow/CashFlow.ts create mode 100644 server/src/services/FinancialStatements/CashFlow/CashFlowDatePeriods.ts create mode 100644 server/src/services/FinancialStatements/CashFlow/CashFlowRepository.ts create mode 100644 server/src/services/FinancialStatements/CashFlow/CashFlowService.ts create mode 100644 server/src/services/FinancialStatements/CashFlow/CashFlowTable.ts create mode 100644 server/src/services/FinancialStatements/CashFlow/schema.ts create mode 100644 server/src/services/FinancialStatements/CustomerBalanceSummary/CustomerBalanceSummaryRepository.ts create mode 100644 server/src/services/FinancialStatements/InventoryDetails/InventoryDetails.ts create mode 100644 server/src/services/FinancialStatements/InventoryDetails/InventoryDetailsRepository.ts create mode 100644 server/src/services/FinancialStatements/InventoryDetails/InventoryDetailsService.ts create mode 100644 server/src/services/FinancialStatements/InventoryDetails/InventoryDetailsTable.ts create mode 100644 server/src/services/FinancialStatements/TransactionsByCustomer/TransactionsByCustomersRepository.ts create mode 100644 server/src/services/FinancialStatements/TransactionsByVendor/TransactionsByVendorRepository.ts create mode 100644 server/src/services/FinancialStatements/VendorBalanceSummary/VendorBalanceSummaryRepository.ts create mode 100644 server/src/utils/deepdash.ts diff --git a/client/package.json b/client/package.json index 61f6187e5..e1aabffbe 100644 --- a/client/package.json +++ b/client/package.json @@ -62,6 +62,7 @@ "postcss-normalize": "8.0.1", "postcss-preset-env": "6.7.0", "postcss-safe-parser": "4.0.1", + "ramda": "^0.27.1", "react": "^16.12.0", "react-app-polyfill": "^1.0.6", "react-body-classname": "^1.3.1", diff --git a/client/src/components/NumberFormatDropdown/NumberFormatFields.js b/client/src/components/NumberFormatDropdown/NumberFormatFields.js index 169a5a5e9..776024de3 100644 --- a/client/src/components/NumberFormatDropdown/NumberFormatFields.js +++ b/client/src/components/NumberFormatDropdown/NumberFormatFields.js @@ -73,7 +73,7 @@ export default function NumberFormatFields({}) { label={} helperText={} intent={inputIntent({ error, touched })} - className={classNames(CLASSES.FILL)} + className={classNames('form-group--money-format', CLASSES.FILL)} > { + const _filter = { + ...filter, + fromDate: moment(filter.fromDate).format('YYYY-MM-DD'), + toDate: moment(filter.toDate).format('YYYY-MM-DD'), + }; + setFilter({ ..._filter }); + }; + + // Handle format number submit. + const handleNumberFormatSubmit = (values) => { + setFilter({ + ...filter, + numberFormat: values, + }); + }; + + useEffect( + () => () => { + toggleCashFlowStatementFilterDrawer(false); + }, + [toggleCashFlowStatementFilterDrawer], + ); + + return ( + + + + + + +
+ +
+
+
+
+ ); +} + +export default compose( + withSettings(({ organizationSettings }) => ({ + organizationName: organizationSettings?.name, + })), + withCashFlowStatementActions, +)(CashFlowStatement); diff --git a/client/src/containers/FinancialStatements/CashFlowStatement/CashFlowStatementActionsBar.js b/client/src/containers/FinancialStatements/CashFlowStatement/CashFlowStatementActionsBar.js new file mode 100644 index 000000000..c8c487ea4 --- /dev/null +++ b/client/src/containers/FinancialStatements/CashFlowStatement/CashFlowStatementActionsBar.js @@ -0,0 +1,134 @@ +import React from 'react'; +import { + NavbarGroup, + NavbarDivider, + Button, + Classes, + Popover, + PopoverInteractionKind, + Position, +} from '@blueprintjs/core'; +import { FormattedMessage as T } from 'react-intl'; +import classNames from 'classnames'; + +import { Icon } from 'components'; +import DashboardActionsBar from 'components/Dashboard/DashboardActionsBar'; +import NumberFormatDropdown from 'components/NumberFormatDropdown'; + +import { useCashFlowStatementContext } from './CashFlowStatementProvider'; +import withCashFlowStatement from './withCashFlowStatement'; +import withCashFlowStatementActions from './withCashFlowStatementActions'; + +import { compose, saveInvoke } from 'utils'; + +/** + * Cash flow statement actions bar. + */ +function CashFlowStatementActionsBar({ + //#withCashFlowStatement + isFilterDrawerOpen, + + //#withCashStatementActions + toggleCashFlowStatementFilterDrawer, + + //#ownProps + numberFormat, + onNumberFormatSubmit, +}) { + const { isCashFlowLoading, refetchCashFlow } = useCashFlowStatementContext(); + + // Handle filter toggle click. + const handleFilterToggleClick = () => { + toggleCashFlowStatementFilterDrawer(); + }; + + // Handle recalculate report button. + const handleRecalculateReport = () => { + refetchCashFlow(); + }; + + // handle number format form submit. + const handleNumberFormatSubmit = (values) => + saveInvoke(onNumberFormatSubmit, values); + + return ( + + + + + + + + + ); +} + +export default compose( + withCashFlowStatement(({ cashFlowStatementDrawerFilter }) => ({ + isFilterDrawerOpen: cashFlowStatementDrawerFilter, + })), + withCashFlowStatementActions, +)(CashFlowStatementHeader); diff --git a/client/src/containers/FinancialStatements/CashFlowStatement/CashFlowStatementProvider.js b/client/src/containers/FinancialStatements/CashFlowStatement/CashFlowStatementProvider.js new file mode 100644 index 000000000..c7ffa3ea8 --- /dev/null +++ b/client/src/containers/FinancialStatements/CashFlowStatement/CashFlowStatementProvider.js @@ -0,0 +1,45 @@ +import React from 'react'; +import FinancialReportPage from '../FinancialReportPage'; +import { useCashFlowStatementReport } from 'hooks/query'; +import { transformFilterFormToQuery } from '../common'; + +const CashFLowStatementContext = React.createContext(); + +/** + * Cash flow statement provider. + */ +function CashFlowStatementProvider({ filter, ...props }) { + // transforms the given filter to query. + const query = React.useMemo( + () => transformFilterFormToQuery(filter), + [filter], + ); + + // fetch the cash flow statement report. + const { + data: cashFlowStatement, + isFetching: isCashFlowFetching, + isLoading: isCashFlowLoading, + refetch: refetchCashFlow, + } = useCashFlowStatementReport(query, { keepPreviousData: true }); + + const provider = { + cashFlowStatement, + isCashFlowFetching, + isCashFlowLoading, + refetchCashFlow, + query, + filter, + }; + + return ( + + + + ); +} + +const useCashFlowStatementContext = () => + React.useContext(CashFLowStatementContext); + +export { CashFlowStatementProvider, useCashFlowStatementContext }; diff --git a/client/src/containers/FinancialStatements/CashFlowStatement/CashFlowStatementTable.js b/client/src/containers/FinancialStatements/CashFlowStatement/CashFlowStatementTable.js new file mode 100644 index 000000000..81425a88a --- /dev/null +++ b/client/src/containers/FinancialStatements/CashFlowStatement/CashFlowStatementTable.js @@ -0,0 +1,63 @@ +import React, { useMemo } from 'react'; +import { useIntl } from 'react-intl'; + +import { DataTable } from 'components'; +import FinancialSheet from 'components/FinancialSheet'; +import { useCashFlowStatementColumns } from './components'; +import { useCashFlowStatementContext } from './CashFlowStatementProvider'; + +import { defaultExpanderReducer } from 'utils'; + +/** + * Cash flow statement table. + */ +export default function CashFlowStatementTable({ + // #ownProps + companyName, +}) { + const { formatMessage } = useIntl(); + + const { + cashFlowStatement: { tableRows }, + isCashFlowLoading, + query, + } = useCashFlowStatementContext(); + + const columns = useCashFlowStatementColumns(); + + const expandedRows = useMemo( + () => defaultExpanderReducer(tableRows, 4), + [tableRows], + ); + + const rowClassNames = (row) => { + return [ + `row-type--${row.original.rowTypes}`, + `row-type--${row.original.id}`, + ]; + }; + + return ( + + + + ); +} diff --git a/client/src/containers/FinancialStatements/CashFlowStatement/components.js b/client/src/containers/FinancialStatements/CashFlowStatement/components.js new file mode 100644 index 000000000..91c328871 --- /dev/null +++ b/client/src/containers/FinancialStatements/CashFlowStatement/components.js @@ -0,0 +1,29 @@ +import React from 'react'; +import { If } from 'components'; + +import { dynamicColumns } from './utils'; +import { useCashFlowStatementContext } from './CashFlowStatementProvider'; +import FinancialLoadingBar from '../FinancialLoadingBar'; + +/** + * Retrieve cash flow statement columns. + */ +export const useCashFlowStatementColumns = () => { + const { + cashFlowStatement: { columns, data }, + } = useCashFlowStatementContext(); + + return React.useMemo(() => dynamicColumns(columns, data), [columns, data]); +}; + +/** + * Cash flow statement loading bar. + */ +export function CashFlowStatementLoadingBar() { + const { isCashFlowLoading } = useCashFlowStatementContext(); + return ( + + + + ); +} diff --git a/client/src/containers/FinancialStatements/CashFlowStatement/utils.js b/client/src/containers/FinancialStatements/CashFlowStatement/utils.js new file mode 100644 index 000000000..16c5a710a --- /dev/null +++ b/client/src/containers/FinancialStatements/CashFlowStatement/utils.js @@ -0,0 +1,68 @@ +import * as R from 'ramda'; +import { CellTextSpan } from 'components/Datatable/Cells'; +import { getColumnWidth } from 'utils'; +import { formatMessage } from 'services/intl'; + +/** + * Account name column mapper. + */ +const accountNameMapper = (column) => ({ + id: column.key, + key: column.key, + Header: formatMessage({ id: 'account_name' }), + accessor: 'cells[0].value', + className: 'account_name', + textOverview: true, + width: 240, + disableSortBy: true, +}); + +/** + * Date range columns mapper. + */ +const dateRangeMapper = (data, index, column) => ({ + id: column.key, + Header: column.label, + key: column.key, + accessor: `cells[${index}].value`, + width: getColumnWidth(data, `cells.${index}.value`, { minWidth: 100 }), + className: `date-period ${column.key}`, + disableSortBy: true, +}); + +/** + * Total column mapper. + */ +const totalMapper = (data, index, column) => ({ + key: 'total', + Header: formatMessage({ id: 'total' }), + accessor: `cells[${index}].value`, + className: 'total', + textOverview: true, + Cell: CellTextSpan, + width: getColumnWidth(data, `cells[${index}].value`, { minWidth: 100 }), + disableSortBy: true, +}); + + +/** + * Detarmines the given string starts with `date-range` string. + */ +const isMatchesDateRange = (r) => R.match(/^date-range/g, r).length > 0; + +/** + * Cash flow dynamic columns. + */ +export const dynamicColumns = (columns, data) => { + const mapper = (column, index) => { + return R.compose( + R.when( + R.pathSatisfies(isMatchesDateRange, ['key']), + R.curry(dateRangeMapper)(data, index), + ), + R.when(R.pathEq(['key'], 'name'), accountNameMapper), + R.when(R.pathEq(['key'], 'total'), R.curry(totalMapper)(data, index)), + )(column); + }; + return columns.map(mapper); +}; diff --git a/client/src/containers/FinancialStatements/CashFlowStatement/withCashFlowStatement.js b/client/src/containers/FinancialStatements/CashFlowStatement/withCashFlowStatement.js new file mode 100644 index 000000000..ab3d6519a --- /dev/null +++ b/client/src/containers/FinancialStatements/CashFlowStatement/withCashFlowStatement.js @@ -0,0 +1,12 @@ +import { connect } from 'react-redux'; +import { getCashFlowStatementFilterDrawer } from 'store/financialStatement/financialStatements.selectors'; + +export default (mapState) => { + const mapStateToProps = (state, props) => { + const mapped = { + cashFlowStatementDrawerFilter: getCashFlowStatementFilterDrawer(state), + }; + return mapState ? mapState(mapped, state, props) : mapped; + }; + return connect(mapStateToProps); +}; diff --git a/client/src/containers/FinancialStatements/CashFlowStatement/withCashFlowStatementActions.js b/client/src/containers/FinancialStatements/CashFlowStatement/withCashFlowStatementActions.js new file mode 100644 index 000000000..689d7ac62 --- /dev/null +++ b/client/src/containers/FinancialStatements/CashFlowStatement/withCashFlowStatementActions.js @@ -0,0 +1,9 @@ +import { connect } from 'react-redux'; +import { toggleCashFlowStatementFilterDrawer } from 'store/financialStatement/financialStatements.actions'; + +const mapDispatchToProps = (dispatch) => ({ + toggleCashFlowStatementFilterDrawer: (toggle) => + dispatch(toggleCashFlowStatementFilterDrawer(toggle)), +}); + +export default connect(null, mapDispatchToProps); diff --git a/client/src/containers/FinancialStatements/CustomersTransactions/components.js b/client/src/containers/FinancialStatements/CustomersTransactions/components.js index 04715aa57..4fb5d3b5e 100644 --- a/client/src/containers/FinancialStatements/CustomersTransactions/components.js +++ b/client/src/containers/FinancialStatements/CustomersTransactions/components.js @@ -9,6 +9,7 @@ import { CellTextSpan } from 'components/Datatable/Cells'; /** * Retrieve customers transactions columns. */ + export const useCustomersTransactionsColumns = () => { const { customersTransactions: { tableRows }, diff --git a/client/src/containers/FinancialStatements/InventoryItemDetails/InventoryItemDetails.js b/client/src/containers/FinancialStatements/InventoryItemDetails/InventoryItemDetails.js new file mode 100644 index 000000000..696d5b6cf --- /dev/null +++ b/client/src/containers/FinancialStatements/InventoryItemDetails/InventoryItemDetails.js @@ -0,0 +1,83 @@ +import React, { useEffect, useState } from 'react'; +import moment from 'moment'; +import 'style/pages/FinancialStatements/InventoryItemDetails.scss'; + +import { FinancialStatement } from 'components'; +import DashboardPageContent from 'components/Dashboard/DashboardPageContent'; + +import InventoryItemDetailsActionsBar from './InventoryItemDetailsActionsBar'; +import InventoryItemDetailsHeader from './InventoryItemDetailsHeader'; +import InventoryItemDetailsTable from './InventoryItemDetailsTable'; + +import withInventoryItemDetailsActions from './withInventoryItemDetailsActions'; +import withSettings from 'containers/Settings/withSettings'; +import { InventoryItemDetailsProvider } from './InventoryItemDetailsProvider'; +import { InventoryItemDetailsLoadingBar } from './components'; + +import { compose } from 'utils'; + +/** + * inventory item details. + */ +function InventoryItemDetails({ + // #withSettings + organizationName, + + //#withInventoryItemDetailsActions + toggleInventoryItemDetailsFilterDrawer: toggleFilterDrawer, +}) { + + const [filter, setFilter] = useState({ + fromDate: moment().startOf('year').format('YYYY-MM-DD'), + toDate: moment().endOf('year').format('YYYY-MM-DD'), + }); + + const handleFilterSubmit = (filter) => { + const _filter = { + ...filter, + fromDate: moment(filter.fromDate).format('YYYY-MM-DD'), + toDate: moment(filter.toDate).format('YYYY-MM-DD'), + }; + setFilter({ ..._filter }); + }; + + // Handle number format submit. + const handleNumberFormatSubmit = (values) => { + setFilter({ + ...filter, + numberFormat: values, + }); + }; + + useEffect(() => () => toggleFilterDrawer(false), [toggleFilterDrawer]); + + return ( + + + + + +
+ +
+
+ +
+
+
+
+ ); +} + +export default compose( + withSettings(({ organizationSettings }) => ({ + organizationName: organizationSettings?.name, + })), + withInventoryItemDetailsActions, +)(InventoryItemDetails); diff --git a/client/src/containers/FinancialStatements/InventoryItemDetails/InventoryItemDetailsActionsBar.js b/client/src/containers/FinancialStatements/InventoryItemDetails/InventoryItemDetailsActionsBar.js new file mode 100644 index 000000000..90fe45962 --- /dev/null +++ b/client/src/containers/FinancialStatements/InventoryItemDetails/InventoryItemDetailsActionsBar.js @@ -0,0 +1,131 @@ +import React from 'react'; +import { + NavbarGroup, + Button, + Classes, + NavbarDivider, + Popover, + PopoverInteractionKind, + Position, +} from '@blueprintjs/core'; +import { FormattedMessage as T } from 'react-intl'; +import classNames from 'classnames'; + +import { Icon } from 'components'; +import DashboardActionsBar from 'components/Dashboard/DashboardActionsBar'; +import NumberFormatDropdown from 'components/NumberFormatDropdown'; + +import { useInventoryItemDetailsContext } from './InventoryItemDetailsProvider'; +import withInventoryItemDetails from './withInventoryItemDetails'; +import withInventoryItemDetailsActions from './withInventoryItemDetailsActions'; + +import { compose, saveInvoke } from 'utils'; + +/** + * Inventory item details actions bar. + */ +function InventoryItemDetailsActionsBar({ + // #ownProps + numberFormat, + onNumberFormatSubmit, + + //#withInventoryItemDetails + isFilterDrawerOpen, + + //#withInventoryItemDetailsActions + toggleInventoryItemDetailsFilterDrawer: toggleFilterDrawer, +}) { + const { isInventoryItemDetailsLoading, inventoryItemDetailsRefetch } = + useInventoryItemDetailsContext(); + + // Handle filter toggle click. + const handleFilterToggleClick = () => { + toggleFilterDrawer(); + }; + //Handle recalculate the report button. + const handleRecalcReport = () => { + inventoryItemDetailsRefetch(); + }; + // Handle number format form submit. + const handleNumberFormatSubmit = (values) => { + saveInvoke(onNumberFormatSubmit, values); + }; + + return ( + + + + + + + + + ); +} + +export default compose( + withInventoryItemDetails(({ inventoryItemDetailDrawerFilter }) => ({ + isFilterDrawerOpen: inventoryItemDetailDrawerFilter, + })), + withInventoryItemDetailsActions, +)(InventoryItemDetailsHeader); diff --git a/client/src/containers/FinancialStatements/InventoryItemDetails/InventoryItemDetailsHeaderGeneralPanel.js b/client/src/containers/FinancialStatements/InventoryItemDetails/InventoryItemDetailsHeaderGeneralPanel.js new file mode 100644 index 000000000..b3aafc5fb --- /dev/null +++ b/client/src/containers/FinancialStatements/InventoryItemDetails/InventoryItemDetailsHeaderGeneralPanel.js @@ -0,0 +1,13 @@ +import React from 'react'; +import FinancialStatementDateRange from 'containers/FinancialStatements/FinancialStatementDateRange'; + +/** + * Inventory item details header - General panel. + */ +export default function InventoryItemDetailsHeaderGeneralPanel() { + return ( +
+ +
+ ); +} diff --git a/client/src/containers/FinancialStatements/InventoryItemDetails/InventoryItemDetailsProvider.js b/client/src/containers/FinancialStatements/InventoryItemDetails/InventoryItemDetailsProvider.js new file mode 100644 index 000000000..746fce999 --- /dev/null +++ b/client/src/containers/FinancialStatements/InventoryItemDetails/InventoryItemDetailsProvider.js @@ -0,0 +1,43 @@ +import React from 'react'; +import FinancialReportPage from '../FinancialReportPage'; +import { useInventoryItemDetailsReport } from 'hooks/query'; +import { transformFilterFormToQuery } from '../common'; + +const InventoryItemDetailsContext = React.createContext(); + +/** + * Inventory item details provider. + */ +function InventoryItemDetailsProvider({ filter, ...props }) { + const query = React.useMemo( + () => transformFilterFormToQuery(filter), + [filter], + ); + + // fetch inventory item details. + const { + data: inventoryItemDetails, + isFetching: isInventoryItemDetailsFetching, + isLoading: isInventoryItemDetailsLoading, + refetch: inventoryItemDetailsRefetch, + } = useInventoryItemDetailsReport(query, { keepPreviousData: true }); + + const provider = { + inventoryItemDetails, + isInventoryItemDetailsFetching, + isInventoryItemDetailsLoading, + inventoryItemDetailsRefetch, + query, + filter, + }; + + return ( + + + + ); +} +const useInventoryItemDetailsContext = () => + React.useContext(InventoryItemDetailsContext); + +export { InventoryItemDetailsProvider, useInventoryItemDetailsContext }; diff --git a/client/src/containers/FinancialStatements/InventoryItemDetails/InventoryItemDetailsTable.js b/client/src/containers/FinancialStatements/InventoryItemDetails/InventoryItemDetailsTable.js new file mode 100644 index 000000000..5fa384c0c --- /dev/null +++ b/client/src/containers/FinancialStatements/InventoryItemDetails/InventoryItemDetailsTable.js @@ -0,0 +1,59 @@ +import React, { useMemo, useCallback } from 'react'; +import { formatMessage } from 'services/intl'; + +import classNames from 'classnames'; + +import FinancialSheet from 'components/FinancialSheet'; +import { DataTable } from 'components'; +import { useInventoryItemDetailsColumns } from './components'; +import { useInventoryItemDetailsContext } from './InventoryItemDetailsProvider'; + +import { defaultExpanderReducer } from 'utils'; + +/** + * Inventory item detail table. + */ +export default function InventoryItemDetailsTable({ + // #ownProps + companyName, +}) { + const { + inventoryItemDetails: { tableRows }, + isInventoryItemDetailsLoading, + query, + } = useInventoryItemDetailsContext(); + + const columns = useInventoryItemDetailsColumns(); + + const expandedRows = useMemo( + () => defaultExpanderReducer(tableRows, 4), + [tableRows], + ); + + const rowClassNames = (row) => { + return [`row-type--${row.original.rowTypes}`]; + }; + + return ( + + + + ); +} diff --git a/client/src/containers/FinancialStatements/InventoryItemDetails/components.js b/client/src/containers/FinancialStatements/InventoryItemDetails/components.js new file mode 100644 index 000000000..95c378daa --- /dev/null +++ b/client/src/containers/FinancialStatements/InventoryItemDetails/components.js @@ -0,0 +1,29 @@ +import React from 'react'; +import { If } from 'components'; + +import { dynamicColumns } from './utils'; +import FinancialLoadingBar from '../FinancialLoadingBar'; +import { useInventoryItemDetailsContext } from './InventoryItemDetailsProvider'; + +/** + * Retrieve inventory item details columns. + */ +export const useInventoryItemDetailsColumns = () => { + const { + inventoryItemDetails: { columns, data }, + } = useInventoryItemDetailsContext(); + + return React.useMemo(() => dynamicColumns(columns, data), [columns, data]); +}; + +/** + * Cash inventory item details loading bar. + */ +export function InventoryItemDetailsLoadingBar() { + const { isInventoryItemDetailsLoading } = useInventoryItemDetailsContext(); + return ( + + + + ); +} diff --git a/client/src/containers/FinancialStatements/InventoryItemDetails/utils.js b/client/src/containers/FinancialStatements/InventoryItemDetails/utils.js new file mode 100644 index 000000000..b0798ece3 --- /dev/null +++ b/client/src/containers/FinancialStatements/InventoryItemDetails/utils.js @@ -0,0 +1,42 @@ +import React from 'react'; +import * as R from 'ramda'; +import { getColumnWidth, getForceWidth } from 'utils'; + +/** + * columns mapper. + */ +const columnsMapper = (data, index, column) => ({ + id: column.key, + key: column.key, + Header: column.label, + accessor: ({ cells }) => { + return ( + + {cells[index]?.value} + + ); + }, + className: column.key, + width: getColumnWidth(data, `cells.${index}.key`, { + minWidth: 130, + magicSpacing: 10, + }), + disableSortBy: true, +}); + +/** + * Inventory item details columns. + */ +export const dynamicColumns = (columns, data) => { + const mapper = (column, index) => { + return R.compose( + R.when(R.pathEq(['key']), R.curry(columnsMapper)(data, index)), + )(column); + }; + return columns.map(mapper); +}; diff --git a/client/src/containers/FinancialStatements/InventoryItemDetails/withInventoryItemDetails.js b/client/src/containers/FinancialStatements/InventoryItemDetails/withInventoryItemDetails.js new file mode 100644 index 000000000..bcdeb7169 --- /dev/null +++ b/client/src/containers/FinancialStatements/InventoryItemDetails/withInventoryItemDetails.js @@ -0,0 +1,15 @@ +import { connect } from 'react-redux'; +import { getInventoryItemDetailsFilterDrawer } from 'store/financialStatement/financialStatements.selectors'; + +export default (mapState) => { + const mapStateToProps = (state, props) => { + const mapped = { + inventoryItemDetailDrawerFilter: getInventoryItemDetailsFilterDrawer( + state, + props, + ), + }; + return mapState ? mapState(mapped, state, props) : mapped; + }; + return connect(mapStateToProps); +}; diff --git a/client/src/containers/FinancialStatements/InventoryItemDetails/withInventoryItemDetailsActions.js b/client/src/containers/FinancialStatements/InventoryItemDetails/withInventoryItemDetailsActions.js new file mode 100644 index 000000000..1daaa3eb5 --- /dev/null +++ b/client/src/containers/FinancialStatements/InventoryItemDetails/withInventoryItemDetailsActions.js @@ -0,0 +1,9 @@ +import { connect } from 'react-redux'; +import { toggleInventoryItemDetailsFilterDrawer } from 'store/financialStatement/financialStatements.actions'; + +const mapActionsToProps = (dispatch) => ({ + toggleInventoryItemDetailsFilterDrawer: (toggle) => + dispatch(toggleInventoryItemDetailsFilterDrawer(toggle)), +}); + +export default connect(null, mapActionsToProps); diff --git a/client/src/hooks/query/financialReports.js b/client/src/hooks/query/financialReports.js index 4989099ca..476a3f1af 100644 --- a/client/src/hooks/query/financialReports.js +++ b/client/src/hooks/query/financialReports.js @@ -402,3 +402,63 @@ export function useVendorsTransactionsReport(query, props) { }, ); } + +/** + * Retrieve cash flow statement report. + */ +export function useCashFlowStatementReport(query, props) { + return useRequestQuery( + [t.FINANCIAL_REPORT, t.CASH_FLOW_STATEMENT, query], + { + method: 'get', + url: '/financial_statements/cash-flow', + params: query, + headers: { + Accept: 'application/json+table', + }, + }, + { + select: (res) => ({ + columns: res.data.table.columns, + data: res.data.table.data, + tableRows: res.data.table.data, + }), + defaultData: { + tableRows: [], + data: [], + columns: [], + }, + ...props, + }, + ); +} + +/** + * Retrieve inventory item detail report. + */ + export function useInventoryItemDetailsReport(query, props) { + return useRequestQuery( + [t.FINANCIAL_REPORT, t.INVENTORY_ITEM_DETAILS, query], + { + method: 'get', + url: '/financial_statements/inventory-item-details', + params: query, + headers: { + Accept: 'application/json+table', + }, + }, + { + select: (res) => ({ + columns: res.data.table.columns, + data: res.data.table.data, + tableRows: res.data.table.data, + }), + defaultData: { + tableRows: [], + data: [], + columns: [], + }, + ...props, + }, + ); +} \ No newline at end of file diff --git a/client/src/hooks/query/types.js b/client/src/hooks/query/types.js index 61db1c84e..c2bfb67d4 100644 --- a/client/src/hooks/query/types.js +++ b/client/src/hooks/query/types.js @@ -20,7 +20,9 @@ const FINANCIAL_REPORTS = { CUSTOMERS_BALANCE_SUMMARY: 'CUSTOMERS_BALANCE_SUMMARY', SALES_BY_ITEMS: 'SALES_BY_ITEMS', PURCHASES_BY_ITEMS: 'PURCHASES_BY_ITEMS', - INVENTORY_VALUATION: 'INVENTORY_VALUATION' + INVENTORY_VALUATION: 'INVENTORY_VALUATION', + CASH_FLOW_STATEMENT: 'CASH_FLOW_STATEMENT', + INVENTORY_ITEM_DETAILS:'INVENTORY_ITEM_DETAILS' }; const BILLS = { diff --git a/client/src/lang/en/index.js b/client/src/lang/en/index.js index 987743345..15e63018b 100644 --- a/client/src/lang/en/index.js +++ b/client/src/lang/en/index.js @@ -1064,4 +1064,7 @@ export default { vendors_transactions: 'Vendors Transactions', reference_type: 'Reference type', transaction_number: 'Transaction number', + cash_flow_statement: 'Cash Flow Statement', + statement_of_cash_flow: 'Statement of Cash Flow ', + inventory_item_details:'Inventory Item Details' }; diff --git a/client/src/routes/dashboard.js b/client/src/routes/dashboard.js index 74121459a..5e2e31aa4 100644 --- a/client/src/routes/dashboard.js +++ b/client/src/routes/dashboard.js @@ -112,8 +112,7 @@ export default [ import('containers/FinancialStatements/GeneralLedger/GeneralLedger'), ), breadcrumb: 'General Ledger', - hint: - 'Reports every transaction going in and out of your accounts and organized by accounts and date to monitoring activity of accounts.', + hint: 'Reports every transaction going in and out of your accounts and organized by accounts and date to monitoring activity of accounts.', hotkey: 'shift+4', pageTitle: formatMessage({ id: 'general_ledger' }), backLink: true, @@ -125,8 +124,7 @@ export default [ import('containers/FinancialStatements/BalanceSheet/BalanceSheet'), ), breadcrumb: 'Balance Sheet', - hint: - "Reports a company's assets, liabilities and shareholders' equity at a specific point in time with comparison period(s).", + hint: "Reports a company's assets, liabilities and shareholders' equity at a specific point in time with comparison period(s).", hotkey: 'shift+1', pageTitle: formatMessage({ id: 'balance_sheet' }), backLink: true, @@ -140,8 +138,7 @@ export default [ ), ), breadcrumb: 'Trial Balance Sheet', - hint: - 'Summarizes the credit and debit balance of each account in your chart of accounts at a specific point in time. ', + hint: 'Summarizes the credit and debit balance of each account in your chart of accounts at a specific point in time. ', hotkey: 'shift+5', pageTitle: formatMessage({ id: 'trial_balance_sheet' }), backLink: true, @@ -153,8 +150,7 @@ export default [ import('containers/FinancialStatements/ProfitLossSheet/ProfitLossSheet'), ), breadcrumb: 'Profit Loss Sheet', - hint: - 'Reports the revenues, costs and expenses incurred during a specific point in time with comparison period(s).', + hint: 'Reports the revenues, costs and expenses incurred during a specific point in time with comparison period(s).', hotkey: 'shift+2', pageTitle: formatMessage({ id: 'profit_loss_sheet' }), backLink: true, @@ -166,8 +162,7 @@ export default [ import('containers/FinancialStatements/ARAgingSummary/ARAgingSummary'), ), breadcrumb: 'Receivable Aging Summary', - hint: - 'Summarize total unpaid balances of customers invoices with number of days the unpaid invoice is overdue.', + hint: 'Summarize total unpaid balances of customers invoices with number of days the unpaid invoice is overdue.', pageTitle: formatMessage({ id: 'receivable_aging_summary' }), backLink: true, sidebarExpand: false, @@ -178,8 +173,7 @@ export default [ import('containers/FinancialStatements/APAgingSummary/APAgingSummary'), ), breadcrumb: 'Payable Aging Summary', - hint: - 'Summarize total unpaid balances of vendors purchase invoices with the number of days the unpaid invoice is overdue.', + hint: 'Summarize total unpaid balances of vendors purchase invoices with the number of days the unpaid invoice is overdue.', pageTitle: formatMessage({ id: 'payable_aging_summary' }), backLink: true, sidebarExpand: false, @@ -190,8 +184,7 @@ export default [ import('containers/FinancialStatements/Journal/Journal'), ), breadcrumb: 'Journal Sheet', - hint: - 'The debit and credit entries of system transactions, sorted by date.', + hint: 'The debit and credit entries of system transactions, sorted by date.', hotkey: 'shift+3', pageTitle: formatMessage({ id: 'journal_sheet' }), sidebarExpand: false, @@ -217,8 +210,7 @@ export default [ ), breadcrumb: 'Sales by Items', pageTitle: formatMessage({ id: 'sales_by_items' }), - hint: - 'Summarize the business’s sold items quantity, income and average income rate of each item during a specific point in time.', + hint: 'Summarize the business’s sold items quantity, income and average income rate of each item during a specific point in time.', backLink: true, sidebarExpand: false, }, @@ -230,8 +222,7 @@ export default [ ), ), breadcrumb: 'Inventory Valuation ', - hint: - 'Summerize your transactions for each inventory item and how they affect quantity, valuation and weighted average.', + hint: 'Summerize your transactions for each inventory item and how they affect quantity, valuation and weighted average.', pageTitle: formatMessage({ id: 'inventory_valuation' }), backLink: true, sidebarExpand: false, @@ -257,7 +248,7 @@ export default [ ), ), breadcrumb: 'Vendors Balance Summary ', - hint: '..', + hint: 'Summerize the total amount your business owes each vendor.', pageTitle: formatMessage({ id: 'vendors_balance_summary' }), backLink: true, sidebarExpand: false, @@ -270,7 +261,7 @@ export default [ ), ), breadcrumb: 'Customers Transactions ', - hint: '..', + hint: 'Reports every transaction going in and out of each customer.', pageTitle: formatMessage({ id: 'customers_transactions' }), backLink: true, sidebarExpand: false, @@ -283,11 +274,37 @@ export default [ ), ), breadcrumb: 'Vendors Transactions ', - hint: '..', + hint: 'Reports every transaction going in and out of each vendor/supplier.', pageTitle: formatMessage({ id: 'vendors_transactions' }), backLink: true, sidebarExpand: false, }, + { + path: `/financial-reports/cash-flow`, + component: lazy(() => + import( + 'containers/FinancialStatements/CashFlowStatement/CashFlowStatement' + ), + ), + breadcrumb: 'Cash Flow Statement', + hint: 'Reports inflow and outflow of cash and cash equivalents between a specific two points of time.', + pageTitle: formatMessage({ id: 'cash_flow_statement' }), + backLink: true, + sidebarExpand: false, + }, + { + path: `/financial-reports/inventory-item-details`, + component: lazy(() => + import( + 'containers/FinancialStatements/InventoryItemDetails/InventoryItemDetails' + ), + ), + breadcrumb: 'Inventory Item Details', + hint: 'Reports every transaction going in and out of your items to monitoring activity of items.', + pageTitle: formatMessage({ id: 'inventory_item_details' }), + backLink: true, + sidebarExpand: false, + }, { path: '/financial-reports', component: lazy(() => diff --git a/client/src/store/financialStatement/financialStatements.actions.js b/client/src/store/financialStatement/financialStatements.actions.js index a972d42db..0ef8f9a41 100644 --- a/client/src/store/financialStatement/financialStatements.actions.js +++ b/client/src/store/financialStatement/financialStatements.actions.js @@ -178,3 +178,29 @@ export function toggleVendorsTransactionsFilterDrawer(toggle) { }, }; } + +/** + * Toggle display of the cash flow statement filter drawer. + * @param {boolean} toggle + */ +export function toggleCashFlowStatementFilterDrawer(toggle) { + return { + type: `${t.CASH_FLOW_STATEMENT}/${t.DISPLAY_FILTER_DRAWER_TOGGLE}`, + payload: { + toggle, + }, + }; +} + +/** + * Toggles display of the inventory item details filter drawer. + * @param {boolean} toggle + */ + export function toggleInventoryItemDetailsFilterDrawer(toggle) { + return { + type: `${t.INVENTORY_ITEM_DETAILS}/${t.DISPLAY_FILTER_DRAWER_TOGGLE}`, + payload: { + toggle, + }, + }; +} diff --git a/client/src/store/financialStatement/financialStatements.reducer.js b/client/src/store/financialStatement/financialStatements.reducer.js index e0b725b33..63fea46b2 100644 --- a/client/src/store/financialStatement/financialStatements.reducer.js +++ b/client/src/store/financialStatement/financialStatements.reducer.js @@ -45,6 +45,12 @@ const initialState = { vendorsTransactions: { displayFilterDrawer: false, }, + cashFlowStatement: { + displayFilterDrawer: false, + }, + inventoryItemDetails: { + displayFilterDrawer: false, + }, }; /** @@ -91,4 +97,9 @@ export default createReducer(initialState, { t.VENDORS_TRANSACTIONS, 'vendorsTransactions', ), + ...financialStatementFilterToggle(t.CASH_FLOW_STATEMENT, 'cashFlowStatement'), + ...financialStatementFilterToggle( + t.INVENTORY_ITEM_DETAILS, + 'inventoryItemDetails', + ), }); diff --git a/client/src/store/financialStatement/financialStatements.selectors.js b/client/src/store/financialStatement/financialStatements.selectors.js index c87c10cc9..f19337fa2 100644 --- a/client/src/store/financialStatement/financialStatements.selectors.js +++ b/client/src/store/financialStatement/financialStatements.selectors.js @@ -65,6 +65,14 @@ export const vendorsTransactionsFilterDrawerSelector = (state) => { return filterDrawerByTypeSelector('vendorsTransactions')(state); }; +export const cashFlowStatementFilterDrawerSelector = (state) => { + return filterDrawerByTypeSelector('cashFlowStatement')(state); +}; + +export const inventoryItemDetailsDrawerFilter = (state) => { + return filterDrawerByTypeSelector('inventoryItemDetails')(state); +}; + /** * Retrieve balance sheet filter drawer. */ @@ -211,3 +219,23 @@ export const getVendorsTransactionsFilterDrawer = createSelector( return isOpen; }, ); + +/** + * Retrieve cash flow statement filter drawer. + */ +export const getCashFlowStatementFilterDrawer = createSelector( + cashFlowStatementFilterDrawerSelector, + (isOpen) => { + return isOpen; + }, +); + +/** + * Retrieve inventory item details filter drawer. + */ +export const getInventoryItemDetailsFilterDrawer = createSelector( + inventoryItemDetailsDrawerFilter, + (isOpen) => { + return isOpen; + }, +); diff --git a/client/src/store/financialStatement/financialStatements.types.js b/client/src/store/financialStatement/financialStatements.types.js index 7b49a6739..ef82d5b92 100644 --- a/client/src/store/financialStatement/financialStatements.types.js +++ b/client/src/store/financialStatement/financialStatements.types.js @@ -14,4 +14,6 @@ export default { VENDORS_BALANCE_SUMMARY: 'VENDORS BALANCE SUMMARY', CUSTOMERS_TRANSACTIONS: 'CUSTOMERS TRANSACTIONS', VENDORS_TRANSACTIONS: 'VENDORS TRANSACTIONS', + CASH_FLOW_STATEMENT: 'CASH FLOW STATEMENT', + INVENTORY_ITEM_DETAILS: 'INVENTORY ITEM DETAILS', }; diff --git a/client/src/style/pages/FinancialStatements/CashFlowStatement.scss b/client/src/style/pages/FinancialStatements/CashFlowStatement.scss new file mode 100644 index 000000000..2ff22b8f2 --- /dev/null +++ b/client/src/style/pages/FinancialStatements/CashFlowStatement.scss @@ -0,0 +1,50 @@ +.financial-sheet { + &--cash-flow-statement { + .financial-sheet__table { + .thead, + .tbody { + .tr .td.account_name ~ .td, + .tr .th.account_name ~ .th { + text-align: right; + } + } + .tbody { + .tr:not(.no-results) { + &.row-type--CASH_END_PERIOD{ + border-bottom: 3px double #333; + } + .td { + border-bottom: 0; + padding-top: 0.4rem; + padding-bottom: 0.4rem; + } + + &.row-type--TOTAL { + font-weight: 500; + + &:not(:first-child) .td { + border-top: 1px solid #bbb; + } + } + } + + .tr.is-expanded { + .td.total, + .td.date-period{ + .cell-inner { + display: none; + } + } + } + } + } + } +} + +.financial-statement--cash-flow { + .financial-header-drawer { + .bp3-drawer { + max-height: 450px; + } + } +} diff --git a/client/src/style/pages/FinancialStatements/InventoryItemDetails.scss b/client/src/style/pages/FinancialStatements/InventoryItemDetails.scss new file mode 100644 index 000000000..d2458d1c3 --- /dev/null +++ b/client/src/style/pages/FinancialStatements/InventoryItemDetails.scss @@ -0,0 +1,77 @@ +.financial-sheet { + &--inventory-item-details { + width: 100%; + + .financial-sheet__table { + .tbody, + .thead { + .tr .td.transaction_id ~ .td, + .tr .th.transaction_id ~ .th { + text-align: right; + } + } + .tbody { + .tr .td { + padding-top: 0.2rem; + padding-bottom: 0.2rem; + border-top-color: transparent; + border-bottom-color: transparent; + &.date { + > div { + display: flex; + } + + span.force-width { + position: relative; + } + } + } + .tr:not(.no-results) .td { + border-left: 1px solid #ececec; + } + + .tr.row-type { + &--ITEM { + .td { + &.transaction_type { + border-left-color: transparent; + } + } + &:not(:first-child).is-expanded .td { + border-top: 1px solid #ddd; + } + } + &--ITEM, + &--OPENING_ENTRY, + &--CLOSING_ENTRY { + font-weight: 500; + } + &--ITEM { + &.is-expanded { + .td.value .cell-inner { + display: none; + } + } + } + } + } + } + } +} +.number-format-dropdown { + .toggles-fields { + .bp3-form-group:first-child { + display: none; + } + } + .form-group--money-format { + display: none; + } +} +.financial-statement--inventory-details { + .financial-header-drawer { + .bp3-drawer { + max-height: 350px; + } + } +} diff --git a/server/package.json b/server/package.json index 9667985c8..b61acca96 100644 --- a/server/package.json +++ b/server/package.json @@ -19,6 +19,7 @@ "dependencies": { "@hapi/boom": "^7.4.3", "@types/i18n": "^0.8.7", + "@types/mathjs": "^6.0.12", "accepts": "^1.3.7", "accounting": "^0.4.1", "agenda": "^3.1.0", @@ -34,6 +35,7 @@ "crypto-random-string": "^3.2.0", "csurf": "^1.10.0", "deep-map": "^2.0.0", + "deepdash": "^5.3.7", "dotenv": "^8.1.0", "errorhandler": "^1.5.1", "es6-weak-map": "^2.0.3", @@ -55,6 +57,7 @@ "knex-db-manager": "^0.6.1", "libphonenumber-js": "^1.9.6", "lodash": "^4.17.15", + "mathjs": "^9.4.0", "memory-cache": "^0.2.0", "moment": "^2.24.0", "moment-range": "^4.0.2", diff --git a/server/src/api/controllers/BaseController.ts b/server/src/api/controllers/BaseController.ts index 5627d57cb..403504c52 100644 --- a/server/src/api/controllers/BaseController.ts +++ b/server/src/api/controllers/BaseController.ts @@ -10,7 +10,7 @@ export default class BaseController { * Converts plain object keys to cameCase style. * @param {Object} data */ - private dataToCamelCase(data) { + protected dataToCamelCase(data) { return mapKeysDeep(data, (v, k) => camelCase(k)); } @@ -19,7 +19,7 @@ export default class BaseController { * @param {Request} req * @param options */ - matchedBodyData(req: Request, options: any = {}) { + protected matchedBodyData(req: Request, options: any = {}) { const data = matchedData(req, { locations: ['body'], includeOptionals: true, @@ -32,7 +32,7 @@ export default class BaseController { * Matches the query data from validation schema. * @param {Request} req */ - matchedQueryData(req: Request) { + protected matchedQueryData(req: Request) { const data = matchedData(req, { locations: ['query'], }); @@ -45,7 +45,7 @@ export default class BaseController { * @param {Response} res * @param {NextFunction} next */ - validationResult(req: Request, res: Response, next: NextFunction) { + protected validationResult(req: Request, res: Response, next: NextFunction) { const validationErrors = validationResult(req); if (!validationErrors.isEmpty()) { @@ -61,7 +61,7 @@ export default class BaseController { * Transform the given data to response. * @param {any} data */ - transfromToResponse( + protected transfromToResponse( data: any, translatable?: string | string[], req?: Request @@ -85,16 +85,16 @@ export default class BaseController { * Async middleware. * @param {function} callback */ - asyncMiddleware(callback) { + protected asyncMiddleware(callback) { return asyncMiddleware(callback); } /** - * - * @param {Request} req - * @returns + * + * @param {Request} req + * @returns */ - accepts(req) { + protected accepts(req) { return accepts(req); } } diff --git a/server/src/api/controllers/FinancialStatements.ts b/server/src/api/controllers/FinancialStatements.ts index 497b499cc..dbda169f6 100644 --- a/server/src/api/controllers/FinancialStatements.ts +++ b/server/src/api/controllers/FinancialStatements.ts @@ -15,6 +15,8 @@ import CustomerBalanceSummaryController from './FinancialStatements/CustomerBala import VendorBalanceSummaryController from './FinancialStatements/VendorBalanceSummary'; import TransactionsByCustomers from './FinancialStatements/TransactionsByCustomers'; import TransactionsByVendors from './FinancialStatements/TransactionsByVendors'; +import CashFlowStatementController from './FinancialStatements/CashFlow/CashFlow'; +import InventoryDetailsController from './FinancialStatements/InventoryDetails'; @Service() export default class FinancialStatementsService { @@ -77,6 +79,14 @@ export default class FinancialStatementsService { '/transactions-by-vendors', Container.get(TransactionsByVendors).router(), ); + router.use( + '/cash-flow', + Container.get(CashFlowStatementController).router(), + ); + router.use( + '/inventory-item-details', + Container.get(InventoryDetailsController).router(), + ); return router; } } diff --git a/server/src/api/controllers/FinancialStatements/CashFlow/CashFlow.ts b/server/src/api/controllers/FinancialStatements/CashFlow/CashFlow.ts new file mode 100644 index 000000000..5434f33c1 --- /dev/null +++ b/server/src/api/controllers/FinancialStatements/CashFlow/CashFlow.ts @@ -0,0 +1,113 @@ +import { Inject, Service } from 'typedi'; +import { query } from 'express-validator'; +import { + NextFunction, + Router, + Request, + Response, + ValidationChain, +} from 'express'; +import BaseFinancialReportController from '../BaseFinancialReportController'; +import CashFlowStatementService from 'services/FinancialStatements/CashFlow/CashFlowService'; +import { ICashFlowStatement } from 'interfaces'; +import CashFlowTable from 'services/FinancialStatements/CashFlow/CashFlowTable'; + +@Service() +export default class CashFlowController extends BaseFinancialReportController { + @Inject() + cashFlowService: CashFlowStatementService; + + /** + * Router constructor. + */ + router() { + const router = Router(); + + router.get( + '/', + this.cashflowValidationSchema, + this.validationResult, + this.asyncMiddleware(this.cashFlow.bind(this)) + ); + return router; + } + + /** + * Balance sheet validation schecma. + * @returns {ValidationChain[]} + */ + get cashflowValidationSchema(): ValidationChain[] { + return [ + ...this.sheetNumberFormatValidationSchema, + query('from_date').optional(), + query('to_date').optional(), + query('display_columns_type').optional().isIn(['date_periods', 'total']), + query('display_columns_by') + .optional({ nullable: true, checkFalsy: true }) + .isIn(['year', 'month', 'week', 'day', 'quarter']), + query('none_zero').optional().isBoolean().toBoolean(), + query('none_transactions').optional().isBoolean().toBoolean(), + ]; + } + + /** + * Retrieve the cashflow statment to json response. + * @param {ICashFlowStatement} cashFlow - + */ + private transformJsonResponse(cashFlow: ICashFlowStatement) { + const { data, query } = cashFlow; + + return { + data: this.transfromToResponse(data), + meta: this.transfromToResponse(query), + }; + } + + /** + * Transformes the report statement to table rows. + * @param {ITransactionsByVendorsStatement} statement - + * + */ + private transformToTableRows(cashFlow: ICashFlowStatement) { + const cashFlowTable = new CashFlowTable(cashFlow); + + return { + table: { + data: cashFlowTable.tableRows(), + columns: cashFlowTable.tableColumns(), + }, + meta: this.transfromToResponse(cashFlow.query), + }; + } + + /** + * Retrieve the cash flow statment. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + * @returns {Response} + */ + async cashFlow(req: Request, res: Response, next: NextFunction) { + const { tenantId, settings } = req; + const filter = { + ...this.matchedQueryData(req), + }; + + try { + const cashFlow = await this.cashFlowService.cashFlow(tenantId, filter); + + const accept = this.accepts(req); + const acceptType = accept.types(['json', 'application/json+table']); + + switch (acceptType) { + case 'application/json+table': + return res.status(200).send(this.transformToTableRows(cashFlow)); + case 'json': + default: + return res.status(200).send(this.transformJsonResponse(cashFlow)); + } + } catch (error) { + next(error); + } + } +} diff --git a/server/src/api/controllers/FinancialStatements/InventoryDetails/index.ts b/server/src/api/controllers/FinancialStatements/InventoryDetails/index.ts new file mode 100644 index 000000000..74f30ded9 --- /dev/null +++ b/server/src/api/controllers/FinancialStatements/InventoryDetails/index.ts @@ -0,0 +1,120 @@ +import { Inject, Service } from 'typedi'; +import { query } from 'express-validator'; +import { + NextFunction, + Router, + Request, + Response, + ValidationChain, +} from 'express'; +import BaseController from 'api/controllers/BaseController'; +import InventoryDetailsService from 'services/FinancialStatements/InventoryDetails/InventoryDetailsService'; +import InventoryDetailsTable from 'services/FinancialStatements/InventoryDetails/InventoryDetailsTable'; + +@Service() +export default class InventoryDetailsController extends BaseController { + @Inject() + inventoryDetailsService: InventoryDetailsService; + + /** + * Router constructor. + */ + router() { + const router = Router(); + + router.get( + '/', + this.validationSchema, + this.validationResult, + this.asyncMiddleware(this.inventoryDetails.bind(this)) + ); + return router; + } + + /** + * Balance sheet validation schecma. + * @returns {ValidationChain[]} + */ + get validationSchema(): ValidationChain[] { + return [ + query('number_format.precision') + .optional() + .isInt({ min: 0, max: 5 }) + .toInt(), + query('number_format.divide_on_1000').optional().isBoolean().toBoolean(), + query('number_format.negative_format') + .optional() + .isIn(['parentheses', 'mines']) + .trim() + .escape(), + query('from_date').optional(), + query('to_date').optional(), + query('none_zero').optional().isBoolean().toBoolean(), + query('none_transactions').optional().isBoolean().toBoolean(), + ]; + } + + /** + * Retrieve the cashflow statment to json response. + * @param {ICashFlowStatement} cashFlow - + */ + private transformJsonResponse(inventoryDetails) { + const { data, query } = inventoryDetails; + + return { + data: this.transfromToResponse(data), + meta: this.transfromToResponse(query), + }; + } + + /** + * Transformes the report statement to table rows. + */ + private transformToTableRows(inventoryDetails) { + const inventoryDetailsTable = new InventoryDetailsTable(inventoryDetails); + + return { + table: { + data: inventoryDetailsTable.tableData(), + columns: inventoryDetailsTable.tableColumns(), + }, + meta: this.transfromToResponse(inventoryDetails.query), + }; + } + + /** + * Retrieve the cash flow statment. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + * @returns {Response} + */ + async inventoryDetails(req: Request, res: Response, next: NextFunction) { + const { tenantId, settings } = req; + const filter = { + ...this.matchedQueryData(req), + }; + + try { + const inventoryDetails = + await this.inventoryDetailsService.inventoryDetails(tenantId, filter); + + const accept = this.accepts(req); + const acceptType = accept.types(['json', 'application/json+table']); + + switch (acceptType) { + case 'application/json+table': + return res + .status(200) + .send(this.transformToTableRows(inventoryDetails)); + case 'json': + default: + return res + .status(200) + .send(this.transformJsonResponse(inventoryDetails)); + } + } catch (error) { + next(error); + } + } +} diff --git a/server/src/data/AccountTypes.ts b/server/src/data/AccountTypes.ts index f3b108627..ceef16bb1 100644 --- a/server/src/data/AccountTypes.ts +++ b/server/src/data/AccountTypes.ts @@ -3,9 +3,9 @@ export const ACCOUNT_TYPE = { BANK: 'bank', ACCOUNTS_RECEIVABLE: 'accounts-receivable', INVENTORY: 'inventory', - OTHER_CURRENT_ASSET: 'other-ACCOUNT_PARENT_TYPE.CURRENT_ASSET', + OTHER_CURRENT_ASSET: 'other-current-asset', FIXED_ASSET: 'fixed-asset', - NON_CURRENT_ASSET: 'non-ACCOUNT_PARENT_TYPE.CURRENT_ASSET', + NON_CURRENT_ASSET: 'none-current-asset', ACCOUNTS_PAYABLE: 'accounts-payable', CREDIT_CARD: 'credit-card', @@ -25,7 +25,7 @@ export const ACCOUNT_TYPE = { export const ACCOUNT_PARENT_TYPE = { CURRENT_ASSET: 'current-asset', FIXED_ASSET: 'fixed-asset', - NON_CURRENT_ASSET: 'non-ACCOUNT_PARENT_TYPE.CURRENT_ASSET', + NON_CURRENT_ASSET: 'non-current-asset', CURRENT_LIABILITY: 'current-liability', LOGN_TERM_LIABILITY: 'long-term-liability', diff --git a/server/src/database/migrations/20200722164255_create_inventory_transaction_meta_table.js b/server/src/database/migrations/20200722164255_create_inventory_transaction_meta_table.js new file mode 100644 index 000000000..15f348a17 --- /dev/null +++ b/server/src/database/migrations/20200722164255_create_inventory_transaction_meta_table.js @@ -0,0 +1,11 @@ +exports.up = function (knex) { + return knex.schema.createTable('inventory_transaction_meta', (table) => { + table.increments('id'); + table.string('transaction_number'); + table.text('description'); + table.integer('inventory_transaction_id').unsigned(); + }); + }; + + exports.down = function (knex) {}; + \ No newline at end of file diff --git a/server/src/database/migrations/20200810121807_create_inventory_cost_lot_tracker_table.js b/server/src/database/migrations/20200810121807_create_inventory_cost_lot_tracker_table.js index 7f45dbdef..d490cbcc7 100644 --- a/server/src/database/migrations/20200810121807_create_inventory_cost_lot_tracker_table.js +++ b/server/src/database/migrations/20200810121807_create_inventory_cost_lot_tracker_table.js @@ -15,6 +15,7 @@ exports.up = function (knex) { table.integer('entry_id').unsigned().index(); table.integer('cost_account_id').unsigned(); + table.integer('inventory_transaction_id').unsigned().index(); table.datetime('created_at').index(); }); diff --git a/server/src/interfaces/Account.ts b/server/src/interfaces/Account.ts index 39303e829..60cf1dbee 100644 --- a/server/src/interfaces/Account.ts +++ b/server/src/interfaces/Account.ts @@ -33,7 +33,13 @@ export interface IAccountsTransactionsFilter { } export interface IAccountTransaction { - + credit: number; + debit: number; + accountId: number; + contactId: number; + date: string|Date; + referenceNumber: string; + account: IAccount; } export interface IAccountResponse extends IAccount { diff --git a/server/src/interfaces/CashFlow.ts b/server/src/interfaces/CashFlow.ts new file mode 100644 index 000000000..1a8877b4d --- /dev/null +++ b/server/src/interfaces/CashFlow.ts @@ -0,0 +1,190 @@ +import { INumberFormatQuery } from './FinancialStatements'; +import { IAccount } from './Account'; +import { ILedger } from './Ledger'; +import { ITableRow } from './Table'; + +export interface ICashFlowStatementQuery { + fromDate: Date|string; + toDate: Date|string; + displayColumnsBy: string; + displayColumnsType: string; + noneZero: boolean; + noneTransactions: boolean; + numberFormat: INumberFormatQuery; + basis: string; +} + +export interface ICashFlowStatementTotal { + amount: number; + formattedAmount: string; + currencyCode: string; +} + +export interface ICashFlowStatementTotalPeriod { + fromDate: Date; + toDate: Date; + total: ICashFlowStatementTotal; +} + +export interface ICashFlowStatementCommonSection { + id: string; + label: string; + total: ICashFlowStatementTotal; + footerLabel?: string; +} + +export interface ICashFlowStatementAccountMeta { + id: number; + label: string; + code: string; + total: ICashFlowStatementTotal; + accountType: string; + adjusmentType: string; + sectionType: ICashFlowStatementSectionType.ACCOUNT; +} + +export enum ICashFlowStatementSectionType { + REGULAR = 'REGULAR', + AGGREGATE = 'AGGREGATE', + NET_INCOME = 'NET_INCOME', + ACCOUNT = 'ACCOUNT', + ACCOUNTS = 'ACCOUNTS', + TOTAL = 'TOTAL', + CASH_AT_BEGINNING = 'CASH_AT_BEGINNING', +} + +export interface ICashFlowStatementAccountSection + extends ICashFlowStatementCommonSection { + sectionType: ICashFlowStatementSectionType.ACCOUNTS; + children: ICashFlowStatementAccountMeta[]; + total: ICashFlowStatementTotal; +} + +export interface ICashFlowStatementNetIncomeSection + extends ICashFlowStatementCommonSection { + sectionType: ICashFlowStatementSectionType.NET_INCOME; +} + +export interface ICashFlowStatementTotalSection + extends ICashFlowStatementCommonSection { + sectionType: ICashFlowStatementSectionType.TOTAL; +} + +export type ICashFlowStatementSection = + | ICashFlowStatementAccountSection + | ICashFlowStatementNetIncomeSection + | ICashFlowStatementTotalSection + | ICashFlowStatementCommonSection; + +export interface ICashFlowStatementColumn {} +export interface ICashFlowStatementMeta {} + +export interface ICashFlowStatementService { + cashFlow( + tenantId: number, + query: ICashFlowStatementQuery + ): Promise; +} + +// CASH FLOW SCHEMA TYPES. +// ----------------------------- +export interface ICashFlowSchemaCommonSection { + id: string; + label: string; + children: ICashFlowSchemaSection[]; + footerLabel?: string; +} + +export enum CASH_FLOW_ACCOUNT_RELATION { + MINES = 'mines', + PLUS = 'plus', +} + +export enum CASH_FLOW_SECTION_ID { + NET_INCOME = 'NET_INCOME', + OPERATING = 'OPERATING', + OPERATING_ACCOUNTS = 'OPERATING_ACCOUNTS', + INVESTMENT = 'INVESTMENT', + FINANCIAL = 'FINANCIAL', + + NET_OPERATING = 'NET_OPERATING', + NET_INVESTMENT = 'NET_INVESTMENT', + NET_FINANCIAL = 'NET_FINANCIAL', + + CASH_BEGINNING_PERIOD = 'CASH_BEGINNING_PERIOD', + CASH_END_PERIOD = 'CASH_END_PERIOD', + NET_CASH_INCREASE = 'NET_CASH_INCREASE', +} + +export interface ICashFlowSchemaAccountsSection + extends ICashFlowSchemaCommonSection { + sectionType: ICashFlowStatementSectionType.ACCOUNT; + accountsRelations: ICashFlowSchemaAccountRelation[]; +} + +export interface ICashFlowSchemaTotalSection + extends ICashFlowStatementCommonSection { + sectionType: ICashFlowStatementSectionType.TOTAL; + equation: string; +} + +export type ICashFlowSchemaSection = + | ICashFlowSchemaAccountsSection + | ICashFlowSchemaTotalSection + | ICashFlowSchemaCommonSection; + +export type ICashFlowStatementData = ICashFlowSchemaSection[]; + +export interface ICashFlowSchemaAccountRelation { + type: string; + direction: CASH_FLOW_ACCOUNT_RELATION.PLUS; +} + +export interface ICashFlowSchemaSectionAccounts + extends ICashFlowStatementCommonSection { + type: ICashFlowStatementSectionType.ACCOUNT; + accountsRelations: ICashFlowSchemaAccountRelation[]; +} + +export interface ICashFlowSchemaSectionTotal { + type: ICashFlowStatementSectionType.TOTAL; + totalEquation: string; +} + +export interface ICashFlowDatePeriod { + fromDate: ICashFlowDate; + toDate: ICashFlowDate; + total: ICashFlowStatementTotal; +} + +export interface ICashFlowDate { + formattedDate: string; + date: Date; +} + +export interface ICashFlowStatement { + /** + * Constructor method. + * @constructor + */ + constructor( + accounts: IAccount[], + ledger: ILedger, + cashLedger: ILedger, + netIncomeLedger: ILedger, + query: ICashFlowStatementQuery, + baseCurrency: string + ): void; + + reportData(): ICashFlowStatementData; +} + +export interface ICashFlowTable { + constructor(reportStatement: ICashFlowStatement): void; + tableRows(): ITableRow[]; +} + +export interface IDateRange { + fromDate: Date, + toDate: Date, +} \ No newline at end of file diff --git a/server/src/interfaces/InventoryDetails.ts b/server/src/interfaces/InventoryDetails.ts new file mode 100644 index 000000000..5ce72b343 --- /dev/null +++ b/server/src/interfaces/InventoryDetails.ts @@ -0,0 +1,76 @@ +import { + INumberFormatQuery, +} from './FinancialStatements'; + +export interface IInventoryDetailsQuery { + fromDate: Date | string; + toDate: Date | string; + numberFormat: INumberFormatQuery; + noneTransactions: boolean; +} + +export interface IInventoryDetailsNumber { + number: number; + formattedNumber: string; +} + +export interface IInventoryDetailsMoney { + amount: number; + formattedAmount: string; + currencyCode: string; +} + +export interface IInventoryDetailsDate { + date: Date; + formattedDate: string; +} + +export interface IInventoryDetailsOpening { + nodeType: 'OPENING_ENTRY'; + date: IInventoryDetailsDate; + quantity: IInventoryDetailsNumber; + value: IInventoryDetailsNumber; +} + +export interface IInventoryDetailsClosing extends IInventoryDetailsOpening { + nodeType: 'CLOSING_ENTRY'; +} + +export interface IInventoryDetailsItem { + id: number; + nodeType: string; + name: string; + code: string; + children: ( + | IInventoryDetailsItemTransaction + | IInventoryDetailsOpening + | IInventoryDetailsClosing + )[]; +} + +export interface IInventoryDetailsItemTransaction { + nodeType: string; + date: IInventoryDetailsDate; + transactionType: string; + transactionNumber: string; + + quantityMovement: IInventoryDetailsNumber; + valueMovement: IInventoryDetailsNumber; + + quantity: IInventoryDetailsNumber; + value: IInventoryDetailsNumber; + cost: IInventoryDetailsNumber; + profitMargin: IInventoryDetailsNumber; + + rate: IInventoryDetailsNumber; + + runningQuantity: IInventoryDetailsNumber; + runningValuation: IInventoryDetailsNumber; + + direction: string; +} + +export type IInventoryDetailsNode = + | IInventoryDetailsItem + | IInventoryDetailsItemTransaction; +export type IInventoryDetailsData = IInventoryDetailsItem[]; diff --git a/server/src/interfaces/InventoryTransaction.ts b/server/src/interfaces/InventoryTransaction.ts index 47e4d4219..f56666bc3 100644 --- a/server/src/interfaces/InventoryTransaction.ts +++ b/server/src/interfaces/InventoryTransaction.ts @@ -1,38 +1,51 @@ - export type TInventoryTransactionDirection = 'IN' | 'OUT'; export interface IInventoryTransaction { - id?: number, - date: Date|string, - direction: TInventoryTransactionDirection, - itemId: number, + id?: number; + date: Date | string; + direction: TInventoryTransactionDirection; + itemId: number; + quantity: number; + rate: number; + transactionType: string; + transcationTypeFormatted: string; + transactionId: number; + entryId: number; + costAccountId: number; + meta?: IInventoryTransactionMeta; + costLotAggregated?: IInventoryCostLotAggregated; + createdAt?: Date; + updatedAt?: Date; +} + +export interface IInventoryTransactionMeta { + id?: number; + transactionNumber: string; + description: string; +} + +export interface IInventoryCostLotAggregated { + cost: number, quantity: number, - rate: number, - transactionType: string, - transactionId: number, - entryId: number, - costAccountId: number, - createdAt?: Date, - updatedAt?: Date, }; export interface IInventoryLotCost { - id?: number, - date: Date, - direction: string, - itemId: number, - quantity: number, - rate: number, - remaining: number, - cost: number, - transactionType: string, - transactionId: number, - costAccountId: number, - entryId: number, - createdAt: Date, -}; + id?: number; + date: Date; + direction: string; + itemId: number; + quantity: number; + rate: number; + remaining: number; + cost: number; + transactionType: string; + transactionId: number; + costAccountId: number; + entryId: number; + createdAt: Date; +} export interface IItemsQuantityChanges { - itemId: number, - balanceChange: number, -}; \ No newline at end of file + itemId: number; + balanceChange: number; +} diff --git a/server/src/interfaces/Ledger.ts b/server/src/interfaces/Ledger.ts index e3dd19288..938a997b5 100644 --- a/server/src/interfaces/Ledger.ts +++ b/server/src/interfaces/Ledger.ts @@ -2,6 +2,7 @@ export interface ILedger { entries: ILedgerEntry[]; getEntries(): ILedgerEntry[]; + whereAccountId(accountId: number): ILedger; whereContactId(contactId: number): ILedger; whereFromDate(fromDate: Date | string): ILedger; whereToDate(toDate: Date | string): ILedger; @@ -15,6 +16,6 @@ export interface ILedgerEntry { accountNormal: string; contactId?: number; date: Date | string; - transactionType: string, - transactionNumber: string, + transactionType?: string, + transactionNumber?: string, } diff --git a/server/src/interfaces/Table.ts b/server/src/interfaces/Table.ts index f04d844e0..944ace6db 100644 --- a/server/src/interfaces/Table.ts +++ b/server/src/interfaces/Table.ts @@ -12,4 +12,14 @@ export interface ITableCell { export type ITableRow = { rows: ITableCell[]; -}; \ No newline at end of file +}; + +export interface ITableColumn { + key: string, + label: string, +} + +export interface ITable { + columns: ITableColumn[], + data: ITableRow[], +} \ No newline at end of file diff --git a/server/src/interfaces/TransactionsByContacts.ts b/server/src/interfaces/TransactionsByContacts.ts index 9f4569ba0..82a38002a 100644 --- a/server/src/interfaces/TransactionsByContacts.ts +++ b/server/src/interfaces/TransactionsByContacts.ts @@ -25,8 +25,8 @@ export interface ITransactionsByContactsContact { } export interface ITransactionsByContactsFilter { - fromDate: Date; - toDate: Date; + fromDate: Date|string; + toDate: Date|string; numberFormat: INumberFormatQuery; noneTransactions: boolean; noneZero: boolean; diff --git a/server/src/interfaces/index.ts b/server/src/interfaces/index.ts index 090f22f32..ba054121d 100644 --- a/server/src/interfaces/index.ts +++ b/server/src/interfaces/index.ts @@ -50,4 +50,6 @@ export * from './TransactionsByCustomers'; export * from './TransactionsByContacts'; export * from './TransactionsByVendors'; export * from './Table'; -export * from './Ledger'; \ No newline at end of file +export * from './Ledger'; +export * from './CashFlow'; +export * from './InventoryDetails'; diff --git a/server/src/models/AccountTransaction.js b/server/src/models/AccountTransaction.js index 0d615a294..0cf9e246a 100644 --- a/server/src/models/AccountTransaction.js +++ b/server/src/models/AccountTransaction.js @@ -1,4 +1,4 @@ -import { Model } from 'objection'; +import { Model, raw } from 'objection'; import moment from 'moment'; import TenantModel from 'models/TenantModel'; @@ -138,6 +138,21 @@ export default class AccountTransaction extends TenantModel { query.sum('credit as credit'); query.sum('debit as debit'); query.select('contactId'); + }, + creditDebitSummation(query) { + query.sum('credit as credit'); + query.sum('debit as debit'); + }, + groupByDateFormat(query, groupType = 'month') { + const groupBy = { + 'day': '%Y-%m-%d', + 'month': '%Y-%m', + 'year': '%Y', + }; + const dateFormat = groupBy[groupType]; + + query.select(raw(`DATE_FORMAT(DATE, '${dateFormat}')`).as('date')); + query.groupByRaw(`DATE_FORMAT(DATE, '${dateFormat}')`); } }; } diff --git a/server/src/models/InventoryTransaction.js b/server/src/models/InventoryTransaction.js index 87146ced0..8c2b10f4c 100644 --- a/server/src/models/InventoryTransaction.js +++ b/server/src/models/InventoryTransaction.js @@ -17,6 +17,33 @@ export default class InventoryTransaction extends TenantModel { return ['createdAt', 'updatedAt']; } + /** + * Retrieve formatted reference type. + * @return {string} + */ + get transcationTypeFormatted() { + return InventoryTransaction.getReferenceTypeFormatted(this.transactionType); + } + + /** + * Reference type formatted. + */ + static getReferenceTypeFormatted(referenceType) { + const mapped = { + 'SaleInvoice': 'Sale invoice', + 'SaleReceipt': 'Sale receipt', + 'PaymentReceive': 'Payment receive', + 'Bill': 'Bill', + 'BillPayment': 'Payment made', + 'VendorOpeningBalance': 'Vendor opening balance', + 'CustomerOpeningBalance': 'Customer opening balance', + 'InventoryAdjustment': 'Inventory adjustment', + 'ManualJournal': 'Manual journal', + 'Journal': 'Manual journal', + }; + return mapped[referenceType] || ''; + } + /** * Model modifiers. */ @@ -59,8 +86,47 @@ export default class InventoryTransaction extends TenantModel { static get relationMappings() { const Item = require('models/Item'); const ItemEntry = require('models/ItemEntry'); + const InventoryTransactionMeta = require('models/InventoryTransactionMeta'); + const InventoryCostLots = require('models/InventoryCostLotTracker'); return { + // Transaction meta. + meta: { + relation: Model.HasOneRelation, + modelClass: InventoryTransactionMeta.default, + join: { + from: 'inventory_transactions.id', + to: 'inventory_transaction_meta.inventoryTransactionId', + }, + }, + // Item cost aggregated. + itemCostAggregated: { + relation: Model.HasOneRelation, + modelClass: InventoryCostLots.default, + join: { + from: 'inventory_transactions.itemId', + to: 'inventory_cost_lot_tracker.itemId', + }, + filter(query) { + query.select('itemId'); + query.sum('cost as cost'); + query.sum('quantity as quantity'); + query.groupBy('itemId'); + }, + }, + costLotAggregated: { + relation: Model.HasOneRelation, + modelClass: InventoryCostLots.default, + join: { + from: 'inventory_transactions.id', + to: 'inventory_cost_lot_tracker.inventoryTransactionId', + }, + filter(query) { + query.sum('cost as cost'); + query.sum('quantity as quantity'); + query.groupBy('inventoryTransactionId'); + } + }, item: { relation: Model.BelongsToOneRelation, modelClass: Item.default, diff --git a/server/src/models/InventoryTransactionMeta.js b/server/src/models/InventoryTransactionMeta.js new file mode 100644 index 000000000..62a232b64 --- /dev/null +++ b/server/src/models/InventoryTransactionMeta.js @@ -0,0 +1,29 @@ +import { Model, raw } from 'objection'; +import TenantModel from 'models/TenantModel'; + +export default class InventoryTransactionMeta extends TenantModel { + /** + * Table name + */ + static get tableName() { + return 'inventory_transaction_meta'; + } + + /** + * Relationship mapping. + */ + static get relationMappings() { + const InventoryTransactions = require('models/InventoryTransaction'); + + return { + inventoryTransaction: { + relation: Model.BelongsToOneRelation, + modelClass: InventoryTransactions.default, + join: { + from: 'inventory_transaction_meta.inventoryTransactionId', + to: 'inventory_transactions.inventoryTransactionId' + } + } + }; + } +} diff --git a/server/src/services/Accounting/Ledger.ts b/server/src/services/Accounting/Ledger.ts index 93870dfdb..fc78e554f 100644 --- a/server/src/services/Accounting/Ledger.ts +++ b/server/src/services/Accounting/Ledger.ts @@ -1,9 +1,6 @@ import moment from 'moment'; import { defaultTo } from 'lodash'; -import { - ILedger, - ILedgerEntry -} from 'interfaces'; +import { IAccountTransaction, ILedger, ILedgerEntry } from 'interfaces'; import EntityRepository from 'repositories/EntityRepository'; export default class Ledger implements ILedger { @@ -11,7 +8,7 @@ export default class Ledger implements ILedger { /** * Constructor method. - * @param {ILedgerEntry[]} entries + * @param {ILedgerEntry[]} entries */ constructor(entries: ILedgerEntry[]) { this.entries = entries; @@ -20,26 +17,45 @@ export default class Ledger implements ILedger { /** * Filters the ledegr entries. * @param callback - * @returns + * @returns {ILedger} */ - filter(callback) { + public filter(callback): ILedger { const entries = this.entries.filter(callback); return new Ledger(entries); } - getEntries(): ILedgerEntry[] { + /** + * Retrieve the all entries of the ledger. + * @return {ILedgerEntry[]} + */ + public getEntries(): ILedgerEntry[] { return this.entries; } - whereContactId(contactId: number): ILedger { + /** + * Filters entries by th given contact id and returns a new ledger. + * @param {number} contactId + * @returns {ILedger} + */ + public whereContactId(contactId: number): ILedger { return this.filter((entry) => entry.contactId === contactId); } - whereAccountId(accountId: number): ILedger { + /** + * Filters entries by the given account id and returns a new ledger. + * @param {number} accountId + * @returns {ILedger} + */ + public whereAccountId(accountId: number): ILedger { return this.filter((entry) => entry.accountId === accountId); } - whereFromDate(fromDate: Date | string): ILedger { + /** + * Filters entries that before or same the given date and returns a new ledger. + * @param {Date|string} fromDate + * @returns {ILedger} + */ + public whereFromDate(fromDate: Date | string): ILedger { const fromDateParsed = moment(fromDate); return this.filter( @@ -48,7 +64,12 @@ export default class Ledger implements ILedger { ); } - whereToDate(toDate: Date | string): ILedger { + /** + * Filters ledger entries that after the given date and retruns a new ledger. + * @param {Date|string} toDate + * @returns {ILedger} + */ + public whereToDate(toDate: Date | string): ILedger { const toDateParsed = moment(toDate); return this.filter( @@ -59,15 +80,14 @@ export default class Ledger implements ILedger { /** * Retrieve the closing balance of the entries. - * @returns {number} + * @returns {number} */ - getClosingBalance() { + public getClosingBalance(): number { let closingBalance = 0; this.entries.forEach((entry) => { if (entry.accountNormal === 'credit') { closingBalance += entry.credit - entry.debit; - } else if (entry.accountNormal === 'debit') { closingBalance += entry.debit - entry.credit; } @@ -75,15 +95,25 @@ export default class Ledger implements ILedger { return closingBalance; } - static mappingTransactions(entries): ILedgerEntry[] { + /** + * Mappes the account transactions to ledger entries. + * @param {IAccountTransaction[]} entries + * @returns {ILedgerEntry[]} + */ + static mappingTransactions(entries: IAccountTransaction[]): ILedgerEntry[] { return entries.map(this.mapTransaction); } - - static mapTransaction(entry): ILedgerEntry { + + /** + * Mappes the account transaction to ledger entry. + * @param {IAccountTransaction} entry + * @returns {ILedgerEntry} + */ + static mapTransaction(entry: IAccountTransaction): ILedgerEntry { return { credit: defaultTo(entry.credit, 0), debit: defaultTo(entry.debit, 0), - accountNormal: entry.accountNormal, + accountNormal: entry.account.accountNormal, accountId: entry.accountId, contactId: entry.contactId, date: entry.date, @@ -91,10 +121,15 @@ export default class Ledger implements ILedger { transactionType: entry.referenceTypeFormatted, referenceNumber: entry.referenceNumber, referenceType: entry.referenceType, - } + }; } - static fromTransactions(transactions) { + /** + * Mappes the account transactions to ledger entries. + * @param {IAccountTransaction[]} transactions + * @returns {ILedger} + */ + static fromTransactions(transactions: IAccountTransaction[]): ILedger { const entries = Ledger.mappingTransactions(transactions); return new Ledger(entries); } diff --git a/server/src/services/FinancialStatements/BalanceSheet/BalanceSheet.ts b/server/src/services/FinancialStatements/BalanceSheet/BalanceSheet.ts index c166e123a..d8b45706c 100644 --- a/server/src/services/FinancialStatements/BalanceSheet/BalanceSheet.ts +++ b/server/src/services/FinancialStatements/BalanceSheet/BalanceSheet.ts @@ -16,13 +16,13 @@ import FinancialSheet from '../FinancialSheet'; export default class BalanceSheetStatement extends FinancialSheet { readonly query: IBalanceSheetQuery; + readonly numberFormat: INumberFormatQuery; readonly tenantId: number; readonly accounts: IAccount & { type: IAccountType }[]; readonly journalFinancial: IJournalPoster; readonly comparatorDateType: string; readonly dateRangeSet: string[]; readonly baseCurrency: string; - readonly numberFormat: INumberFormatQuery; /** * Constructor method. @@ -46,7 +46,6 @@ export default class BalanceSheetStatement extends FinancialSheet { this.accounts = accounts; this.journalFinancial = journalFinancial; this.baseCurrency = baseCurrency; - this.comparatorDateType = query.displayColumnsType === 'total' ? 'day' : query.displayColumnsBy; diff --git a/server/src/services/FinancialStatements/BalanceSheet/BalanceSheetService.ts b/server/src/services/FinancialStatements/BalanceSheet/BalanceSheetService.ts index 5fb30b244..3c0c7b5ba 100644 --- a/server/src/services/FinancialStatements/BalanceSheet/BalanceSheetService.ts +++ b/server/src/services/FinancialStatements/BalanceSheet/BalanceSheetService.ts @@ -53,7 +53,7 @@ export default class BalanceSheetStatementService * @param {number} tenantId - * @returns {IBalanceSheetMeta} */ - reportMetadata(tenantId: number): IBalanceSheetMeta { + private reportMetadata(tenantId: number): IBalanceSheetMeta { const settings = this.tenancy.settings(tenantId); const isCostComputeRunning = this.inventoryService @@ -113,7 +113,7 @@ export default class BalanceSheetStatementService // Retrieve all journal transactions based on the given query. const transactions = await transactionsRepository.journal({ fromDate: query.fromDate, - toDate: query.toDate, + toDate: query.toDate, }); // Transform transactions to journal collection. const transactionsJournal = Journal.fromTransactions( diff --git a/server/src/services/FinancialStatements/CashFlow/CashFlow.ts b/server/src/services/FinancialStatements/CashFlow/CashFlow.ts new file mode 100644 index 000000000..d000210b8 --- /dev/null +++ b/server/src/services/FinancialStatements/CashFlow/CashFlow.ts @@ -0,0 +1,761 @@ +import * as R from 'ramda'; +import { defaultTo, map, set, sumBy, isEmpty, mapValues, get } from 'lodash'; +import * as mathjs from 'mathjs'; +import moment from 'moment'; +import { + IAccount, + ILedger, + INumberFormatQuery, + ICashFlowSchemaSection, + ICashFlowStatementQuery, + ICashFlowStatementNetIncomeSection, + ICashFlowStatementAccountSection, + ICashFlowSchemaSectionAccounts, + ICashFlowStatementAccountMeta, + ICashFlowSchemaAccountRelation, + ICashFlowStatementSectionType, + ICashFlowStatementData, + ICashFlowDatePeriod, + ICashFlowStatement, + ICashFlowSchemaTotalSection, + ICashFlowStatementTotalSection, + ICashFlowStatementSection, +} from 'interfaces'; +import CASH_FLOW_SCHEMA from './schema'; +import FinancialSheet from '../FinancialSheet'; +import { + transformToMapBy, + accumSum, + dateRangeFromToCollection, + applyMixins, +} from 'utils'; +import { + reduceDeep, + iteratee, + mapValuesDeep, + filterDeep, +} from 'utils/deepdash'; +import { ACCOUNT_ROOT_TYPE } from 'data/AccountTypes'; +import CashFlowDatePeriods from './CashFlowDatePeriods'; + +const MAP_CONFIG = { childrenPath: 'children', pathFormat: 'array' }; + +const DISPLAY_COLUMNS_BY = { + DATE_PERIODS: 'date_periods', + TOTAL: 'total', +}; + +class CashFlowStatement extends FinancialSheet implements ICashFlowStatement { + readonly baseCurrency: string; + readonly sectionsByIds = {}; + readonly cashFlowSchemaMap: Map; + readonly cashFlowSchemaSeq: Array; + readonly accountByTypeMap: Map; + readonly accountsByRootType: Map; + readonly ledger: ILedger; + readonly cashLedger: ILedger; + readonly netIncomeLedger: ILedger; + readonly schemaSectionParserIteratee: any; + readonly query: ICashFlowStatementQuery; + readonly numberFormat: INumberFormatQuery; + + readonly comparatorDateType: string; + readonly dateRangeSet: { fromDate: Date; toDate: Date }[]; + + /** + * Constructor method. + * @constructor + */ + constructor( + accounts: IAccount[], + ledger: ILedger, + cashLedger: ILedger, + netIncomeLedger: ILedger, + query: ICashFlowStatementQuery, + baseCurrency: string + ) { + super(); + + this.baseCurrency = baseCurrency; + this.ledger = ledger; + this.cashLedger = cashLedger; + this.netIncomeLedger = netIncomeLedger; + this.accountByTypeMap = transformToMapBy(accounts, 'accountType'); + this.accountsByRootType = transformToMapBy(accounts, 'accountRootType'); + this.schemaSectionParserIteratee = iteratee(this.schemaSectionParser); + this.query = query; + this.numberFormat = this.query.numberFormat; + this.dateRangeSet = []; + + this.comparatorDateType = + query.displayColumnsType === 'total' ? 'day' : query.displayColumnsBy; + + this.initDateRangeCollection(); + } + + // -------------------------------------------- + // # GENERAL UTILITIES + // -------------------------------------------- + /** + * Retrieve the expense accounts ids. + * @return {number[]} + */ + private getAccountsIdsByType(accountType: string): number[] { + const expenseAccounts = this.accountsByRootType.get(accountType); + const expenseAccountsIds = map(expenseAccounts, 'id'); + + return expenseAccountsIds; + } + + /** + * Detarmines the given display columns by type. + * @param {string} displayColumnsBy + * @returns {boolean} + */ + private isDisplayColumnsBy(displayColumnsBy: string): boolean { + return this.query.displayColumnsType === displayColumnsBy; + } + + /** + * Adjustments the given amount. + * @param {string} direction + * @param {number} amount - + * @return {number} + */ + private amountAdjustment(direction: 'mines' | 'plus', amount): number { + return R.when( + R.always(R.equals(direction, 'mines')), + R.multiply(-1) + )(amount); + } + + // -------------------------------------------- + // # NET INCOME NODE + // -------------------------------------------- + + /** + * Retrieve the accounts net income. + * @returns {number} - Amount of net income. + */ + private getAccountsNetIncome(): number { + // Mapping income/expense accounts ids. + const incomeAccountsIds = this.getAccountsIdsByType( + ACCOUNT_ROOT_TYPE.INCOME + ); + const expenseAccountsIds = this.getAccountsIdsByType( + ACCOUNT_ROOT_TYPE.EXPENSE + ); + + // Income closing balance. + const incomeClosingBalance = accumSum(incomeAccountsIds, (id) => + this.netIncomeLedger.whereAccountId(id).getClosingBalance() + ); + // Expense closing balance. + const expenseClosingBalance = accumSum(expenseAccountsIds, (id) => + this.netIncomeLedger.whereAccountId(id).getClosingBalance() + ); + // Net income = income - expenses. + const netIncome = incomeClosingBalance - expenseClosingBalance; + + return netIncome; + } + + /** + * Parses the net income section from the given section schema. + * @param {ICashFlowSchemaSection} sectionSchema - Report section schema. + * @returns {ICashFlowStatementNetIncomeSection} + */ + private netIncomeSectionMapper( + sectionSchema: ICashFlowSchemaSection + ): ICashFlowStatementNetIncomeSection { + const netIncome = this.getAccountsNetIncome(); + + return R.compose( + R.when( + R.always(this.isDisplayColumnsBy(DISPLAY_COLUMNS_BY.DATE_PERIODS)), + this.assocPeriodsToNetIncomeNode.bind(this) + ) + )({ + id: sectionSchema.id, + label: sectionSchema.label, + total: this.getAmountMeta(netIncome), + sectionType: ICashFlowStatementSectionType.NET_INCOME, + }); + } + + // -------------------------------------------- + // # ACCOUNT NODE + // -------------------------------------------- + + /** + * Retrieve account meta. + * @param {ICashFlowSchemaAccountRelation} relation - Account relation. + * @param {IAccount} account - + * @returns {ICashFlowStatementAccountMeta} + */ + private accountMetaMapper( + relation: ICashFlowSchemaAccountRelation, + account: IAccount + ): ICashFlowStatementAccountMeta { + // Retrieve the closing balance of the given account. + const getClosingBalance = (id) => + this.ledger.whereAccountId(id).getClosingBalance(); + + const closingBalance = R.compose( + // Multiplies the amount by -1 in case the relation in mines. + R.curry(this.amountAdjustment)(relation.direction) + )(getClosingBalance(account.id)); + + return R.compose( + R.when( + R.always(this.isDisplayColumnsBy(DISPLAY_COLUMNS_BY.DATE_PERIODS)), + this.assocPeriodsToAccountNode.bind(this) + ) + )({ + id: account.id, + code: account.code, + label: account.name, + accountType: account.accountType, + adjusmentType: relation.direction, + total: this.getAmountMeta(closingBalance), + sectionType: ICashFlowStatementSectionType.ACCOUNT, + }); + } + + /** + * Retrieve accounts sections by the given schema relation. + * @param {ICashFlowSchemaAccountRelation} relation + * @returns {ICashFlowStatementAccountMeta[]} + */ + private getAccountsBySchemaRelation( + relation: ICashFlowSchemaAccountRelation + ): ICashFlowStatementAccountMeta[] { + const accounts = defaultTo(this.accountByTypeMap.get(relation.type), []); + const accountMetaMapper = R.curry(this.accountMetaMapper.bind(this))( + relation + ); + return R.map(accountMetaMapper)(accounts); + } + + /** + * Retrieve the accounts meta. + * @param {string[]} types + * @returns {ICashFlowStatementAccountMeta[]} + */ + private getAccountsBySchemaRelations( + relations: ICashFlowSchemaAccountRelation[] + ): ICashFlowStatementAccountMeta[] { + return R.pipe( + R.append(R.map(this.getAccountsBySchemaRelation.bind(this))(relations)), + R.flatten + )([]); + } + + /** + * Calculates the accounts total + * @param {ICashFlowStatementAccountMeta[]} accounts + * @returns {number} + */ + private getAccountsMetaTotal( + accounts: ICashFlowStatementAccountMeta[] + ): number { + return sumBy(accounts, 'total.amount'); + } + + /** + * Retrieve the accounts section from the section schema. + * @param {ICashFlowSchemaSectionAccounts} sectionSchema + * @returns {ICashFlowStatementAccountSection} + */ + private accountsSectionParser( + sectionSchema: ICashFlowSchemaSectionAccounts + ): ICashFlowStatementAccountSection { + const { accountsRelations } = sectionSchema; + + const accounts = this.getAccountsBySchemaRelations(accountsRelations); + const total = this.getAccountsMetaTotal(accounts); + + return R.compose( + R.when( + R.always(this.isDisplayColumnsBy(DISPLAY_COLUMNS_BY.DATE_PERIODS)), + this.assocPeriodsToAggregateNode.bind(this) + ) + )({ + sectionType: ICashFlowStatementSectionType.ACCOUNTS, + id: sectionSchema.id, + label: sectionSchema.label, + footerLabel: sectionSchema.footerLabel, + children: accounts, + total: this.getTotalAmountMeta(total), + }); + } + + /** + * Detarmines the schema section type. + * @param {string} type + * @param {ICashFlowSchemaSection} section + * @returns {boolean} + */ + private isSchemaSectionType( + type: string, + section: ICashFlowSchemaSection + ): boolean { + return type === section.sectionType; + } + + // -------------------------------------------- + // # AGGREGATE NODE + // -------------------------------------------- + + /** + * + * @param {ICashFlowSchemaSection} schemaSection + * @returns + */ + private regularSectionParser( + schemaSection: ICashFlowSchemaSection + ): ICashFlowStatementSection { + return { + id: schemaSection.id, + label: schemaSection.label, + footerLabel: schemaSection.footerLabel, + sectionType: ICashFlowStatementSectionType.REGULAR, + }; + } + + private transformSectionsToMap(sections: ICashFlowSchemaSection[]) { + return reduceDeep( + sections, + (acc, section) => { + if (section.id) { + acc[`${section.id}`] = section; + } + return acc; + }, + {}, + MAP_CONFIG + ); + } + + // -------------------------------------------- + // # TOTAL EQUATION NODE + // -------------------------------------------- + + private sectionsMapToTotal(mappedSections: { [key: number]: any }) { + return mapValues(mappedSections, (node) => get(node, 'total.amount') || 0); + } + + /** + * Evauluate equaation string with the given scope table. + * @param {string} equation - + * @param {{ [key: string]: number }} scope - + * @return {number} + */ + private evaluateEquation( + equation: string, + scope: { [key: string | number]: number } + ): number { + return mathjs.evaluate(equation, scope); + } + + /** + * Retrieve the total section from the eqauation parser. + * @param {ICashFlowSchemaTotalSection} sectionSchema + * @param {ICashFlowSchemaSection[]} accumlatedSections + */ + private totalEquationSectionParser( + accumlatedSections: ICashFlowSchemaSection[], + sectionSchema: ICashFlowSchemaTotalSection + ): ICashFlowStatementTotalSection { + const mappedSectionsById = this.transformSectionsToMap(accumlatedSections); + const nodesTotalById = this.sectionsMapToTotal(mappedSectionsById); + + const total = this.evaluateEquation(sectionSchema.equation, nodesTotalById); + + return R.compose( + R.when( + R.always(this.isDisplayColumnsBy(DISPLAY_COLUMNS_BY.DATE_PERIODS)), + R.curry(this.assocTotalEquationDatePeriods.bind(this))( + mappedSectionsById, + sectionSchema.equation + ) + ) + )({ + sectionType: ICashFlowStatementSectionType.TOTAL, + id: sectionSchema.id, + label: sectionSchema.label, + total: this.getTotalAmountMeta(total), + }); + } + + /** + * Retrieve the beginning cash from date. + * @param {Date|string} fromDate - + * @return {Date} + */ + private beginningCashFrom(fromDate: string | Date): Date { + return moment(fromDate).subtract(1, 'days').toDate(); + } + + /** + * Retrieve account meta. + * @param {ICashFlowSchemaAccountRelation} relation + * @param {IAccount} account + * @returns {ICashFlowStatementAccountMeta} + */ + private cashAccountMetaMapper( + relation: ICashFlowSchemaAccountRelation, + account: IAccount + ): ICashFlowStatementAccountMeta { + const cashToDate = this.beginningCashFrom(this.query.fromDate); + + const closingBalance = this.cashLedger + .whereToDate(cashToDate) + .whereAccountId(account.id) + .getClosingBalance(); + + return R.compose( + R.when( + R.always(this.isDisplayColumnsBy(DISPLAY_COLUMNS_BY.DATE_PERIODS)), + this.assocCashAtBeginningAccountDatePeriods.bind(this) + ) + )({ + id: account.id, + code: account.code, + label: account.name, + accountType: account.accountType, + adjusmentType: relation.direction, + total: this.getAmountMeta(closingBalance), + sectionType: ICashFlowStatementSectionType.ACCOUNT, + }); + } + + /** + * Retrieve accounts sections by the given schema relation. + * @param {ICashFlowSchemaAccountRelation} relation + * @returns {ICashFlowStatementAccountMeta[]} + */ + private getCashAccountsBySchemaRelation( + relation: ICashFlowSchemaAccountRelation + ): ICashFlowStatementAccountMeta[] { + const accounts = this.accountByTypeMap.get(relation.type) || []; + const accountMetaMapper = R.curry(this.cashAccountMetaMapper.bind(this))( + relation + ); + return accounts.map(accountMetaMapper); + } + + /** + * Retrieve the accounts meta. + * @param {string[]} types + * @returns {ICashFlowStatementAccountMeta[]} + */ + private getCashAccountsBySchemaRelations( + relations: ICashFlowSchemaAccountRelation[] + ): ICashFlowStatementAccountMeta[] { + return R.concat( + ...R.map(this.getCashAccountsBySchemaRelation.bind(this))(relations) + ); + } + + /** + * Parses the cash at beginning section. + * @param {ICashFlowSchemaTotalSection} sectionSchema - + * @return {} + */ + private cashAtBeginningSectionParser(sectionSchema) { + const { accountsRelations } = sectionSchema; + + const children = this.getCashAccountsBySchemaRelations(accountsRelations); + const total = this.getAccountsMetaTotal(children); + + return R.compose( + R.when( + R.always(this.isDisplayColumnsBy(DISPLAY_COLUMNS_BY.DATE_PERIODS)), + this.assocCashAtBeginningDatePeriods.bind(this) + ) + )({ + sectionType: ICashFlowStatementSectionType.CASH_AT_BEGINNING, + id: sectionSchema.id, + label: sectionSchema.label, + children, + total: this.getTotalAmountMeta(total), + }); + } + + /** + * Parses the schema section. + * @param {ICashFlowSchemaSection} section + * @returns {ICashFlowSchemaSection} + */ + private schemaSectionParser( + section: ICashFlowSchemaSection, + key: number, + parentValue: ICashFlowSchemaSection[], + context, + accumlatedSections: ICashFlowSchemaSection[] + ): ICashFlowSchemaSection { + const isSchemaSectionType = R.curry(this.isSchemaSectionType); + + return R.compose( + // Accounts node. + R.when( + isSchemaSectionType(ICashFlowStatementSectionType.ACCOUNTS), + this.accountsSectionParser.bind(this) + ), + // Net income node. + R.when( + isSchemaSectionType(ICashFlowStatementSectionType.NET_INCOME), + this.netIncomeSectionMapper.bind(this) + ), + // Cash at beginning node. + R.when( + isSchemaSectionType(ICashFlowStatementSectionType.CASH_AT_BEGINNING), + R.curry(this.cashAtBeginningSectionParser.bind(this)) + ), + // Aggregate node. (that has no section type). + R.when( + isSchemaSectionType(ICashFlowStatementSectionType.AGGREGATE), + this.regularSectionParser.bind(this) + ) + )(section); + } + + /** + * Parses the schema section. + * @param {ICashFlowSchemaSection} section + * @returns {ICashFlowSchemaSection} + */ + private schemaSectionTotalParser( + section: ICashFlowSchemaSection | ICashFlowStatementSection, + key: number, + parentValue: ICashFlowSchemaSection[], + context, + accumlatedSections: ICashFlowSchemaSection | ICashFlowStatementSection[] + ): ICashFlowSchemaSection { + const isSchemaSectionType = R.curry(this.isSchemaSectionType); + + return R.compose( + // Total equation section. + R.when( + isSchemaSectionType(ICashFlowStatementSectionType.TOTAL), + R.curry(this.totalEquationSectionParser.bind(this))(accumlatedSections) + ) + )(section); + } + + /** + * + * @param acc + * @param value + * @param key + * @param parentValue + * @param context + * @returns + */ + private schemaSectionsReducer(acc, value, key, parentValue, context) { + set( + acc, + context.path, + this.schemaSectionParserIteratee(value, key, parentValue, context, acc) + ); + return acc; + } + + /** + * Schema sections parser. + * @param {ICashFlowSchemaSection[]}schema + * @returns + */ + private schemaSectionsParser(schema: ICashFlowSchemaSection[]) { + return reduceDeep( + schema, + this.schemaSectionsReducer.bind(this), + [], + MAP_CONFIG + ); + } + + /** + * Writes the `total` property to the aggregate node. + * @return {ICashFlowStatementSection} + */ + private assocRegularSectionTotal(section: ICashFlowStatementSection) { + const total = this.getAccountsMetaTotal(section.children); + return R.assoc('total', this.getTotalAmountMeta(total), section); + } + + /** + * Parses the given node on stage 2. + * @param {ICashFlowStatementSection} node + * @return {ICashFlowStatementSection} + */ + private sectionMapperAfterParsing(section: ICashFlowStatementSection) { + const isSchemaSectionType = R.curry(this.isSchemaSectionType); + + return R.compose( + R.when( + isSchemaSectionType(ICashFlowStatementSectionType.REGULAR), + this.assocRegularSectionTotal.bind(this) + ), + R.when( + isSchemaSectionType(ICashFlowStatementSectionType.REGULAR), + R.when( + R.always(this.isDisplayColumnsBy(DISPLAY_COLUMNS_BY.DATE_PERIODS)), + this.assocPeriodsToAggregateNode.bind(this) + ) + ) + )(section); + } + + private regularSectionsTotal( + sections: ICashFlowSchemaSection[] + ): ICashFlowSchemaSection[] { + return mapValuesDeep( + sections, + this.sectionMapperAfterParsing.bind(this), + MAP_CONFIG + ); + } + + private totalSectionsParser( + sections: ICashFlowSchemaSection | ICashFlowStatementSection[] + ) { + return reduceDeep( + sections, + (acc, value, key, parentValue, context) => { + set( + acc, + context.path, + this.schemaSectionTotalParser(value, key, parentValue, context, acc) + ); + return acc; + }, + [], + MAP_CONFIG + ); + } + + // -------------------------------------------- + // REPORT FILTERING + // -------------------------------------------- + + /** + * Detarmines the given section has children and not empty. + * @param {ICashFlowStatementSection} section + * @returns {boolean} + */ + private isSectionHasChildren(section: ICashFlowStatementSection): boolean { + return !isEmpty(section.children); + } + + /** + * Detarmines whether the section has no zero amount. + * @param {ICashFlowStatementSection} section + * @returns {boolean} + */ + private isSectionNoneZero(section: ICashFlowStatementSection): boolean { + return section.total.amount !== 0; + } + + /** + * Detarmines whether the parent accounts sections has children. + * @param {ICashFlowStatementSection} section + * @returns {boolean} + */ + private isAccountsSectionHasChildren( + section: ICashFlowStatementSection[] + ): boolean { + const isSchemaSectionType = R.curry(this.isSchemaSectionType); + + return R.ifElse( + isSchemaSectionType(ICashFlowStatementSectionType.ACCOUNTS), + this.isSectionHasChildren.bind(this), + R.always(true) + )(section); + } + + /** + * Detarmines the account section has no zero otherwise returns true. + * @param {ICashFlowStatementSection} section + * @returns {boolean} + */ + private isAccountLeafNoneZero(section: ICashFlowStatementSection[]): boolean { + const isSchemaSectionType = R.curry(this.isSchemaSectionType); + + return R.ifElse( + isSchemaSectionType(ICashFlowStatementSectionType.ACCOUNT), + this.isSectionNoneZero.bind(this), + R.always(true) + )(section); + } + + /** + * Deep filters the non-zero accounts leafs of the report sections. + * @param {ICashFlowStatementSection[]} sections + * @returns {ICashFlowStatementSection[]} + */ + private filterNoneZeroAccountsLeafs(sections: ICashFlowStatementSection[]) { + return filterDeep( + sections, + this.isAccountLeafNoneZero.bind(this), + MAP_CONFIG + ); + } + + /** + * Deep filter the non-children sections of the report sections. + * @param {ICashFlowStatementSection[]} sections + * @returns {ICashFlowStatementSection[]} + */ + private filterNoneChildrenSections(sections: ICashFlowStatementSection[]) { + return filterDeep( + sections, + this.isAccountsSectionHasChildren.bind(this), + MAP_CONFIG + ); + } + + /** + * Filters the report data. + * @param {ICashFlowStatementSection[]} sections + * @returns {ICashFlowStatementSection[]} + */ + private filterReportData(sections: ICashFlowStatementSection[]) { + return R.compose( + this.filterNoneChildrenSections.bind(this), + this.filterNoneZeroAccountsLeafs.bind(this) + )(sections); + } + + /** + * Schema parser. + * @param {ICashFlowSchemaSection[]} schema + * @returns {ICashFlowSchemaSection[]} + */ + private schemaParser( + schema: ICashFlowSchemaSection[] + ): ICashFlowSchemaSection[] { + return R.compose( + R.when( + R.always(!this.query.noneTransactions && !this.query.noneZero), + this.filterReportData.bind(this) + ), + this.totalSectionsParser.bind(this), + this.regularSectionsTotal.bind(this), + this.schemaSectionsParser.bind(this) + )(schema); + } + + /** + * Retrieve the cashflow statement data. + * @return {ICashFlowStatementData} + */ + public reportData(): ICashFlowStatementData { + return this.schemaParser(R.clone(CASH_FLOW_SCHEMA)); + } +} + +applyMixins(CashFlowStatement, [CashFlowDatePeriods]); + +export default CashFlowStatement; diff --git a/server/src/services/FinancialStatements/CashFlow/CashFlowDatePeriods.ts b/server/src/services/FinancialStatements/CashFlow/CashFlowDatePeriods.ts new file mode 100644 index 000000000..75465b74b --- /dev/null +++ b/server/src/services/FinancialStatements/CashFlow/CashFlowDatePeriods.ts @@ -0,0 +1,410 @@ +import * as R from 'ramda'; +import { sumBy, mapValues, get } from 'lodash'; +import { ACCOUNT_ROOT_TYPE } from 'data/AccountTypes'; +import { accumSum, dateRangeFromToCollection } from 'utils'; +import { + ICashFlowDatePeriod, + ICashFlowStatementNetIncomeSection, + ICashFlowStatementAccountSection, + ICashFlowStatementSection, + ICashFlowSchemaTotalSection, + IFormatNumberSettings, + ICashFlowStatementTotalSection, + IDateRange, + ICashFlowStatementQuery +} from 'interfaces'; + +export default class CashFlowStatementDatePeriods { + dateRangeSet: IDateRange[]; + query: ICashFlowStatementQuery; + + /** + * Initialize date range set. + */ + private initDateRangeCollection() { + this.dateRangeSet = dateRangeFromToCollection( + this.query.fromDate, + this.query.toDate, + this.comparatorDateType + ); + } + + /** + * Retrieve the date period meta. + * @param {number} total - Total amount. + * @param {Date} fromDate - From date. + * @param {Date} toDate - To date. + * @return {ICashFlowDatePeriod} + */ + private getDatePeriodTotalMeta( + total: number, + fromDate: Date, + toDate: Date, + overrideSettings: IFormatNumberSettings = {} + ): ICashFlowDatePeriod { + return this.getDatePeriodMeta(total, fromDate, toDate, { + money: true, + ...overrideSettings, + }); + } + + /** + * Retrieve the date period meta. + * @param {number} total - Total amount. + * @param {Date} fromDate - From date. + * @param {Date} toDate - To date. + * @return {ICashFlowDatePeriod} + */ + private getDatePeriodMeta( + total: number, + fromDate: Date, + toDate: Date, + overrideSettings?: IFormatNumberSettings + ): ICashFlowDatePeriod { + return { + fromDate: this.getDateMeta(fromDate), + toDate: this.getDateMeta(toDate), + total: this.getAmountMeta(total, overrideSettings), + }; + } + + // Net income -------------------- + + /** + * Retrieve the net income between the given date range. + * @param {Date} fromDate + * @param {Date} toDate + * @returns {number} + */ + private getNetIncomeDateRange(fromDate: Date, toDate: Date) { + // Mapping income/expense accounts ids. + const incomeAccountsIds = this.getAccountsIdsByType( + ACCOUNT_ROOT_TYPE.INCOME + ); + const expenseAccountsIds = this.getAccountsIdsByType( + ACCOUNT_ROOT_TYPE.EXPENSE + ); + + // Income closing balance. + const incomeClosingBalance = accumSum(incomeAccountsIds, (id) => + this.netIncomeLedger + .whereFromDate(fromDate) + .whereToDate(toDate) + .whereAccountId(id) + .getClosingBalance() + ); + // Expense closing balance. + const expenseClosingBalance = accumSum(expenseAccountsIds, (id) => + this.netIncomeLedger + .whereToDate(toDate) + .whereFromDate(fromDate) + .whereAccountId(id) + .getClosingBalance() + ); + // Net income = income - expenses. + const netIncome = incomeClosingBalance - expenseClosingBalance; + + return netIncome; + } + + /** + * Retrieve the net income of date period. + * @param {IDateRange} dateRange - + * @retrun {ICashFlowDatePeriod} + */ + private getNetIncomeDatePeriod(dateRange): ICashFlowDatePeriod { + const total = this.getNetIncomeDateRange( + dateRange.fromDate, + dateRange.toDate + ); + return this.getDatePeriodMeta(total, dateRange.fromDate, dateRange.toDate); + } + + /** + * Retrieve the net income node between the given date ranges. + * @param {Date} fromDate + * @param {Date} toDate + * @returns {ICashFlowDatePeriod[]} + */ + private getNetIncomeDatePeriods( + section: ICashFlowStatementNetIncomeSection + ): ICashFlowDatePeriod[] { + return this.dateRangeSet.map(this.getNetIncomeDatePeriod.bind(this)); + } + + /** + * Writes periods property to net income section. + * @param {ICashFlowStatementNetIncomeSection} section + * @returns {ICashFlowStatementNetIncomeSection} + */ + private assocPeriodsToNetIncomeNode( + section: ICashFlowStatementNetIncomeSection + ): ICashFlowStatementNetIncomeSection { + const incomeDatePeriods = this.getNetIncomeDatePeriods(section); + return R.assoc('periods', incomeDatePeriods, section); + } + + // Account nodes -------------------- + + /** + * Retrieve the account total between date range. + * @param {Date} fromDate - From date. + * @param {Date} toDate - To date. + * @return {number} + */ + private getAccountTotalDateRange( + node: ICashFlowStatementAccountSection, + fromDate: Date, + toDate: Date + ): number { + const closingBalance = this.ledger + .whereFromDate(fromDate) + .whereToDate(toDate) + .whereAccountId(node.id) + .getClosingBalance(); + + return this.amountAdjustment(node.adjusmentType, closingBalance); + } + + /** + * Retrieve the given account node total date period. + * @param {ICashFlowStatementAccountSection} node - + * @param {Date} fromDate - From date. + * @param {Date} toDate - To date. + * @return {ICashFlowDatePeriod} + */ + private getAccountTotalDatePeriod( + node: ICashFlowStatementAccountSection, + fromDate: Date, + toDate: Date + ): ICashFlowDatePeriod { + const total = this.getAccountTotalDateRange(node, fromDate, toDate); + return this.getDatePeriodMeta(total, fromDate, toDate); + } + + /** + * Retrieve the accounts date periods nodes of the give account node. + * @param {ICashFlowStatementAccountSection} node - + * @return {ICashFlowDatePeriod[]} + */ + private getAccountDatePeriods( + node: ICashFlowStatementAccountSection + ): ICashFlowDatePeriod[] { + return this.getNodeDatePeriods( + node, + this.getAccountTotalDatePeriod.bind(this) + ); + } + + /** + * Writes `periods` property to account node. + * @param {ICashFlowStatementAccountSection} node - + * @return {ICashFlowStatementAccountSection} + */ + private assocPeriodsToAccountNode( + node: ICashFlowStatementAccountSection + ): ICashFlowStatementAccountSection { + const datePeriods = this.getAccountDatePeriods(node); + return R.assoc('periods', datePeriods, node); + } + + // Aggregate node ------------------------- + + /** + * Retrieve total of the given period index for node that has children nodes. + * @return {number} + */ + private getChildrenTotalPeriodByIndex( + node: ICashFlowStatementSection, + index: number + ): number { + return sumBy(node.children, `periods[${index}].total.amount`); + } + + /** + * Retrieve date period meta of the given node index. + * @param {ICashFlowStatementSection} node - + * @param {number} index - Loop index. + * @param {Date} fromDate - From date. + * @param {Date} toDate - To date. + */ + private getChildrenTotalPeriodMetaByIndex( + node: ICashFlowStatementSection, + index: number, + fromDate: Date, + toDate: Date + ) { + const total = this.getChildrenTotalPeriodByIndex(node, index); + return this.getDatePeriodTotalMeta(total, fromDate, toDate); + } + + /** + * Retrieve the date periods of aggregate node. + * @param {ICashFlowStatementSection} node + */ + private getAggregateNodeDatePeriods(node: ICashFlowStatementSection) { + const getChildrenTotalPeriodMetaByIndex = R.curry( + this.getChildrenTotalPeriodMetaByIndex.bind(this) + )(node); + + return this.dateRangeSet.map((dateRange, index) => + getChildrenTotalPeriodMetaByIndex( + index, + dateRange.fromDate, + dateRange.toDate + ) + ); + } + + /** + * Writes `periods` property to aggregate section node. + * @param {ICashFlowStatementSection} node - + * @return {ICashFlowStatementSection} + */ + private assocPeriodsToAggregateNode( + node: ICashFlowStatementSection + ): ICashFlowStatementSection { + const datePeriods = this.getAggregateNodeDatePeriods(node); + return R.assoc('periods', datePeriods, node); + } + + // Total equation node -------------------- + + private sectionsMapToTotalPeriod( + mappedSections: { [key: number]: any }, + index + ) { + return mapValues( + mappedSections, + (node) => get(node, `periods[${index}].total.amount`) || 0 + ); + } + + /** + * Retrieve the date periods of the given total equation. + * @param {} + * @param {string} equation - + * @return {ICashFlowDatePeriod[]} + */ + private getTotalEquationDatePeriods( + node: ICashFlowSchemaTotalSection, + equation: string, + nodesTable + ): ICashFlowDatePeriod[] { + return this.getNodeDatePeriods(node, (node, fromDate, toDate, index) => { + const periodScope = this.sectionsMapToTotalPeriod(nodesTable, index); + const total = this.evaluateEquation(equation, periodScope); + + return this.getDatePeriodTotalMeta(total, fromDate, toDate); + }); + } + + /** + * Associates the total periods of total equation to the ginve total node.. + * @param {ICashFlowSchemaTotalSection} totalSection - + * @return {ICashFlowStatementTotalSection} + */ + private assocTotalEquationDatePeriods( + nodesTable: any, + equation: string, + node: ICashFlowSchemaTotalSection + ): ICashFlowStatementTotalSection { + const datePeriods = this.getTotalEquationDatePeriods( + node, + equation, + nodesTable + ); + + return R.assoc('periods', datePeriods, node); + } + + // Cash at beginning ---------------------- + + /** + * Retrieve the date preioods of the given node and accumlated function. + * @param {} node + * @param {} + * @return {} + */ + private getNodeDatePeriods(node, callback) { + const curriedCallback = R.curry(callback)(node); + + return this.dateRangeSet.map((dateRange, index) => { + return curriedCallback(dateRange.fromDate, dateRange.toDate, index); + }); + } + + /** + * Retrieve the account total between date range. + * @param {Date} fromDate - From date. + * @param {Date} toDate - To date. + * @return {number} + */ + private getBeginningCashAccountDateRange( + node: ICashFlowStatementSection, + fromDate: Date, + toDate: Date + ) { + const cashToDate = this.beginningCashFrom(fromDate); + + return this.cashLedger + .whereToDate(cashToDate) + .whereAccountId(node.id) + .getClosingBalance(); + } + + /** + * Retrieve the beginning cash date period. + * @param {ICashFlowStatementSection} node - + * @param {Date} fromDate - From date. + * @param {Date} toDate - To date. + * @return {ICashFlowDatePeriod} + */ + private getBeginningCashDatePeriod( + node: ICashFlowStatementSection, + fromDate: Date, + toDate: Date + ) { + const total = this.getBeginningCashAccountDateRange(node, fromDate, toDate); + + return this.getDatePeriodTotalMeta(total, fromDate, toDate); + } + + /** + * Retrieve the beginning cash account periods. + * @param {ICashFlowStatementSection} node + * @return {ICashFlowDatePeriod} + */ + private getBeginningCashAccountPeriods( + node: ICashFlowStatementSection + ): ICashFlowDatePeriod { + return this.getNodeDatePeriods( + node, + this.getBeginningCashDatePeriod.bind(this) + ); + } + + /** + * Writes `periods` property to cash at beginning date periods. + * @param {ICashFlowStatementSection} section - + * @return {ICashFlowStatementSection} + */ + private assocCashAtBeginningDatePeriods( + node: ICashFlowStatementSection + ): ICashFlowStatementSection { + const datePeriods = this.getAggregateNodeDatePeriods(node); + return R.assoc('periods', datePeriods, node); + } + + /** + * Associates `periods` propery to cash at beginning account node. + * @param {ICashFlowStatementSection} node - + * @return {ICashFlowStatementSection} + */ + private assocCashAtBeginningAccountDatePeriods( + node: ICashFlowStatementSection + ): ICashFlowStatementSection { + const datePeriods = this.getBeginningCashAccountPeriods(node); + return R.assoc('periods', datePeriods, node); + } +} diff --git a/server/src/services/FinancialStatements/CashFlow/CashFlowRepository.ts b/server/src/services/FinancialStatements/CashFlow/CashFlowRepository.ts new file mode 100644 index 000000000..1bb2431d2 --- /dev/null +++ b/server/src/services/FinancialStatements/CashFlow/CashFlowRepository.ts @@ -0,0 +1,149 @@ +import { Inject, Service } from 'typedi'; +import moment from 'moment'; +import { + ICashFlowStatementQuery, + IAccountTransaction, + IAccount, +} from 'interfaces'; +import HasTenancyService from 'services/Tenancy/TenancyService'; + +@Service() +export default class CashFlowRepository { + @Inject() + tenancy: HasTenancyService; + + /** + * Retrieve the group type from periods type. + * @param {string} displayType + * @returns {string} + */ + protected getGroupTypeFromPeriodsType(displayType: string) { + const displayTypes = { + year: 'year', + day: 'day', + month: 'month', + quarter: 'month', + week: 'day', + }; + return displayTypes[displayType] || 'month'; + } + + /** + * Retrieve the cashflow accounts. + * @returns {Promise} + */ + public async cashFlowAccounts(tenantId: number): Promise { + const { Account } = this.tenancy.models(tenantId); + + const accounts = await Account.query(); + + return accounts; + } + + /** + * Retrieve total of csah at beginning transactions. + * @param {number} tenantId - + * @param {ICashFlowStatementQuery} filter - + * @return {Promise} + */ + public cashAtBeginningTotalTransactions( + tenantId: number, + filter: ICashFlowStatementQuery + ): Promise { + const { AccountTransaction } = this.tenancy.models(tenantId); + const cashBeginningPeriod = moment(filter.fromDate) + .subtract(1, 'day') + .toDate(); + + return AccountTransaction.query().onBuild((query) => { + query.modify('creditDebitSummation'); + + query.select('accountId'); + query.groupBy('accountId'); + + query.withGraphFetched('account'); + query.modify('filterDateRange', null, cashBeginningPeriod); + }); + } + + /** + * Retrieve accounts transactions. + * @param {number} tenantId - + * @param {ICashFlowStatementQuery} filter + * @return {Promise} + */ + public getAccountsTransactions( + tenantId: number, + filter: ICashFlowStatementQuery + ): Promise { + const { AccountTransaction } = this.tenancy.models(tenantId); + const groupByDateType = this.getGroupTypeFromPeriodsType( + filter.displayColumnsBy + ); + + return AccountTransaction.query().onBuild((query) => { + query.modify('creditDebitSummation'); + query.modify('groupByDateFormat', groupByDateType); + + query.select('accountId'); + + query.groupBy('accountId'); + query.withGraphFetched('account'); + query.modify('filterDateRange', filter.fromDate, filter.toDate); + }); + } + + /** + * Retrieve the net income tranasctions. + * @param {number} tenantId - + * @param {ICashFlowStatementQuery} query - + * @return {Promise} + */ + public getNetIncomeTransactions( + tenantId: number, + filter: ICashFlowStatementQuery + ): Promise { + const { AccountTransaction } = this.tenancy.models(tenantId); + const groupByDateType = this.getGroupTypeFromPeriodsType( + filter.displayColumnsBy + ); + + return AccountTransaction.query().onBuild((query) => { + query.modify('creditDebitSummation'); + query.modify('groupByDateFormat', groupByDateType); + + query.select('accountId'); + query.groupBy('accountId'); + + query.withGraphFetched('account'); + query.modify('filterDateRange', filter.fromDate, filter.toDate); + }); + } + + /** + * Retrieve peridos of cash at beginning transactions. + * @param {number} tenantId - + * @param {ICashFlowStatementQuery} filter - + * @return {Promise} + */ + public cashAtBeginningPeriodTransactions( + tenantId: number, + filter: ICashFlowStatementQuery + ): Promise { + const { AccountTransaction } = this.tenancy.models(tenantId); + const groupByDateType = this.getGroupTypeFromPeriodsType( + filter.displayColumnsBy + ); + + return AccountTransaction.query().onBuild((query) => { + query.modify('creditDebitSummation'); + query.modify('groupByDateFormat', groupByDateType); + + query.select('accountId'); + query.groupBy('accountId'); + + query.withGraphFetched('account'); + query.modify('filterDateRange', filter.fromDate, filter.toDate); + }); + } +} diff --git a/server/src/services/FinancialStatements/CashFlow/CashFlowService.ts b/server/src/services/FinancialStatements/CashFlow/CashFlowService.ts new file mode 100644 index 000000000..ddd28365c --- /dev/null +++ b/server/src/services/FinancialStatements/CashFlow/CashFlowService.ts @@ -0,0 +1,144 @@ +import moment from 'moment'; +import { Service, Inject } from 'typedi'; +import * as R from 'ramda'; +import TenancyService from 'services/Tenancy/TenancyService'; +import FinancialSheet from '../FinancialSheet'; +import { + ICashFlowStatementService, + ICashFlowStatementQuery, + ICashFlowStatement, + IAccountTransaction, +} from 'interfaces'; +import CashFlowStatement from './CashFlow'; +import Ledger from 'services/Accounting/Ledger'; +import CashFlowRepository from './CashFlowRepository'; + +@Service() +export default class CashFlowStatementService + extends FinancialSheet + implements ICashFlowStatementService +{ + @Inject() + tenancy: TenancyService; + + @Inject() + cashFlowRepo: CashFlowRepository; + + /** + * Defaults balance sheet filter query. + * @return {IBalanceSheetQuery} + */ + get defaultQuery(): ICashFlowStatementQuery { + return { + displayColumnsType: 'total', + displayColumnsBy: 'day', + fromDate: moment().startOf('year').format('YYYY-MM-DD'), + toDate: moment().endOf('year').format('YYYY-MM-DD'), + numberFormat: { + precision: 2, + divideOn1000: false, + showZero: false, + formatMoney: 'total', + negativeFormat: 'mines', + }, + noneZero: false, + noneTransactions: false, + basis: 'cash', + }; + } + + /** + * Retrieve cash at beginning transactions. + * @param {number} tenantId - + * @param {ICashFlowStatementQuery} filter - + * @retrun {Promise} + */ + private async cashAtBeginningTransactions( + tenantId: number, + filter: ICashFlowStatementQuery + ): Promise { + const appendPeriodsOperToChain = (trans) => + R.append( + this.cashFlowRepo.cashAtBeginningPeriodTransactions(tenantId, filter), + trans + ); + + const promisesChain = R.pipe( + R.append( + this.cashFlowRepo.cashAtBeginningTotalTransactions(tenantId, filter) + ), + R.when( + R.always(R.equals(filter.displayColumnsType, 'date_periods')), + appendPeriodsOperToChain + ) + )([]); + const promisesResults = await Promise.all(promisesChain); + const transactions = R.flatten(promisesResults); + + return transactions; + } + + /** + * Retrieve the cash flow sheet statement. + * @param {number} tenantId + * @param {ICashFlowStatementQuery} query + * @returns {Promise} + */ + public async cashFlow( + tenantId: number, + query: ICashFlowStatementQuery + ): Promise<{ + data: ICashFlowStatement; + query: ICashFlowStatementQuery; + }> { + // Retrieve all accounts on the storage. + const accounts = await this.cashFlowRepo.cashFlowAccounts(tenantId); + + // Settings tenant service. + const settings = this.tenancy.settings(tenantId); + const baseCurrency = settings.get({ + group: 'organization', + key: 'base_currency', + }); + + const filter = { + ...this.defaultQuery, + ...query, + }; + // Retrieve the accounts transactions. + const transactions = await this.cashFlowRepo.getAccountsTransactions( + tenantId, + filter + ); + // Retrieve the net income transactions. + const netIncome = await this.cashFlowRepo.getNetIncomeTransactions( + tenantId, + filter + ); + // Retrieve the cash at beginning transactions. + const cashAtBeginningTransactions = await this.cashAtBeginningTransactions( + tenantId, + filter + ); + + // Transformes the transactions to ledgers. + const ledger = Ledger.fromTransactions(transactions); + const cashLedger = Ledger.fromTransactions(cashAtBeginningTransactions); + const netIncomeLedger = Ledger.fromTransactions(netIncome); + + // Cash flow statement. + const cashFlowInstance = new CashFlowStatement( + accounts, + ledger, + cashLedger, + netIncomeLedger, + filter, + baseCurrency + ); + + return { + data: cashFlowInstance.reportData(), + query: filter, + }; + } +} diff --git a/server/src/services/FinancialStatements/CashFlow/CashFlowTable.ts b/server/src/services/FinancialStatements/CashFlow/CashFlowTable.ts new file mode 100644 index 000000000..35eee34bc --- /dev/null +++ b/server/src/services/FinancialStatements/CashFlow/CashFlowTable.ts @@ -0,0 +1,365 @@ +import * as R from 'ramda'; +import { isEmpty } from 'lodash'; +import moment from 'moment'; +import { + ICashFlowStatementSection, + ICashFlowStatementSectionType, + ICashFlowStatement, + ITableRow, + ITableColumn, + ICashFlowStatementQuery, + IDateRange +} from 'interfaces'; +import { dateRangeFromToCollection, tableRowMapper } from 'utils'; +import { mapValuesDeep } from 'utils/deepdash'; + +enum IROW_TYPE { + AGGREGATE = 'AGGREGATE', + NET_INCOME = 'NET_INCOME', + ACCOUNTS = 'ACCOUNTS', + ACCOUNT = 'ACCOUNT', + TOTAL = 'TOTAL', +} +const DEEP_CONFIG = { childrenPath: 'children', pathFormat: 'array' }; +const DISPLAY_COLUMNS_BY = { + DATE_PERIODS: 'date_periods', + TOTAL: 'total', +}; + + +export default class CashFlowTable implements ICashFlowTable { + private report: { + data: ICashFlowStatement; + query: ICashFlowStatementQuery; + }; + private dateRangeSet: IDateRange[]; + + /** + * Constructor method. + * @param {ICashFlowStatement} reportStatement + */ + constructor(reportStatement: { + data: ICashFlowStatement; + query: ICashFlowStatementQuery; + }) { + this.report = reportStatement; + this.dateRangeSet = []; + this.initDateRangeCollection(); + } + + /** + * Initialize date range set. + */ + private initDateRangeCollection() { + this.dateRangeSet = dateRangeFromToCollection( + this.report.query.fromDate, + this.report.query.toDate, + this.report.query.displayColumnsBy, + ); + } + + /** + * Retrieve the date periods columns accessors. + */ + private datePeriodsColumnsAccessors() { + return this.dateRangeSet.map((dateRange: IDateRange, index) => ({ + key: `date-range-${index}`, + accessor: `periods[${index}].total.formattedAmount`, + })); + } + + /** + * Retrieve the total column accessor. + */ + private totalColumnAccessor() { + return [{ key: 'total', accessor: 'total.formattedAmount' }]; + } + + /** + * Retrieve the common columns for all report nodes. + */ + private commonColumns() { + return R.compose( + R.concat([{ key: 'label', accessor: 'label' }]), + R.when( + R.always(this.isDisplayColumnsBy(DISPLAY_COLUMNS_BY.DATE_PERIODS)), + R.concat(this.datePeriodsColumnsAccessors()), + ), + R.concat(this.totalColumnAccessor()), + )([]); + } + + /** + * Retrieve the table rows of regular section. + * @param {ICashFlowStatementSection} section + * @returns {ITableRow[]} + */ + private regularSectionMapper(section: ICashFlowStatementSection): ITableRow { + const columns = this.commonColumns(); + + return tableRowMapper(section, columns, { + rowTypes: [IROW_TYPE.AGGREGATE], + id: section.id, + }); + } + + /** + * Retrieve the net income table rows of the section. + * @param {ICashFlowStatementSection} section + * @returns {ITableRow} + */ + private netIncomeSectionMapper( + section: ICashFlowStatementSection + ): ITableRow { + const columns = this.commonColumns(); + + return tableRowMapper(section, columns, { + rowTypes: [IROW_TYPE.NET_INCOME, IROW_TYPE.TOTAL], + id: section.id, + }); + } + + /** + * Retrieve the accounts table rows of the section. + * @param {ICashFlowStatementSection} section + * @returns {ITableRow} + */ + private accountsSectionMapper(section: ICashFlowStatementSection): ITableRow { + const columns = this.commonColumns(); + + return tableRowMapper(section, columns, { + rowTypes: [IROW_TYPE.ACCOUNTS], + id: section.id, + }); + } + + /** + * Retrieve the account table row of account section. + * @param {ICashFlowStatementSection} section + * @returns {ITableRow} + */ + private accountSectionMapper(section: ICashFlowStatementSection): ITableRow { + const columns = this.commonColumns(); + + return tableRowMapper(section, columns, { + rowTypes: [IROW_TYPE.ACCOUNT], + id: `account-${section.id}`, + }); + } + + /** + * Retrieve the total table rows from the given total section. + * @param {ICashFlowStatementSection} section + * @returns {ITableRow} + */ + private totalSectionMapper(section: ICashFlowStatementSection): ITableRow { + const columns = this.commonColumns(); + + return tableRowMapper(section, columns, { + rowTypes: [IROW_TYPE.TOTAL], + id: section.id, + }); + } + + /** + * Detarmines the schema section type. + * @param {string} type + * @param {ICashFlowSchemaSection} section + * @returns {boolean} + */ + private isSectionHasType( + type: string, + section: ICashFlowStatementSection + ): boolean { + return type === section.sectionType; + } + + /** + * The report section mapper. + * @param {ICashFlowStatementSection} section + * @returns {ITableRow} + */ + private sectionMapper( + section: ICashFlowStatementSection, + key: string, + parentSection: ICashFlowStatementSection + ): ITableRow { + const isSectionHasType = R.curry(this.isSectionHasType); + + return R.pipe( + R.when( + isSectionHasType(ICashFlowStatementSectionType.REGULAR), + this.regularSectionMapper.bind(this) + ), + R.when( + isSectionHasType(ICashFlowStatementSectionType.CASH_AT_BEGINNING), + this.regularSectionMapper.bind(this) + ), + R.when( + isSectionHasType(ICashFlowStatementSectionType.NET_INCOME), + this.netIncomeSectionMapper.bind(this) + ), + R.when( + isSectionHasType(ICashFlowStatementSectionType.ACCOUNTS), + this.accountsSectionMapper.bind(this) + ), + R.when( + isSectionHasType(ICashFlowStatementSectionType.ACCOUNT), + this.accountSectionMapper.bind(this) + ), + R.when( + isSectionHasType(ICashFlowStatementSectionType.TOTAL), + this.totalSectionMapper.bind(this) + ) + )(section); + } + + /** + * Mappes the sections to the table rows. + * @param {ICashFlowStatementSection[]} sections + * @returns {ITableRow[]} + */ + private mapSectionsToTableRows( + sections: ICashFlowStatementSection[] + ): ITableRow[] { + return mapValuesDeep(sections, this.sectionMapper.bind(this), DEEP_CONFIG); + } + + /** + * Appends the total to section's children. + * @param {ICashFlowStatementSection} section + * @returns {ICashFlowStatementSection} + */ + private appendTotalToSectionChildren( + section: ICashFlowStatementSection + ): ICashFlowStatementSection { + const label = section.footerLabel + ? section.footerLabel + : `Total ${section.label}`; + + section.children.push({ + sectionType: ICashFlowStatementSectionType.TOTAL, + label, + periods: section.periods, + total: section.total, + }); + return section; + } + + /** + * + * @param {ICashFlowStatementSection} section + * @returns {ICashFlowStatementSection} + */ + private mapSectionsToAppendTotalChildren( + section: ICashFlowStatementSection + ): ICashFlowStatementSection { + const isSectionHasChildren = (section) => !isEmpty(section.children); + + return R.compose( + R.when(isSectionHasChildren, this.appendTotalToSectionChildren.bind(this)) + )(section); + } + + /** + * Appends total node to children section. + * @param {ICashFlowStatementSection[]} sections + * @returns {ICashFlowStatementSection[]} + */ + private appendTotalToChildren(sections: ICashFlowStatementSection[]) { + return mapValuesDeep( + sections, + this.mapSectionsToAppendTotalChildren.bind(this), + DEEP_CONFIG + ); + } + + /** + * Retrieve the table rows of cash flow statement. + * @param {ICashFlowStatementSection[]} sections + * @returns {ITableRow[]} + */ + public tableRows(): ITableRow[] { + const sections = this.report.data; + + return R.pipe( + this.appendTotalToChildren.bind(this), + this.mapSectionsToTableRows.bind(this) + )(sections); + } + + /** + * Retrieve the total columns. + * @returns {ITableColumn} + */ + private totalColumns(): ITableColumn[] { + return [{ key: 'total', label: 'Total' }]; + } + + /** + * Retrieve the formatted column label from the given date range. + * @param {ICashFlowDateRange} dateRange - + * @return {string} + */ + private formatColumnLabel(dateRange: ICashFlowDateRange) { + const monthFormat = (range) => moment(range.toDate).format('YYYY-MM'); + const yearFormat = (range) => moment(range.toDate).format('YYYY'); + const dayFormat = (range) => moment(range.toDate).format('YYYY-MM-DD'); + + const conditions = [ + ['month', monthFormat], + ['year', yearFormat], + ['day', dayFormat], + ['quarter', monthFormat], + ['week', dayFormat], + ]; + const conditionsPairs = R.map(([type, formatFn]) => ([ + R.always(this.isDisplayColumnsType(type)), formatFn, + ]), conditions); + + return R.compose(R.cond(conditionsPairs))(dateRange); + } + + /** + * Date periods columns. + * @returns {ITableColumn[]} + */ + private datePeriodsColumns(): ITableColumn[] { + return this.dateRangeSet.map((dateRange, index) => ({ + key: `date-range-${index}`, + label: this.formatColumnLabel(dateRange), + })); + } + + /** + * Detarmines the given column type is the current. + * @reutrns {boolean} + */ + private isDisplayColumnsBy(displayColumnsType: string): Boolean { + return this.report.query.displayColumnsType === displayColumnsType; + } + + /** + * Detarmines whether the given display columns type is the current. + * @param {string} displayColumnsBy + * @returns {boolean} + */ + private isDisplayColumnsType(displayColumnsBy: string): Boolean { + return this.report.query.displayColumnsBy === displayColumnsBy; + } + + /** + * Retrieve the table columns. + * @return {ITableColumn[]} + */ + public tableColumns(): ITableColumn[] { + return R.compose( + R.concat([{ key: 'name', label: 'Account name' }]), + R.when( + R.always(this.isDisplayColumnsBy(DISPLAY_COLUMNS_BY.DATE_PERIODS)), + R.concat(this.datePeriodsColumns()) + ), + R.concat(this.totalColumns()) + )([]); + } +} diff --git a/server/src/services/FinancialStatements/CashFlow/schema.ts b/server/src/services/FinancialStatements/CashFlow/schema.ts new file mode 100644 index 000000000..ce786c2e9 --- /dev/null +++ b/server/src/services/FinancialStatements/CashFlow/schema.ts @@ -0,0 +1,75 @@ +import { ICashFlowSchemaSection, CASH_FLOW_SECTION_ID, ICashFlowStatementSectionType } from 'interfaces'; +import { ACCOUNT_TYPE } from 'data/AccountTypes'; + +export default [ + { + id: CASH_FLOW_SECTION_ID.OPERATING, + label: 'OPERATING ACTIVITIES', + sectionType: ICashFlowStatementSectionType.AGGREGATE, + children: [ + { + id: CASH_FLOW_SECTION_ID.NET_INCOME, + label: 'Net income', + sectionType: ICashFlowStatementSectionType.NET_INCOME, + }, + { + id: CASH_FLOW_SECTION_ID.OPERATING_ACCOUNTS, + label: 'Adjustments net income by operating activities.', + sectionType: ICashFlowStatementSectionType.ACCOUNTS, + accountsRelations: [ + { type: ACCOUNT_TYPE.ACCOUNTS_RECEIVABLE, direction: 'mines' }, + { type: ACCOUNT_TYPE.INVENTORY, direction: 'mines' }, + { type: ACCOUNT_TYPE.NON_CURRENT_ASSET, direction: 'mines' }, + { type: ACCOUNT_TYPE.ACCOUNTS_PAYABLE, direction: 'plus' }, + { type: ACCOUNT_TYPE.CREDIT_CARD, direction: 'plus' }, + { type: ACCOUNT_TYPE.TAX_PAYABLE, direction: 'plus' }, + { type: ACCOUNT_TYPE.OTHER_CURRENT_ASSET, direction: 'mines' }, + { type: ACCOUNT_TYPE.OTHER_CURRENT_LIABILITY, direction: 'plus' }, + { type: ACCOUNT_TYPE.NON_CURRENT_LIABILITY, direction: 'plus' }, + ], + showAlways: true, + }, + ], + footerLabel: 'Net cash provided by operating activities', + }, + { + id: CASH_FLOW_SECTION_ID.INVESTMENT, + sectionType: ICashFlowStatementSectionType.ACCOUNTS, + label: 'INVESTMENT ACTIVITIES', + accountsRelations: [ + { type: ACCOUNT_TYPE.FIXED_ASSET, direction: 'mines' } + ], + footerLabel: 'Net cash provided by investing activities', + }, + { + id: CASH_FLOW_SECTION_ID.FINANCIAL, + label: 'FINANCIAL ACTIVITIES', + sectionType: ICashFlowStatementSectionType.ACCOUNTS, + accountsRelations: [ + { type: ACCOUNT_TYPE.LOGN_TERM_LIABILITY, direction: 'plus' }, + { type: ACCOUNT_TYPE.EQUITY, direction: 'plus' }, + ], + footerLabel: 'Net cash provided by financing activities', + }, + { + id: CASH_FLOW_SECTION_ID.CASH_BEGINNING_PERIOD, + sectionType: ICashFlowStatementSectionType.CASH_AT_BEGINNING, + label: 'Cash at beginning of period', + accountsRelations: [ + { type: ACCOUNT_TYPE.CASH, direction: 'plus' }, + { type: ACCOUNT_TYPE.BANK, direction: 'plus' }, + ], + }, + { + id: CASH_FLOW_SECTION_ID.NET_CASH_INCREASE, + sectionType: ICashFlowStatementSectionType.TOTAL, + equation: 'OPERATING + INVESTMENT + FINANCIAL', + label: 'NET CASH INCREASE FOR PERIOD', + }, + { + id: CASH_FLOW_SECTION_ID.CASH_END_PERIOD, + label: 'CASH AT END OF PERIOD', + sectionType: ICashFlowStatementSectionType.TOTAL, + equation: 'NET_CASH_INCREASE + CASH_BEGINNING_PERIOD', + }, +] as ICashFlowSchemaSection[]; diff --git a/server/src/services/FinancialStatements/CustomerBalanceSummary/CustomerBalanceSummaryRepository.ts b/server/src/services/FinancialStatements/CustomerBalanceSummary/CustomerBalanceSummaryRepository.ts new file mode 100644 index 000000000..5c5f6842f --- /dev/null +++ b/server/src/services/FinancialStatements/CustomerBalanceSummary/CustomerBalanceSummaryRepository.ts @@ -0,0 +1,69 @@ +import { Inject, Service } from 'typedi'; +import { map, isEmpty } from 'lodash'; +import { ICustomer, IAccount } from 'interfaces'; +import HasTenancyService from 'services/Tenancy/TenancyService'; +import { ACCOUNT_TYPE } from 'data/AccountTypes'; + +@Service() +export default class CustomerBalanceSummaryRepository { + @Inject() + tenancy: HasTenancyService; + + /** + * Retrieve the report customers. + * @param {number} tenantId + * @param {number[]} customersIds + * @returns {ICustomer[]} + */ + public getCustomers(tenantId: number, customersIds: number[]): ICustomer[] { + const { Customer } = this.tenancy.models(tenantId); + + return Customer.query() + .orderBy('displayName') + .onBuild((query) => { + if (!isEmpty(customersIds)) { + query.whereIn('id', customersIds); + } + }); + } + + /** + * Retrieve the A/R accounts. + * @param {number} tenantId + * @returns {Promise} + */ + public getReceivableAccounts(tenantId: number): Promise { + const { Account } = this.tenancy.models(tenantId); + + return Account.query().where( + 'accountType', + ACCOUNT_TYPE.ACCOUNTS_RECEIVABLE + ); + } + + /** + * Retrieve the customers credit/debit totals + * @param {number} tenantId + * @returns + */ + public async getCustomersTransactions(tenantId: number, asDate: any) { + const { AccountTransaction } = this.tenancy.models(tenantId); + + // Retrieve the receivable accounts A/R. + const receivableAccounts = await this.getReceivableAccounts(tenantId); + const receivableAccountsIds = map(receivableAccounts, 'id'); + + // Retrieve the customers transactions of A/R accounts. + const customersTranasctions = await AccountTransaction.query().onBuild( + (query) => { + query.whereIn('accountId', receivableAccountsIds); + query.modify('filterDateRange', null, asDate); + query.groupBy('contactId'); + query.sum('credit as credit'); + query.sum('debit as debit'); + query.select('contactId'); + } + ); + return customersTranasctions; + } +} diff --git a/server/src/services/FinancialStatements/CustomerBalanceSummary/CustomerBalanceSummaryService.ts b/server/src/services/FinancialStatements/CustomerBalanceSummary/CustomerBalanceSummaryService.ts index 7f648194a..e3ad79584 100644 --- a/server/src/services/FinancialStatements/CustomerBalanceSummary/CustomerBalanceSummaryService.ts +++ b/server/src/services/FinancialStatements/CustomerBalanceSummary/CustomerBalanceSummaryService.ts @@ -7,20 +7,26 @@ import { ICustomerBalanceSummaryService, ICustomerBalanceSummaryQuery, ICustomerBalanceSummaryStatement, - ICustomer + ICustomer, + ILedgerEntry, } from 'interfaces'; import { CustomerBalanceSummaryReport } from './CustomerBalanceSummary'; -import { ACCOUNT_TYPE } from 'data/AccountTypes'; + import Ledger from 'services/Accounting/Ledger'; +import CustomerBalanceSummaryRepository from './CustomerBalanceSummaryRepository'; export default class CustomerBalanceSummaryService - implements ICustomerBalanceSummaryService { + implements ICustomerBalanceSummaryService +{ @Inject() tenancy: TenancyService; @Inject('logger') logger: any; + @Inject() + reportRepository: CustomerBalanceSummaryRepository; + /** * Defaults balance sheet filter query. * @return {ICustomerBalanceSummaryQuery} @@ -43,64 +49,24 @@ export default class CustomerBalanceSummaryService }; } - /** - * Retrieve the A/R accounts. - * @param tenantId - * @returns - */ - private getReceivableAccounts(tenantId: number) { - const { Account } = this.tenancy.models(tenantId); - - return Account.query().where( - 'accountType', - ACCOUNT_TYPE.ACCOUNTS_RECEIVABLE - ); - } /** - * Retrieve the customers credit/debit totals + * Retrieve the customers ledger entries mapped from accounts transactions. * @param {number} tenantId - * @returns + * @param {Date|string} asDate + * @returns {Promise} */ - private async getReportCustomersTransactions(tenantId: number, asDate: any) { - const { AccountTransaction } = this.tenancy.models(tenantId); - - // Retrieve the receivable accounts A/R. - const receivableAccounts = await this.getReceivableAccounts(tenantId); - const receivableAccountsIds = map(receivableAccounts, 'id'); - - // Retrieve the customers transactions of A/R accounts. - const customersTranasctions = await AccountTransaction.query().onBuild( - (query) => { - query.whereIn('accountId', receivableAccountsIds); - query.modify('filterDateRange', null, asDate); - query.groupBy('contactId'); - query.sum('credit as credit'); - query.sum('debit as debit'); - query.select('contactId'); - } + private async getReportCustomersEntries( + tenantId: number, + asDate: Date | string + ): Promise { + const transactions = await this.reportRepository.getCustomersTransactions( + tenantId, + asDate ); const commonProps = { accountNormal: 'debit', date: asDate }; - return R.map(R.merge(commonProps))(customersTranasctions); - } - - /** - * Retrieve the report customers. - * @param {number} tenantId - * @param {number[]} customersIds - * @returns {ICustomer[]} - */ - private getReportCustomers(tenantId: number, customersIds: number[]): ICustomer[] { - const { Customer } = this.tenancy.models(tenantId); - - return Customer.query() - .orderBy('displayName') - .onBuild((query) => { - if (!isEmpty(customersIds)) { - query.whereIn('id', customersIds); - } - }); + return R.map(R.merge(commonProps))(transactions); } /** @@ -130,17 +96,17 @@ export default class CustomerBalanceSummaryService } ); // Retrieve the customers list ordered by the display name. - const customers = await this.getReportCustomers( + const customers = await this.reportRepository.getCustomers( tenantId, query.customersIds ); // Retrieve the customers debit/credit totals. - const customersTransactions = await this.getReportCustomersTransactions( + const customersEntries = await this.getReportCustomersEntries( tenantId, filter.asDate ); // Ledger query. - const ledger = Ledger.fromTransactions(customersTransactions); + const ledger = new Ledger(customersEntries); // Report instance. const report = new CustomerBalanceSummaryReport( @@ -153,7 +119,7 @@ export default class CustomerBalanceSummaryService return { data: report.reportData(), columns: report.reportColumns(), - query: filter + query: filter, }; } } diff --git a/server/src/services/FinancialStatements/FinancialSheet.ts b/server/src/services/FinancialStatements/FinancialSheet.ts index d1a83ad31..8fe170244 100644 --- a/server/src/services/FinancialStatements/FinancialSheet.ts +++ b/server/src/services/FinancialStatements/FinancialSheet.ts @@ -1,4 +1,9 @@ -import { IFormatNumberSettings, INumberFormatQuery } from 'interfaces'; +import moment from 'moment'; +import { + ICashFlowStatementTotal, + IFormatNumberSettings, + INumberFormatQuery, +} from 'interfaces'; import { formatNumber } from 'utils'; export default class FinancialSheet { @@ -37,7 +42,7 @@ export default class FinancialSheet { }; return formatNumber(number, settings); } - + /** * Formatting full amount with different format settings. * @param {number} amount - @@ -52,24 +57,68 @@ export default class FinancialSheet { return this.formatNumber(amount, { money: numberFormat.formatMoney === 'none' ? false : true, excerptZero: false, - ...settings + ...settings, }); } /** * Formates the amount to the percentage string. - * @param {number} amount + * @param {number} amount * @returns {string} */ - protected formatPercentage( - amount - ): string { + protected formatPercentage(amount): string { const percentage = amount * 100; return formatNumber(percentage, { symbol: '%', excerptZero: true, money: false, - }) + }); + } + + /** + * Retrieve the amount meta object. + * @param {number} amount + * @returns {ICashFlowStatementTotal} + */ + protected getAmountMeta( + amount: number, + overrideSettings?: IFormatNumberSettings + ): ICashFlowStatementTotal { + return { + amount, + formattedAmount: this.formatNumber(amount, overrideSettings), + currencyCode: this.baseCurrency, + }; + } + + /** + * Retrieve the total amount meta object. + * @param {number} amount + * @returns {ICashFlowStatementTotal} + */ + protected getTotalAmountMeta( + amount: number, + title?: string + ): ICashFlowStatementTotal { + return { + ...(title ? { title } : {}), + amount, + formattedAmount: this.formatTotalNumber(amount), + currencyCode: this.baseCurrency, + }; + } + + /** + * Retrieve the date meta. + * @param {Date} date + * @param {string} format + * @returns + */ + protected getDateMeta(date: Date, format = 'YYYY-MM-DD') { + return { + formattedDate: moment(date).format(format), + date: moment(date).toDate(), + }; } } diff --git a/server/src/services/FinancialStatements/InventoryDetails/InventoryDetails.ts b/server/src/services/FinancialStatements/InventoryDetails/InventoryDetails.ts new file mode 100644 index 000000000..c94ec6e94 --- /dev/null +++ b/server/src/services/FinancialStatements/InventoryDetails/InventoryDetails.ts @@ -0,0 +1,407 @@ +import * as R from 'ramda'; +import { defaultTo, sumBy, get } from 'lodash'; +import { + IInventoryDetailsQuery, + IItem, + IInventoryTransaction, + TInventoryTransactionDirection, + IInventoryDetailsNumber, + IInventoryDetailsDate, + IInventoryDetailsData, + IInventoryDetailsItem, + IInventoryDetailsClosing, + INumberFormatQuery, + IInventoryDetailsOpening, + IInventoryDetailsItemTransaction, + IFormatNumberSettings, +} from 'interfaces'; +import FinancialSheet from '../FinancialSheet'; +import { transformToMapBy, transformToMapKeyValue } from 'utils'; +import { filterDeep } from 'utils/deepdash'; +import moment from 'moment'; + +const MAP_CONFIG = { childrenPath: 'children', pathFormat: 'array' }; + +enum INodeTypes { + ITEM = 'item', + TRANSACTION = 'transaction', + OPENING_ENTRY = 'OPENING_ENTRY', + CLOSING_ENTRY = 'CLOSING_ENTRY', +} + +export default class InventoryDetails extends FinancialSheet { + readonly inventoryTransactionsByItemId: Map; + readonly openingBalanceTransactions: Map; + readonly query: IInventoryDetailsQuery; + readonly numberFormat: INumberFormatQuery; + readonly baseCurrency: string; + readonly items: IItem[]; + + /** + * Constructor method. + * @param {IItem[]} items - Items. + * @param {IInventoryTransaction[]} inventoryTransactions - Inventory transactions. + * @param {IInventoryDetailsQuery} query - Report query. + * @param {string} baseCurrency - The base currency. + */ + constructor( + items: IItem[], + openingBalanceTransactions: IInventoryTransaction[], + inventoryTransactions: IInventoryTransaction[], + query: IInventoryDetailsQuery, + baseCurrency: string + ) { + super(); + + this.inventoryTransactionsByItemId = transformToMapBy( + inventoryTransactions, + 'itemId' + ); + this.openingBalanceTransactions = transformToMapKeyValue( + openingBalanceTransactions, + 'itemId' + ); + this.query = query; + this.numberFormat = this.query.numberFormat; + this.items = items; + this.baseCurrency = baseCurrency; + } + + /** + * Retrieve the number meta. + * @param {number} number + * @returns + */ + private getNumberMeta( + number: number, + settings?: IFormatNumberSettings + ): IInventoryDetailsNumber { + return { + formattedNumber: this.formatNumber(number, { + excerptZero: true, + money: false, + ...settings, + }), + number: number, + }; + } + + /** + * Retrieve the total number meta. + * @param {number} number - + * @param {IFormatNumberSettings} settings - + * @retrun {IInventoryDetailsNumber} + */ + private getTotalNumberMeta( + number: number, + settings?: IFormatNumberSettings + ): IInventoryDetailsNumber { + return this.getNumberMeta(number, { excerptZero: false, ...settings }); + } + + /** + * Retrieve the date meta. + * @param {Date|string} date + * @returns {IInventoryDetailsDate} + */ + private getDateMeta(date: Date | string): IInventoryDetailsDate { + return { + formattedDate: moment(date).format('YYYY-MM-DD'), + date: moment(date).toDate(), + }; + } + + /** + * Adjusts the movement amount. + * @param {number} amount + * @param {TInventoryTransactionDirection} direction + * @returns {number} + */ + private adjustAmountMovement( + direction: TInventoryTransactionDirection, + amount: number + ): number { + return direction === 'OUT' ? amount * -1 : amount; + } + + /** + * Accumlate and mapping running quantity on transactions. + * @param {IInventoryDetailsItemTransaction[]} transactions + * @returns {IInventoryDetailsItemTransaction[]} + */ + private mapAccumTransactionsRunningQuantity( + transactions: IInventoryDetailsItemTransaction[] + ): IInventoryDetailsItemTransaction[] { + const initial = this.getNumberMeta(0); + + const mapAccumAppender = (a, b) => { + const total = a.runningQuantity.number + b.quantityMovement.number; + const totalMeta = this.getNumberMeta(total, { excerptZero: false }); + const accum = { ...b, runningQuantity: totalMeta }; + + return [accum, accum]; + }; + return R.mapAccum( + mapAccumAppender, + { runningQuantity: initial }, + transactions + )[1]; + } + + /** + * Accumlate and mapping running valuation on transactions. + * @param {IInventoryDetailsItemTransaction[]} transactions + * @returns {IInventoryDetailsItemTransaction} + */ + private mapAccumTransactionsRunningValuation( + transactions: IInventoryDetailsItemTransaction[] + ): IInventoryDetailsItemTransaction[] { + const initial = this.getNumberMeta(0); + + const mapAccumAppender = (a, b) => { + const total = a.runningValuation.number + b.valueMovement.number; + const totalMeta = this.getNumberMeta(total, { excerptZero: false }); + const accum = { ...b, runningValuation: totalMeta }; + + return [accum, accum]; + }; + return R.mapAccum( + mapAccumAppender, + { runningValuation: initial }, + transactions + )[1]; + } + + /** + * Mappes the item transaction to inventory item transaction node. + * @param {IItem} item + * @param {IInvetoryTransaction} transaction + * @returns {IInventoryDetailsItemTransaction} + */ + private itemTransactionMapper( + item: IItem, + transaction: IInventoryTransaction + ): IInventoryDetailsItemTransaction { + const value = transaction.quantity * transaction.rate; + const amountMovement = R.curry(this.adjustAmountMovement)( + transaction.direction + ); + // Quantity movement. + const quantityMovement = amountMovement(transaction.quantity); + const valueMovement = amountMovement(value); + + // Profit margin. + const profitMargin = Math.max( + value - transaction.costLotAggregated.cost, + 0 + ); + + return { + nodeType: INodeTypes.TRANSACTION, + date: this.getDateMeta(transaction.date), + transactionType: transaction.transcationTypeFormatted, + transactionNumber: transaction.meta.transactionNumber, + direction: transaction.direction, + + quantityMovement: this.getNumberMeta(quantityMovement), + valueMovement: this.getNumberMeta(valueMovement), + + quantity: this.getNumberMeta(transaction.quantity), + value: this.getNumberMeta(value), + + rate: this.getNumberMeta(transaction.rate), + cost: this.getNumberMeta(transaction.costLotAggregated.cost), + + profitMargin: this.getNumberMeta(profitMargin), + + runningQuantity: this.getNumberMeta(0), + runningValuation: this.getNumberMeta(0), + }; + } + + /** + * Retrieve the inventory transcations by item id. + * @param {number} itemId + * @returns {IInventoryTransaction[]} + */ + private getInventoryTransactionsByItemId( + itemId: number + ): IInventoryTransaction[] { + return defaultTo(this.inventoryTransactionsByItemId.get(itemId + ''), []); + } + + /** + * Retrieve the item transaction node by the given item. + * @param {IItem} item + * @returns {IInventoryDetailsItemTransaction[]} + */ + private getItemTransactions(item: IItem): IInventoryDetailsItemTransaction[] { + const transactions = this.getInventoryTransactionsByItemId(item.id); + + return R.compose( + this.mapAccumTransactionsRunningQuantity.bind(this), + this.mapAccumTransactionsRunningValuation.bind(this), + R.map(R.curry(this.itemTransactionMapper.bind(this))(item)) + )(transactions); + } + + /** + * Mappes the given item transactions. + * @param {IItem} item - + * @returns {( + * IInventoryDetailsItemTransaction + * | IInventoryDetailsOpening + * | IInventoryDetailsClosing + * )[]} + */ + private itemTransactionsMapper( + item: IItem + ): ( + | IInventoryDetailsItemTransaction + | IInventoryDetailsOpening + | IInventoryDetailsClosing + )[] { + const transactions = this.getItemTransactions(item); + const openingValuation = this.getItemOpeingValuation(item); + const closingValuation = this.getItemClosingValuation(item, transactions); + + const hasTransactions = transactions.length > 0; + const isItemHasOpeningBalance = this.isItemHasOpeningBalance(item.id); + + return R.pipe( + R.concat(transactions), + R.when(R.always(isItemHasOpeningBalance), R.prepend(openingValuation)), + R.when(R.always(hasTransactions), R.append(closingValuation)) + )([]); + } + + /** + * Detarmines the given item has opening balance transaction. + * @param {number} itemId - Item id. + * @return {boolean} + */ + private isItemHasOpeningBalance(itemId: number): boolean { + return !!this.openingBalanceTransactions.get(itemId); + } + + /** + * Retrieve the given item opening valuation. + * @param {IItem} item - + * @returns {IInventoryDetailsOpening} + */ + private getItemOpeingValuation(item: IItem): IInventoryDetailsOpening { + const openingBalance = this.openingBalanceTransactions.get(item.id); + const quantity = defaultTo(get(openingBalance, 'quantity'), 0); + const value = defaultTo(get(openingBalance, 'value'), 0); + + return { + nodeType: INodeTypes.OPENING_ENTRY, + date: this.getDateMeta(this.query.fromDate), + quantity: this.getTotalNumberMeta(quantity), + value: this.getTotalNumberMeta(value), + }; + } + + /** + * Retrieve the given item closing valuation. + * @param {IItem} item - + * @returns {IInventoryDetailsOpening} + */ + private getItemClosingValuation( + item: IItem, + transactions: IInventoryDetailsItemTransaction[] + ): IInventoryDetailsOpening { + const value = sumBy(transactions, 'valueMovement.number'); + const quantity = sumBy(transactions, 'quantityMovement.number'); + const profitMargin = sumBy(transactions, 'profitMargin.number'); + const cost = sumBy(transactions, 'cost.number'); + + return { + nodeType: INodeTypes.CLOSING_ENTRY, + date: this.getDateMeta(this.query.toDate), + quantity: this.getTotalNumberMeta(quantity), + value: this.getTotalNumberMeta(value), + cost: this.getTotalNumberMeta(cost), + profitMargin: this.getTotalNumberMeta(profitMargin), + }; + } + + /** + * Retrieve the item node of the report. + * @param {IItem} item + * @returns {IInventoryDetailsItem} + */ + private itemsNodeMapper(item: IItem): IInventoryDetailsItem { + return { + id: item.id, + name: item.name, + code: item.code, + nodeType: INodeTypes.ITEM, + children: this.itemTransactionsMapper(item), + }; + } + + /** + * Detarmines the given node equals the given type. + * @param {string} nodeType + * @param {IItem} node + * @returns {boolean} + */ + private isNodeTypeEquals( + nodeType: string, + node: IInventoryDetailsItem + ): boolean { + return nodeType === node.nodeType; + } + + /** + * Detarmines whether the given item node has transactions. + * @param {IInventoryDetailsItem} item + * @returns {boolean} + */ + private isItemNodeHasTransactions(item: IInventoryDetailsItem) { + return !!this.inventoryTransactionsByItemId.get(item.id); + } + + /** + * Detarmines the filter + * @param {IInventoryDetailsItem} item + * @return {boolean} + */ + private isFilterNode(item: IInventoryDetailsItem): boolean { + return R.ifElse( + R.curry(this.isNodeTypeEquals)(INodeTypes.ITEM), + this.isItemNodeHasTransactions.bind(this), + R.always(true) + )(item); + } + + /** + * Filters items nodes. + * @param {IInventoryDetailsItem[]} items - + * @returns {IInventoryDetailsItem[]} + */ + private filterItemsNodes(items: IInventoryDetailsItem[]) { + return filterDeep(items, this.isFilterNode.bind(this), MAP_CONFIG); + } + + /** + * Retrieve the items nodes of the report. + * @param {IItem} items + * @returns {IInventoryDetailsItem[]} + */ + private itemsNodes(items: IItem[]): IInventoryDetailsItem[] { + return R.compose( + this.filterItemsNodes.bind(this), + R.map(this.itemsNodeMapper.bind(this)) + )(items); + } + + /** + * Retrieve the inventory item details report data. + * @returns {IInventoryDetailsData} + */ + public reportData(): IInventoryDetailsData { + return this.itemsNodes(this.items); + } +} diff --git a/server/src/services/FinancialStatements/InventoryDetails/InventoryDetailsRepository.ts b/server/src/services/FinancialStatements/InventoryDetails/InventoryDetailsRepository.ts new file mode 100644 index 000000000..c8d0abe87 --- /dev/null +++ b/server/src/services/FinancialStatements/InventoryDetails/InventoryDetailsRepository.ts @@ -0,0 +1,87 @@ +import { Inject } from 'typedi'; +import { raw } from 'objection'; +import moment from 'moment'; +import { IItem, IInventoryDetailsQuery, IInventoryTransaction } from 'interfaces'; +import HasTenancyService from 'services/Tenancy/TenancyService'; + +export default class InventoryDetailsRepository { + @Inject() + tenancy: HasTenancyService; + + /** + * Retrieve inventory items. + * @param {number} tenantId - + * @returns {Promise} + */ + public getInventoryItems(tenantId: number): Promise { + const { Item } = this.tenancy.models(tenantId); + + return Item.query().where('type', 'inventory'); + } + + /** + * Retrieve the items opening balance transactions. + * @param {number} tenantId - + * @param {IInventoryDetailsQuery} + * @return {Promise} + */ + public async openingBalanceTransactions( + tenantId: number, + filter: IInventoryDetailsQuery + ): Promise { + const { InventoryTransaction } = this.tenancy.models(tenantId); + + const openingBalanceDate = moment(filter.fromDate) + .subtract(1, 'days') + .toDate(); + + // Opening inventory transactions. + const openingTransactions = InventoryTransaction.query() + .select('*') + .select(raw("IF(`DIRECTION` = 'IN', `QUANTITY`, 0) as 'QUANTITY_IN'")) + .select(raw("IF(`DIRECTION` = 'OUT', `QUANTITY`, 0) as 'QUANTITY_OUT'")) + .select( + raw("IF(`DIRECTION` = 'IN', `QUANTITY` * `RATE`, 0) as 'VALUE_IN'") + ) + .select( + raw("IF(`DIRECTION` = 'OUT', `QUANTITY` * `RATE`, 0) as 'VALUE_OUT'") + ) + .modify('filterDateRange', null, openingBalanceDate) + .as('inventory_transactions'); + + const openingBalanceTransactions = await InventoryTransaction.query() + .from(openingTransactions) + .select('itemId') + .select(raw('SUM(`QUANTITY_IN` - `QUANTITY_OUT`) AS `QUANTITY`')) + .select(raw('SUM(`VALUE_IN` - `VALUE_OUT`) AS `VALUE`')) + .groupBy('itemId') + .sum('rate as rate') + .sum('quantityIn as quantityIn') + .sum('quantityOut as quantityOut') + .sum('valueIn as valueIn') + .sum('valueOut as valueOut') + .withGraphFetched('itemCostAggregated'); + + return openingBalanceTransactions; + } + + /** + * Retrieve the items inventory tranasactions. + * @param {number} tenantId - + * @param {IInventoryDetailsQuery} + * @return {Promise} + */ + public async itemInventoryTransactions( + tenantId: number, + filter: IInventoryDetailsQuery + ): Promise { + const { InventoryTransaction } = this.tenancy.models(tenantId); + + const inventoryTransactions = InventoryTransaction.query() + .modify('filterDateRange', filter.fromDate, filter.toDate) + .withGraphFetched('meta') + .withGraphFetched('costLotAggregated'); + + return inventoryTransactions; + } +} diff --git a/server/src/services/FinancialStatements/InventoryDetails/InventoryDetailsService.ts b/server/src/services/FinancialStatements/InventoryDetails/InventoryDetailsService.ts new file mode 100644 index 000000000..186d5d29e --- /dev/null +++ b/server/src/services/FinancialStatements/InventoryDetails/InventoryDetailsService.ts @@ -0,0 +1,81 @@ +import moment from 'moment'; +import { Service, Inject } from 'typedi'; +import { raw } from 'objection'; +import { IInventoryDetailsQuery, IInventoryTransaction } from 'interfaces'; +import TenancyService from 'services/Tenancy/TenancyService'; +import InventoryDetails from './InventoryDetails'; +import FinancialSheet from '../FinancialSheet'; +import InventoryDetailsRepository from './InventoryDetailsRepository'; + +@Service() +export default class InventoryDetailsService extends FinancialSheet { + @Inject() + tenancy: TenancyService; + + @Inject() + reportRepo: InventoryDetailsRepository; + + /** + * Defaults balance sheet filter query. + * @return {IBalanceSheetQuery} + */ + get defaultQuery(): IInventoryDetailsQuery { + return { + fromDate: moment().startOf('year').format('YYYY-MM-DD'), + toDate: moment().endOf('year').format('YYYY-MM-DD'), + numberFormat: { + precision: 2, + divideOn1000: false, + showZero: false, + formatMoney: 'total', + negativeFormat: 'mines', + }, + noneTransactions: false, + }; + } + + /** + * Retrieve the inventory details report data. + * @param {number} tenantId - + * @param {IInventoryDetailsQuery} query - + */ + public async inventoryDetails( + tenantId: number, + query: IInventoryDetailsQuery + ): Promise { + // Settings tenant service. + const settings = this.tenancy.settings(tenantId); + const baseCurrency = settings.get({ + group: 'organization', + key: 'base_currency', + }); + + const filter = { + ...this.defaultQuery, + ...query, + }; + // Retrieves the items. + const items = await this.reportRepo.getInventoryItems(tenantId); + + // Opening balance transactions. + const openingBalanceTransactions = + await this.reportRepo.openingBalanceTransactions(tenantId, filter); + + // Retrieves the inventory transaction. + const inventoryTransactions = + await this.reportRepo.itemInventoryTransactions(tenantId, filter); + + // Inventory details report mapper. + const inventoryDetailsInstance = new InventoryDetails( + items, + openingBalanceTransactions, + inventoryTransactions, + filter, + baseCurrency + ); + + return { + data: inventoryDetailsInstance.reportData(), + }; + } +} diff --git a/server/src/services/FinancialStatements/InventoryDetails/InventoryDetailsTable.ts b/server/src/services/FinancialStatements/InventoryDetails/InventoryDetailsTable.ts new file mode 100644 index 000000000..9af965aad --- /dev/null +++ b/server/src/services/FinancialStatements/InventoryDetails/InventoryDetailsTable.ts @@ -0,0 +1,183 @@ +import * as R from 'ramda'; +import { + IInventoryDetailsItem, + IInventoryDetailsItemTransaction, + IInventoryDetailsClosing, + ITableColumn, + ITableRow, + IInventoryDetailsNode, + IInventoryDetailsOpening, +} from 'interfaces'; +import { mapValuesDeep } from 'utils/deepdash'; +import { tableRowMapper } from 'utils'; + +enum IROW_TYPE { + ITEM = 'ITEM', + TRANSACTION = 'TRANSACTION', + CLOSING_ENTRY = 'CLOSING_ENTRY', + OPENING_ENTRY = 'OPENING_ENTRY', +} + +const MAP_CONFIG = { childrenPath: 'children', pathFormat: 'array' }; + +export default class InventoryDetailsTable { + /** + * Constructor methiod. + * @param {ICashFlowStatement} reportStatement + */ + constructor(reportStatement) { + this.report = reportStatement; + } + + /** + * Mappes the item node to table rows. + * @param {IInventoryDetailsItem} item + * @returns {ITableRow} + */ + private itemNodeMapper(item: IInventoryDetailsItem) { + const columns = [{ key: 'item_name', accessor: 'name' }]; + + return tableRowMapper(item, columns, { + rowTypes: [IROW_TYPE.ITEM], + }); + } + + /** + * Mappes the item inventory transaction to table row. + * @param {IInventoryDetailsItemTransaction} transaction + * @returns {ITableRow} + */ + private itemTransactionNodeMapper( + transaction: IInventoryDetailsItemTransaction + ) { + const columns = [ + { key: 'date', accessor: 'date.formattedDate' }, + { key: 'transaction_type', accessor: 'transactionType' }, + { key: 'transaction_id', accessor: 'transactionNumber' }, + { + key: 'quantity_movement', + accessor: 'quantityMovement.formattedNumber', + }, + { key: 'rate', accessor: 'rate.formattedNumber' }, + { key: 'value_movement', accessor: 'valueMovement.formattedNumber' }, + { key: 'cost', accessor: 'cost.formattedNumber' }, + { key: 'profit_margin', accessor: 'profitMargin.formattedNumber' }, + { key: 'running_quantity', accessor: 'runningQuantity.formattedNumber' }, + { key: 'running_valuation', accessor: 'runningValuation.formattedNumber' }, + ]; + return tableRowMapper(transaction, columns, { + rowTypes: [IROW_TYPE.TRANSACTION], + }); + } + + /** + * Opening balance transaction mapper to table row. + * @param {IInventoryDetailsOpening} transaction + * @returns {ITableRow} + */ + private openingNodeMapper(transaction: IInventoryDetailsOpening): ITableRow { + const columns = [ + { key: 'date', accessor: 'date.formattedDate' }, + { key: 'closing', value: 'Opening Balance' }, + { key: 'empty' }, + { key: 'quantity', accessor: 'quantity.formattedNumber' }, + { key: 'empty' }, + { key: 'value', accessor: 'value.formattedNumber' }, + ]; + + return tableRowMapper(transaction, columns, { + rowTypes: [IROW_TYPE.OPENING_ENTRY], + }); + } + + /** + * Closing balance transaction mapper to table raw. + * @param {IInventoryDetailsClosing} transaction + * @returns {ITableRow} + */ + private closingNodeMapper(transaction: IInventoryDetailsClosing): ITableRow { + const columns = [ + { key: 'date', accessor: 'date.formattedDate' }, + { key: 'closing', value: 'Closing Balance' }, + { key: 'empty' }, + { key: 'quantity', accessor: 'quantity.formattedNumber' }, + { key: 'empty' }, + { key: 'value', accessor: 'value.formattedNumber' }, + { key: 'cost', accessor: 'cost.formattedNumber' }, + { key: 'profitMargin', accessor: 'profitMargin.formattedNumber' }, + ]; + + return tableRowMapper(transaction, columns, { + rowTypes: [IROW_TYPE.CLOSING_ENTRY], + }); + } + + /** + * Detarmines the ginve inventory details node type. + * @param {string} type + * @param {IInventoryDetailsNode} node + * @returns {boolean} + */ + private isNodeTypeEquals(type: string, node: IInventoryDetailsNode): boolean { + return node.nodeType === type; + } + + /** + * Mappes the given item or transactions node to table rows. + * @param {IInventoryDetailsNode} node - + * @return {ITableRow} + */ + private itemMapper(node: IInventoryDetailsNode): ITableRow { + return R.compose( + R.when( + R.curry(this.isNodeTypeEquals)('OPENING_ENTRY'), + this.openingNodeMapper + ), + R.when( + R.curry(this.isNodeTypeEquals)('CLOSING_ENTRY'), + this.closingNodeMapper + ), + R.when(R.curry(this.isNodeTypeEquals)('item'), this.itemNodeMapper), + R.when( + R.curry(this.isNodeTypeEquals)('transaction'), + this.itemTransactionNodeMapper.bind(this) + ) + )(node); + } + + /** + * Mappes the items nodes to table rows. + * @param {IInventoryDetailsItem[]} items + * @returns {ITableRow[]} + */ + private itemsMapper(items: IInventoryDetailsItem[]): ITableRow[] { + return mapValuesDeep(items, this.itemMapper.bind(this), MAP_CONFIG); + } + + /** + * Retrieve the table rows of the inventory item details. + * @returns {ITableRow[]} + */ + public tableData(): ITableRow[] { + return this.itemsMapper(this.report.data); + } + + /** + * Retrieve the table columns of inventory details report. + * @returns {ITableColumn[]} + */ + public tableColumns(): ITableColumn[] { + return [ + { key: 'date', label: 'Date' }, + { key: 'transaction_type', label: 'Transaction type' }, + { key: 'transaction_id', label: 'Transaction #' }, + { key: 'quantity_movement', label: 'Quantity' }, + { key: 'rate', label: 'Rate' }, + { key: 'value_movement', label: 'Value' }, + { key: 'cost', label: 'Cost' }, + { key: 'profit_margin', label: 'Profit Margin' }, + { key: 'quantity_on_hand', label: 'Running quantity' }, + { key: 'value', label: 'Running Value' }, + ]; + } +} diff --git a/server/src/services/FinancialStatements/TransactionsByCustomer/TransactionsByCustomersRepository.ts b/server/src/services/FinancialStatements/TransactionsByCustomer/TransactionsByCustomersRepository.ts new file mode 100644 index 000000000..603c339db --- /dev/null +++ b/server/src/services/FinancialStatements/TransactionsByCustomer/TransactionsByCustomersRepository.ts @@ -0,0 +1,92 @@ +import { map } from 'lodash'; +import { IAccount, IAccountTransaction } from 'interfaces'; +import { ACCOUNT_TYPE } from 'data/AccountTypes'; +import HasTenancyService from 'services/Tenancy/TenancyService'; +import { Inject } from 'typedi'; + +export default class TransactionsByCustomersRepository { + @Inject() + tenancy: HasTenancyService; + + /** + * Retrieve the report customers. + * @param {number} tenantId + * @returns {Promise} + */ + public async getCustomers(tenantId: number) { + const { Customer } = this.tenancy.models(tenantId); + + return Customer.query().orderBy('displayName'); + } + + /** + * Retrieve the accounts receivable. + * @param {number} tenantId + * @returns {Promise} + */ + public async getReceivableAccounts(tenantId: number): Promise { + const { Account } = this.tenancy.models(tenantId); + + const accounts = await Account.query().where( + 'accountType', + ACCOUNT_TYPE.ACCOUNTS_RECEIVABLE + ); + return accounts; + } + + /** + * Retrieve the customers opening balance transactions. + * @param {number} tenantId - Tenant id. + * @param {number} openingDate - Opening date. + * @param {number} customersIds - Customers ids. + * @returns {Promise} + */ + public async getCustomersOpeningBalanceTransactions( + tenantId: number, + openingDate: Date, + customersIds?: number[] + ): Promise { + const { AccountTransaction } = this.tenancy.models(tenantId); + + const receivableAccounts = await this.getReceivableAccounts(tenantId); + const receivableAccountsIds = map(receivableAccounts, 'id'); + + const openingTransactions = await AccountTransaction.query().modify( + 'contactsOpeningBalance', + openingDate, + receivableAccountsIds, + customersIds + ); + return openingTransactions; + } + + /** + * Retrieve the customers periods transactions. + * @param {number} tenantId - Tenant id. + * @param {Date|string} openingDate - Opening date. + * @param {number[]} customersIds - Customers ids. + * @return {Promise} + */ + public async getCustomersPeriodTransactions( + tenantId: number, + fromDate: Date, + toDate: Date + ): Promise { + const { AccountTransaction } = this.tenancy.models(tenantId); + + const receivableAccounts = await this.getReceivableAccounts(tenantId); + const receivableAccountsIds = map(receivableAccounts, 'id'); + + const transactions = await AccountTransaction.query().onBuild((query) => { + // Filter by date. + query.modify('filterDateRange', fromDate, toDate); + + // Filter by customers. + query.whereNot('contactId', null); + + // Filter by accounts. + query.whereIn('accountId', receivableAccountsIds); + }); + return transactions; + } +} diff --git a/server/src/services/FinancialStatements/TransactionsByCustomer/TransactionsByCustomersService.ts b/server/src/services/FinancialStatements/TransactionsByCustomer/TransactionsByCustomersService.ts index 0bf91adba..0de5a070c 100644 --- a/server/src/services/FinancialStatements/TransactionsByCustomer/TransactionsByCustomersService.ts +++ b/server/src/services/FinancialStatements/TransactionsByCustomer/TransactionsByCustomersService.ts @@ -1,25 +1,29 @@ import { Inject } from 'typedi'; import * as R from 'ramda'; import moment from 'moment'; -import { map } from 'lodash'; import TenancyService from 'services/Tenancy/TenancyService'; import { ITransactionsByCustomersService, ITransactionsByCustomersFilter, ITransactionsByCustomersStatement, + ILedgerEntry, } from 'interfaces'; import TransactionsByCustomers from './TransactionsByCustomers'; import Ledger from 'services/Accounting/Ledger'; -import { ACCOUNT_TYPE } from 'data/AccountTypes'; +import TransactionsByCustomersRepository from './TransactionsByCustomersRepository'; export default class TransactionsByCustomersService - implements ITransactionsByCustomersService { + implements ITransactionsByCustomersService +{ @Inject() tenancy: TenancyService; @Inject('logger') logger: any; + @Inject() + reportRepository: TransactionsByCustomersRepository; + /** * Defaults balance sheet filter query. * @return {ICustomerBalanceSummaryQuery} @@ -44,43 +48,24 @@ export default class TransactionsByCustomersService } /** - * Retrieve the accounts receivable. + * Retrieve the customers opening balance ledger entries. * @param {number} tenantId - * @returns + * @param {Date} openingDate + * @param {number[]} customersIds + * @returns {Promise} */ - async getReceivableAccounts(tenantId: number) { - const { Account } = this.tenancy.models(tenantId); - - const accounts = await Account.query().where( - 'accountType', - ACCOUNT_TYPE.ACCOUNTS_RECEIVABLE - ); - return accounts; - } - - /** - * Retrieve the customers opening balance transactions. - * @param {number} tenantId - * @param {number} openingDate - * @param {number} customersIds - * @returns {} - */ - async getCustomersOpeningBalance( + private async getCustomersOpeningBalanceEntries( tenantId: number, openingDate: Date, customersIds?: number[] ): Promise { - const { AccountTransaction } = this.tenancy.models(tenantId); + const openingTransactions = + await this.reportRepository.getCustomersOpeningBalanceTransactions( + tenantId, + openingDate, + customersIds + ); - const receivableAccounts = await this.getReceivableAccounts(tenantId); - const receivableAccountsIds = map(receivableAccounts, 'id'); - - const openingTransactions = await AccountTransaction.query().modify( - 'contactsOpeningBalance', - openingDate, - receivableAccountsIds, - customersIds - ); return R.compose( R.map(R.assoc('date', openingDate)), R.map(R.assoc('accountNormal', 'debit')) @@ -88,38 +73,29 @@ export default class TransactionsByCustomersService } /** - * + * Retrieve the customers periods ledger entries. * @param {number} tenantId - * @param {Date|string} openingDate - * @param {number[]} customersIds + * @param {Date} fromDate + * @param {Date} toDate + * @returns {Promise} */ - async getCustomersPeriodTransactions( + private async getCustomersPeriodsEntries( tenantId: number, - fromDate: Date, - toDate: Date + fromDate: Date|string, + toDate: Date|string, ): Promise { - const { AccountTransaction } = this.tenancy.models(tenantId); - - const receivableAccounts = await this.getReceivableAccounts(tenantId); - const receivableAccountsIds = map(receivableAccounts, 'id'); - - const transactions = await AccountTransaction.query().onBuild((query) => { - // Filter by date. - query.modify('filterDateRange', fromDate, toDate); - - // Filter by customers. - query.whereNot('contactId', null); - - // Filter by accounts. - query.whereIn('accountId', receivableAccountsIds); - }); - + const transactions = + await this.reportRepository.getCustomersPeriodTransactions( + tenantId, + fromDate, + toDate + ); return R.compose( R.map(R.assoc('accountNormal', 'debit')), R.map((trans) => ({ ...trans, referenceTypeFormatted: trans.referenceTypeFormatted, - })), + })) )(transactions); } @@ -133,7 +109,6 @@ export default class TransactionsByCustomersService tenantId: number, query: ITransactionsByCustomersFilter ): Promise { - const { Customer } = this.tenancy.models(tenantId); const { accountRepository } = this.tenancy.repositories(tenantId); // Settings tenant service. @@ -148,29 +123,31 @@ export default class TransactionsByCustomersService ...query, }; const accountsGraph = await accountRepository.getDependencyGraph(); - const customers = await Customer.query().orderBy('displayName'); + + // Retrieve the report customers. + const customers = await this.reportRepository.getCustomers(tenantId); const openingBalanceDate = moment(filter.fromDate) .subtract(1, 'days') .toDate(); // Retrieve all ledger transactions of the opening balance of. - const openingBalanceTransactions = await this.getCustomersOpeningBalance( + const openingBalanceEntries = await this.getCustomersOpeningBalanceEntries( tenantId, openingBalanceDate ); // Retrieve all ledger transactions between opeing and closing period. - const customersTransactions = await this.getCustomersPeriodTransactions( + const customersTransactions = await this.getCustomersPeriodsEntries( tenantId, query.fromDate, query.toDate ); // Concats the opening balance and period customer ledger transactions. const journalTransactions = [ - ...openingBalanceTransactions, + ...openingBalanceEntries, ...customersTransactions, ]; - const journal = Ledger.fromTransactions(journalTransactions); + const journal = new Ledger(journalTransactions); // Transactions by customers data mapper. const reportInstance = new TransactionsByCustomers( diff --git a/server/src/services/FinancialStatements/TransactionsByVendor/TransactionsByVendorRepository.ts b/server/src/services/FinancialStatements/TransactionsByVendor/TransactionsByVendorRepository.ts new file mode 100644 index 000000000..c67b09741 --- /dev/null +++ b/server/src/services/FinancialStatements/TransactionsByVendor/TransactionsByVendorRepository.ts @@ -0,0 +1,92 @@ +import { Inject, Service } from 'typedi'; +import { map } from 'lodash'; +import { IVendor, IAccount, IAccountTransaction } from 'interfaces'; +import HasTenancyService from 'services/Tenancy/TenancyService'; +import { ACCOUNT_TYPE } from 'data/AccountTypes'; + +@Service() +export default class TransactionsByVendorRepository { + @Inject() + tenancy: HasTenancyService; + + /** + * Retrieve the report vendors. + * @param {number} tenantId + * @returns {Promise} + */ + public getVendors(tenantId: number): Promise { + const { Vendor } = this.tenancy.models(tenantId); + + return Vendor.query().orderBy('displayName'); + } + + /** + * Retrieve the accounts receivable. + * @param {number} tenantId + * @returns {Promise} + */ + private async getPayableAccounts(tenantId: number): Promise { + const { Account } = this.tenancy.models(tenantId); + + const accounts = await Account.query().where( + 'accountType', + ACCOUNT_TYPE.ACCOUNTS_PAYABLE + ); + return accounts; + } + + /** + * Retrieve the customers opening balance transactions. + * @param {number} tenantId + * @param {number} openingDate + * @param {number} customersIds + * @returns {} + */ + public async getVendorsOpeningBalance( + tenantId: number, + openingDate: Date, + customersIds?: number[] + ): Promise { + const { AccountTransaction } = this.tenancy.models(tenantId); + + const payableAccounts = await this.getPayableAccounts(tenantId); + const payableAccountsIds = map(payableAccounts, 'id'); + + const openingTransactions = await AccountTransaction.query().modify( + 'contactsOpeningBalance', + openingDate, + payableAccountsIds, + customersIds + ); + return openingTransactions; + } + + /** + * Retrieve vendors periods transactions. + * @param {number} tenantId + * @param {Date|string} openingDate + * @param {number[]} customersIds + */ + public async getVendorsPeriodTransactions( + tenantId: number, + fromDate: Date, + toDate: Date + ): Promise { + const { AccountTransaction } = this.tenancy.models(tenantId); + + const receivableAccounts = await this.getPayableAccounts(tenantId); + const receivableAccountsIds = map(receivableAccounts, 'id'); + + const transactions = await AccountTransaction.query().onBuild((query) => { + // Filter by date. + query.modify('filterDateRange', fromDate, toDate); + + // Filter by customers. + query.whereNot('contactId', null); + + // Filter by accounts. + query.whereIn('accountId', receivableAccountsIds); + }); + return transactions; + } +} diff --git a/server/src/services/FinancialStatements/TransactionsByVendor/TransactionsByVendorService.ts b/server/src/services/FinancialStatements/TransactionsByVendor/TransactionsByVendorService.ts index df7686a1d..354e5fa45 100644 --- a/server/src/services/FinancialStatements/TransactionsByVendor/TransactionsByVendorService.ts +++ b/server/src/services/FinancialStatements/TransactionsByVendor/TransactionsByVendorService.ts @@ -4,23 +4,27 @@ import * as R from 'ramda'; import { map } from 'lodash'; import TenancyService from 'services/Tenancy/TenancyService'; import { - IVendor, ITransactionsByVendorsService, ITransactionsByVendorsFilter, ITransactionsByVendorsStatement, + ILedgerEntry, } from 'interfaces'; import TransactionsByVendor from './TransactionsByVendor'; -import { ACCOUNT_TYPE } from 'data/AccountTypes'; import Ledger from 'services/Accounting/Ledger'; +import TransactionsByVendorRepository from './TransactionsByVendorRepository'; export default class TransactionsByVendorsService - implements ITransactionsByVendorsService { + implements ITransactionsByVendorsService +{ @Inject() tenancy: TenancyService; @Inject('logger') logger: any; + @Inject() + reportRepository: TransactionsByVendorRepository; + /** * Defaults balance sheet filter query. * @return {IVendorBalanceSummaryQuery} @@ -44,55 +48,24 @@ export default class TransactionsByVendorsService }; } - /** - * Retrieve the report vendors. - * @param tenantId - * @returns - */ - private getReportVendors(tenantId: number): Promise { - const { Vendor } = this.tenancy.models(tenantId); - - return Vendor.query().orderBy('displayName'); - } - - /** - * Retrieve the accounts receivable. - * @param {number} tenantId - * @returns - */ - private async getPayableAccounts(tenantId: number) { - const { Account } = this.tenancy.models(tenantId); - - const accounts = await Account.query().where( - 'accountType', - ACCOUNT_TYPE.ACCOUNTS_PAYABLE - ); - return accounts; - } - /** * Retrieve the customers opening balance transactions. * @param {number} tenantId * @param {number} openingDate * @param {number} customersIds - * @returns {} + * @returns {Promise} */ - private async getVendorsOpeningBalance( + private async getVendorsOpeningBalanceEntries( tenantId: number, openingDate: Date, customersIds?: number[] ): Promise { - const { AccountTransaction } = this.tenancy.models(tenantId); - - const payableAccounts = await this.getPayableAccounts(tenantId); - const payableAccountsIds = map(payableAccounts, 'id'); - - const openingTransactions = await AccountTransaction.query().modify( - 'contactsOpeningBalance', - openingDate, - payableAccountsIds, - customersIds - ); + const openingTransactions = + await this.reportRepository.getVendorsOpeningBalance( + tenantId, + openingDate, + customersIds + ); return R.compose( R.map(R.assoc('date', openingDate)), R.map(R.assoc('accountNormal', 'credit')) @@ -105,42 +78,46 @@ export default class TransactionsByVendorsService * @param {Date|string} openingDate * @param {number[]} customersIds */ - async getVendorsPeriodTransactions( + private async getVendorsPeriodEntries( tenantId: number, fromDate: Date, toDate: Date ): Promise { - const { AccountTransaction } = this.tenancy.models(tenantId); - - const receivableAccounts = await this.getPayableAccounts(tenantId); - const receivableAccountsIds = map(receivableAccounts, 'id'); - - const transactions = await AccountTransaction.query().onBuild((query) => { - // Filter by date. - query.modify('filterDateRange', fromDate, toDate); - - // Filter by customers. - query.whereNot('contactId', null); - - // Filter by accounts. - query.whereIn('accountId', receivableAccountsIds); - }); - + const transactions = + await this.reportRepository.getVendorsPeriodTransactions( + tenantId, + fromDate, + toDate + ); return R.compose( R.map(R.assoc('accountNormal', 'credit')), R.map((trans) => ({ ...trans, referenceTypeFormatted: trans.referenceTypeFormatted, - })), + })) )(transactions); } - async getReportTransactions(tenantId: number, fromDate: Date, toDate: Date) { + /** + * Retrieve the report ledger entries from repository. + * @param {number} tenantId + * @param {Date} fromDate + * @param {Date} toDate + * @returns {Promise} + */ + private async getReportEntries( + tenantId: number, + fromDate: Date, + toDate: Date + ): Promise { const openingBalanceDate = moment(fromDate).subtract(1, 'days').toDate(); return [ - ...(await this.getVendorsOpeningBalance(tenantId, openingBalanceDate)), - ...(await this.getVendorsPeriodTransactions(tenantId, fromDate, toDate)), + ...(await this.getVendorsOpeningBalanceEntries( + tenantId, + openingBalanceDate + )), + ...(await this.getVendorsPeriodEntries(tenantId, fromDate, toDate)), ]; } @@ -155,7 +132,7 @@ export default class TransactionsByVendorsService query: ITransactionsByVendorsFilter ): Promise { const { accountRepository } = this.tenancy.repositories(tenantId); - + // Settings tenant service. const settings = this.tenancy.settings(tenantId); const baseCurrency = settings.get({ @@ -166,19 +143,19 @@ export default class TransactionsByVendorsService const filter = { ...this.defaultQuery, ...query }; // Retrieve the report vendors. - const vendors = await this.getReportVendors(tenantId); + const vendors = await this.reportRepository.getVendors(tenantId); // Retrieve the accounts graph. const accountsGraph = await accountRepository.getDependencyGraph(); // Journal transactions. - const journalTransactions = await this.getReportTransactions( + const reportEntries = await this.getReportEntries( tenantId, filter.fromDate, filter.toDate ); // Ledger collection. - const journal = Ledger.fromTransactions(journalTransactions); + const journal = new Ledger(reportEntries); // Transactions by customers data mapper. const reportInstance = new TransactionsByVendor( diff --git a/server/src/services/FinancialStatements/VendorBalanceSummary/VendorBalanceSummaryRepository.ts b/server/src/services/FinancialStatements/VendorBalanceSummary/VendorBalanceSummaryRepository.ts new file mode 100644 index 000000000..ed929bb9d --- /dev/null +++ b/server/src/services/FinancialStatements/VendorBalanceSummary/VendorBalanceSummaryRepository.ts @@ -0,0 +1,69 @@ +import { Inject, Service } from 'typedi'; +import { isEmpty, map } from 'lodash'; +import { IVendor, IAccount } from 'interfaces'; +import HasTenancyService from 'services/Tenancy/TenancyService'; +import { ACCOUNT_TYPE } from 'data/AccountTypes'; + +@Service() +export default class VendorBalanceSummaryRepository { + @Inject() + tenancy: HasTenancyService; + + /** + * Retrieve the report vendors. + * @param {number} tenantId + * @param {number[]} vendorsIds - Vendors ids. + * @returns {IVendor[]} + */ + public getVendors( + tenantId: number, + vendorsIds?: number[] + ): Promise { + const { Vendor } = this.tenancy.models(tenantId); + + const vendorQuery = Vendor.query().orderBy('displayName'); + + if (!isEmpty(vendorsIds)) { + vendorQuery.whereIn('id', vendorsIds); + } + return vendorQuery; + } + + /** + * Retrieve the payable accounts. + * @param {number} tenantId + * @returns {Promise} + */ + public getPayableAccounts(tenantId: number): Promise { + const { Account } = this.tenancy.models(tenantId); + + return Account.query().where('accountType', ACCOUNT_TYPE.ACCOUNTS_PAYABLE); + } + + /** + * Retrieve the vendors transactions. + * @param {number} tenantId + * @param {Date} asDate + * @returns + */ + public async getVendorsTransactions(tenantId: number, asDate: Date | string) { + const { AccountTransaction } = this.tenancy.models(tenantId); + + // Retrieve payable accounts . + const payableAccounts = await this.getPayableAccounts(tenantId); + const payableAccountsIds = map(payableAccounts, 'id'); + + // Retrieve the customers transactions of A/R accounts. + const customersTranasctions = await AccountTransaction.query().onBuild( + (query) => { + query.whereIn('accountId', payableAccountsIds); + query.modify('filterDateRange', null, asDate); + query.groupBy('contactId'); + query.sum('credit as credit'); + query.sum('debit as debit'); + query.select('contactId'); + } + ); + return customersTranasctions; + } +} diff --git a/server/src/services/FinancialStatements/VendorBalanceSummary/VendorBalanceSummaryService.ts b/server/src/services/FinancialStatements/VendorBalanceSummary/VendorBalanceSummaryService.ts index bf8a93673..19065abb3 100644 --- a/server/src/services/FinancialStatements/VendorBalanceSummary/VendorBalanceSummaryService.ts +++ b/server/src/services/FinancialStatements/VendorBalanceSummary/VendorBalanceSummaryService.ts @@ -8,20 +8,24 @@ import { IVendorBalanceSummaryService, IVendorBalanceSummaryQuery, IVendorBalanceSummaryStatement, + ILedgerEntry, } from 'interfaces'; import { VendorBalanceSummaryReport } from './VendorBalanceSummary'; -import { isEmpty } from 'lodash'; -import { ACCOUNT_TYPE } from 'data/AccountTypes'; import Ledger from 'services/Accounting/Ledger'; +import VendorBalanceSummaryRepository from './VendorBalanceSummaryRepository'; export default class VendorBalanceSummaryService - implements IVendorBalanceSummaryService { + implements IVendorBalanceSummaryService +{ @Inject() tenancy: TenancyService; @Inject('logger') logger: any; + @Inject() + reportRepo: VendorBalanceSummaryRepository; + /** * Defaults balance sheet filter query. * @return {IVendorBalanceSummaryQuery} @@ -45,59 +49,22 @@ export default class VendorBalanceSummaryService } /** - * Retrieve the report vendors. - * @param {number} tenantId - * @param {number[]} vendorsIds - Vendors ids. - * @returns {IVendor[]} + * Retrieve the vendors ledger entrjes. + * @param {number} tenantId - + * @param {Date|string} date - + * @returns {Promise} */ - getReportVendors( + private async getReportVendorsEntries( tenantId: number, - vendorsIds?: number[] - ): Promise { - const { Vendor } = this.tenancy.models(tenantId); - - return Vendor.query() - .orderBy('displayName') - .onBuild((query) => { - if (!isEmpty(vendorsIds)) { - query.whereIn('id', vendorsIds); - } - }); - } - - getPayableAccounts(tenantId: number) { - const { Account } = this.tenancy.models(tenantId); - - return Account.query().where('accountType', ACCOUNT_TYPE.ACCOUNTS_PAYABLE); - } - - /** - * Retrieve - * @param tenantId - * @param asDate - * @returns - */ - async getReportVendorsTransactions(tenantId: number, asDate: Date | string) { - const { AccountTransaction } = this.tenancy.models(tenantId); - - // Retrieve payable accounts . - const payableAccounts = await this.getPayableAccounts(tenantId); - const payableAccountsIds = map(payableAccounts, 'id'); - - // Retrieve the customers transactions of A/R accounts. - const customersTranasctions = await AccountTransaction.query().onBuild( - (query) => { - query.whereIn('accountId', payableAccountsIds); - query.modify('filterDateRange', null, asDate); - query.groupBy('contactId'); - query.sum('credit as credit'); - query.sum('debit as debit'); - query.select('contactId'); - } + date: Date | string + ): Promise { + const transactions = await this.reportRepo.getVendorsTransactions( + tenantId, + date ); - const commonProps = { accountNormal: 'credit', date: asDate }; + const commonProps = { accountNormal: 'credit' }; - return R.map(R.merge(commonProps))(customersTranasctions); + return R.map(R.merge(commonProps))(transactions); } /** @@ -126,19 +93,21 @@ export default class VendorBalanceSummaryService } ); // Retrieve the vendors transactions. - const vendorsTransactions = await this.getReportVendorsTransactions( + const vendorsEntries = await this.getReportVendorsEntries( tenantId, query.asDate ); // Retrieve the customers list ordered by the display name. - const vendors = await this.getReportVendors(tenantId, query.vendorsIds); - + const vendors = await this.reportRepo.getVendors( + tenantId, + query.vendorsIds + ); // Ledger query. - const ledger = Ledger.fromTransactions(vendorsTransactions); + const vendorsLedger = new Ledger(vendorsEntries); // Report instance. const reportInstance = new VendorBalanceSummaryReport( - ledger, + vendorsLedger, vendors, filter, baseCurrency diff --git a/server/src/services/Inventory/Inventory.ts b/server/src/services/Inventory/Inventory.ts index 64b03fa15..22d909c09 100644 --- a/server/src/services/Inventory/Inventory.ts +++ b/server/src/services/Inventory/Inventory.ts @@ -37,6 +37,7 @@ export default class InventoryService { transformItemEntriesToInventory(transaction: { transactionId: number; transactionType: IItemEntryTransactionType; + transactionNumber?: string; date: Date | string; direction: TInventoryTransactionDirection; @@ -56,6 +57,10 @@ export default class InventoryService { entryId: entry.id, createdAt: transaction.createdAt, costAccountId: entry.costAccountId, + meta: { + transactionNumber: transaction.transactionNumber, + description: entry.description, + } })); } @@ -205,7 +210,7 @@ export default class InventoryService { inventoryEntry.transactionType ); } - return InventoryTransaction.query().insert({ + return InventoryTransaction.query().insertGraph({ ...inventoryEntry, }); } diff --git a/server/src/services/Inventory/InventoryAverageCost.ts b/server/src/services/Inventory/InventoryAverageCost.ts index ca7801dd6..b69613a91 100644 --- a/server/src/services/Inventory/InventoryAverageCost.ts +++ b/server/src/services/Inventory/InventoryAverageCost.ts @@ -165,9 +165,9 @@ export default class InventoryAverageCostMethod 'transactionId', 'transactionType', 'createdAt', - 'costAccountId', ]), + inventoryTransactionId: invTransaction.id, }; switch (invTransaction.direction) { case 'IN': diff --git a/server/src/services/Sales/SalesInvoices.ts b/server/src/services/Sales/SalesInvoices.ts index d8094ca0e..dfdf04290 100644 --- a/server/src/services/Sales/SalesInvoices.ts +++ b/server/src/services/Sales/SalesInvoices.ts @@ -536,6 +536,7 @@ export default class SaleInvoicesService implements ISalesInvoicesService { const transaction = { transactionId: saleInvoice.id, transactionType: 'SaleInvoice', + transactionNumber: saleInvoice.invoiceNo, date: saleInvoice.invoiceDate, direction: 'OUT', diff --git a/server/src/utils/deepdash.ts b/server/src/utils/deepdash.ts new file mode 100644 index 000000000..b1e2f96f4 --- /dev/null +++ b/server/src/utils/deepdash.ts @@ -0,0 +1,52 @@ +import _ from 'lodash'; +import deepdash from 'deepdash'; + +const { + condense, + condenseDeep, + eachDeep, + exists, + filterDeep, + findDeep, + findPathDeep, + findValueDeep, + forEachDeep, + index, + keysDeep, + mapDeep, + mapKeysDeep, + mapValuesDeep, + omitDeep, + pathMatches, + pathToString, + paths, + pickDeep, + reduceDeep, + someDeep, + iteratee, +} = deepdash(_); + +export { + iteratee, + condense, + condenseDeep, + eachDeep, + exists, + filterDeep, + findDeep, + findPathDeep, + findValueDeep, + forEachDeep, + index, + keysDeep, + mapDeep, + mapKeysDeep, + mapValuesDeep, + omitDeep, + pathMatches, + pathToString, + paths, + pickDeep, + reduceDeep, + someDeep, +}; diff --git a/server/src/utils/index.ts b/server/src/utils/index.ts index 9d8fb0edd..a5f894765 100644 --- a/server/src/utils/index.ts +++ b/server/src/utils/index.ts @@ -51,6 +51,30 @@ const dateRangeCollection = ( return collection; }; +const dateRangeFromToCollection = ( + fromDate, + toDate, + addType = 'day', + increment = 1 +) => { + const collection = []; + const momentFromDate = moment(fromDate); + const dateFormat = 'YYYY-MM-DD'; + + for ( + let i = momentFromDate; + i.isBefore(toDate, addType) || i.isSame(toDate, addType); + i.add(increment, `${addType}s`) + ) { + collection.push({ + fromDate: i.startOf(addType).format(dateFormat), + toDate: i.endOf(addType).format(dateFormat), + }); + } + return collection; +}; + + const dateRangeFormat = (rangeType) => { switch (rangeType) { case 'year': @@ -329,8 +353,28 @@ var increment = (n) => { }; }; +const transformToMapBy = (collection, key) => { + return new Map( + Object.entries(_.groupBy(collection, key)), + ); +} + +const transformToMapKeyValue = (collection, key) => { + return new Map( + collection.map((item) => [item[key], item]), + ); +}; + + +const accumSum = (data, callback) => { + return data.reduce((acc, _data) => { + const amount = callback(_data); + return acc + amount; + }, 0) +} export { + accumSum, increment, hashPassword, origin, @@ -354,4 +398,7 @@ export { defaultToTransform, transformToMap, transactionIncrement, + transformToMapBy, + dateRangeFromToCollection, + transformToMapKeyValue };