From d49992a6d723cbd3182c3fc8340070fe6a3467a5 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Thu, 10 Dec 2020 13:04:49 +0200 Subject: [PATCH] refactoring: balance sheet report. refactoring: trial balance sheet report. refactoring: general ledger report. refactoring: journal report. refactoring: P&L report. --- server/nodemon.json | 10 +- server/package.json | 3 +- server/src/api/controllers/BaseController.ts | 5 + .../api/controllers/FinancialStatements.js | 28 -- .../api/controllers/FinancialStatements.ts | 30 ++ .../FinancialStatements/ARAgingSummary.ts | 69 ++++ .../FinancialStatements/BalanceSheet.js | 237 ----------- .../FinancialStatements/BalanceSheet.ts | 93 +++++ .../FinancialStatementMixin.js | 13 - .../FinancialStatements/GeneralLedger.js | 165 -------- .../FinancialStatements/GeneralLedger.ts | 74 ++++ .../FinancialStatements/JournalSheet.js | 120 ------ .../FinancialStatements/JournalSheet.ts | 82 ++++ .../FinancialStatements/ProfitLossSheet.js | 259 ------------ .../FinancialStatements/ProfitLossSheet.ts | 79 ++++ .../ReceivableAgingSummary.js | 218 ----------- .../FinancialStatements/TrialBalanceSheet.js | 115 ------ .../FinancialStatements/TrialBalanceSheet.ts | 74 ++++ server/src/api/controllers/ManualJournals.ts | 6 +- server/src/api/index.ts | 2 +- server/src/data/BalanceSheetStructure.js | 62 --- server/src/data/BalanceSheetStructure.ts | 65 +++ .../20190423085241_seed_accounts_types.js | 4 +- server/src/interfaces/ARAgingSummaryReport.ts | 45 +++ server/src/interfaces/Account.ts | 4 + server/src/interfaces/BalanceSheet.ts | 74 ++++ server/src/interfaces/Contact.ts | 1 + server/src/interfaces/FinancialStatements.ts | 2 + server/src/interfaces/GeneralLedgerSheet.ts | 61 +++ server/src/interfaces/Journal.ts | 6 + server/src/interfaces/JournalReport.ts | 28 ++ server/src/interfaces/ProfitLossSheet.ts | 58 +++ server/src/interfaces/TrialBalanceSheet.ts | 37 ++ server/src/interfaces/index.ts | 10 +- server/src/loaders/tenantRepositories.ts | 2 + server/src/models/AccountTransaction.js | 16 +- server/src/repositories/AccountRepository.ts | 46 +-- .../AccountTransactionRepository.ts | 60 +++ .../src/repositories/AccountTypeRepository.ts | 16 - server/src/repositories/CachableRepository.ts | 19 + server/src/repositories/ContactRepository.ts | 16 - server/src/repositories/CustomerRepository.ts | 16 +- server/src/repositories/ExpenseRepository.ts | 15 - server/src/repositories/JournalRepository.ts | 18 + server/src/repositories/TenantRepository.ts | 33 +- server/src/repositories/VendorRepository.ts | 14 - server/src/repositories/ViewRepository.ts | 16 - server/src/repositories/ViewRoleRepository.ts | 17 - .../services/Accounting/JournalFinancial.ts | 206 +--------- .../src/services/Accounting/JournalPoster.ts | 243 +++++++++++- .../src/services/Accounts/AccountsService.ts | 5 +- .../AgingSummary/APAgingSummaryService.ts | 6 + .../AgingSummary/APAgingSummarySheet.ts | 12 + .../AgingSummary/ARAgingSummaryService.ts | 93 +++++ .../AgingSummary/ARAgingSummarySheet.ts | 154 ++++++++ .../AgingSummary/AgingSummary.ts | 75 ++++ .../BalanceSheet/BalanceSheet.ts | 284 ++++++++++++++ .../BalanceSheet/BalanceSheetService.ts | 103 +++++ .../FinancialStatements/FinancialSheet.ts | 16 + .../GeneralLedger/GeneralLedger.ts | 150 +++++++ .../GeneralLedger/GeneralLedgerService.ts | 125 ++++++ .../JournalSheet/JournalSheet.ts | 88 +++++ .../JournalSheet/JournalSheetService.ts | 85 ++++ .../ProfitLossSheet/ProfitLossSheet.ts | 369 ++++++++++++++++++ .../ProfitLossSheet/ProfitLossSheetService.ts | 98 +++++ .../TrialBalanceSheet/TrialBalanceSheet.ts | 101 +++++ .../TrialBalanceSheetService.ts | 88 +++++ .../src/services/FinancialStatements/utils.ts | 13 + .../ManualJournals/ManualJournalsService.ts | 2 +- server/src/services/Tenancy/TenancyService.ts | 2 +- server/src/utils/index.js | 13 + 71 files changed, 3203 insertions(+), 1571 deletions(-) delete mode 100644 server/src/api/controllers/FinancialStatements.js create mode 100644 server/src/api/controllers/FinancialStatements.ts create mode 100644 server/src/api/controllers/FinancialStatements/ARAgingSummary.ts delete mode 100644 server/src/api/controllers/FinancialStatements/BalanceSheet.js create mode 100644 server/src/api/controllers/FinancialStatements/BalanceSheet.ts delete mode 100644 server/src/api/controllers/FinancialStatements/FinancialStatementMixin.js delete mode 100644 server/src/api/controllers/FinancialStatements/GeneralLedger.js create mode 100644 server/src/api/controllers/FinancialStatements/GeneralLedger.ts delete mode 100644 server/src/api/controllers/FinancialStatements/JournalSheet.js create mode 100644 server/src/api/controllers/FinancialStatements/JournalSheet.ts delete mode 100644 server/src/api/controllers/FinancialStatements/ProfitLossSheet.js create mode 100644 server/src/api/controllers/FinancialStatements/ProfitLossSheet.ts delete mode 100644 server/src/api/controllers/FinancialStatements/ReceivableAgingSummary.js delete mode 100644 server/src/api/controllers/FinancialStatements/TrialBalanceSheet.js create mode 100644 server/src/api/controllers/FinancialStatements/TrialBalanceSheet.ts delete mode 100644 server/src/data/BalanceSheetStructure.js create mode 100644 server/src/data/BalanceSheetStructure.ts create mode 100644 server/src/interfaces/ARAgingSummaryReport.ts create mode 100644 server/src/interfaces/BalanceSheet.ts create mode 100644 server/src/interfaces/FinancialStatements.ts create mode 100644 server/src/interfaces/GeneralLedgerSheet.ts create mode 100644 server/src/interfaces/JournalReport.ts create mode 100644 server/src/interfaces/ProfitLossSheet.ts create mode 100644 server/src/interfaces/TrialBalanceSheet.ts create mode 100644 server/src/repositories/AccountTransactionRepository.ts create mode 100644 server/src/repositories/CachableRepository.ts create mode 100644 server/src/repositories/JournalRepository.ts create mode 100644 server/src/services/FinancialStatements/AgingSummary/APAgingSummaryService.ts create mode 100644 server/src/services/FinancialStatements/AgingSummary/APAgingSummarySheet.ts create mode 100644 server/src/services/FinancialStatements/AgingSummary/ARAgingSummaryService.ts create mode 100644 server/src/services/FinancialStatements/AgingSummary/ARAgingSummarySheet.ts create mode 100644 server/src/services/FinancialStatements/AgingSummary/AgingSummary.ts create mode 100644 server/src/services/FinancialStatements/BalanceSheet/BalanceSheet.ts create mode 100644 server/src/services/FinancialStatements/BalanceSheet/BalanceSheetService.ts create mode 100644 server/src/services/FinancialStatements/FinancialSheet.ts create mode 100644 server/src/services/FinancialStatements/GeneralLedger/GeneralLedger.ts create mode 100644 server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerService.ts create mode 100644 server/src/services/FinancialStatements/JournalSheet/JournalSheet.ts create mode 100644 server/src/services/FinancialStatements/JournalSheet/JournalSheetService.ts create mode 100644 server/src/services/FinancialStatements/ProfitLossSheet/ProfitLossSheet.ts create mode 100644 server/src/services/FinancialStatements/ProfitLossSheet/ProfitLossSheetService.ts create mode 100644 server/src/services/FinancialStatements/TrialBalanceSheet/TrialBalanceSheet.ts create mode 100644 server/src/services/FinancialStatements/TrialBalanceSheet/TrialBalanceSheetService.ts create mode 100644 server/src/services/FinancialStatements/utils.ts diff --git a/server/nodemon.json b/server/nodemon.json index b4bc4681a..346bc45eb 100644 --- a/server/nodemon.json +++ b/server/nodemon.json @@ -1,11 +1,17 @@ { + "restartable": "rs", "watch": [ "src", ".env" ], - "ext": "js,ts,json", "ignore": [ + ".git", "src/**/*.spec.ts" ], - "exec": "ts-node --transpile-only ./src/server.ts" + "execMap": { + "ts": "node --inspect -r ts-node/register/transpile-only ./src/server.ts" + }, + "env": { + "NODE_ENV": "development" + } } \ No newline at end of file diff --git a/server/package.json b/server/package.json index 979dd4dc5..a23578396 100644 --- a/server/package.json +++ b/server/package.json @@ -6,7 +6,7 @@ "scripts": { "build": "webpack", "start": "cross-env NODE_PATH=./src nodemon", - "inspect": "cross-env NODE_PATH=./src nodemon --inspect src/server.ts" + "inspect": "cross-env NODE_PATH=./src nodemon src/server.ts" }, "author": "Ahmed Bouhuolia, ", "license": "ISC", @@ -59,6 +59,7 @@ "node-cache": "^4.2.1", "nodemailer": "^6.3.0", "nodemon": "^1.19.1", + "object-hash": "^2.0.3", "objection": "^2.0.10", "objection-filter": "^4.0.1", "objection-soft-delete": "^1.0.7", diff --git a/server/src/api/controllers/BaseController.ts b/server/src/api/controllers/BaseController.ts index 6299a9ac0..6e974fc5e 100644 --- a/server/src/api/controllers/BaseController.ts +++ b/server/src/api/controllers/BaseController.ts @@ -2,6 +2,7 @@ import { Response, Request, NextFunction } from 'express'; import { matchedData, validationResult } from "express-validator"; import { camelCase, snakeCase, omit } from "lodash"; import { mapKeysDeep } from 'utils' +import asyncMiddleware from 'api/middleware/asyncMiddleware'; export default class BaseController { @@ -63,4 +64,8 @@ export default class BaseController { transfromToResponse(data: any) { return mapKeysDeep(data, (v, k) => snakeCase(k)); } + + asyncMiddleware(callback) { + return asyncMiddleware(callback); + } } \ No newline at end of file diff --git a/server/src/api/controllers/FinancialStatements.js b/server/src/api/controllers/FinancialStatements.js deleted file mode 100644 index d050ce6f0..000000000 --- a/server/src/api/controllers/FinancialStatements.js +++ /dev/null @@ -1,28 +0,0 @@ -import express from 'express'; - -import BalanceSheetController from './FinancialStatements/BalanceSheet'; -import TrialBalanceSheetController from './FinancialStatements/TrialBalanceSheet'; -import GeneralLedgerController from './FinancialStatements/generalLedger'; -import JournalSheetController from './FinancialStatements/JournalSheet'; -import ProfitLossController from './FinancialStatements/ProfitLossSheet'; -import ReceivableAgingSummary from './FinancialStatements/ReceivableAgingSummary'; -import PayableAgingSummary from './FinancialStatements/PayableAgingSummary'; - -export default { - /** - * Router constructor. - */ - router() { - const router = express.Router(); - - router.use('/balance_sheet', BalanceSheetController.router()); - router.use('/profit_loss_sheet', ProfitLossController.router()); - router.use('/general_ledger', GeneralLedgerController.router()); - router.use('/trial_balance_sheet', TrialBalanceSheetController.router()); - router.use('/journal', JournalSheetController.router()); - router.use('/receivable_aging_summary', ReceivableAgingSummary.router()); - router.use('/payable_aging_summary', PayableAgingSummary.router()); - - return router; - }, -}; diff --git a/server/src/api/controllers/FinancialStatements.ts b/server/src/api/controllers/FinancialStatements.ts new file mode 100644 index 000000000..f4e4650bb --- /dev/null +++ b/server/src/api/controllers/FinancialStatements.ts @@ -0,0 +1,30 @@ +import { Router } from 'express'; +import { Container, Service } from 'typedi'; + +import BalanceSheetController from './FinancialStatements/BalanceSheet'; +import TrialBalanceSheetController from './FinancialStatements/TrialBalanceSheet'; +import GeneralLedgerController from './FinancialStatements/GeneralLedger'; +import JournalSheetController from './FinancialStatements/JournalSheet'; +import ProfitLossController from './FinancialStatements/ProfitLossSheet'; +import ReceivableAgingSummary from './FinancialStatements/ARAgingSummary'; +// import PayableAgingSummary from './FinancialStatements/PayableAgingSummary'; + +@Service() +export default class FinancialStatementsService { + /** + * Router constructor. + */ + router() { + const router = Router(); + + router.use('/balance_sheet', Container.get(BalanceSheetController).router()); + router.use('/profit_loss_sheet', Container.get(ProfitLossController).router()); + router.use('/general_ledger', Container.get(GeneralLedgerController).router()); + router.use('/trial_balance_sheet', Container.get(TrialBalanceSheetController).router()); + router.use('/journal', Container.get(JournalSheetController).router()); + router.use('/receivable_aging_summary', Container.get(ReceivableAgingSummary).router()); + // router.use('/payable_aging_summary', PayableAgingSummary.router()); + + return router; + } +}; diff --git a/server/src/api/controllers/FinancialStatements/ARAgingSummary.ts b/server/src/api/controllers/FinancialStatements/ARAgingSummary.ts new file mode 100644 index 000000000..d79bf8b68 --- /dev/null +++ b/server/src/api/controllers/FinancialStatements/ARAgingSummary.ts @@ -0,0 +1,69 @@ +import { Service, Inject } from 'typedi'; +import { Router, Request, Response } from 'express'; +import { query, oneOf } from 'express-validator'; +import BaseController from '../BaseController'; +import ARAgingSummaryService from 'services/FinancialStatements/AgingSummary/ARAgingSummaryService'; + +@Service() +export default class ARAgingSummaryReportController extends BaseController { + @Inject() + ARAgingSummaryService: ARAgingSummaryService; + + /** + * Router constructor. + */ + router() { + const router = Router(); + + router.get( + '/', + this.validationSchema, + this.validationResult, + this.asyncMiddleware(this.receivableAgingSummary.bind(this)) + ); + return router; + } + + /** + * Receivable aging summary validation roles. + */ + get validationSchema() { + return [ + query('as_date').optional().isISO8601(), + query('aging_days_before').optional().isNumeric().toInt(), + query('aging_periods').optional().isNumeric().toInt(), + query('number_format.no_cents').optional().isBoolean().toBoolean(), + query('number_format.1000_divide').optional().isBoolean().toBoolean(), + oneOf( + [ + query('customer_ids').optional().isArray({ min: 1 }), + query('customer_ids.*').isNumeric().toInt(), + ], + [query('customer_ids').optional().isNumeric().toInt()] + ), + query('none_zero').optional().isBoolean().toBoolean(), + ]; + } + + /** + * Retrieve receivable aging summary report. + */ + async receivableAgingSummary(req: Request, res: Response) { + const { tenantId } = req; + const filter = this.matchedQueryData(req); + + try { + const { + data, + columns + } = await this.ARAgingSummaryService.ARAgingSummary(tenantId, filter); + + return res.status(200).send({ + data: this.transfromToResponse(data), + columns: this.transfromToResponse(columns), + }); + } catch (error) { + console.log(error); + } + } +} diff --git a/server/src/api/controllers/FinancialStatements/BalanceSheet.js b/server/src/api/controllers/FinancialStatements/BalanceSheet.js deleted file mode 100644 index 9948ab3a1..000000000 --- a/server/src/api/controllers/FinancialStatements/BalanceSheet.js +++ /dev/null @@ -1,237 +0,0 @@ -import express from 'express'; -import { query, validationResult } from 'express-validator'; -import moment from 'moment'; -import { pick, omit, sumBy } from 'lodash'; -import JournalPoster from 'services/Accounting/JournalPoster'; -import { dateRangeCollection, itemsStartWith, getTotalDeep } from 'utils'; -import asyncMiddleware from 'api/middleware/asyncMiddleware'; -import { formatNumberClosure } from './FinancialStatementMixin'; -import BalanceSheetStructure from 'data/BalanceSheetStructure'; - -export default { - /** - * Router constructor. - */ - router() { - const router = express.Router(); - - router.get( - '/', - this.balanceSheet.validation, - asyncMiddleware(this.balanceSheet.handler) - ); - - return router; - }, - - /** - * Retrieve the balance sheet. - */ - balanceSheet: { - validation: [ - query('accounting_method').optional().isIn(['cash', 'accural']), - 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('number_format.no_cents').optional().isBoolean().toBoolean(), - query('number_format.divide_1000').optional().isBoolean().toBoolean(), - query('account_ids').isArray().optional(), - query('account_ids.*').isNumeric().toInt(), - query('none_zero').optional().isBoolean().toBoolean(), - ], - async handler(req, res) { - const validationErrors = validationResult(req); - - if (!validationErrors.isEmpty()) { - return res.boom.badData(null, { - code: 'validation_error', - ...validationErrors, - }); - } - const { Account, AccountType } = req.models; - - const filter = { - display_columns_type: 'total', - display_columns_by: '', - from_date: moment().startOf('year').format('YYYY-MM-DD'), - to_date: moment().endOf('year').format('YYYY-MM-DD'), - number_format: { - no_cents: false, - divide_1000: false, - }, - none_zero: false, - basis: 'cash', - account_ids: [], - ...req.query, - }; - if (!Array.isArray(filter.account_ids)) { - filter.account_ids = [filter.account_ids]; - } - // Account balance formmatter based on the given query. - const amountFormatter = formatNumberClosure(filter.number_format); - const comparatorDateType = - filter.display_columns_type === 'total' - ? 'day' - : filter.display_columns_by; - - const balanceSheetTypes = await AccountType.query().where( - 'balance_sheet', - true - ); - // Fetch all balance sheet accounts from the storage. - const accounts = await Account.query() - .whereIn( - 'account_type_id', - balanceSheetTypes.map((a) => a.id) - ) - .modify('filterAccounts', filter.account_ids) - .withGraphFetched('type') - .withGraphFetched('transactions') - .modifyGraph('transactions', (builder) => { - builder.modify('filterDateRange', null, filter.to_date); - }); - // Accounts dependency graph. - const accountsGraph = Account.toDependencyGraph(accounts); - // Load all entries that associated to the given accounts. - const journalEntriesCollected = Account.collectJournalEntries(accounts); - const journalEntries = new JournalPoster(accountsGraph); - journalEntries.loadEntries(journalEntriesCollected); - - // Date range collection. - const dateRangeSet = - filter.display_columns_type === 'date_periods' - ? dateRangeCollection( - filter.from_date, - filter.to_date, - comparatorDateType - ) - : []; - // Gets the date range set from start to end date. - const getAccountTotalPeriods = (account) => ({ - total_periods: dateRangeSet.map((date) => { - const amount = journalEntries.getAccountBalance( - account.id, - date, - comparatorDateType - ); - return { - amount, - date, - formatted_amount: amountFormatter(amount), - }; - }), - }); - // Retrieve accounts total periods. - const getAccountsTotalPeriods = (_accounts) => - Object.values( - dateRangeSet.reduce((acc, date, index) => { - const amount = sumBy(_accounts, `total_periods[${index}].amount`); - acc[date] = { - date, - amount, - formatted_amount: amountFormatter(amount), - }; - return acc; - }, {}) - ); - // Retrieve account total and total periods with account meta. - const getAccountTotal = (account) => { - const closingBalance = journalEntries.getAccountBalance( - account.id, - filter.to_date - ); - const totalPeriods = - (filter.display_columns_type === 'date_periods' && - getAccountTotalPeriods(account)) || - null; - - return { - ...pick(account, ['id', 'index', 'name', 'code', 'parentAccountId']), - ...(totalPeriods && { totalPeriods }), - total: { - amount: closingBalance, - formatted_amount: amountFormatter(closingBalance), - date: filter.to_date, - }, - }; - }; - // Get accounts total of the given structure section - const getAccountsSectionTotal = (_accounts) => { - const total = getTotalDeep(_accounts, 'children', 'total.amount'); - return { - total: { - total, - formatted_amount: amountFormatter(total), - }, - }; - }; - // Strcuture accounts related mapper. - const structureAccountsRelatedMapper = (accountsTypes) => { - const filteredAccounts = accounts - // Filter accounts that have no transaction when `none_zero` is on. - .filter( - (account) => account.transactions.length > 0 || !filter.none_zero - ) - // Filter accounts that associated to the section accounts types. - .filter( - (account) => accountsTypes.indexOf(account.type.childType) !== -1 - ) - .map(getAccountTotal); - // Gets total amount of the given accounts. - const totalAmount = sumBy(filteredAccounts, 'total.amount'); - - return { - children: Account.toNestedArray(filteredAccounts), - total: { - amount: totalAmount, - formatted_amount: amountFormatter(totalAmount), - }, - ...(filter.display_columns_type === 'date_periods' - ? { - total_periods: getAccountsTotalPeriods(filteredAccounts), - } - : {}), - }; - }; - // Structure section mapper. - const structureSectionMapper = (structure) => { - const result = { - ...omit(structure, itemsStartWith(Object.keys(structure), '_')), - ...(structure.children - ? { - children: balanceSheetWalker(structure.children), - } - : {}), - ...(structure._accounts_types_related - ? { - ...structureAccountsRelatedMapper( - structure._accounts_types_related - ), - } - : {}), - }; - return { - ...result, - ...(!structure._accounts_types_related - ? getAccountsSectionTotal(result.children) - : {}), - }; - }; - const balanceSheetWalker = (reportStructure) => - reportStructure.map(structureSectionMapper).filter( - // Filter the structure sections that have no children. - (structure) => structure.children.length > 0 || structure._forceShow - ); - - // Response. - return res.status(200).send({ - query: { ...filter }, - columns: { ...dateRangeSet }, - balance_sheet: [...balanceSheetWalker(BalanceSheetStructure)], - }); - }, - }, -}; diff --git a/server/src/api/controllers/FinancialStatements/BalanceSheet.ts b/server/src/api/controllers/FinancialStatements/BalanceSheet.ts new file mode 100644 index 000000000..d8878ab4a --- /dev/null +++ b/server/src/api/controllers/FinancialStatements/BalanceSheet.ts @@ -0,0 +1,93 @@ +import { Inject, Service } from 'typedi'; +import { Router, Request, Response, NextFunction } from 'express'; +import { query, ValidationChain } from 'express-validator'; +import { castArray } from 'lodash'; +import asyncMiddleware from 'api/middleware/asyncMiddleware'; +import BaseController from '../BaseController'; +import BalanceSheetStatementService from 'services/FinancialStatements/BalanceSheet/BalanceSheetService'; + +@Service() +export default class BalanceSheetStatementController extends BaseController{ + @Inject() + balanceSheetService: BalanceSheetStatementService; + + /** + * Router constructor. + */ + router() { + const router = Router(); + + router.get( + '/', + this.balanceSheetValidationSchema, + this.validationResult, + asyncMiddleware(this.balanceSheet.bind(this)) + ); + return router; + } + + /** + * Balance sheet validation schecma. + * @returns {ValidationChain[]} + */ + get balanceSheetValidationSchema(): ValidationChain[] { + return [ + query('accounting_method') + .optional() + .isIn(['cash', 'accural']), + 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('number_format.no_cents') + .optional() + .isBoolean() + .toBoolean(), + query('number_format.divide_1000') + .optional() + .isBoolean() + .toBoolean(), + query('account_ids').isArray().optional(), + query('account_ids.*').isNumeric().toInt(), + query('none_zero').optional().isBoolean().toBoolean(), + query('none_transactions').optional().isBoolean().toBoolean(), + ]; + } + + /** + * Retrieve the balance sheet. + */ + async balanceSheet(req: Request, res: Response, next: NextFunction) { + const { tenantId, settings } = req; + let filter = this.matchedQueryData(req); + + filter = { + ...filter, + accountsIds: castArray(filter.accountsIds), + }; + const organizationName = settings.get({ group: 'organization', key: 'name' }); + const baseCurrency = settings.get({ group: 'organization', key: 'base_currency' }); + + try { + const { + data, + columns, + query + } = await this.balanceSheetService.balanceSheet(tenantId, filter); + + return res.status(200).send({ + organization_name: organizationName, + base_currency: baseCurrency, + data: this.transfromToResponse(data), + columns: this.transfromToResponse(columns), + query: this.transfromToResponse(query), + }) + } catch (error) { + next(error); + } + } +}; diff --git a/server/src/api/controllers/FinancialStatements/FinancialStatementMixin.js b/server/src/api/controllers/FinancialStatements/FinancialStatementMixin.js deleted file mode 100644 index 623cb4210..000000000 --- a/server/src/api/controllers/FinancialStatements/FinancialStatementMixin.js +++ /dev/null @@ -1,13 +0,0 @@ - - -export const formatNumberClosure = (filter) => (balance) => { - let formattedBalance = parseFloat(balance); - - if (filter.no_cents) { - formattedBalance = parseInt(formattedBalance, 10); - } - if (filter.divide_1000) { - formattedBalance /= 1000; - } - return formattedBalance; -}; \ No newline at end of file diff --git a/server/src/api/controllers/FinancialStatements/GeneralLedger.js b/server/src/api/controllers/FinancialStatements/GeneralLedger.js deleted file mode 100644 index fb90a3010..000000000 --- a/server/src/api/controllers/FinancialStatements/GeneralLedger.js +++ /dev/null @@ -1,165 +0,0 @@ -import express from 'express'; -import { query, validationResult } from 'express-validator'; -import moment from 'moment'; -import { pick, difference } from 'lodash'; -import JournalPoster from 'services/Accounting/JournalPoster'; -import { formatNumberClosure } from './FinancialStatementMixin'; -import asyncMiddleware from 'api/middleware/asyncMiddleware'; -import DependencyGraph from 'lib/DependencyGraph'; - -export default { - /** - * Router constructor. - */ - router() { - const router = express.Router(); - - router.get('/', - this.generalLedger.validation, - asyncMiddleware(this.generalLedger.handler)); - - return router; - }, - - /** - * Retrieve the general ledger financial statement. - */ - generalLedger: { - validation: [ - query('from_date').optional().isISO8601(), - query('to_date').optional().isISO8601(), - query('basis').optional(), - query('number_format.no_cents').optional().isBoolean().toBoolean(), - query('number_format.divide_1000').optional().isBoolean().toBoolean(), - query('none_zero').optional().isBoolean().toBoolean(), - query('accounts_ids').optional(), - query('accounts_ids.*').isNumeric().toInt(), - query('orderBy').optional().isIn(['created_at', 'name', 'code']), - query('order').optional().isIn(['desc', 'asc']), - ], - async handler(req, res) { - const validationErrors = validationResult(req); - - if (!validationErrors.isEmpty()) { - return res.boom.badData(null, { - code: 'validation_error', ...validationErrors, - }); - } - const { AccountTransaction, Account } = req.models; - - const filter = { - from_date: moment().startOf('year').format('YYYY-MM-DD'), - to_date: moment().endOf('year').format('YYYY-MM-DD'), - basis: 'cash', - number_format: { - no_cents: false, - divide_1000: false, - }, - none_zero: false, - accounts_ids: [], - ...req.query, - }; - if (!Array.isArray(filter.accounts_ids)) { - filter.accounts_ids = [filter.accounts_ids]; - } - filter.accounts_ids = filter.accounts_ids.map((id) => parseInt(id, 10)); - - const errorReasons = []; - - if (filter.accounts_ids.length > 0) { - const accounts = await Account.query().whereIn('id', filter.accounts_ids); - const accountsIds = accounts.map((a) => a.id); - - if (difference(filter.accounts_ids, accountsIds).length > 0) { - errorReasons.push({ type: 'FILTER.ACCOUNTS.IDS.NOT.FOUND', code: 200 }); - } - } - if (errorReasons.length > 0) { - return res.status(400).send({ error: errorReasons }); - } - const accounts = await Account.query() - // .remember('general_ledger_accounts') - .orderBy('index', 'DESC') - .modify('filterAccounts', filter.accounts_ids) - .withGraphFetched('type') - .withGraphFetched('transactions') - .modifyGraph('transactions', (builder) => { - builder.modify('filterDateRange', filter.from_date, filter.to_date); - }); - - // Accounts dependency graph. - const accountsGraph = DependencyGraph.fromArray( - accounts, { itemId: 'id', parentItemId: 'parentAccountId' } - ); - - const openingBalanceTransactions = await AccountTransaction.query() - // .remember() - .modify('filterDateRange', null, filter.from_date) - .modify('sumationCreditDebit') - .withGraphFetched('account.type'); - - const closingBalanceTransactions = await AccountTransaction.query() - // .remember() - .modify('filterDateRange', null, filter.to_date) - .modify('sumationCreditDebit') - .withGraphFetched('account.type'); - - const opeingBalanceCollection = new JournalPoster(accountsGraph); - const closingBalanceCollection = new JournalPoster(accountsGraph); - - opeingBalanceCollection.loadEntries(openingBalanceTransactions); - closingBalanceCollection.loadEntries(closingBalanceTransactions); - - // Transaction amount formatter based on the given query. - const formatNumber = formatNumberClosure(filter.number_format); - - const accountsResponse = accounts - .filter((account) => ( - account.transactions.length > 0 || !filter.none_zero - )) - .map((account) => ({ - ...pick(account, ['id', 'name', 'code', 'index', 'parentAccountId']), - transactions: [ - ...account.transactions.map((transaction) => { - let amount = 0; - - if (account.type.normal === 'credit') { - amount += transaction.credit - transaction.debit; - } else if (account.type.normal === 'debit') { - amount += transaction.debit - transaction.credit; - } - return { - ...pick(transaction, ['id', 'note', 'transactionType', 'referenceType', - 'referenceId', 'date', 'createdAt']), - amount, - formatted_amount: formatNumber(amount), - }; - }), - ], - opening: (() => { - const openingAmount = opeingBalanceCollection.getAccountBalance(account.id); - - return { - date: filter.from_date, - amount: openingAmount, - formatted_amount: formatNumber(openingAmount), - } - })(), - closing: (() => { - const closingAmount = closingBalanceCollection.getAccountBalance(account.id); - - return { - date: filter.to_date, - amount: closingAmount, - formatted_amount: formatNumber(closingAmount), - } - })(), - })); - - return res.status(200).send({ - query: { ...filter }, - accounts: Account.toNestedArray(accountsResponse), - }); - }, - }, -} \ No newline at end of file diff --git a/server/src/api/controllers/FinancialStatements/GeneralLedger.ts b/server/src/api/controllers/FinancialStatements/GeneralLedger.ts new file mode 100644 index 000000000..7f307d2e9 --- /dev/null +++ b/server/src/api/controllers/FinancialStatements/GeneralLedger.ts @@ -0,0 +1,74 @@ +import { Router, Request, Response } from 'express'; +import { query, ValidationChain } from 'express-validator'; +import asyncMiddleware from 'api/middleware/asyncMiddleware'; +import BaseController from '../BaseController'; +import { Inject, Service } from 'typedi'; +import GeneralLedgerService from 'services/FinancialStatements/GeneralLedger/GeneralLedgerService'; + +@Service() +export default class GeneralLedgerReportController extends BaseController{ + + @Inject() + generalLedgetService: GeneralLedgerService; + + /** + * Router constructor. + */ + router() { + const router = Router(); + + router.get('/', + this.validationSchema, + this.validationResult, + asyncMiddleware(this.generalLedger.bind(this)) + ); + return router; + } + + /** + * Validation schema. + */ + get validationSchema(): ValidationChain[] { + return [ + query('from_date').optional().isISO8601(), + query('to_date').optional().isISO8601(), + query('basis').optional(), + query('number_format.no_cents').optional().isBoolean().toBoolean(), + query('number_format.divide_1000').optional().isBoolean().toBoolean(), + query('none_transactions').optional().isBoolean().toBoolean(), + query('accounts_ids').optional(), + query('accounts_ids.*').isNumeric().toInt(), + query('orderBy').optional().isIn(['created_at', 'name', 'code']), + query('order').optional().isIn(['desc', 'asc']), + ]; + } + + /** + * Retrieve the general ledger financial statement. + * @param {Request} req - + * @param {Response} res - + */ + async generalLedger(req: Request, res: Response) { + const { tenantId, settings } = req; + const filter = this.matchedQueryData(req); + + const organizationName = settings.get({ group: 'organization', key: 'name' }); + const baseCurrency = settings.get({ group: 'organization', key: 'base_currency' }); + + try { + const { + data, + query, + } = await this.generalLedgetService.generalLedger(tenantId, filter); + + return res.status(200).send({ + organization_name: organizationName, + base_currency: baseCurrency, + data: this.transfromToResponse(data), + query: this.transfromToResponse(query), + }); + } catch (error) { + console.log(error); + } + } +} \ No newline at end of file diff --git a/server/src/api/controllers/FinancialStatements/JournalSheet.js b/server/src/api/controllers/FinancialStatements/JournalSheet.js deleted file mode 100644 index bf76acc74..000000000 --- a/server/src/api/controllers/FinancialStatements/JournalSheet.js +++ /dev/null @@ -1,120 +0,0 @@ -import express from 'express'; -import { query, oneOf, validationResult } from 'express-validator'; -import moment from 'moment'; -import { groupBy } from 'lodash'; -import JournalPoster from 'services/Accounting/JournalPoster'; -import asyncMiddleware from 'api/middleware/asyncMiddleware'; -import { formatNumberClosure } from './FinancialStatementMixin'; - - -export default { - /** - * Router constructor. - */ - router() { - const router = express.Router(); - - router.get('/', - this.journal.validation, - asyncMiddleware(this.journal.handler)); - - return router; - }, - - /** - * Retrieve the ledger report of the given account. - */ - journal: { - validation: [ - query('from_date').optional().isISO8601(), - query('to_date').optional().isISO8601(), - oneOf([ - query('transaction_types').optional().isArray({ min: 1 }), - query('transaction_types.*').optional().isNumeric().toInt(), - ], [ - query('transaction_types').optional().trim().escape(), - ]), - oneOf([ - query('account_ids').optional().isArray({ min: 1 }), - query('account_ids.*').optional().isNumeric().toInt(), - ], [ - query('account_ids').optional().isNumeric().toInt(), - ]), - query('from_range').optional().isNumeric().toInt(), - query('to_range').optional().isNumeric().toInt(), - query('number_format.no_cents').optional().isBoolean().toBoolean(), - query('number_format.divide_1000').optional().isBoolean().toBoolean(), - ], - async handler(req, res) { - const validationErrors = validationResult(req); - - if (!validationErrors.isEmpty()) { - return res.boom.badData(null, { - code: 'validation_error', ...validationErrors, - }); - } - const { AccountTransaction } = req.models; - - const filter = { - from_date: moment().startOf('year').format('YYYY-MM-DD'), - to_date: moment().endOf('year').format('YYYY-MM-DD'), - from_range: null, - to_range: null, - account_ids: [], - transaction_types: [], - number_format: { - no_cents: false, - divide_1000: false, - }, - ...req.query, - }; - if (!Array.isArray(filter.transaction_types)) { - filter.transaction_types = [filter.transaction_types]; - } - if (!Array.isArray(filter.account_ids)) { - filter.account_ids = [filter.account_ids]; - } - filter.account_ids = filter.account_ids.map((id) => parseInt(id, 10)); - - const accountsJournalEntries = await AccountTransaction.query() - // .remember() - .modify('filterDateRange', filter.from_date, filter.to_date) - .modify('filterAccounts', filter.account_ids) - .modify('filterTransactionTypes', filter.transaction_types) - .modify('filterAmountRange', filter.from_range, filter.to_range) - .withGraphFetched('account.type'); - - const formatNumber = formatNumberClosure(filter.number_format); - - const journalGrouped = groupBy(accountsJournalEntries, - (entry) => `${entry.referenceId}-${entry.referenceType}`); - - const journal = Object.keys(journalGrouped).map((key) => { - const transactionsGroup = journalGrouped[key]; - - const journalPoster = new JournalPoster(); - journalPoster.loadEntries(transactionsGroup); - - const trialBalance = journalPoster.getTrialBalance(); - - return { - id: key, - entries: transactionsGroup, - - credit: trialBalance.credit, - debit: trialBalance.debit, - - formatted_credit: formatNumber(trialBalance.credit), - formatted_debit: formatNumber(trialBalance.debit), - }; - }); - - return res.status(200).send({ - query: { ...filter }, - journal, - }); - }, - }, - - -} \ No newline at end of file diff --git a/server/src/api/controllers/FinancialStatements/JournalSheet.ts b/server/src/api/controllers/FinancialStatements/JournalSheet.ts new file mode 100644 index 000000000..57ec8d05e --- /dev/null +++ b/server/src/api/controllers/FinancialStatements/JournalSheet.ts @@ -0,0 +1,82 @@ +import { Inject, Service } from 'typedi'; +import { Request, Response, Router } from 'express'; +import { castArray } from 'lodash'; +import { query, oneOf } from 'express-validator'; +import JournalSheetService from 'services/FinancialStatements/JournalSheet/JournalSheetService'; +import BaseController from '../BaseController'; + +@Service() +export default class JournalSheetController extends BaseController { + @Inject() + journalService: JournalSheetService; + + /** + * Router constructor. + */ + router() { + const router = Router(); + + router.get('/', + this.journalValidationSchema, + this.validationResult, + this.asyncMiddleware(this.journal.bind(this)) + ); + return router; + } + + /** + * Validation schema. + */ + get journalValidationSchema() { + return [ + query('from_date').optional().isISO8601(), + query('to_date').optional().isISO8601(), + oneOf([ + query('transaction_types').optional().isArray({ min: 1 }), + query('transaction_types.*').optional().isNumeric().toInt(), + ], [ + query('transaction_types').optional().trim().escape(), + ]), + oneOf([ + query('account_ids').optional().isArray({ min: 1 }), + query('account_ids.*').optional().isNumeric().toInt(), + ], [ + query('account_ids').optional().isNumeric().toInt(), + ]), + query('from_range').optional().isNumeric().toInt(), + query('to_range').optional().isNumeric().toInt(), + query('number_format.no_cents').optional().isBoolean().toBoolean(), + query('number_format.divide_1000').optional().isBoolean().toBoolean(), + ]; + } + + /** + * Retrieve the ledger report of the given account. + * @param {Request} req - + * @param {Response} res - + */ + async journal(req: Request, res: Response) { + const { tenantId, settings } = req; + let filter = this.matchedQueryData(req); + + filter = { + ...filter, + accountsIds: castArray(filter.accountsIds), + }; + const organizationName = settings.get({ group: 'organization', key: 'name' }); + const baseCurrency = settings.get({ group: 'organization', key: 'base_currency' }); + + try { + const data = await this.journalService.journalSheet(tenantId, filter); + + return res.status(200).send({ + organization_name: organizationName, + base_currency: baseCurrency, + data: this.transfromToResponse(data), + query: this.transfromToResponse(query), + }); + } catch (error) { + console.log(error); + } + } +} \ No newline at end of file diff --git a/server/src/api/controllers/FinancialStatements/ProfitLossSheet.js b/server/src/api/controllers/FinancialStatements/ProfitLossSheet.js deleted file mode 100644 index 82a5fa1be..000000000 --- a/server/src/api/controllers/FinancialStatements/ProfitLossSheet.js +++ /dev/null @@ -1,259 +0,0 @@ -import express from 'express'; -import { query, oneOf, validationResult } from 'express-validator'; -import moment from 'moment'; -import { pick, sumBy } from 'lodash'; -import JournalPoster from 'services/Accounting/JournalPoster'; -import { dateRangeCollection } from 'utils'; -import asyncMiddleware from 'api/middleware/asyncMiddleware'; -import { formatNumberClosure } from './FinancialStatementMixin'; - -export default { - /** - * Router constructor. - */ - router() { - const router = express.Router(); - - router.get( - '/', - this.profitLossSheet.validation, - asyncMiddleware(this.profitLossSheet.handler) - ); - return router; - }, - - /** - * Retrieve profit/loss financial statement. - */ - profitLossSheet: { - validation: [ - query('basis').optional(), - query('from_date').optional().isISO8601(), - query('to_date').optional().isISO8601(), - query('number_format.no_cents').optional().isBoolean(), - query('number_format.divide_1000').optional().isBoolean(), - query('basis').optional(), - query('none_zero').optional().isBoolean().toBoolean(), - query('account_ids').isArray().optional(), - query('account_ids.*').isNumeric().toInt(), - query('display_columns_type').optional().isIn(['total', 'date_periods']), - query('display_columns_by') - .optional({ nullable: true, checkFalsy: true }) - .isIn(['year', 'month', 'week', 'day', 'quarter']), - ], - async handler(req, res) { - const validationErrors = validationResult(req); - - if (!validationErrors.isEmpty()) { - return res.boom.badData(null, { - code: 'validation_error', - ...validationErrors, - }); - } - const { Account, AccountType } = req.models; - const filter = { - from_date: moment().startOf('year').format('YYYY-MM-DD'), - to_date: moment().endOf('year').format('YYYY-MM-DD'), - number_format: { - no_cents: false, - divide_1000: false, - }, - basis: 'accural', - none_zero: false, - display_columns_type: 'total', - display_columns_by: 'month', - account_ids: [], - ...req.query, - }; - if (!Array.isArray(filter.account_ids)) { - filter.account_ids = [filter.account_ids]; - } - const incomeStatementTypes = await AccountType.query().where( - 'income_sheet', - true - ); - // Fetch all income accounts from storage. - const accounts = await Account.query() - // .remember('profit_loss_accounts') - .modify('filterAccounts', filter.account_ids) - .whereIn( - 'account_type_id', - incomeStatementTypes.map((t) => t.id) - ) - .withGraphFetched('type') - .withGraphFetched('transactions'); - - // Accounts dependency graph. - const accountsGraph = Account.toDependencyGraph(accounts); - - // Filter all none zero accounts if it was enabled. - const filteredAccounts = accounts.filter( - (account) => account.transactions.length > 0 || !filter.none_zero - ); - const journalEntriesCollected = Account.collectJournalEntries(accounts); - const journalEntries = new JournalPoster(accountsGraph); - journalEntries.loadEntries(journalEntriesCollected); - - // Account balance formmatter based on the given query. - const numberFormatter = formatNumberClosure(filter.number_format); - const comparatorDateType = - filter.display_columns_type === 'total' - ? 'day' - : filter.display_columns_by; - // Gets the date range set from start to end date. - const dateRangeSet = dateRangeCollection( - filter.from_date, - filter.to_date, - comparatorDateType - ); - const accountsMapper = (incomeExpenseAccounts) => - incomeExpenseAccounts.map((account) => ({ - ...pick(account, ['id', 'index', 'name', 'code', 'parentAccountId']), - // Total closing balance of the account. - ...(filter.display_columns_type === 'total' && { - total: (() => { - const amount = journalEntries.getAccountBalance( - account.id, - filter.to_date - ); - return { - amount, - date: filter.to_date, - formatted_amount: numberFormatter(amount), - }; - })(), - }), - // Date periods when display columns type `periods`. - ...(filter.display_columns_type === 'date_periods' && { - periods: dateRangeSet.map((date) => { - const type = comparatorDateType; - const amount = journalEntries.getAccountBalance( - account.id, - date, - type - ); - return { - date, - amount, - formatted_amount: numberFormatter(amount), - }; - }), - }), - })); - - const accountsIncome = Account.toNestedArray( - accountsMapper( - filteredAccounts.filter((account) => account.type.normal === 'credit') - ) - ); - const accountsExpenses = Account.toNestedArray( - accountsMapper( - filteredAccounts.filter((account) => account.type.normal === 'debit') - ) - ); - const totalPeriodsMapper = (incomeExpenseAccounts) => - Object.values( - dateRangeSet.reduce((acc, date, index) => { - let amount = sumBy( - incomeExpenseAccounts, - `periods[${index}].amount` - ); - acc[date] = { - date, - amount, - formatted_amount: numberFormatter(amount), - }; - return acc; - }, {}) - ); - - // Total income - Total expenses = Net income - const netIncomePeriodsMapper = ( - totalIncomeAcocunts, - totalExpenseAccounts - ) => - dateRangeSet.map((date, index) => { - const totalIncome = totalIncomeAcocunts[index]; - const totalExpenses = totalExpenseAccounts[index]; - - let amount = totalIncome.amount || 0; - amount -= totalExpenses.amount || 0; - return { date, amount, formatted_amount: numberFormatter(amount) }; - }); - - // @return {Object} - const netIncomeTotal = (totalIncome, totalExpenses) => { - const netIncomeAmount = totalIncome.amount - totalExpenses.amount; - return { - amount: netIncomeAmount, - formatted_amount: netIncomeAmount, - date: filter.to_date, - }; - }; - - const incomeResponse = { - entry_normal: 'credit', - accounts: accountsIncome, - ...(filter.display_columns_type === 'total' && - (() => { - const totalIncomeAccounts = sumBy(accountsIncome, 'total.amount'); - return { - total: { - amount: totalIncomeAccounts, - date: filter.to_date, - formatted_amount: numberFormatter(totalIncomeAccounts), - }, - }; - })()), - ...(filter.display_columns_type === 'date_periods' && { - total_periods: [...totalPeriodsMapper(accountsIncome)], - }), - }; - const expenseResponse = { - entry_normal: 'debit', - accounts: accountsExpenses, - ...(filter.display_columns_type === 'total' && - (() => { - const totalExpensesAccounts = sumBy( - accountsExpenses, - 'total.amount' - ); - return { - total: { - amount: totalExpensesAccounts, - date: filter.to_date, - formatted_amount: numberFormatter(totalExpensesAccounts), - }, - }; - })()), - ...(filter.display_columns_type === 'date_periods' && { - total_periods: [...totalPeriodsMapper(accountsExpenses)], - }), - }; - const netIncomeResponse = { - ...(filter.display_columns_type === 'total' && { - total: { - ...netIncomeTotal(incomeResponse.total, expenseResponse.total), - }, - }), - ...(filter.display_columns_type === 'date_periods' && { - total_periods: [ - ...netIncomePeriodsMapper( - incomeResponse.total_periods, - expenseResponse.total_periods - ), - ], - }), - }; - return res.status(200).send({ - query: { ...filter }, - columns: [...dateRangeSet], - profitLoss: { - income: incomeResponse, - expenses: expenseResponse, - net_income: netIncomeResponse, - }, - }); - }, - }, -}; diff --git a/server/src/api/controllers/FinancialStatements/ProfitLossSheet.ts b/server/src/api/controllers/FinancialStatements/ProfitLossSheet.ts new file mode 100644 index 000000000..a7c96d3d5 --- /dev/null +++ b/server/src/api/controllers/FinancialStatements/ProfitLossSheet.ts @@ -0,0 +1,79 @@ +import { Service, Inject } from 'typedi'; +import { Router, Request, Response, NextFunction } from 'express'; +import { query, ValidationChain } from 'express-validator'; +import BaseController from '../BaseController'; +import ProfitLossSheetService from 'services/FinancialStatements/ProfitLossSheet/ProfitLossSheetService'; + +@Service() +export default class ProfitLossSheetController extends BaseController { + @Inject() + profitLossSheetService: ProfitLossSheetService; + + /** + * Router constructor. + */ + router() { + const router = Router(); + + router.get( + '/', + this.validationSchema, + this.validationResult, + this.asyncMiddleware(this.profitLossSheet.bind(this)), + ); + return router; + } + + /** + * Validation schema. + */ + get validationSchema(): ValidationChain[] { + return [ + query('basis').optional(), + query('from_date').optional().isISO8601(), + query('to_date').optional().isISO8601(), + query('number_format.no_cents').optional().isBoolean(), + query('number_format.divide_1000').optional().isBoolean(), + query('basis').optional(), + query('none_zero').optional().isBoolean().toBoolean(), + query('none_transactions').optional().isBoolean().toBoolean(), + query('accounts_ids').isArray().optional(), + query('accounts_ids.*').isNumeric().toInt(), + query('display_columns_type').optional().isIn(['total', 'date_periods']), + query('display_columns_by') + .optional({ nullable: true, checkFalsy: true }) + .isIn(['year', 'month', 'week', 'day', 'quarter']), + ]; + } + + /** + * Retrieve profit/loss financial statement. + * @param {Request} req - + * @param {Response} res - + */ + async profitLossSheet(req: Request, res: Response, next: NextFunction) { + const { tenantId, settings } = req; + const filter = this.matchedQueryData(req); + + const organizationName = settings.get({ group: 'organization', key: 'name' }); + const baseCurrency = settings.get({ group: 'organization', key: 'base_currency' }); + + try { + const { + data, + columns, + query, + } = await this.profitLossSheetService.profitLossSheet(tenantId, filter); + + return res.status(200).send({ + organization_name: organizationName, + base_currency: baseCurrency, + data: this.transfromToResponse(data), + columns: this.transfromToResponse(columns), + query: this.transfromToResponse(query), + }); + } catch (error) { + console.log(error); + } + } +} \ No newline at end of file diff --git a/server/src/api/controllers/FinancialStatements/ReceivableAgingSummary.js b/server/src/api/controllers/FinancialStatements/ReceivableAgingSummary.js deleted file mode 100644 index 991fd6823..000000000 --- a/server/src/api/controllers/FinancialStatements/ReceivableAgingSummary.js +++ /dev/null @@ -1,218 +0,0 @@ -import express from 'express'; -import { query, oneOf } from 'express-validator'; -import { difference } from 'lodash'; -import JournalPoster from 'services/Accounting/JournalPoster'; -import asyncMiddleware from 'api/middleware/asyncMiddleware'; -import AgingReport from 'api/controllers/FinancialStatements/AgingReport'; -import moment from 'moment'; - -export default class ReceivableAgingSummary extends AgingReport { - /** - * Router constructor. - */ - static router() { - const router = express.Router(); - - router.get( - '/', - this.receivableAgingSummaryRoles, - this.validateResults, - asyncMiddleware(this.validateCustomersIds.bind(this)), - asyncMiddleware(this.receivableAgingSummary.bind(this)) - ); - return router; - } - - /** - * Validates the report customers ids query. - */ - static async validateCustomersIds(req, res, next) { - const { Customer } = req.models; - - const filter = { - customer_ids: [], - ...req.query, - }; - if (!Array.isArray(filter.customer_ids)) { - filter.customer_ids = [filter.customer_ids]; - } - if (filter.customer_ids.length > 0) { - const storedCustomers = await Customer.query().whereIn( - 'id', - filter.customer_ids - ); - const storedCustomersIds = storedCustomers.map((c) => parseInt(c.id, 10)); - const notStoredCustomersIds = difference( - filter.customer_ids.map(a => parseInt(a, 10)), - storedCustomersIds - ); - - if (notStoredCustomersIds.length) { - return res.status(400).send({ - errors: [ - { - type: 'CUSTOMERS.IDS.NOT.FOUND', - code: 300, - ids: notStoredCustomersIds, - }, - ], - }); - } - } - next(); - } - - /** - * Receivable aging summary validation roles. - */ - static get receivableAgingSummaryRoles() { - return [ - query('as_date').optional().isISO8601(), - query('aging_days_before').optional().isNumeric().toInt(), - query('aging_periods').optional().isNumeric().toInt(), - query('number_format.no_cents').optional().isBoolean().toBoolean(), - query('number_format.1000_divide').optional().isBoolean().toBoolean(), - oneOf( - [ - query('customer_ids').optional().isArray({ min: 1 }), - query('customer_ids.*').isNumeric().toInt(), - ], - [query('customer_ids').optional().isNumeric().toInt()] - ), - query('none_zero').optional().isBoolean().toBoolean(), - ]; - } - - /** - * Retrieve receivable aging summary report. - */ - static async receivableAgingSummary(req, res) { - const { Customer, Account, AccountTransaction, AccountType } = req.models; - - const filter = { - as_date: moment().format('YYYY-MM-DD'), - aging_days_before: 30, - aging_periods: 3, - number_format: { - no_cents: false, - divide_1000: false, - }, - customer_ids: [], - none_zero: false, - ...req.query, - }; - if (!Array.isArray(filter.customer_ids)) { - filter.customer_ids = [filter.customer_ids]; - } - - const storedCustomers = await Customer.query().onBuild((builder) => { - if (filter.customer_ids.length > 0) { - builder.modify('filterCustomerIds', filter.customer_ids); - } - return builder; - }); - - const accountsReceivableType = await AccountType.query() - .where('key', 'accounts_receivable') - .first(); - - const accountsReceivable = await Account.query() - .where('account_type_id', accountsReceivableType.id) - .remember() - .first(); - - const transactions = await AccountTransaction.query().onBuild((query) => { - query.modify('filterDateRange', null, filter.as_date) - query.where('account_id', accountsReceivable.id) - query.modify('filterContactType', 'customer'); - - if (filter.customer_ids.length> 0) { - query.modify('filterContactIds', filter.customer_ids) - } - query.remember(); - return query; - }); - - const journalPoster = new JournalPoster(); - journalPoster.loadEntries(transactions); - - const agingPeriods = this.agingRangePeriods( - filter.as_date, - filter.aging_days_before, - filter.aging_periods - ); - // Total amount formmatter based on the given query. - const totalFormatter = this.formatNumberClosure(filter.number_format); - - const customers = storedCustomers.map((customer) => { - // Calculate the trial balance total of the given customer. - const customerBalance = journalPoster.getContactTrialBalance( - accountsReceivable.id, - customer.id, - 'customer' - ); - const agingClosingBalance = agingPeriods.map((agingPeriod) => { - // Calculate the trial balance between the given date period. - const agingTrialBalance = journalPoster.getContactTrialBalance( - accountsReceivable.id, - customer.id, - 'customer', - agingPeriod.from_period - ); - return { - ...agingPeriod, - closingBalance: agingTrialBalance.debit, - }; - }); - const aging = this.contactAgingBalance( - agingClosingBalance, - customerBalance.credit - ); - return { - customer_name: customer.displayName, - aging: aging.map((item) => ({ - ...item, - formatted_total: totalFormatter(item.total), - })), - total: customerBalance.balance, - formatted_total: totalFormatter(customerBalance.balance), - }; - }); - - const agingClosingBalance = agingPeriods.map((agingPeriod) => { - const closingTrialBalance = journalPoster.getContactTrialBalance( - accountsReceivable.id, - null, - 'customer', - agingPeriod.from_period - ); - return { - ...agingPeriod, - closingBalance: closingTrialBalance.balance, - }; - }); - - const totalClosingBalance = journalPoster.getContactTrialBalance( - accountsReceivable.id, - null, - 'customer' - ); - const agingTotal = this.contactAgingBalance( - agingClosingBalance, - totalClosingBalance.credit - ); - - return res.status(200).send({ - columns: [...agingPeriods], - aging: { - customers, - total: [ - ...agingTotal.map((item) => ({ - ...item, - formatted_total: totalFormatter(item.total), - })), - ], - }, - }); - } -} diff --git a/server/src/api/controllers/FinancialStatements/TrialBalanceSheet.js b/server/src/api/controllers/FinancialStatements/TrialBalanceSheet.js deleted file mode 100644 index 8df585262..000000000 --- a/server/src/api/controllers/FinancialStatements/TrialBalanceSheet.js +++ /dev/null @@ -1,115 +0,0 @@ -import express from 'express'; -import { query, validationResult } from 'express-validator'; -import moment from 'moment'; -import JournalPoster from 'services/Accounting/JournalPoster'; -import asyncMiddleware from 'api/middleware/asyncMiddleware'; -import DependencyGraph from 'lib/DependencyGraph'; -import { formatNumberClosure }from './FinancialStatementMixin'; - -export default { - /** - * Router constructor. - */ - router() { - const router = express.Router(); - - router.get('/', - this.trialBalanceSheet.validation, - asyncMiddleware(this.trialBalanceSheet.handler)); - - return router; - }, - - /** - * Retrieve the trial balance sheet. - */ - trialBalanceSheet: { - validation: [ - query('basis').optional(), - query('from_date').optional().isISO8601(), - query('to_date').optional().isISO8601(), - query('number_format.no_cents').optional().isBoolean().toBoolean(), - query('number_format.1000_divide').optional().isBoolean().toBoolean(), - query('account_ids').isArray().optional(), - query('account_ids.*').isNumeric().toInt(), - query('basis').optional(), - query('none_zero').optional().isBoolean().toBoolean(), - ], - async handler(req, res) { - const validationErrors = validationResult(req); - - if (!validationErrors.isEmpty()) { - return res.boom.badData(null, { - code: 'validation_error', ...validationErrors, - }); - } - const { Account } = req.models; - const filter = { - from_date: moment().startOf('year').format('YYYY-MM-DD'), - to_date: moment().endOf('year').format('YYYY-MM-DD'), - number_format: { - no_cents: false, - divide_1000: false, - }, - basis: 'accural', - none_zero: false, - account_ids: [], - ...req.query, - }; - if (!Array.isArray(filter.account_ids)) { - filter.account_ids = [filter.account_ids]; - } - - const accounts = await Account.query() - // .remember('trial_balance_accounts') - .modify('filterAccounts', filter.account_ids) - .withGraphFetched('type') - .withGraphFetched('transactions') - .modifyGraph('transactions', (builder) => { - builder.modify('sumationCreditDebit'); - builder.modify('filterDateRange', filter.from_date, filter.to_date); - }); - - // Accounts dependency graph. - const accountsGraph = DependencyGraph.fromArray( - accounts, { itemId: 'id', parentItemId: 'parentAccountId' } - ); - - const journalEntriesCollect = Account.collectJournalEntries(accounts); - const journalEntries = new JournalPoster(accountsGraph); - journalEntries.loadEntries(journalEntriesCollect); - - // Account balance formmatter based on the given query. - const balanceFormatter = formatNumberClosure(filter.number_format); - - const accountsResponse = accounts - .filter((account) => ( - account.transactions.length > 0 || !filter.none_zero - )) - .map((account) => { - const trial = journalEntries.getTrialBalanceWithDepands(account.id); - - return { - id: account.id, - parentAccountId: account.parentAccountId, - name: account.name, - code: account.code, - accountNormal: account.type.normal, - - credit: trial.credit, - debit: trial.debit, - balance: trial.balance, - - formatted_credit: balanceFormatter(trial.credit), - formatted_debit: balanceFormatter(trial.debit), - formatted_balance: balanceFormatter(trial.balance), - }; - }); - - return res.status(200).send({ - query: { ...filter }, - accounts: [...Account.toNestedArray(accountsResponse) ], - }); - }, - }, -} \ No newline at end of file diff --git a/server/src/api/controllers/FinancialStatements/TrialBalanceSheet.ts b/server/src/api/controllers/FinancialStatements/TrialBalanceSheet.ts new file mode 100644 index 000000000..a9baf0997 --- /dev/null +++ b/server/src/api/controllers/FinancialStatements/TrialBalanceSheet.ts @@ -0,0 +1,74 @@ +import { Inject, Service } from 'typedi'; +import { Request, Response, Router, NextFunction } from 'express'; +import { query, ValidationChain } from 'express-validator'; +import asyncMiddleware from 'api/middleware/asyncMiddleware'; +import BaseController from '../BaseController'; +import TrialBalanceSheetService from 'services/FinancialStatements/TrialBalanceSheet/TrialBalanceSheetService'; +import { castArray } from 'lodash'; + +@Service() +export default class TrialBalanceSheetController extends BaseController { + @Inject() + trialBalanceSheetService: TrialBalanceSheetService; + + /** + * Router constructor. + */ + router() { + const router = Router(); + + router.get('/', + this.trialBalanceSheetValidationSchema, + this.validationResult, + asyncMiddleware(this.trialBalanceSheet.bind(this)) + ); + return router; + } + + /** + * Validation schema. + * @return {ValidationChain[]} + */ + get trialBalanceSheetValidationSchema(): ValidationChain[] { + return [ + query('basis').optional(), + query('from_date').optional().isISO8601(), + query('to_date').optional().isISO8601(), + query('number_format.no_cents').optional().isBoolean().toBoolean(), + query('number_format.1000_divide').optional().isBoolean().toBoolean(), + query('account_ids').isArray().optional(), + query('account_ids.*').isNumeric().toInt(), + query('basis').optional(), + query('none_zero').optional().isBoolean().toBoolean(), + ]; + } + + /** + * Retrieve the trial balance sheet. + */ + public async trialBalanceSheet(req: Request, res: Response, next: NextFunction) { + const { tenantId, settings } = req; + let filter = this.matchedQueryData(req); + + filter = { + ...filter, + accountsIds: castArray(filter.accountsIds), + }; + const organizationName = settings.get({ group: 'organization', key: 'name' }); + const baseCurrency = settings.get({ group: 'organization', key: 'base_currency' }); + + try { + const { data, query } = await this.trialBalanceSheetService + .trialBalanceSheet(tenantId, filter); + + return res.status(200).send({ + organization_name: organizationName, + base_currency: baseCurrency, + data: this.transfromToResponse(data), + query: this.transfromToResponse(query) + }); + } catch (error) { + next(error); + } + } +} \ No newline at end of file diff --git a/server/src/api/controllers/ManualJournals.ts b/server/src/api/controllers/ManualJournals.ts index 21764dd48..4cc9245ee 100644 --- a/server/src/api/controllers/ManualJournals.ts +++ b/server/src/api/controllers/ManualJournals.ts @@ -338,7 +338,11 @@ export default class ManualJournalsController extends BaseController { filter.filterRoles = JSON.parse(filter.stringifiedFilterRoles); } try { - const { manualJournals, pagination, filterMeta } = await this.manualJournalsService.getManualJournals(tenantId, filter); + const { + manualJournals, + pagination, + filterMeta + } = await this.manualJournalsService.getManualJournals(tenantId, filter); return res.status(200).send({ manual_journals: manualJournals, diff --git a/server/src/api/index.ts b/server/src/api/index.ts index ed8286ca1..c6312edfa 100644 --- a/server/src/api/index.ts +++ b/server/src/api/index.ts @@ -91,7 +91,7 @@ export default () => { dashboard.use('/items', Container.get(Items).router()); dashboard.use('/item_categories', Container.get(ItemCategories).router()); dashboard.use('/expenses', Container.get(Expenses).router()); - dashboard.use('/financial_statements', FinancialStatements.router()); + dashboard.use('/financial_statements', Container.get(FinancialStatements).router()); dashboard.use('/customers', Container.get(Customers).router()); dashboard.use('/vendors', Container.get(Vendors).router()); dashboard.use('/sales', Container.get(Sales).router()); diff --git a/server/src/data/BalanceSheetStructure.js b/server/src/data/BalanceSheetStructure.js deleted file mode 100644 index 5b9261ad1..000000000 --- a/server/src/data/BalanceSheetStructure.js +++ /dev/null @@ -1,62 +0,0 @@ - -export default [ - { - name: 'Assets', - section_type: 'assets', - type: 'section', - children: [ - { - name: 'Current Asset', - type: 'section', - _accounts_types_related: ['current_asset'], - }, - { - name: 'Fixed Asset', - type: 'section', - _accounts_types_related: ['fixed_asset'], - }, - { - name: 'Other Asset', - type: 'section', - _accounts_types_related: ['other_asset'], - }, - ], - _forceShow: true, - }, - { - name: 'Liabilities and Equity', - section_type: 'liabilities_equity', - type: 'section', - children: [ - { - name: 'Liabilities', - section_type: 'liability', - type: 'section', - children: [ - { - name: 'Current Liability', - type: 'section', - _accounts_types_related: ['current_liability'], - }, - { - name: 'Long Term Liability', - type: 'section', - _accounts_types_related: ['long_term_liability'], - }, - { - name: 'Other Liability', - type: 'section', - _accounts_types_related: ['other_liability'], - }, - ], - }, - { - name: 'Equity', - section_type: 'equity', - type: 'section', - _accounts_types_related: ['equity'], - }, - ], - _forceShow: true, - }, -]; diff --git a/server/src/data/BalanceSheetStructure.ts b/server/src/data/BalanceSheetStructure.ts new file mode 100644 index 000000000..688e02596 --- /dev/null +++ b/server/src/data/BalanceSheetStructure.ts @@ -0,0 +1,65 @@ +import { IBalanceSheetStructureSection } from 'interfaces'; + +const balanceSheetStructure: IBalanceSheetStructureSection[] = [ + { + name: 'Assets', + sectionType: 'assets', + type: 'section', + children: [ + { + name: 'Current Asset', + type: 'accounts_section', + _accountsTypesRelated: ['current_asset'], + }, + { + name: 'Fixed Asset', + type: 'accounts_section', + _accountsTypesRelated: ['fixed_asset'], + }, + { + name: 'Other Asset', + type: 'accounts_section', + _accountsTypesRelated: ['other_asset'], + }, + ], + _forceShow: true, + }, + { + name: 'Liabilities and Equity', + sectionType: 'liabilities_equity', + type: 'section', + children: [ + { + name: 'Liabilities', + sectionType: 'liability', + type: 'section', + children: [ + { + name: 'Current Liability', + type: 'accounts_section', + _accountsTypesRelated: ['current_liability'], + }, + { + name: 'Long Term Liability', + type: 'accounts_section', + _accountsTypesRelated: ['long_term_liability'], + }, + { + name: 'Other Liability', + type: 'accounts_section', + _accountsTypesRelated: ['other_liability'], + }, + ], + }, + { + name: 'Equity', + sectionType: 'equity', + type: 'accounts_section', + _accountsTypesRelated: ['equity'], + }, + ], + _forceShow: true, + }, +]; + +export default balanceSheetStructure; \ No newline at end of file diff --git a/server/src/database/seeds/core/20190423085241_seed_accounts_types.js b/server/src/database/seeds/core/20190423085241_seed_accounts_types.js index ffe6b9b77..bd681092a 100644 --- a/server/src/database/seeds/core/20190423085241_seed_accounts_types.js +++ b/server/src/database/seeds/core/20190423085241_seed_accounts_types.js @@ -111,8 +111,8 @@ exports.up = function (knex) { normal: 'debit', root_type: 'expenses', child_type: 'expenses', - balance_sheet: true, - income_sheet: false, + balance_sheet: false, + income_sheet: true, }, { id: 8, diff --git a/server/src/interfaces/ARAgingSummaryReport.ts b/server/src/interfaces/ARAgingSummaryReport.ts new file mode 100644 index 000000000..3cadb1f19 --- /dev/null +++ b/server/src/interfaces/ARAgingSummaryReport.ts @@ -0,0 +1,45 @@ + + + +export interface IARAgingSummaryQuery { + asDate: Date | string, + agingDaysBefore: number, + agingPeriods: number, + numberFormat: { + noCents: number, + divideOn1000: number, + }, + customersIds: number[], + noneZero: boolean, +} + +export interface IAgingPeriod { + fromPeriod: Date, + toPeriod: Date, + beforeDays: number, + toDays: number, +}; + +export interface IAgingPeriodClosingBalance extends IAgingPeriod { + closingBalance: number, +}; + +export interface IAgingPeriodTotal extends IAgingPeriod { + total: number, +}; + +export interface ARAgingSummaryCustomerPeriod { + +} + +export interface ARAgingSummaryCustomerTotal { + amount: number, + formattedAmount: string, + currencyCode: string, +} + +export interface ARAgingSummaryCustomer { + customerName: string, + aging: IAgingPeriodTotal[], + total: ARAgingSummaryCustomerTotal, +}; diff --git a/server/src/interfaces/Account.ts b/server/src/interfaces/Account.ts index 76dcc40a4..c62fedb03 100644 --- a/server/src/interfaces/Account.ts +++ b/server/src/interfaces/Account.ts @@ -10,9 +10,11 @@ export interface IAccountDTO { }; export interface IAccount { + id: number, name: string, slug: string, code: string, + index: number, description: string, accountTypeId: number, parentAccountId: number, @@ -20,6 +22,8 @@ export interface IAccount { predefined: boolean, amount: number, currencyCode: string, + transactions?: any[], + type?: any[], }; export interface IAccountsFilter extends IDynamicListFilterDTO { diff --git a/server/src/interfaces/BalanceSheet.ts b/server/src/interfaces/BalanceSheet.ts new file mode 100644 index 000000000..9e52c1131 --- /dev/null +++ b/server/src/interfaces/BalanceSheet.ts @@ -0,0 +1,74 @@ + +export interface IBalanceSheetQuery{ + displayColumnsType: 'total' | 'date_periods', + displayColumnsBy: string, + fromDate: Date|string, + toDate: Date|string, + numberFormat: { + noCents: boolean, + divideOn1000: boolean, + }, + noneZero: boolean, + noneTransactions: boolean, + basis: 'cash' | 'accural', + accountIds: number[], +} + +export interface IBalanceSheetStatementService { + balanceSheet(tenantId: number, query: IBalanceSheetQuery): Promise; +} + +export interface IBalanceSheetStatementColumns { + +} + +export interface IBalanceSheetStatementData { + +} + +export interface IBalanceSheetStatement { + query: IBalanceSheetQuery, + columns: IBalanceSheetStatementColumns, + data: IBalanceSheetStatementData, +} + +export interface IBalanceSheetStructureSection { + name: string, + sectionType?: string, + type: 'section' | 'accounts_section', + children?: IBalanceSheetStructureSection[], + _accountsTypesRelated?: string[], + _forceShow?: boolean, +} + +export interface IBalanceSheetAccountTotal { + amount: number, + formattedAmount: string, + currencyCode: string, + date?: string|Date, +} + +export interface IBalanceSheetAccount { + id: number, + index: number, + name: string, + code: string, + parentAccountId: number, + type: 'account', + hasTransactions: boolean, + children?: IBalanceSheetAccount[], + total: IBalanceSheetAccountTotal, + totalPeriods?: IBalanceSheetAccountTotal[], +} + +export interface IBalanceSheetSection { + name: string, + sectionType?: string, + type: 'section' | 'accounts_section', + children: IBalanceSheetAccount[] | IBalanceSheetSection[], + total: IBalanceSheetAccountTotal, + totalPeriods?: IBalanceSheetAccountTotal[]; + + _accountsTypesRelated?: string[], + _forceShow?: boolean, +} \ No newline at end of file diff --git a/server/src/interfaces/Contact.ts b/server/src/interfaces/Contact.ts index dff2ff7ef..862741d3c 100644 --- a/server/src/interfaces/Contact.ts +++ b/server/src/interfaces/Contact.ts @@ -43,6 +43,7 @@ export interface IContactAddressDTO { shippingAddressState?: string, }; export interface IContact extends IContactAddress{ + id?: number, contactService: 'customer' | 'vendor', contactType: string, diff --git a/server/src/interfaces/FinancialStatements.ts b/server/src/interfaces/FinancialStatements.ts new file mode 100644 index 000000000..139597f9c --- /dev/null +++ b/server/src/interfaces/FinancialStatements.ts @@ -0,0 +1,2 @@ + + diff --git a/server/src/interfaces/GeneralLedgerSheet.ts b/server/src/interfaces/GeneralLedgerSheet.ts new file mode 100644 index 000000000..5c75a1137 --- /dev/null +++ b/server/src/interfaces/GeneralLedgerSheet.ts @@ -0,0 +1,61 @@ + + +export interface IGeneralLedgerSheetQuery { + fromDate: Date | string, + toDate: Date | string, + basis: string, + numberFormat: { + noCents: boolean, + divideOn1000: boolean, + }, + noneTransactions: boolean, + accountsIds: number[], +}; + +export interface IGeneralLedgerSheetAccountTransaction { + id: number, + amount: number, + formattedAmount: string, + currencyCode: string, + note?: string, + transactionType?: string, + referenceId?: number, + referenceType?: string, + date: Date|string, +}; + +export interface IGeneralLedgerSheetAccountBalance { + date: Date|string, + amount: number, + formattedAmount: string, + currencyCode: string, +} + +export interface IGeneralLedgerSheetAccount { + id: number, + name: string, + code: string, + index: number, + parentAccountId: number, + transactions: IGeneralLedgerSheetAccountTransaction[], + opening: IGeneralLedgerSheetAccountBalance, + closing: IGeneralLedgerSheetAccountBalance, +} + +export interface IAccountTransaction { + id: number, + index: number, + draft: boolean, + note: string, + accountId: number, + transactionType: string, + referenceType: string, + referenceId: number, + contactId: number, + contactType: string, + credit: number, + debit: number, + date: string|Date, + createdAt: string|Date, + updatedAt: string|Date, +} \ No newline at end of file diff --git a/server/src/interfaces/Journal.ts b/server/src/interfaces/Journal.ts index ef3dab7b1..147ee663f 100644 --- a/server/src/interfaces/Journal.ts +++ b/server/src/interfaces/Journal.ts @@ -1,6 +1,7 @@ export interface IJournalEntry { + id: number, index?: number, date: Date, @@ -18,6 +19,8 @@ export interface IJournalEntry { }; export interface IJournalPoster { + entries: IJournalEntry[], + credit(entry: IJournalEntry): void; debit(entry: IJournalEntry): void; @@ -26,6 +29,9 @@ export interface IJournalPoster { saveEntries(): void; saveBalance(): void; deleteEntries(): void; + + getAccountBalance(accountId: number, closingDate?: Date | string, dateType?: string): number; + getAccountEntries(accountId: number): IJournalEntry[]; } export type TEntryType = 'credit' | 'debit'; diff --git a/server/src/interfaces/JournalReport.ts b/server/src/interfaces/JournalReport.ts new file mode 100644 index 000000000..b3b21d92a --- /dev/null +++ b/server/src/interfaces/JournalReport.ts @@ -0,0 +1,28 @@ +import { IJournalEntry } from './Journal'; + +export interface IJournalReportQuery { + fromDate: Date | string, + toDate: Date | string, + numberFormat: { + noCents: boolean, + divideOn1000: boolean, + }, + transactionTypes: string | string[], + accountsIds: number | number[], + fromRange: number, + toRange: number, +} + +export interface IJournalReportEntriesGroup { + id: string, + entries: IJournalEntry[], + currencyCode: string, + credit: number, + debit: number, + formattedCredit: string, + formattedDebit: string, +} + +export interface IJournalReport { + entries: IJournalReportEntriesGroup[], +} \ No newline at end of file diff --git a/server/src/interfaces/ProfitLossSheet.ts b/server/src/interfaces/ProfitLossSheet.ts new file mode 100644 index 000000000..be4d2f16a --- /dev/null +++ b/server/src/interfaces/ProfitLossSheet.ts @@ -0,0 +1,58 @@ + + +export interface IProfitLossSheetQuery { + basis: string, + fromDate: Date | string, + toDate: Date | string, + numberFormat: { + noCents: boolean, + divideOn1000: boolean, + }, + noneZero: boolean, + noneTransactions: boolean, + accountsIds: number[], + displayColumnsType: 'total' | 'date_periods', + displayColumnsBy: string, +}; + +export interface IProfitLossSheetTotal { + amount: number, + formattedAmount: string, + currencyCode: string, + date?: Date|string, +}; + +export interface IProfitLossSheetAccount { + id: number, + index: number, + name: string, + code: string, + parentAccountId: number, + hasTransactions: boolean, + total: IProfitLossSheetTotal, + totalPeriods: IProfitLossSheetTotal[], +}; + +export interface IProfitLossSheetAccountsSection { + sectionTitle: string, + entryNormal: 'credit', + accounts: IProfitLossSheetAccount[], + total: IProfitLossSheetTotal, + totalPeriods?: IProfitLossSheetTotal[], +}; + +export interface IProfitLossSheetTotalSection { + total: IProfitLossSheetTotal, + totalPeriods?: IProfitLossSheetTotal[], +}; + +export interface IProfitLossSheetStatement { + income: IProfitLossSheetAccountsSection, + costOfSales: IProfitLossSheetAccountsSection, + expenses: IProfitLossSheetAccountsSection, + otherExpenses: IProfitLossSheetAccountsSection, + + netIncome: IProfitLossSheetTotalSection; + operatingProfit: IProfitLossSheetTotalSection; + grossProfit: IProfitLossSheetTotalSection; +}; \ No newline at end of file diff --git a/server/src/interfaces/TrialBalanceSheet.ts b/server/src/interfaces/TrialBalanceSheet.ts new file mode 100644 index 000000000..99f267846 --- /dev/null +++ b/server/src/interfaces/TrialBalanceSheet.ts @@ -0,0 +1,37 @@ + +export interface ITrialBalanceSheetQuery { + fromDate: Date|string, + toDate: Date|string, + numberFormat: { + noCents: boolean, + divideOn1000: boolean, + }, + basis: 'cash' | 'accural', + noneZero: boolean, + noneTransactions: boolean, + accountIds: number[], +} + +export interface ITrialBalanceAccount { + id: number, + parentAccountId: number, + name: string, + code: string, + accountNormal: string, + hasTransactions: boolean, + + credit: number, + debit: number, + balance: number, + + formattedCredit: string, + formattedDebit: string, + formattedBalance: string, +} + +export type ITrialBalanceSheetData = IBalanceSheetSection[]; + +export interface ITrialBalanceStatement { + data: ITrialBalanceSheetData, + query: ITrialBalanceSheetQuery, +} \ No newline at end of file diff --git a/server/src/interfaces/index.ts b/server/src/interfaces/index.ts index f5f5f970b..8c4c9f2e7 100644 --- a/server/src/interfaces/index.ts +++ b/server/src/interfaces/index.ts @@ -28,4 +28,12 @@ export * from './ManualJournal'; export * from './Currency'; export * from './ExchangeRate'; export * from './Media'; -export * from './SaleEstimate'; \ No newline at end of file +export * from './SaleEstimate'; +export * from './FinancialStatements'; +export * from './BalanceSheet'; +export * from './TrialBalanceSheet'; +export * from './GeneralLedgerSheet' +export * from './ProfitLossSheet'; +export * from './JournalReport'; + +export * from './ARAgingSummaryReport'; \ No newline at end of file diff --git a/server/src/loaders/tenantRepositories.ts b/server/src/loaders/tenantRepositories.ts index 28b2b30c1..ffe80e046 100644 --- a/server/src/loaders/tenantRepositories.ts +++ b/server/src/loaders/tenantRepositories.ts @@ -6,10 +6,12 @@ import ExpenseRepository from 'repositories/ExpenseRepository'; import ViewRepository from 'repositories/ViewRepository'; import ViewRoleRepository from 'repositories/ViewRoleRepository'; import ContactRepository from 'repositories/ContactRepository'; +import AccountTransactionsRepository from 'repositories/AccountTransactionRepository'; export default (tenantId: number) => { return { accountRepository: new AccountRepository(tenantId), + transactionsRepository: new AccountTransactionsRepository(tenantId), accountTypeRepository: new AccountTypeRepository(tenantId), customerRepository: new CustomerRepository(tenantId), vendorRepository: new VendorRepository(tenantId), diff --git a/server/src/models/AccountTransaction.js b/server/src/models/AccountTransaction.js index c5c36c19e..309f62bc9 100644 --- a/server/src/models/AccountTransaction.js +++ b/server/src/models/AccountTransaction.js @@ -28,7 +28,7 @@ export default class AccountTransaction extends TenantModel { * @param {number[]} accountsIds */ filterAccounts(query, accountsIds) { - if (accountsIds.length > 0) { + if (Array.isArray(accountsIds) && accountsIds.length > 0) { query.whereIn('account_id', accountsIds); } }, @@ -63,9 +63,11 @@ export default class AccountTransaction extends TenantModel { q.where('credit', '<=', toAmount); q.orWhere('debit', '<=', toAmount); }); - } + } }, sumationCreditDebit(query) { + query.select(['accountId']); + query.sum('credit as credit'); query.sum('debit as debit'); query.groupBy('account_id'); @@ -76,6 +78,16 @@ export default class AccountTransaction extends TenantModel { filterContactIds(query, contactIds) { query.whereIn('contact_id', contactIds); }, + + openingBalance(query, fromDate) { + query.modify('filterDateRange', null, fromDate) + query.modify('sumationCreditDebit') + }, + + closingBalance(query, toDate) { + query.modify('filterDateRange', null, toDate) + query.modify('sumationCreditDebit') + }, }; } diff --git a/server/src/repositories/AccountRepository.ts b/server/src/repositories/AccountRepository.ts index 078671fc2..6e04cc5db 100644 --- a/server/src/repositories/AccountRepository.ts +++ b/server/src/repositories/AccountRepository.ts @@ -1,25 +1,7 @@ import TenantRepository from 'repositories/TenantRepository'; import { IAccount } from 'interfaces'; -import { Account } from 'models'; export default class AccountRepository extends TenantRepository { - models: any; - repositories: any; - cache: any; - - /** - * Constructor method. - * @param {number} tenantId - The given tenant id. - */ - constructor( - tenantId: number, - ) { - super(tenantId); - - this.models = this.tenancy.models(tenantId); - this.cache = this.tenancy.cache(tenantId); - } - /** * Retrieve accounts dependency graph. * @returns {} @@ -27,8 +9,9 @@ export default class AccountRepository extends TenantRepository { async getDependencyGraph() { const { Account } = this.models; const accounts = await this.allAccounts(); + const cacheKey = this.getCacheKey('accounts.depGraph'); - return this.cache.get('accounts.depGraph', async () => { + return this.cache.get(cacheKey, async () => { return Account.toDependencyGraph(accounts); }); } @@ -37,10 +20,13 @@ export default class AccountRepository extends TenantRepository { * Retrieve all accounts on the storage. * @return {IAccount[]} */ - allAccounts(): IAccount[] { + allAccounts(withRelations?: string|string[]): IAccount[] { const { Account } = this.models; - return this.cache.get('accounts', async () => { - return Account.query(); + const cacheKey = this.getCacheKey('accounts.depGraph', withRelations); + + return this.cache.get(cacheKey, async () => { + return Account.query() + .withGraphFetched(withRelations); }); } @@ -51,7 +37,9 @@ export default class AccountRepository extends TenantRepository { */ getBySlug(slug: string): IAccount { const { Account } = this.models; - return this.cache.get(`accounts.slug.${slug}`, () => { + const cacheKey = this.getCacheKey('accounts.slug', slug); + + return this.cache.get(cacheKey, () => { return Account.query().findOne('slug', slug); }); } @@ -63,7 +51,9 @@ export default class AccountRepository extends TenantRepository { */ findById(id: number): IAccount { const { Account } = this.models; - return this.cache.get(`accounts.id.${id}`, () => { + const cacheKey = this.getCacheKey('accounts.id', id); + + return this.cache.get(cacheKey, () => { return Account.query().findById(id); }); } @@ -75,7 +65,11 @@ export default class AccountRepository extends TenantRepository { */ findByIds(accountsIds: number[]) { const { Account } = this.models; - return Account.query().whereIn('id', accountsIds); + const cacheKey = this.getCacheKey('accounts.id', accountsIds); + + return this.cache.get(cacheKey, () => { + return Account.query().whereIn('id', accountsIds); + }); } /** @@ -143,6 +137,6 @@ export default class AccountRepository extends TenantRepository { * Flush repository cache. */ flushCache(): void { - this.cache.delStartWith('accounts'); + this.cache.delStartWith(this.repositoryName); } } \ No newline at end of file diff --git a/server/src/repositories/AccountTransactionRepository.ts b/server/src/repositories/AccountTransactionRepository.ts new file mode 100644 index 000000000..28a17c800 --- /dev/null +++ b/server/src/repositories/AccountTransactionRepository.ts @@ -0,0 +1,60 @@ + +import { QueryBuilder } from 'knex'; +import { AccountTransaction } from 'models'; +import hashObject from 'object-hash'; +import TenantRepository from 'repositories/TenantRepository'; + + +interface IJournalTransactionsFilter { + fromDate: string | Date, + toDate: string | Date, + accountsIds: number[], + sumationCreditDebit: boolean, + fromAmount: number, + toAmount: number, + contactsIds?: number[], + contactType?: string, +}; + +export default class AccountTransactionsRepository extends TenantRepository { + + journal(filter: IJournalTransactionsFilter) { + const { AccountTransaction } = this.models; + const cacheKey = this.getCacheKey('transactions.journal', filter); + + return this.cache.get(cacheKey, () => { + return AccountTransaction.query() + .modify('filterAccounts', filter.accountsIds) + .modify('filterDateRange', filter.fromDate, filter.toDate) + .withGraphFetched('account.type') + .onBuild((query) => { + if (filter.sumationCreditDebit) { + query.modify('sumationCreditDebit'); + } + if (filter.fromAmount || filter.toAmount) { + query.modify('filterAmountRange', filter.fromAmount, filter.toAmount); + } + if (filter.contactsIds) { + query.modify('filterContactIds', filter.contactsIds); + } + if (filter.contactType) { + query.where('contact_type', filter.contactType); + } + }); + }); + } + + openingBalance(fromDate) { + return this.cache.get('transaction.openingBalance', () => { + return AccountTransaction.query() + .modify('openingBalance', fromDate); + }) + } + + closingOpening(toDate) { + return this.cache.get('transaction.closingBalance', () => { + return AccountTransaction.query() + .modify('closingBalance', toDate); + }); + } +} \ No newline at end of file diff --git a/server/src/repositories/AccountTypeRepository.ts b/server/src/repositories/AccountTypeRepository.ts index f1421c061..7ad3071c4 100644 --- a/server/src/repositories/AccountTypeRepository.ts +++ b/server/src/repositories/AccountTypeRepository.ts @@ -2,22 +2,6 @@ import TenantRepository from 'repositories/TenantRepository'; import { IAccountType } from 'interfaces'; export default class AccountTypeRepository extends TenantRepository { - cache: any; - models: any; - - /** - * Constructor method. - * @param {number} tenantId - The given tenant id. - */ - constructor( - tenantId: number, - ) { - super(tenantId); - - this.models = this.tenancy.models(tenantId); - this.cache = this.tenancy.cache(tenantId); - } - /** * Retrieve all accounts types. * @return {IAccountType[]} diff --git a/server/src/repositories/CachableRepository.ts b/server/src/repositories/CachableRepository.ts new file mode 100644 index 000000000..de88ff723 --- /dev/null +++ b/server/src/repositories/CachableRepository.ts @@ -0,0 +1,19 @@ +import hashObject from 'object-hash'; + + +export default class CachableRepository { + repositoryName: string; + + /** + * Retrieve the cache key of the method name and arguments. + * @param {string} method + * @param {...any} args + * @return {string} + */ + getCacheKey(method, ...args) { + const hashArgs = hashObject({ ...args }); + const repositoryName = this.repositoryName; + + return `${repositoryName}-${method}-${hashArgs}`; + } +} \ No newline at end of file diff --git a/server/src/repositories/ContactRepository.ts b/server/src/repositories/ContactRepository.ts index ee5e903be..a0bf5ccbb 100644 --- a/server/src/repositories/ContactRepository.ts +++ b/server/src/repositories/ContactRepository.ts @@ -2,22 +2,6 @@ import TenantRepository from 'repositories/TenantRepository'; import { IContact } from 'interfaces'; export default class ContactRepository extends TenantRepository { - cache: any; - models: any; - - /** - * Constructor method. - * @param {number} tenantId - The given tenant id. - */ - constructor( - tenantId: number, - ) { - super(tenantId); - - this.models = this.tenancy.models(tenantId); - this.cache = this.tenancy.cache(tenantId); - } - /** * Retrieve the given contact model. * @param {number} contactId diff --git a/server/src/repositories/CustomerRepository.ts b/server/src/repositories/CustomerRepository.ts index 67d3e51c6..0fff61b03 100644 --- a/server/src/repositories/CustomerRepository.ts +++ b/server/src/repositories/CustomerRepository.ts @@ -1,18 +1,12 @@ import TenantRepository from "./TenantRepository"; export default class CustomerRepository extends TenantRepository { - models: any; - cache: any; + all() { + const { Contact } = this.models; - /** - * Constructor method. - * @param {number} tenantId - */ - constructor(tenantId: number) { - super(tenantId); - - this.models = this.tenancy.models(tenantId); - this.cache = this.tenancy.cache(tenantId); + return this.cache.get('customers', () => { + return Contact.query().modify('customer'); + }); } /** diff --git a/server/src/repositories/ExpenseRepository.ts b/server/src/repositories/ExpenseRepository.ts index f10ebbcdd..d63be77bf 100644 --- a/server/src/repositories/ExpenseRepository.ts +++ b/server/src/repositories/ExpenseRepository.ts @@ -3,21 +3,6 @@ import { IExpense } from 'interfaces'; import moment from "moment"; export default class ExpenseRepository extends TenantRepository { - models: any; - repositories: any; - cache: any; - - /** - * Constructor method. - * @param {number} tenantId - */ - constructor(tenantId: number) { - super(tenantId); - - this.models = this.tenancy.models(tenantId); - this.cache = this.tenancy.cache(tenantId); - } - /** * Retrieve the given expense by id. * @param {number} expenseId diff --git a/server/src/repositories/JournalRepository.ts b/server/src/repositories/JournalRepository.ts new file mode 100644 index 000000000..ad8fb5bc6 --- /dev/null +++ b/server/src/repositories/JournalRepository.ts @@ -0,0 +1,18 @@ +import { IBalanceSheetQuery } from 'interfaces'; +import TenantRepository from 'repositories/TenantRepository'; + + +export default class JournalRepository extends TenantRepository { + + balanceSheet(query: IBalanceSheetQuery) { + + // Accounts dependency graph. + const accountsGraph = Account.toDependencyGraph(balanceSheetAccounts); + + // Load all entries that associated to the given accounts. + const journalEntriesCollected = Account.collectJournalEntries(balanceSheetAccounts); + + const journalEntries = new JournalPoster(accountsGraph); + journalEntries.loadEntries(journalEntriesCollected); + } +} \ No newline at end of file diff --git a/server/src/repositories/TenantRepository.ts b/server/src/repositories/TenantRepository.ts index 390229028..d692a24ec 100644 --- a/server/src/repositories/TenantRepository.ts +++ b/server/src/repositories/TenantRepository.ts @@ -1,16 +1,45 @@ import { Container } from 'typedi'; import TenancyService from 'services/Tenancy/TenancyService'; +import CachableRepository from './CachableRepository'; -export default class TenantRepository { +export default class TenantRepository extends CachableRepository { + repositoryName: string; tenantId: number; tenancy: TenancyService; - + modelsInstance: any; + repositoriesInstance: any; + cacheInstance: any; + /** * Constructor method. * @param {number} tenantId */ constructor(tenantId: number) { + super(); + this.tenantId = tenantId; this.tenancy = Container.get(TenancyService); + this.repositoryName = this.constructor.name; + } + + get models() { + if (!this.modelsInstance) { + this.modelsInstance = this.tenancy.models(this.tenantId); + } + return this.modelsInstance; + } + + get repositories() { + if (!this.repositoriesInstance) { + this.repositoriesInstance = this.tenancy.repositories(this.tenantId); + } + return this.repositoriesInstance; + } + + get cache() { + if (!this.cacheInstance) { + this.cacheInstance = this.tenancy.cache(this.tenantId); + } + return this.cacheInstance; } } \ No newline at end of file diff --git a/server/src/repositories/VendorRepository.ts b/server/src/repositories/VendorRepository.ts index 36e683053..aa19a4249 100644 --- a/server/src/repositories/VendorRepository.ts +++ b/server/src/repositories/VendorRepository.ts @@ -3,19 +3,6 @@ import TenantRepository from "./TenantRepository"; export default class VendorRepository extends TenantRepository { - models: any; - cache: any; - - /** - * Constructor method. - * @param {number} tenantId - */ - constructor(tenantId: number) { - super(tenantId); - - this.models = this.tenancy.models(tenantId); - this.cache = this.tenancy.cache(tenantId); - } /** * Retrieve vendor details of the given id. @@ -68,7 +55,6 @@ export default class VendorRepository extends TenantRepository { [changeMethod]('balance', Math.abs(amount)); } - async changeDiffBalance( vendorId: number, amount: number, diff --git a/server/src/repositories/ViewRepository.ts b/server/src/repositories/ViewRepository.ts index b7488da99..288f7ad2d 100644 --- a/server/src/repositories/ViewRepository.ts +++ b/server/src/repositories/ViewRepository.ts @@ -2,22 +2,6 @@ import { IView } from 'interfaces'; import TenantRepository from 'repositories/TenantRepository'; export default class ViewRepository extends TenantRepository { - models: any; - cache: any; - repositories: any; - - /** - * Constructor method. - * @param {number} tenantId - The given tenant id. - */ - constructor( - tenantId: number, - ) { - super(tenantId); - - this.models = this.tenancy.models(tenantId); - this.cache = this.tenancy.cache(tenantId); - } /** * Retrieve view model by the given id. diff --git a/server/src/repositories/ViewRoleRepository.ts b/server/src/repositories/ViewRoleRepository.ts index bbd254f4b..a42ddb987 100644 --- a/server/src/repositories/ViewRoleRepository.ts +++ b/server/src/repositories/ViewRoleRepository.ts @@ -2,23 +2,6 @@ import { omit } from 'lodash'; import TenantRepository from 'repositories/TenantRepository'; export default class ViewRoleRepository extends TenantRepository { - models: any; - cache: any; - repositories: any; - - /** - * Constructor method. - * @param {number} tenantId - The given tenant id. - */ - constructor( - tenantId: number, - ) { - super(tenantId); - - this.models = this.tenancy.models(tenantId); - this.cache = this.tenancy.cache(tenantId); - this.repositories = this.tenancy.cache(tenantId); - } allByView(viewId: number) { const { ViewRole } = this.models; diff --git a/server/src/services/Accounting/JournalFinancial.ts b/server/src/services/Accounting/JournalFinancial.ts index aa8081d84..6de40e8f8 100644 --- a/server/src/services/Accounting/JournalFinancial.ts +++ b/server/src/services/Accounting/JournalFinancial.ts @@ -1,207 +1,19 @@ import moment from 'moment'; +import { IJournalPoster } from 'interfaces'; export default class JournalFinancial { - accountsBalanceTable: { [key: number]: number; } = {}; + journal: IJournalPoster; + + accountsDepGraph: any; /** - * Retrieve the closing balance for the given account and closing date. - * @param {Number} accountId - - * @param {Date} closingDate - - * @param {string} dataType? - - * @return {number} + * Journal poster. + * @param {IJournalPoster} journal */ - getClosingBalance( - accountId: number, - closingDate: Date|string, - dateType: string = 'day' - ): number { - let closingBalance = 0; - const momentClosingDate = moment(closingDate); - - this.entries.forEach((entry) => { - // Can not continue if not before or event same closing date. - if ( - (!momentClosingDate.isAfter(entry.date, dateType) && - !momentClosingDate.isSame(entry.date, dateType)) || - (entry.account !== accountId && accountId) - ) { - return; - } - if (entry.accountNormal === 'credit') { - closingBalance += entry.credit ? entry.credit : -1 * entry.debit; - } else if (entry.accountNormal === 'debit') { - closingBalance += entry.debit ? entry.debit : -1 * entry.credit; - } - }); - return closingBalance; + constructor(journal: IJournalPoster) { + this.journal = journal; + this.accountsDepGraph = this.journal.accountsDepGraph; } - /** - * Retrieve the given account balance with dependencies accounts. - * @param {Number} accountId - * @param {Date} closingDate - * @param {String} dateType - * @return {Number} - */ - getAccountBalance(accountId: number, closingDate: Date|string, dateType: string) { - const accountNode = this.accountsDepGraph.getNodeData(accountId); - const depAccountsIds = this.accountsDepGraph.dependenciesOf(accountId); - const depAccounts = depAccountsIds - .map((id) => this.accountsDepGraph.getNodeData(id)); - - let balance: number = 0; - - [...depAccounts, accountNode].forEach((account) => { - const closingBalance = this.getClosingBalance( - account.id, - closingDate, - dateType - ); - this.accountsBalanceTable[account.id] = closingBalance; - balance += this.accountsBalanceTable[account.id]; - }); - return balance; - } - - /** - * Retrieve the credit/debit sumation for the given account and date. - * @param {Number} account - - * @param {Date|String} closingDate - - */ - getTrialBalance(accountId, closingDate, dateType) { - const momentClosingDate = moment(closingDate); - const result = { - credit: 0, - debit: 0, - balance: 0, - }; - this.entries.forEach((entry) => { - if ( - (!momentClosingDate.isAfter(entry.date, dateType) && - !momentClosingDate.isSame(entry.date, dateType)) || - (entry.account !== accountId && accountId) - ) { - return; - } - result.credit += entry.credit; - result.debit += entry.debit; - - if (entry.accountNormal === 'credit') { - result.balance += entry.credit - entry.debit; - } else if (entry.accountNormal === 'debit') { - result.balance += entry.debit - entry.credit; - } - }); - return result; - } - - /** - * Retrieve trial balance of the given account with depends. - * @param {Number} accountId - * @param {Date} closingDate - * @param {String} dateType - * @return {Number} - */ - - getTrialBalanceWithDepands(accountId: number, closingDate: Date, dateType: string) { - const accountNode = this.accountsDepGraph.getNodeData(accountId); - const depAccountsIds = this.accountsDepGraph.dependenciesOf(accountId); - const depAccounts = depAccountsIds.map((id) => - this.accountsDepGraph.getNodeData(id) - ); - const trialBalance = { credit: 0, debit: 0, balance: 0 }; - - [...depAccounts, accountNode].forEach((account) => { - const _trialBalance = this.getTrialBalance( - account.id, - closingDate, - dateType - ); - - trialBalance.credit += _trialBalance.credit; - trialBalance.debit += _trialBalance.debit; - trialBalance.balance += _trialBalance.balance; - }); - return trialBalance; - } - - getContactTrialBalance( - accountId: number, - contactId: number, - contactType: string, - closingDate: Date|string, - openingDate: Date|string, - ) { - const momentClosingDate = moment(closingDate); - const momentOpeningDate = moment(openingDate); - const trial = { - credit: 0, - debit: 0, - balance: 0, - }; - - this.entries.forEach((entry) => { - if ( - (closingDate && - !momentClosingDate.isAfter(entry.date, 'day') && - !momentClosingDate.isSame(entry.date, 'day')) || - (openingDate && - !momentOpeningDate.isBefore(entry.date, 'day') && - !momentOpeningDate.isSame(entry.date)) || - (accountId && entry.account !== accountId) || - (contactId && entry.contactId !== contactId) || - entry.contactType !== contactType - ) { - return; - } - if (entry.credit) { - trial.balance -= entry.credit; - trial.credit += entry.credit; - } - if (entry.debit) { - trial.balance += entry.debit; - trial.debit += entry.debit; - } - }); - return trial; - } - - /** - * Retrieve total balnace of the given customer/vendor contact. - * @param {Number} accountId - * @param {Number} contactId - * @param {String} contactType - * @param {Date} closingDate - */ - getContactBalance( - accountId: number, - contactId: number, - contactType: string, - closingDate: Date, - openingDate: Date, - ) { - const momentClosingDate = moment(closingDate); - let balance = 0; - - this.entries.forEach((entry) => { - if ( - (closingDate && - !momentClosingDate.isAfter(entry.date, 'day') && - !momentClosingDate.isSame(entry.date, 'day')) || - (entry.account !== accountId && accountId) || - (contactId && entry.contactId !== contactId) || - entry.contactType !== contactType - ) { - return; - } - if (entry.credit) { - balance -= entry.credit; - } - if (entry.debit) { - balance += entry.debit; - } - }); - return balance; - } } \ No newline at end of file diff --git a/server/src/services/Accounting/JournalPoster.ts b/server/src/services/Accounting/JournalPoster.ts index bb75616be..896074630 100644 --- a/server/src/services/Accounting/JournalPoster.ts +++ b/server/src/services/Accounting/JournalPoster.ts @@ -1,4 +1,5 @@ -import { omit } from 'lodash'; +import { omit, get } from 'lodash'; +import moment from 'moment'; import { Container } from 'typedi'; import JournalEntry from 'services/Accounting/JournalEntry'; import TenancyService from 'services/Tenancy/TenancyService'; @@ -22,18 +23,25 @@ export default class JournalPoster implements IJournalPoster { balancesChange: IAccountsChange = {}; accountsDepGraph: IAccountsChange = {}; + accountsBalanceTable: { [key: number]: number; } = {}; + /** * Journal poster constructor. * @param {number} tenantId - */ constructor( tenantId: number, + accountsGraph?: any, ) { this.initTenancy(); this.tenantId = tenantId; this.models = this.tenancy.models(tenantId); this.repositories = this.tenancy.repositories(tenantId); + + if (accountsGraph) { + this.accountsDepGraph = accountsGraph; + } } /** @@ -54,10 +62,13 @@ export default class JournalPoster implements IJournalPoster { * @private * @returns {Promise} */ - private async initializeAccountsDepGraph(): Promise { + public async initAccountsDepGraph(): Promise { const { accountRepository } = this.repositories; - const accountsDepGraph = await accountRepository.getDependencyGraph(); - this.accountsDepGraph = accountsDepGraph; + + if (!this.accountsDepGraph) { + const accountsDepGraph = await accountRepository.getDependencyGraph(); + this.accountsDepGraph = accountsDepGraph; + } } /** @@ -176,7 +187,7 @@ export default class JournalPoster implements IJournalPoster { * @returns {Promise} */ public async saveBalance() { - await this.initializeAccountsDepGraph(); + await this.initAccountsDepGraph(); const { Account } = this.models; const accountsChange = this.balanceChangeWithDepends(this.balancesChange); @@ -311,15 +322,17 @@ export default class JournalPoster implements IJournalPoster { * Load fetched accounts journal entries. * @param {IJournalEntry[]} entries - */ - loadEntries(entries: IJournalEntry[]): void { - entries.forEach((entry: IJournalEntry) => { + fromTransactions(transactions) { + transactions.forEach((transaction) => { this.entries.push({ - ...entry, - account: entry.account ? entry.account.id : entry.accountId, + ...transaction, + account: transaction.accountId, + accountNormal: get(transaction, 'account.type.normal'), }); }); } + /** * Calculates the entries balance change. * @public @@ -334,4 +347,216 @@ export default class JournalPoster implements IJournalPoster { } }); } + + static fromTransactions(entries, ...args: [number, ...any]) { + const journal = new this(...args); + journal.fromTransactions(entries); + + return journal; + } + + /** + * Retrieve the closing balance for the given account and closing date. + * @param {Number} accountId - + * @param {Date} closingDate - + * @param {string} dataType? - + * @return {number} + */ + getClosingBalance( + accountId: number, + closingDate: Date|string, + dateType: string = 'day' + ): number { + let closingBalance = 0; + const momentClosingDate = moment(closingDate); + + this.entries.forEach((entry) => { + // Can not continue if not before or event same closing date. + if ( + (!momentClosingDate.isAfter(entry.date, dateType) && + !momentClosingDate.isSame(entry.date, dateType)) || + (entry.account !== accountId && accountId) + ) { + return; + } + if (entry.accountNormal === 'credit') { + closingBalance += entry.credit ? entry.credit : -1 * entry.debit; + } else if (entry.accountNormal === 'debit') { + closingBalance += entry.debit ? entry.debit : -1 * entry.credit; + } + }); + return closingBalance; + } + + /** + * Retrieve the given account balance with dependencies accounts. + * @param {Number} accountId - + * @param {Date} closingDate - + * @param {String} dateType - + * @return {Number} + */ + getAccountBalance(accountId: number, closingDate: Date|string, dateType: string) { + const accountNode = this.accountsDepGraph.getNodeData(accountId); + const depAccountsIds = this.accountsDepGraph.dependenciesOf(accountId); + const depAccounts = depAccountsIds + .map((id) => this.accountsDepGraph.getNodeData(id)); + + let balance: number = 0; + + [...depAccounts, accountNode].forEach((account) => { + const closingBalance = this.getClosingBalance( + account.id, + closingDate, + dateType + ); + this.accountsBalanceTable[account.id] = closingBalance; + balance += this.accountsBalanceTable[account.id]; + }); + return balance; + } + + /** + * Retrieve the credit/debit sumation for the given account and date. + * @param {Number} account - + * @param {Date|String} closingDate - + */ + getTrialBalance(accountId, closingDate, dateType) { + const momentClosingDate = moment(closingDate); + const result = { + credit: 0, + debit: 0, + balance: 0, + }; + this.entries.forEach((entry) => { + if ( + (!momentClosingDate.isAfter(entry.date, dateType) && + !momentClosingDate.isSame(entry.date, dateType)) || + (entry.account !== accountId && accountId) + ) { + return; + } + result.credit += entry.credit; + result.debit += entry.debit; + + if (entry.accountNormal === 'credit') { + result.balance += entry.credit - entry.debit; + } else if (entry.accountNormal === 'debit') { + result.balance += entry.debit - entry.credit; + } + }); + return result; + } + + /** + * Retrieve trial balance of the given account with depends. + * @param {Number} accountId + * @param {Date} closingDate + * @param {String} dateType + * @return {Number} + */ + + getTrialBalanceWithDepands(accountId: number, closingDate: Date, dateType: string) { + const accountNode = this.accountsDepGraph.getNodeData(accountId); + const depAccountsIds = this.accountsDepGraph.dependenciesOf(accountId); + const depAccounts = depAccountsIds.map((id) => + this.accountsDepGraph.getNodeData(id) + ); + const trialBalance = { credit: 0, debit: 0, balance: 0 }; + + [...depAccounts, accountNode].forEach((account) => { + const _trialBalance = this.getTrialBalance( + account.id, + closingDate, + dateType + ); + + trialBalance.credit += _trialBalance.credit; + trialBalance.debit += _trialBalance.debit; + trialBalance.balance += _trialBalance.balance; + }); + return trialBalance; + } + + getContactTrialBalance( + accountId: number, + contactId: number, + contactType: string, + closingDate: Date|string, + openingDate: Date|string, + ) { + const momentClosingDate = moment(closingDate); + const momentOpeningDate = moment(openingDate); + const trial = { + credit: 0, + debit: 0, + balance: 0, + }; + + this.entries.forEach((entry) => { + if ( + (closingDate && + !momentClosingDate.isAfter(entry.date, 'day') && + !momentClosingDate.isSame(entry.date, 'day')) || + (openingDate && + !momentOpeningDate.isBefore(entry.date, 'day') && + !momentOpeningDate.isSame(entry.date)) || + (accountId && entry.account !== accountId) || + (contactId && entry.contactId !== contactId) || + entry.contactType !== contactType + ) { + return; + } + if (entry.credit) { + trial.balance -= entry.credit; + trial.credit += entry.credit; + } + if (entry.debit) { + trial.balance += entry.debit; + trial.debit += entry.debit; + } + }); + return trial; + } + + /** + * Retrieve total balnace of the given customer/vendor contact. + * @param {Number} accountId + * @param {Number} contactId + * @param {String} contactType + * @param {Date} closingDate + */ + getContactBalance( + accountId: number, + contactId: number, + contactType: string, + closingDate: Date, + openingDate: Date, + ) { + const momentClosingDate = moment(closingDate); + let balance = 0; + + this.entries.forEach((entry) => { + if ( + (closingDate && + !momentClosingDate.isAfter(entry.date, 'day') && + !momentClosingDate.isSame(entry.date, 'day')) || + (entry.account !== accountId && accountId) || + (contactId && entry.contactId !== contactId) || + entry.contactType !== contactType + ) { + return; + } + if (entry.credit) { + balance -= entry.credit; + } + if (entry.debit) { + balance += entry.debit; + } + }); + return balance; + } + + getAccountEntries(accountId: number) { + return this.entries.filter((entry) => entry.account === accountId); + } } diff --git a/server/src/services/Accounts/AccountsService.ts b/server/src/services/Accounts/AccountsService.ts index dabb50960..f50a1329d 100644 --- a/server/src/services/Accounts/AccountsService.ts +++ b/server/src/services/Accounts/AccountsService.ts @@ -340,7 +340,7 @@ export default class AccountsService { * @param {number[]} accountsIds * @return {IAccount[]} */ - private async getAccountsOrThrowError( + public async getAccountsOrThrowError( tenantId: number, accountsIds: number[] ): Promise { @@ -521,8 +521,7 @@ export default class AccountsService { * ----------- * Precedures. * ----------- - * - Transfer the given account transactions to another account - * with the same root type. + * - Transfer the given account transactions to another account with the same root type. * - Delete the given account. * ------- * @param {number} tenantId - diff --git a/server/src/services/FinancialStatements/AgingSummary/APAgingSummaryService.ts b/server/src/services/FinancialStatements/AgingSummary/APAgingSummaryService.ts new file mode 100644 index 000000000..992fc2323 --- /dev/null +++ b/server/src/services/FinancialStatements/AgingSummary/APAgingSummaryService.ts @@ -0,0 +1,6 @@ + + + +export default class PayableAgingSummaryService { + +} \ No newline at end of file diff --git a/server/src/services/FinancialStatements/AgingSummary/APAgingSummarySheet.ts b/server/src/services/FinancialStatements/AgingSummary/APAgingSummarySheet.ts new file mode 100644 index 000000000..da413b72e --- /dev/null +++ b/server/src/services/FinancialStatements/AgingSummary/APAgingSummarySheet.ts @@ -0,0 +1,12 @@ +import FinancialSheet from "../FinancialSheet"; + + + +export default class APAgingSummarySheet extends FinancialSheet { + + + + reportData() { + + } +} \ No newline at end of file diff --git a/server/src/services/FinancialStatements/AgingSummary/ARAgingSummaryService.ts b/server/src/services/FinancialStatements/AgingSummary/ARAgingSummaryService.ts new file mode 100644 index 000000000..24dbd74ce --- /dev/null +++ b/server/src/services/FinancialStatements/AgingSummary/ARAgingSummaryService.ts @@ -0,0 +1,93 @@ +import moment from 'moment'; +import { Inject, Service } from 'typedi'; +import { IARAgingSummaryQuery } from 'interfaces'; +import TenancyService from 'services/Tenancy/TenancyService'; +import Journal from 'services/Accounting/JournalPoster'; +import ARAgingSummarySheet from './ARAgingSummarySheet'; + +@Service() +export default class ARAgingSummaryService { + @Inject() + tenancy: TenancyService; + + @Inject('logger') + logger: any; + + /** + * Default report query. + */ + get defaultQuery() { + return { + asDate: moment().format('YYYY-MM-DD'), + agingDaysBefore: 30, + agingPeriods: 3, + numberFormat: { + no_cents: false, + divide_1000: false, + }, + customersIds: [], + noneZero: false, + }; + } + + /** + * Retreive th accounts receivable aging summary data and columns. + * @param {number} tenantId + * @param query + */ + async ARAgingSummary(tenantId: number, query: IARAgingSummaryQuery) { + const { + customerRepository, + accountRepository, + transactionsRepository, + accountTypeRepository + } = this.tenancy.repositories(tenantId); + + const { Account } = this.tenancy.models(tenantId); + const filter = { + ...this.defaultQuery, + ...query, + }; + this.logger.info('[AR_Aging_Summary] try to calculate the report.', { tenantId, filter }); + + // Settings tenant service. + const settings = this.tenancy.settings(tenantId); + const baseCurrency = settings.get({ group: 'organization', key: 'base_currency' }); + + // Retrieve all accounts graph on the storage. + const accountsGraph = await accountRepository.getDependencyGraph(); + + // Retrieve all customers from the storage. + const customers = await customerRepository.all(); + + // Retrieve AR account type. + const ARType = await accountTypeRepository.getByKey('accounts_receivable'); + + // Retreive AR account. + const ARAccount = await Account.query().findOne('account_type_id', ARType.id); + + // Retrieve journal transactions based on the given query. + const transactions = await transactionsRepository.journal({ + toDate: filter.asDate, + contactType: 'customer', + contactsIds: customers.map(customer => customer.id), + }); + // Converts transactions array to journal collection. + const journal = Journal.fromTransactions(transactions, tenantId, accountsGraph); + + // AR aging summary report instnace. + const ARAgingSummaryReport = new ARAgingSummarySheet( + tenantId, + filter, + customers, + journal, + ARAccount, + baseCurrency + ); + // AR aging summary report data and columns. + const data = ARAgingSummaryReport.reportData(); + const columns = ARAgingSummaryReport.reportColumns(); + + return { data, columns }; + } +} \ No newline at end of file diff --git a/server/src/services/FinancialStatements/AgingSummary/ARAgingSummarySheet.ts b/server/src/services/FinancialStatements/AgingSummary/ARAgingSummarySheet.ts new file mode 100644 index 000000000..988032c7c --- /dev/null +++ b/server/src/services/FinancialStatements/AgingSummary/ARAgingSummarySheet.ts @@ -0,0 +1,154 @@ +import { + ICustomer, + IARAgingSummaryQuery, + ARAgingSummaryCustomer, + IAgingPeriodClosingBalance, + IAgingPeriodTotal, + IJournalPoster, + IAccount, + IAgingPeriod +} from "interfaces"; +import AgingSummaryReport from './AgingSummary'; + + +export default class ARAgingSummarySheet extends AgingSummaryReport { + tenantId: number; + query: IARAgingSummaryQuery; + customers: ICustomer[]; + journal: IJournalPoster; + ARAccount: IAccount; + agingPeriods: IAgingPeriod[]; + baseCurrency: string; + + /** + * Constructor method. + * @param {number} tenantId + * @param {IARAgingSummaryQuery} query + * @param {ICustomer[]} customers + * @param {IJournalPoster} journal + */ + constructor( + tenantId: number, + query: IARAgingSummaryQuery, + customers: ICustomer[], + journal: IJournalPoster, + ARAccount: IAccount, + baseCurrency: string, + ) { + super(); + + this.tenantId = tenantId; + this.customers = customers; + this.query = query; + this.numberFormat = this.query.numberFormat; + this.journal = journal; + this.ARAccount = ARAccount; + this.baseCurrency = baseCurrency; + + this.initAgingPeriod(); + } + + /** + * Initializes the aging periods. + */ + private initAgingPeriod() { + this.agingPeriods = this.agingRangePeriods( + this.query.asDate, + this.query.agingDaysBefore, + this.query.agingPeriods + ); + } + + /** + * + * @param {ICustomer} customer + * @param {IAgingPeriod} agingPeriod + */ + private agingPeriodCloser( + customer: ICustomer, + agingPeriod: IAgingPeriod, + ): IAgingPeriodClosingBalance { + // Calculate the trial balance between the given date period. + const agingTrialBalance = this.journal.getContactTrialBalance( + this.ARAccount.id, + customer.id, + 'customer', + agingPeriod.fromPeriod, + ); + return { + ...agingPeriod, + closingBalance: agingTrialBalance.debit, + }; + } + + /** + * + * @param {ICustomer} customer + */ + private getCustomerAging(customer: ICustomer, totalReceivable: number): IAgingPeriodTotal[] { + const agingClosingBalance = this.agingPeriods + .map((agingPeriod: IAgingPeriod) => this.agingPeriodCloser(customer, agingPeriod)); + + const aging = this.contactAgingBalance( + agingClosingBalance, + totalReceivable + ); + return aging; + } + + /** + * Mapping aging customer. + * @param {ICustomer} customer - + * @return {ARAgingSummaryCustomer[]} + */ + private customerMapper(customer: ICustomer): ARAgingSummaryCustomer { + // Calculate the trial balance total of the given customer. + const trialBalance = this.journal.getContactTrialBalance( + this.ARAccount.id, + customer.id, + 'customer' + ); + const amount = trialBalance.balance; + const formattedAmount = this.formatNumber(amount); + const currencyCode = this.baseCurrency; + + return { + customerName: customer.displayName, + aging: this.getCustomerAging(customer, trialBalance.balance), + total: { + amount, + formattedAmount, + currencyCode, + }, + }; + } + + /** + * Retrieve customers walker. + * @param {ICustomer[]} customers + * @return {ARAgingSummaryCustomer[]} + */ + private customersWalker(customers: ICustomer[]): ARAgingSummaryCustomer[] { + return customers + .map((customer: ICustomer) => this.customerMapper(customer)) + + // Filter customers that have zero total amount when `noneZero` is on. + .filter((customer: ARAgingSummaryCustomer) => + !(customer.total.amount === 0 && this.query.noneZero), + ); + } + + /** + * Retrieve AR. aging summary report data. + */ + public reportData() { + return this.customersWalker(this.customers); + } + + /** + * Retrieve AR aging summary report columns. + */ + reportColumns() { + return [] + } +} \ No newline at end of file diff --git a/server/src/services/FinancialStatements/AgingSummary/AgingSummary.ts b/server/src/services/FinancialStatements/AgingSummary/AgingSummary.ts new file mode 100644 index 000000000..0f0b7b91b --- /dev/null +++ b/server/src/services/FinancialStatements/AgingSummary/AgingSummary.ts @@ -0,0 +1,75 @@ +import moment from 'moment'; +import { omit, reverse } from 'lodash'; +import { IAgingPeriod, IAgingPeriodClosingBalance, IAgingPeriodTotal } from 'interfaces'; +import FinancialSheet from '../FinancialSheet'; + +export default class AgingSummaryReport extends FinancialSheet{ + + /** + * + * @param {Array} agingPeriods + * @param {Numeric} customerBalance + */ + contactAgingBalance( + agingPeriods: IAgingPeriodClosingBalance[], + receivableTotalCredit: number, + ): IAgingPeriodTotal[] { + let prevAging = 0; + let receivableCredit = receivableTotalCredit; + let diff = receivableCredit; + + const periods = reverse(agingPeriods).map((agingPeriod) => { + const agingAmount = (agingPeriod.closingBalance - prevAging); + const subtract = Math.min(diff, agingAmount); + diff -= Math.min(agingAmount, diff); + + const total = Math.max(agingAmount - subtract, 0); + + const output = { + ...omit(agingPeriod, ['closingBalance']), + total, + }; + prevAging = agingPeriod.closingBalance; + return output; + }); + return reverse(periods); + } + + /** + * + * @param {*} asDay + * @param {*} agingDaysBefore + * @param {*} agingPeriodsFreq + */ + agingRangePeriods(asDay, agingDaysBefore, agingPeriodsFreq): IAgingPeriod[] { + const totalAgingDays = agingDaysBefore * agingPeriodsFreq; + const startAging = moment(asDay).startOf('day'); + const endAging = startAging.clone().subtract('days', totalAgingDays).endOf('day'); + + const agingPeriods: IAgingPeriod[] = []; + const startingAging = startAging.clone(); + + let beforeDays = 1; + let toDays = 0; + + while (startingAging > endAging) { + const currentAging = startingAging.clone(); + startingAging.subtract('days', agingDaysBefore).endOf('day'); + toDays += agingDaysBefore; + + agingPeriods.push({ + fromPeriod: moment(currentAging).toDate(), + toPeriod: moment(startingAging).toDate(), + beforeDays: beforeDays === 1 ? 0 : beforeDays, + toDays: toDays, + ...(startingAging.valueOf() === endAging.valueOf()) ? { + toPeriod: null, + toDays: null, + } : {}, + }); + beforeDays += agingDaysBefore; + } + return agingPeriods; + } + +} \ No newline at end of file diff --git a/server/src/services/FinancialStatements/BalanceSheet/BalanceSheet.ts b/server/src/services/FinancialStatements/BalanceSheet/BalanceSheet.ts new file mode 100644 index 000000000..106b7f4fd --- /dev/null +++ b/server/src/services/FinancialStatements/BalanceSheet/BalanceSheet.ts @@ -0,0 +1,284 @@ +import { sumBy, pick } from 'lodash'; +import { + IBalanceSheetQuery, + IBalanceSheetStructureSection, + IBalanceSheetAccountTotal, + IBalanceSheetAccount, + IBalanceSheetSection, + IAccount, + IJournalPoster, + IAccountType, +} from 'interfaces'; +import { + dateRangeCollection, + flatToNestedArray, +} from 'utils'; +import BalanceSheetStructure from 'data/BalanceSheetStructure'; +import FinancialSheet from '../FinancialSheet'; + +export default class BalanceSheetStatement extends FinancialSheet { + query: IBalanceSheetQuery; + tenantId: number; + accounts: IAccount & { type: IAccountType }[]; + journalFinancial: IJournalPoster; + comparatorDateType: string; + dateRangeSet: string[]; + baseCurrency: string; + + /** + * Constructor method. + * @param {number} tenantId - + * @param {IBalanceSheetQuery} query - + * @param {IAccount[]} accounts - + * @param {IJournalFinancial} journalFinancial - + */ + constructor( + tenantId: number, + query: IBalanceSheetQuery, + accounts: IAccount & { type: IAccountType }[], + journalFinancial: IJournalPoster, + baseCurrency: string, + ) { + super(); + + this.tenantId = tenantId; + this.query = query; + this.numberFormat = this.query.numberFormat; + this.accounts = accounts; + this.journalFinancial = journalFinancial; + this.baseCurrency = baseCurrency; + + this.comparatorDateType = query.displayColumnsType === 'total' + ? 'day' + : query.displayColumnsBy; + + this.initDateRangeCollection(); + } + + /** + * Initialize date range set. + */ + initDateRangeCollection() { + if (this.query.displayColumnsType === 'date_periods') { + this.dateRangeSet = dateRangeCollection( + this.query.fromDate, + this.query.toDate, + this.comparatorDateType + ); + } + } + + /** + * Calculates accounts total deeply of the given accounts graph. + * @param {IBalanceSheetSection[]} sections - + * @return {IBalanceSheetAccountTotal} + */ + private getSectionTotal(sections: IBalanceSheetSection[]): IBalanceSheetAccountTotal { + const amount = sumBy(sections, 'total.amount'); + const formattedAmount = this.formatNumber(amount); + const currencyCode = this.baseCurrency; + + return { amount, formattedAmount, currencyCode }; + }; + + /** + * Retrieve accounts total periods. + * @param {IBalanceSheetAccount[]} sections - + * @return {IBalanceSheetAccountTotal[]} + */ + private getSectionTotalPeriods(sections: IBalanceSheetAccount[]): IBalanceSheetAccountTotal[] { + return this.dateRangeSet.map((date, index) => { + const amount = sumBy(sections, `totalPeriods[${index}].amount`); + const formattedAmount = this.formatNumber(amount); + const currencyCode = this.baseCurrency; + + return { date, amount, formattedAmount, currencyCode }; + }); + } + + /** + * Gets the date range set from start to end date. + * @param {IAccount} account + * @return {IBalanceSheetAccountTotal[]} + */ + private getAccountTotalPeriods (account: IAccount): IBalanceSheetAccountTotal[] { + return this.dateRangeSet.map((date) => { + const amount = this.journalFinancial.getAccountBalance( + account.id, + date, + this.comparatorDateType + ); + const formattedAmount = this.formatNumber(amount); + const currencyCode = this.baseCurrency; + + return { amount, formattedAmount, currencyCode, date }; + }); + } + + /** + * Retrieve account total and total periods with account meta. + * @param {IAccount} account - + * @param {IBalanceSheetQuery} query - + * @return {IBalanceSheetAccount} + */ + private balanceSheetAccountMapper(account: IAccount): IBalanceSheetAccount { + // Calculates the closing balance of the given account in the specific date point. + const amount = this.journalFinancial.getAccountBalance( + account.id, this.query.toDate, + ); + const formattedAmount = this.formatNumber(amount); + + // Retrieve all entries that associated to the given account. + const entries = this.journalFinancial.getAccountEntries(account.id) + + return { + ...pick(account, ['id', 'index', 'name', 'code', 'parentAccountId']), + type: 'account', + hasTransactions: entries.length > 0, + // Total date periods. + ...this.query.displayColumnsType === 'date_periods' && ({ + totalPeriods: this.getAccountTotalPeriods(account), + }), + total: { + amount, + formattedAmount, + currencyCode: this.baseCurrency, + }, + }; + }; + + /** + * Strcuture accounts related mapper. + * @param {string[]} sectionAccountsTypes - + * @param {IAccount[]} accounts - + * @param {IBalanceSheetQuery} query - + */ + private structureRelatedAccountsMapper( + sectionAccountsTypes: string[], + accounts: IAccount & { type: IAccountType }[], + ): { + children: IBalanceSheetAccount[], + total: IBalanceSheetAccountTotal, + } { + const filteredAccounts = accounts + // Filter accounts that associated to the section accounts types. + .filter( + (account) => sectionAccountsTypes.indexOf(account.type.childType) !== -1 + ) + .map((account) => this.balanceSheetAccountMapper(account)) + // Filter accounts that have no transaction when `noneTransactions` is on. + .filter( + (section: IBalanceSheetAccount) => + !(!section.hasTransactions && this.query.noneTransactions), + ) + // Filter accounts that have zero total amount when `noneZero` is on. + .filter( + (section: IBalanceSheetAccount) => + !(section.total.amount === 0 && this.query.noneZero) + ); + + // Gets total amount of the given accounts. + const totalAmount = sumBy(filteredAccounts, 'total.amount'); + + return { + children: flatToNestedArray( + filteredAccounts, + { id: 'id', parentId: 'parentAccountId' } + ), + total: { + amount: totalAmount, + formattedAmount: this.formatNumber(totalAmount), + currencyCode: this.baseCurrency, + }, + ...(this.query.displayColumnsType === 'date_periods' + ? { + totalPeriods: this.getSectionTotalPeriods(filteredAccounts), + } + : {}), + }; + }; + + /** + * Balance sheet structure mapper. + * @param {IBalanceSheetStructureSection} structure - + * @return {IBalanceSheetSection} + */ + private balanceSheetStructureMapper( + structure: IBalanceSheetStructureSection, + accounts: IAccount & { type: IAccountType }[], + ): IBalanceSheetSection { + const result = { + name: structure.name, + sectionType: structure.sectionType, + type: structure.type, + ...(structure.type === 'accounts_section' + ? { + ...this.structureRelatedAccountsMapper( + structure._accountsTypesRelated, + accounts, + ), + } + : (() => { + const children = this.balanceSheetStructureWalker( + structure.children, + accounts, + ); + return { + children, + total: this.getSectionTotal(children), + }; + })()), + }; + return result; + } + + /** + * Balance sheet structure walker. + * @param {IBalanceSheetStructureSection[]} reportStructure - + * @return {IBalanceSheetSection} + */ + private balanceSheetStructureWalker( + reportStructure: IBalanceSheetStructureSection[], + balanceSheetAccounts: IAccount & { type: IAccountType }[], + ): IBalanceSheetSection[] { + return reportStructure + .map((structure: IBalanceSheetStructureSection) => + this.balanceSheetStructureMapper(structure, balanceSheetAccounts) + ) + // Filter the structure sections that have no children. + .filter((structure: IBalanceSheetSection) => + structure.children.length > 0 || structure._forceShow + ); + } + + /** + * Retrieve date range columns of the given query. + * @param {IBalanceSheetQuery} query + * @return {string[]} + */ + private dateRangeColumns(): string[] { + return this.dateRangeSet; + } + + /** + * Retrieve balance sheet columns in different display columns types. + * @return {string[]} + */ + public reportColumns(): string[] { + // Date range collection. + return this.query.displayColumnsType === 'date_periods' + ? this.dateRangeColumns() + : ['total']; + } + + /** + * Retrieve balance sheet statement data. + * @return {IBalanceSheetSection[]} + */ + public reportData(): IBalanceSheetSection[] { + return this.balanceSheetStructureWalker( + BalanceSheetStructure, + this.accounts, + ) + } +} \ No newline at end of file diff --git a/server/src/services/FinancialStatements/BalanceSheet/BalanceSheetService.ts b/server/src/services/FinancialStatements/BalanceSheet/BalanceSheetService.ts new file mode 100644 index 000000000..20e16d7ce --- /dev/null +++ b/server/src/services/FinancialStatements/BalanceSheet/BalanceSheetService.ts @@ -0,0 +1,103 @@ +import { Service, Inject } from 'typedi'; +import moment from 'moment'; +import { + IBalanceSheetStatementService, + IBalanceSheetQuery, + IBalanceSheetStatement, +} from 'interfaces'; +import TenancyService from 'services/Tenancy/TenancyService'; +import Journal from 'services/Accounting/JournalPoster'; +import BalanceSheetStatement from './BalanceSheet'; + +@Service() +export default class BalanceSheetStatementService + implements IBalanceSheetStatementService { + @Inject() + tenancy: TenancyService; + + @Inject('logger') + logger: any; + + /** + * Defaults balance sheet filter query. + * @return {IBalanceSheetQuery} + */ + get defaultQuery(): IBalanceSheetQuery { + return { + displayColumnsType: 'total', + displayColumnsBy: 'day', + fromDate: moment().startOf('year').format('YYYY-MM-DD'), + toDate: moment().endOf('year').format('YYYY-MM-DD'), + numberFormat: { + noCents: false, + divideOn1000: false, + }, + noneZero: false, + noneTransactions: false, + basis: 'cash', + accountIds: [], + }; + } + + /** + * Retrieve balance sheet statement. + * ------------- + * @param {number} tenantId + * @param {IBalanceSheetQuery} query + * + * @return {IBalanceSheetStatement} + */ + public async balanceSheet( + tenantId: number, + query: IBalanceSheetQuery + ): Promise { + const { + accountRepository, + transactionsRepository, + } = this.tenancy.repositories(tenantId); + + // Settings tenant service. + const settings = this.tenancy.settings(tenantId); + const baseCurrency = settings.get({ group: 'organization', key: 'base_currency' }); + + const filter = { + ...this.defaultQuery, + ...query, + }; + this.logger.info('[balance_sheet] trying to calculate the report.', { filter, tenantId }); + + // Retrieve all accounts on the storage. + const accounts = await accountRepository.allAccounts('type'); + const accountsGraph = await accountRepository.getDependencyGraph(); + + // Retrieve all journal transactions based on the given query. + const transactions = await transactionsRepository.journal({ + fromDate: query.toDate, + }); + // Transform transactions to journal collection. + const transactionsJournal = Journal.fromTransactions( + transactions, + tenantId, + accountsGraph, + ); + // Balance sheet report instance. + const balanceSheetInstanace = new BalanceSheetStatement( + tenantId, + filter, + accounts, + transactionsJournal, + baseCurrency + ); + // Balance sheet data. + const balanceSheetData = balanceSheetInstanace.reportData(); + + // Retrieve balance sheet columns. + const balanceSheetColumns = balanceSheetInstanace.reportColumns(); + + return { + data: balanceSheetData, + columns: balanceSheetColumns, + query: filter, + }; + } +} diff --git a/server/src/services/FinancialStatements/FinancialSheet.ts b/server/src/services/FinancialStatements/FinancialSheet.ts new file mode 100644 index 000000000..bab23900b --- /dev/null +++ b/server/src/services/FinancialStatements/FinancialSheet.ts @@ -0,0 +1,16 @@ +import { + formatNumber +} from 'utils'; + +export default class FinancialSheet { + numberFormat: { noCents: boolean, divideOn1000: boolean }; + + /** + * Formating amount based on the given report query. + * @param {number} number + * @return {string} + */ + protected formatNumber(number): string { + return formatNumber(number, this.numberFormat); + } +} \ No newline at end of file diff --git a/server/src/services/FinancialStatements/GeneralLedger/GeneralLedger.ts b/server/src/services/FinancialStatements/GeneralLedger/GeneralLedger.ts new file mode 100644 index 000000000..751a87e20 --- /dev/null +++ b/server/src/services/FinancialStatements/GeneralLedger/GeneralLedger.ts @@ -0,0 +1,150 @@ +import { pick } from 'lodash'; +import { + IGeneralLedgerSheetQuery, + IGeneralLedgerSheetAccount, + IGeneralLedgerSheetAccountBalance, + IGeneralLedgerSheetAccountTransaction, + IAccount, + IJournalPoster, + IAccountType, + IJournalEntry +} from 'interfaces'; +import FinancialSheet from "../FinancialSheet"; + +export default class GeneralLedgerSheet extends FinancialSheet { + tenantId: number; + accounts: IAccount[]; + query: IGeneralLedgerSheetQuery; + openingBalancesJournal: IJournalPoster; + closingBalancesJournal: IJournalPoster; + transactions: IJournalPoster; + baseCurrency: string; + + /** + * Constructor method. + * @param {number} tenantId - + * @param {IAccount[]} accounts - + * @param {IJournalPoster} transactions - + * @param {IJournalPoster} openingBalancesJournal - + * @param {IJournalPoster} closingBalancesJournal - + */ + constructor( + tenantId: number, + query: IGeneralLedgerSheetQuery, + accounts: IAccount[], + transactions: IJournalPoster, + openingBalancesJournal: IJournalPoster, + closingBalancesJournal: IJournalPoster, + baseCurrency: string, + ) { + super(); + + this.tenantId = tenantId; + this.query = query; + this.numberFormat = this.query.numberFormat; + this.accounts = accounts; + this.transactions = transactions; + this.openingBalancesJournal = openingBalancesJournal; + this.closingBalancesJournal = closingBalancesJournal; + this.baseCurrency = baseCurrency; + } + + /** + * Mapping the account transactions to general ledger transactions of the given account. + * @param {IAccount} account + * @return {IGeneralLedgerSheetAccountTransaction[]} + */ + private accountTransactionsMapper( + account: IAccount & { type: IAccountType } + ): IGeneralLedgerSheetAccountTransaction[] { + const entries = this.transactions.getAccountEntries(account.id); + + return entries.map((transaction: IJournalEntry): IGeneralLedgerSheetAccountTransaction => { + let amount = 0; + + if (account.type.normal === 'credit') { + amount += transaction.credit - transaction.debit; + } else if (account.type.normal === 'debit') { + amount += transaction.debit - transaction.credit; + } + const formattedAmount = this.formatNumber(amount); + + return { + ...pick(transaction, ['id', 'note', 'transactionType', 'referenceType', + 'referenceId', 'date']), + amount, + formattedAmount, + currencyCode: this.baseCurrency, + }; + }); + } + + /** + * Retrieve account opening balance. + * @param {IAccount} account + * @return {IGeneralLedgerSheetAccountBalance} + */ + private accountOpeningBalance(account: IAccount): IGeneralLedgerSheetAccountBalance { + const amount = this.openingBalancesJournal.getAccountBalance(account.id); + const formattedAmount = this.formatNumber(amount); + const currencyCode = this.baseCurrency; + const date = this.query.fromDate; + + return { amount, formattedAmount, currencyCode, date }; + } + + /** + * Retrieve account closing balance. + * @param {IAccount} account + * @return {IGeneralLedgerSheetAccountBalance} + */ + private accountClosingBalance(account: IAccount): IGeneralLedgerSheetAccountBalance { + const amount = this.closingBalancesJournal.getAccountBalance(account.id); + const formattedAmount = this.formatNumber(amount); + const currencyCode = this.baseCurrency; + const date = this.query.toDate; + + return { amount, formattedAmount, currencyCode, date }; + } + + /** + * Retreive general ledger accounts sections. + * @param {IAccount} account + * @return {IGeneralLedgerSheetAccount} + */ + private accountMapper( + account: IAccount & { type: IAccountType }, + ): IGeneralLedgerSheetAccount { + return { + ...pick(account, ['id', 'name', 'code', 'index', 'parentAccountId']), + opening: this.accountOpeningBalance(account), + transactions: this.accountTransactionsMapper(account), + closing: this.accountClosingBalance(account), + } + } + + /** + * Retrieve mapped accounts with general ledger transactions and opeing/closing balance. + * @param {IAccount[]} accounts - + * @return {IGeneralLedgerSheetAccount[]} + */ + private accountsWalker( + accounts: IAccount & { type: IAccountType }[] + ): IGeneralLedgerSheetAccount[] { + return accounts + .map((account: IAccount & { type: IAccountType }) => this.accountMapper(account)) + + // Filter general ledger accounts that have no transactions when `noneTransactions` is on. + .filter((generalLedgerAccount: IGeneralLedgerSheetAccount) => ( + !(generalLedgerAccount.transactions.length === 0 && this.query.noneTransactions) + )); + } + + /** + * Retrieve general ledger report data. + * @return {IGeneralLedgerSheetAccount[]} + */ + public reportData(): IGeneralLedgerSheetAccount[] { + return this.accountsWalker(this.accounts); + } +} \ No newline at end of file diff --git a/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerService.ts b/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerService.ts new file mode 100644 index 000000000..5544ae7df --- /dev/null +++ b/server/src/services/FinancialStatements/GeneralLedger/GeneralLedgerService.ts @@ -0,0 +1,125 @@ +import { Service, Inject } from "typedi"; +import moment from 'moment'; +import { ServiceError } from "exceptions"; +import { difference } from 'lodash'; +import { IGeneralLedgerSheetQuery } from 'interfaces'; +import TenancyService from 'services/Tenancy/TenancyService'; +import Journal from "services/Accounting/JournalPoster"; +import GeneralLedgerSheet from 'services/FinancialStatements/GeneralLedger/GeneralLedger'; + +const ERRORS = { + ACCOUNTS_NOT_FOUND: 'ACCOUNTS_NOT_FOUND', +}; + +@Service() +export default class GeneralLedgerService { + @Inject() + tenancy: TenancyService; + + @Inject('logger') + logger: any; + + /** + * Defaults general ledger report filter query. + * @return {IBalanceSheetQuery} + */ + get defaultQuery() { + return { + fromDate: moment().startOf('year').format('YYYY-MM-DD'), + toDate: moment().endOf('year').format('YYYY-MM-DD'), + basis: 'cash', + numberFormat: { + noCents: false, + divideOn1000: false, + }, + noneZero: false, + accountsIds: [], + }; + } + + /** + * Validates accounts existance on the storage. + * @param {number} tenantId + * @param {number[]} accountsIds + */ + async validateAccountsExistance(tenantId: number, accountsIds: number[]) { + const { Account } = this.tenancy.models(tenantId); + + const storedAccounts = await Account.query().whereIn('id', accountsIds); + const storedAccountsIds = storedAccounts.map((a) => a.id); + + if (difference(accountsIds, storedAccountsIds).length > 0) { + throw new ServiceError(ERRORS.ACCOUNTS_NOT_FOUND) + } + } + + /** + * Retrieve general ledger report statement. + * ---------- + * @param {number} tenantId + * @param {IGeneralLedgerSheetQuery} query + * @return {IGeneralLedgerStatement} + */ + async generalLedger(tenantId: number, query: IGeneralLedgerSheetQuery): + Promise<{ + data: any, + query: IGeneralLedgerSheetQuery, + }> { + const { + accountRepository, + transactionsRepository, + } = this.tenancy.repositories(tenantId); + + const filter = { + ...this.defaultQuery, + ...query, + }; + this.logger.info('[general_ledger] trying to calculate the report.', { tenantId, filter }) + + const settings = this.tenancy.settings(tenantId); + const baseCurrency = settings.get({ group: 'organization', key: 'base_currency' }); + + // Retrieve all accounts from the storage. + const accounts = await accountRepository.allAccounts('type'); + const accountsGraph = await accountRepository.getDependencyGraph(); + + // Retreive journal transactions from/to the given date. + const transactions = await transactionsRepository.journal({ + fromDate: filter.fromDate, + toDate: filter.toDate, + }); + // Retreive opening balance credit/debit sumation. + const openingBalanceTrans = await transactionsRepository.journal({ + toDate: filter.fromDate, + sumationCreditDebit: true, + }); + // Retreive closing balance credit/debit sumation. + const closingBalanceTrans = await transactionsRepository.journal({ + toDate: filter.toDate, + sumationCreditDebit: true, + }); + + // Transform array transactions to journal collection. + const transactionsJournal = Journal.fromTransactions(transactions, tenantId, accountsGraph); + const openingTransJournal = Journal.fromTransactions(openingBalanceTrans, tenantId, accountsGraph); + const closingTransJournal = Journal.fromTransactions(closingBalanceTrans, tenantId, accountsGraph); + + // General ledger report instance. + const generalLedgerInstance = new GeneralLedgerSheet( + tenantId, + filter, + accounts, + transactionsJournal, + openingTransJournal, + closingTransJournal, + baseCurrency + ); + // Retrieve general ledger report data. + const reportData = generalLedgerInstance.reportData(); + + return { + data: reportData, + query: filter, + } + } +} \ No newline at end of file diff --git a/server/src/services/FinancialStatements/JournalSheet/JournalSheet.ts b/server/src/services/FinancialStatements/JournalSheet/JournalSheet.ts new file mode 100644 index 000000000..a8be532b9 --- /dev/null +++ b/server/src/services/FinancialStatements/JournalSheet/JournalSheet.ts @@ -0,0 +1,88 @@ +import { sumBy, chain } from 'lodash'; +import { + IJournalEntry, + IJournalPoster, + IJournalReportEntriesGroup, + IJournalReportQuery, + IJournalReport +} from "interfaces"; +import FinancialSheet from "../FinancialSheet"; + +export default class JournalSheet extends FinancialSheet { + tenantId: number; + journal: IJournalPoster; + query: IJournalReportQuery; + baseCurrency: string; + + /** + * Constructor method. + * @param {number} tenantId + * @param {IJournalPoster} journal + */ + constructor( + tenantId: number, + query: IJournalReportQuery, + journal: IJournalPoster, + baseCurrency: string, + ) { + super(); + + this.tenantId = tenantId; + this.journal = journal; + this.query = query; + this.numberFormat = this.query.numberFormat; + this.baseCurrency = baseCurrency; + } + + /** + * Mapping journal entries groups. + * @param {IJournalEntry[]} entriesGroup - + * @param {string} key - + * @return {IJournalReportEntriesGroup} + */ + entriesGroupMapper( + entriesGroup: IJournalEntry[], + key: string, + ): IJournalReportEntriesGroup { + const totalCredit = sumBy(entriesGroup, 'credit'); + const totalDebit = sumBy(entriesGroup, 'debit'); + + return { + id: key, + entries: entriesGroup, + + currencyCode: this.baseCurrency, + + credit: totalCredit, + debit: totalDebit, + + formattedCredit: this.formatNumber(totalCredit), + formattedDebit: this.formatNumber(totalDebit), + }; + } + + /** + * Mapping the journal entries to entries groups. + * @param {IJournalEntry[]} entries + * @return {IJournalReportEntriesGroup[]} + */ + entriesWalker(entries: IJournalEntry[]): IJournalReportEntriesGroup[] { + return chain(entries) + .groupBy((entry) => `${entry.referenceId}-${entry.referenceType}`) + .map(( + entriesGroup: IJournalEntry[], + key: string + ) => this.entriesGroupMapper(entriesGroup, key)) + .value(); + } + + /** + * Retrieve journal report. + * @return {IJournalReport} + */ + reportData(): IJournalReport { + return { + entries: this.entriesWalker(this.journal.entries), + }; + } +} \ No newline at end of file diff --git a/server/src/services/FinancialStatements/JournalSheet/JournalSheetService.ts b/server/src/services/FinancialStatements/JournalSheet/JournalSheetService.ts new file mode 100644 index 000000000..fa72c03a6 --- /dev/null +++ b/server/src/services/FinancialStatements/JournalSheet/JournalSheetService.ts @@ -0,0 +1,85 @@ +import { Service, Inject } from "typedi"; +import { IJournalReportQuery } from 'interfaces'; +import moment from 'moment'; +import JournalSheet from "./JournalSheet"; +import TenancyService from "services/Tenancy/TenancyService"; +import Journal from "services/Accounting/JournalPoster"; + +@Service() +export default class JournalSheetService { + @Inject() + tenancy: TenancyService; + + @Inject('logger') + logger: any; + + /** + * Default journal sheet filter queyr. + */ + get defaultQuery() { + return { + fromDate: moment().startOf('year').format('YYYY-MM-DD'), + toDate: moment().endOf('year').format('YYYY-MM-DD'), + fromRange: null, + toRange: null, + accountsIds: [], + transactionTypes: [], + numberFormat: { + noCents: false, + divideOn1000: false, + }, + }; + } + + /** + * Journal sheet. + * @param {number} tenantId + * @param {IJournalSheetFilterQuery} query + */ + async journalSheet( + tenantId: number, + query: IJournalReportQuery, + ) { + const { + accountRepository, + transactionsRepository, + } = this.tenancy.repositories(tenantId); + + const filter = { + ...this.defaultQuery, + ...query, + }; + this.logger.info('[journal] trying to calculate the report.', { tenantId, filter }); + + // Settings service. + const settings = this.tenancy.settings(tenantId); + const baseCurrency = settings.get({ group: 'organization', key: 'base_currency' }); + + // Retrieve all accounts on the storage. + const accountsGraph = await accountRepository.getDependencyGraph(); + + // Retrieve all journal transactions based on the given query. + const transactions = await transactionsRepository.journal({ + fromDate: filter.fromDate, + toDate: filter.toDate, + transactionsTypes: filter.transactionTypes, + fromAmount: filter.fromRange, + toAmount: filter.toRange, + }); + + // Transform the transactions array to journal collection. + const transactionsJournal = Journal.fromTransactions(transactions, tenantId, accountsGraph); + + // Journal report instance. + const journalSheetInstance = new JournalSheet( + tenantId, + filter, + transactionsJournal, + baseCurrency + ); + // Retrieve journal report columns. + const journalSheetData = journalSheetInstance.reportData(); + + return journalSheetData; + } +} \ No newline at end of file diff --git a/server/src/services/FinancialStatements/ProfitLossSheet/ProfitLossSheet.ts b/server/src/services/FinancialStatements/ProfitLossSheet/ProfitLossSheet.ts new file mode 100644 index 000000000..d70c8f3cc --- /dev/null +++ b/server/src/services/FinancialStatements/ProfitLossSheet/ProfitLossSheet.ts @@ -0,0 +1,369 @@ +import { flatten, pick, sumBy } from 'lodash'; +import { IProfitLossSheetQuery } from "interfaces/ProfitLossSheet"; +import FinancialSheet from "../FinancialSheet"; +import { + IAccount, + IAccountType, + IJournalPoster, + IProfitLossSheetAccount, + IProfitLossSheetTotal, + IProfitLossSheetStatement, + IProfitLossSheetAccountsSection, + IProfitLossSheetTotalSection, +} from 'interfaces'; +import { flatToNestedArray, dateRangeCollection } from 'utils'; + +export default class ProfitLossSheet extends FinancialSheet { + tenantId: number; + query: IProfitLossSheetQuery; + accounts: IAccount & { type: IAccountType }[]; + journal: IJournalPoster; + dateRangeSet: string[]; + comparatorDateType: string; + baseCurrency: string; + + /** + * Constructor method. + * @param {number} tenantId - + * @param {IProfitLossSheetQuery} query - + * @param {IAccount[]} accounts - + * @param {IJournalPoster} transactionsJournal - + */ + constructor( + tenantId: number, + query: IProfitLossSheetQuery, + accounts: IAccount & { type: IAccountType }[], + journal: IJournalPoster, + baseCurrency: string, + ) { + super(); + + this.tenantId = tenantId; + this.query = query; + this.numberFormat = this.query.numberFormat; + this.accounts = accounts; + this.journal = journal; + this.baseCurrency = baseCurrency; + this.comparatorDateType = query.displayColumnsType === 'total' + ? 'day' + : query.displayColumnsBy; + + this.initDateRangeCollection(); + } + + /** + * Filtering income accounts. + * @return {IAccount & { type: IAccountType }[]} + */ + get incomeAccounts() { + return this.accounts.filter(a => a.type.key === 'income'); + } + + /** + * Filtering expenses accounts. + * @return {IAccount & { type: IAccountType }[]} + */ + get expensesAccounts() { + return this.accounts.filter(a => a.type.key === 'expense'); + } + + /** + * Filter other expenses accounts. + * @return {IAccount & { type: IAccountType }[]}} + */ + get otherExpensesAccounts() { + return this.accounts.filter(a => a.type.key === 'other_expense'); + } + + /** + * Filtering cost of sales accounts. + * @return {IAccount & { type: IAccountType }[]} + */ + get costOfSalesAccounts() { + return this.accounts.filter(a => a.type.key === 'cost_of_goods_sold'); + } + + /** + * Initialize date range set. + */ + initDateRangeCollection() { + if (this.query.displayColumnsType === 'date_periods') { + this.dateRangeSet = dateRangeCollection( + this.query.fromDate, + this.query.toDate, + this.comparatorDateType + ); + } + } + + /** + * Retrieve account total in the query date. + * @param {IAccount} account - + * @return {IProfitLossSheetTotal} + */ + private getAccountTotal(account: IAccount): IProfitLossSheetTotal { + const amount = this.journal.getAccountBalance( + account.id, + this.query.toDate, + this.comparatorDateType, + ); + const formattedAmount = this.formatNumber(amount); + const currencyCode = this.baseCurrency; + + return { amount, formattedAmount, currencyCode }; + } + + /** + * Retrieve account total periods. + * @param {IAccount} account - + * @return {IProfitLossSheetTotal[]} + */ + private getAccountTotalPeriods(account: IAccount): IProfitLossSheetTotal[] { + return this.dateRangeSet.map((date) => { + const amount = this.journal.getAccountBalance( + account.id, + date, + this.comparatorDateType, + ); + const formattedAmount = this.formatNumber(amount); + const currencyCode = this.baseCurrency; + + return { date, amount, formattedAmount, currencyCode }; + }) + } + + /** + * Mapping the given account to total result with account metadata. + * @param {IAccount} account - + * @return {IProfitLossSheetAccount} + */ + private accountMapper(account: IAccount): IProfitLossSheetAccount { + const entries = this.journal.getAccountEntries(account.id); + + return { + ...pick(account, ['id', 'index', 'name', 'code', 'parentAccountId']), + hasTransactions: entries.length > 0, + total: this.getAccountTotal(account), + + // Date periods when display columns type `periods`. + ...(this.query.displayColumnsType === 'date_periods' && { + totalPeriods: this.getAccountTotalPeriods(account), + }), + }; + } + + /** + * + * @param {IAccount[]} accounts - + * @return {IProfitLossSheetAccount[]} + */ + private accountsWalker(accounts: IAccount & { type: IAccountType }[]): IProfitLossSheetAccount[] { + const flattenAccounts = accounts + .map(this.accountMapper.bind(this)) + // Filter accounts that have no transaction when `noneTransactions` is on. + .filter((account: IProfitLossSheetAccount) => + !(!account.hasTransactions && this.query.noneTransactions), + ) + // Filter accounts that have zero total amount when `noneZero` is on. + .filter((account: IProfitLossSheetAccount) => + !(account.total.amount === 0 && this.query.noneZero) + ); + + return flatToNestedArray( + flattenAccounts, + { id: 'id', parentId: 'parentAccountId' }, + ); + } + + /** + * Retreive the report total section. + * @param {IAccount[]} accounts - + * @return {IProfitLossSheetTotal} + */ + private gatTotalSection(accounts: IProfitLossSheetAccount[]): IProfitLossSheetTotal { + const amount = sumBy(accounts, 'total.amount'); + const formattedAmount = this.formatNumber(amount); + const currencyCode = this.baseCurrency; + + return { amount, formattedAmount, currencyCode }; + } + + /** + * Retrieve the report total section in periods display type. + * @param {IAccount} accounts - + * @return {IProfitLossSheetTotal[]} + */ + private getTotalPeriodsSection(accounts: IProfitLossSheetAccount[]): IProfitLossSheetTotal[] { + return this.dateRangeSet.map((date, index) => { + const amount = sumBy(accounts, `totalPeriods[${index}].amount`); + const formattedAmount = this.formatNumber(amount); + const currencyCode = this.baseCurrency; + + return { amount, formattedAmount, currencyCode }; + }); + } + + sectionMapper(sectionAccounts) { + const accounts = this.accountsWalker(sectionAccounts); + const total = this.gatTotalSection(accounts); + + return { + accounts, + total, + ...(this.query.displayColumnsType === 'date_periods' && { + totalPeriods: this.getTotalPeriodsSection(accounts), + }), + } + } + + /** + * Retrieve income section. + * @return {IProfitLossSheetIncomeSection} + */ + private get incomeSection(): IProfitLossSheetAccountsSection { + return { + sectionTitle: 'Income accounts', + entryNormal: 'credit', + ...this.sectionMapper(this.incomeAccounts), + }; + } + + /** + * Retreive expenses section. + * @return {IProfitLossSheetLossSection} + */ + private get expensesSection(): IProfitLossSheetAccountsSection { + return { + sectionTitle: 'Expense accounts', + entryNormal: 'debit', + ...this.sectionMapper(this.expensesAccounts), + }; + } + + /** + * Retrieve other expenses section. + * @return {IProfitLossSheetAccountsSection} + */ + private get otherExpensesSection(): IProfitLossSheetAccountsSection { + return { + sectionTitle: 'Other expenses accounts', + entryNormal: 'debit', + ...this.sectionMapper(this.otherExpensesAccounts), + }; + } + + /** + * Cost of sales section. + * @return {IProfitLossSheetAccountsSection} + */ + private get costOfSalesSection(): IProfitLossSheetAccountsSection { + return { + sectionTitle: 'Cost of sales', + entryNormal: 'debit', + ...this.sectionMapper(this.costOfSalesAccounts), + }; + } + + private getSummarySectionDatePeriods( + positiveSections: IProfitLossSheetTotalSection[], + minesSections: IProfitLossSheetTotalSection[], + ) { + return this.dateRangeSet.map((date, index: number) => { + const totalPositive = sumBy(positiveSections, `totalPeriods[${index}].amount`); + const totalMines = sumBy(minesSections, `totalPeriods[${index}].amount`); + + const amount = totalPositive - totalMines; + const formattedAmount = this.formatNumber(amount); + const currencyCode = this.baseCurrency; + + return { date, amount, formattedAmount, currencyCode }; + }); + }; + + private getSummarySectionTotal( + positiveSections: IProfitLossSheetTotalSection[], + minesSections: IProfitLossSheetTotalSection[], + ) { + const totalPositiveSections = sumBy(positiveSections, 'total.amount'); + const totalMinesSections = sumBy(minesSections, 'total.amount'); + + const amount = totalPositiveSections - totalMinesSections; + const formattedAmount = this.formatNumber(amount); + const currencyCode = this.baseCurrency; + + return { amount, formattedAmount, currencyCode }; + } + + /** + * Retrieve the summary section + * @param + */ + private getSummarySection( + sections: IProfitLossSheetTotalSection|IProfitLossSheetTotalSection[], + subtractSections: IProfitLossSheetTotalSection|IProfitLossSheetTotalSection[] + ): IProfitLossSheetTotalSection { + const positiveSections = Array.isArray(sections) ? sections : [sections]; + const minesSections = Array.isArray(subtractSections) ? subtractSections : [subtractSections]; + + return { + total: this.getSummarySectionTotal(positiveSections, minesSections), + ...(this.query.displayColumnsType === 'date_periods' && { + totalPeriods: [ + ...this.getSummarySectionDatePeriods( + positiveSections, + minesSections, + ), + ], + }), + } + } + + /** + * Retrieve date range columns of the given query. + * @param {IBalanceSheetQuery} query + * @return {string[]} + */ + private dateRangeColumns(): string[] { + return this.dateRangeSet; + } + + /** + * Retrieve profit/loss report data. + * @return {IProfitLossSheetStatement} + */ + public reportData(): IProfitLossSheetStatement { + const income = this.incomeSection; + const costOfSales = this.costOfSalesSection; + const expenses = this.expensesSection; + const otherExpenses = this.otherExpensesSection; + + // - Gross profit = Total income - COGS. + const grossProfit = this.getSummarySection(income, costOfSales); + + // - Operating profit = Gross profit - Expenses. + const operatingProfit = this.getSummarySection(grossProfit, [expenses, costOfSales]); + + // - Net income = Operating profit - Other expenses. + const netIncome = this.getSummarySection(operatingProfit, otherExpenses); + + return { + income, + costOfSales, + grossProfit, + expenses, + otherExpenses, + netIncome, + operatingProfit, + }; + } + + /** + * Retrieve profit/loss report columns. + */ + public reportColumns() { + // Date range collection. + return this.query.displayColumnsType === 'date_periods' + ? this.dateRangeColumns() + : ['total']; + } +} \ No newline at end of file diff --git a/server/src/services/FinancialStatements/ProfitLossSheet/ProfitLossSheetService.ts b/server/src/services/FinancialStatements/ProfitLossSheet/ProfitLossSheetService.ts new file mode 100644 index 000000000..a9c88b0c0 --- /dev/null +++ b/server/src/services/FinancialStatements/ProfitLossSheet/ProfitLossSheetService.ts @@ -0,0 +1,98 @@ +import { Service, Inject } from 'typedi'; +import moment from 'moment'; +import Journal from 'services/Accounting/JournalPoster'; +import { IProfitLossSheetQuery } from 'interfaces'; +import ProfitLossSheet from './ProfitLossSheet'; +import TenancyService from 'services/Tenancy/TenancyService'; +import AccountsService from 'services/Accounts/AccountsService'; + +// Profit/Loss sheet service. +@Service() +export default class ProfitLossSheetService { + @Inject() + tenancy: TenancyService; + + @Inject('logger') + logger: any; + + @Inject() + accountsService: AccountsService; + + /** + * Default sheet filter query. + * @return {IBalanceSheetQuery} + */ + get defaultQuery(): IProfitLossSheetQuery { + return { + fromDate: moment().startOf('year').format('YYYY-MM-DD'), + toDate: moment().endOf('year').format('YYYY-MM-DD'), + numberFormat: { + noCents: false, + divideOn1000: false, + }, + basis: 'accural', + noneZero: false, + noneTransactions: false, + displayColumnsType: 'total', + displayColumnsBy: 'month', + accountsIds: [], + }; + } + + /** + * Retrieve profit/loss sheet statement. + * @param {number} tenantId + * @param {IProfitLossSheetQuery} query + * @return { } + */ + async profitLossSheet(tenantId: number, query: IProfitLossSheetQuery) { + const { + accountRepository, + transactionsRepository, + } = this.tenancy.repositories(tenantId); + + const filter = { + ...this.defaultQuery, + ...query, + }; + this.logger.info('[profit_loss_sheet] trying to calculate the report.', { tenantId, filter }); + + // Get the given accounts or throw not found service error. + if (filter.accountsIds.length > 0) { + await this.accountsService.getAccountsOrThrowError(tenantId, filter.accountsIds); + } + // Settings tenant service. + const settings = this.tenancy.settings(tenantId); + const baseCurrency = settings.get({ group: 'organization', key: 'base_currency' }); + + // Retrieve all accounts on the storage. + const accounts = await accountRepository.allAccounts('type'); + const accountsGraph = await accountRepository.getDependencyGraph(); + + // Retrieve all journal transactions based on the given query. + const transactions = await transactionsRepository.journal({ + fromDate: query.fromDate, + toDate: query.toDate, + }); + // Transform transactions to journal collection. + const transactionsJournal = Journal.fromTransactions(transactions, tenantId, accountsGraph); + + // Profit/Loss report instance. + const profitLossInstance = new ProfitLossSheet( + tenantId, + filter, + accounts, + transactionsJournal, + baseCurrency + ); + // Profit/loss report data and collumns. + const profitLossData = profitLossInstance.reportData(); + const profitLossColumns = profitLossInstance.reportColumns(); + + return { + data: profitLossData, + columns: profitLossColumns, + query: filter, + }; + } +} \ No newline at end of file diff --git a/server/src/services/FinancialStatements/TrialBalanceSheet/TrialBalanceSheet.ts b/server/src/services/FinancialStatements/TrialBalanceSheet/TrialBalanceSheet.ts new file mode 100644 index 000000000..7e7e2da29 --- /dev/null +++ b/server/src/services/FinancialStatements/TrialBalanceSheet/TrialBalanceSheet.ts @@ -0,0 +1,101 @@ + +import { + ITrialBalanceSheetQuery, + ITrialBalanceAccount, + IAccount, + IAccountType, +} from 'interfaces'; +import FinancialSheet from '../FinancialSheet'; +import { flatToNestedArray } from 'utils'; + +export default class TrialBalanceSheet extends FinancialSheet{ + tenantId: number; + query: ITrialBalanceSheetQuery; + accounts: IAccount & { type: IAccountType }[]; + journalFinancial: any; + + /** + * Constructor method. + * @param {number} tenantId + * @param {ITrialBalanceSheetQuery} query + * @param {IAccount[]} accounts + * @param journalFinancial + */ + constructor( + tenantId: number, + query: ITrialBalanceSheetQuery, + accounts: IAccount & { type: IAccountType }[], + journalFinancial: any + ) { + super(); + + this.tenantId = tenantId; + this.query = query; + this.numberFormat = this.query.numberFormat; + this.accounts = accounts; + this.journalFinancial = journalFinancial; + this.numberFormat = this.query.numberFormat; + } + + /** + * Account mapper. + * @param {IAccount} account + */ + private accountMapper(account: IAccount & { type: IAccountType }): ITrialBalanceAccount { + const trial = this.journalFinancial.getTrialBalanceWithDepands(account.id); + + // Retrieve all entries that associated to the given account. + const entries = this.journalFinancial.getAccountEntries(account.id) + + return { + id: account.id, + parentAccountId: account.parentAccountId, + name: account.name, + code: account.code, + accountNormal: account.type.normal, + hasTransactions: entries.length > 0, + + credit: trial.credit, + debit: trial.debit, + balance: trial.balance, + + formattedCredit: this.formatNumber(trial.credit), + formattedDebit: this.formatNumber(trial.debit), + formattedBalance: this.formatNumber(trial.balance), + }; + } + + /** + * Accounts walker. + * @param {IAccount[]} accounts + */ + private accountsWalker( + accounts: IAccount & { type: IAccountType }[] + ): ITrialBalanceAccount[] { + const flattenAccounts = accounts + // Mapping the trial balance accounts sections. + .map((account: IAccount & { type: IAccountType }) => this.accountMapper(account)) + + // Filter accounts that have no transaction when `noneTransactions` is on. + .filter((trialAccount: ITrialBalanceAccount): boolean => + !(!trialAccount.hasTransactions && this.query.noneTransactions), + ) + // Filter accounts that have zero total amount when `noneZero` is on. + .filter( + (trialAccount: ITrialBalanceAccount): boolean => + !(trialAccount.credit === 0 && trialAccount.debit === 0 && this.query.noneZero) + ); + + return flatToNestedArray( + flattenAccounts, + { id: 'id', parentId: 'parentAccountId' }, + ); + } + + /** + * Retrieve trial balance sheet statement data. + */ + public reportData() { + return this.accountsWalker(this.accounts); + } +} \ No newline at end of file diff --git a/server/src/services/FinancialStatements/TrialBalanceSheet/TrialBalanceSheetService.ts b/server/src/services/FinancialStatements/TrialBalanceSheet/TrialBalanceSheetService.ts new file mode 100644 index 000000000..90287a38c --- /dev/null +++ b/server/src/services/FinancialStatements/TrialBalanceSheet/TrialBalanceSheetService.ts @@ -0,0 +1,88 @@ +import { Service, Inject } from "typedi"; +import moment from 'moment'; +import TenancyService from 'services/Tenancy/TenancyService'; +import { ITrialBalanceSheetQuery, ITrialBalanceStatement } from 'interfaces'; +import TrialBalanceSheet from "./TrialBalanceSheet"; +import Journal from 'services/Accounting/JournalPoster'; + +@Service() +export default class TrialBalanceSheetService { + + @Inject() + tenancy: TenancyService; + + @Inject('logger') + logger: any; + + /** + * Defaults trial balance sheet filter query. + * @return {IBalanceSheetQuery} + */ + get defaultQuery(): ITrialBalanceSheetQuery { + return { + fromDate: moment().startOf('year').format('YYYY-MM-DD'), + toDate: moment().endOf('year').format('YYYY-MM-DD'), + numberFormat: { + noCents: false, + divideOn1000: false, + }, + basis: 'accural', + noneZero: false, + noneTransactions: false, + accountIds: [], + }; + } + + /** + * Retrieve trial balance sheet statement. + * ------------- + * @param {number} tenantId + * @param {IBalanceSheetQuery} query + * + * @return {IBalanceSheetStatement} + */ + public async trialBalanceSheet( + tenantId: number, + query: ITrialBalanceSheetQuery, + ): Promise { + + const filter = { + ...this.defaultQuery, + ...query, + }; + const { + accountRepository, + transactionsRepository, + } = this.tenancy.repositories(tenantId); + + this.logger.info('[trial_balance_sheet] trying to calcualte the report.', { tenantId, filter }); + + // Retrieve all accounts on the storage. + const accounts = await accountRepository.allAccounts('type'); + const accountsGraph = await accountRepository.getDependencyGraph(); + + // Retrieve all journal transactions based on the given query. + const transactions = await transactionsRepository.journal({ + fromDate: query.fromDate, + toDate: query.toDate, + sumationCreditDebit: true, + }); + // Transform transactions array to journal collection. + const transactionsJournal = Journal.fromTransactions(transactions, tenantId, accountsGraph); + + // Trial balance report instance. + const trialBalanceInstance = new TrialBalanceSheet( + tenantId, + filter, + accounts, + transactionsJournal, + ); + // Trial balance sheet data. + const trialBalanceSheetData = trialBalanceInstance.reportData(); + + return { + data: trialBalanceSheetData, + query: filter, + } + } +} \ No newline at end of file diff --git a/server/src/services/FinancialStatements/utils.ts b/server/src/services/FinancialStatements/utils.ts new file mode 100644 index 000000000..1114131b9 --- /dev/null +++ b/server/src/services/FinancialStatements/utils.ts @@ -0,0 +1,13 @@ + + +export const formatNumber = (balance, { noCents, divideOn1000 }): string => { + let formattedBalance: number = parseFloat(balance); + + if (noCents) { + formattedBalance = parseInt(formattedBalance, 10); + } + if (divideOn1000) { + formattedBalance /= 1000; + } + return formattedBalance; +}; \ No newline at end of file diff --git a/server/src/services/ManualJournals/ManualJournalsService.ts b/server/src/services/ManualJournals/ManualJournalsService.ts index f7cace98d..f861168e6 100644 --- a/server/src/services/ManualJournals/ManualJournalsService.ts +++ b/server/src/services/ManualJournals/ManualJournalsService.ts @@ -356,7 +356,7 @@ export default class ManualJournalsService implements IManualJournalsService { // Triggers `onManualJournalCreated` event. this.eventDispatcher.dispatch(events.manualJournals.onCreated, { tenantId, - manualJournal, + manualJournal: { ...manualJournal, entries: manualJournalObj.entries }, }); this.logger.info( '[manual_journal] the manual journal inserted successfully.', diff --git a/server/src/services/Tenancy/TenancyService.ts b/server/src/services/Tenancy/TenancyService.ts index 7ca787375..73941ac42 100644 --- a/server/src/services/Tenancy/TenancyService.ts +++ b/server/src/services/Tenancy/TenancyService.ts @@ -81,7 +81,7 @@ export default class HasTenancyService { setI18nLocals(tenantId: number, locals: any) { return this.singletonService(tenantId, 'i18n', () => { return locals; - }) + }); } /** diff --git a/server/src/utils/index.js b/server/src/utils/index.js index 54fc9f72f..11728228a 100644 --- a/server/src/utils/index.js +++ b/server/src/utils/index.js @@ -211,6 +211,18 @@ const convertEmptyStringToNull = (value) => { : value; }; +const formatNumber = (balance, { noCents = false, divideOn1000 = false }) => { + let formattedBalance = parseFloat(balance); + + if (noCents) { + formattedBalance = parseInt(formattedBalance, 10); + } + if (divideOn1000) { + formattedBalance /= 1000; + } + return formattedBalance + ''; +}; + export { hashPassword, origin, @@ -229,4 +241,5 @@ export { getDefinedOptions, entriesAmountDiff, convertEmptyStringToNull, + formatNumber };