Files
bigcapital/server/src/services/FinancialStatements/ProfitLossSheet/ProfitLossSheet.ts
2021-03-24 15:05:06 +02:00

412 lines
11 KiB
TypeScript

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';
import { ACCOUNT_TYPE } from 'data/AccountTypes';
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();
}
get otherIncomeAccounts() {
return this.accounts.filter(
(a) => a.accountType === ACCOUNT_TYPE.OTHER_INCOME
);
}
/**
* Filtering income accounts.
* @return {IAccount & { type: IAccountType }[]}
*/
get incomeAccounts() {
return this.accounts.filter((a) => a.accountType === ACCOUNT_TYPE.INCOME);
}
/**
* Filtering expenses accounts.
* @return {IAccount & { type: IAccountType }[]}
*/
get expensesAccounts() {
return this.accounts.filter((a) => a.accountType === ACCOUNT_TYPE.EXPENSE);
}
/**
* Filter other expenses accounts.
* @return {IAccount & { type: IAccountType }[]}}
*/
get otherExpensesAccounts() {
return this.accounts.filter(
(a) => a.accountType === ACCOUNT_TYPE.OTHER_EXPENSE
);
}
/**
* Filtering cost of sales accounts.
* @return {IAccount & { type: IAccountType }[]}
*/
get costOfSalesAccounts() {
return this.accounts.filter(
(a) => a.accountType === ACCOUNT_TYPE.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.formatTotalNumber(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.formatTotalNumber(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 {
name: 'Income accounts',
entryNormal: 'credit',
...this.sectionMapper(this.incomeAccounts),
};
}
private get otherIncomeSection(): any {
return {
name: 'Other Income',
entryNormal: 'credit',
...this.sectionMapper(this.otherIncomeAccounts),
};
}
/**
* Retreive expenses section.
* @return {IProfitLossSheetLossSection}
*/
private get expensesSection(): IProfitLossSheetAccountsSection {
return {
name: 'Expense accounts',
entryNormal: 'debit',
...this.sectionMapper(this.expensesAccounts),
};
}
/**
* Retrieve other expenses section.
* @return {IProfitLossSheetAccountsSection}
*/
private get otherExpensesSection(): IProfitLossSheetAccountsSection {
return {
name: 'Other expenses accounts',
entryNormal: 'debit',
...this.sectionMapper(this.otherExpensesAccounts),
};
}
/**
* Cost of sales section.
* @return {IProfitLossSheetAccountsSection}
*/
private get costOfSalesSection(): IProfitLossSheetAccountsSection {
return {
name: '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.formatTotalNumber(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.formatTotalNumber(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 {
if (this.journal.isEmpty()) {
return null;
}
const income = this.incomeSection;
const costOfSales = this.costOfSalesSection;
const expenses = this.expensesSection;
const otherExpenses = this.otherExpensesSection;
const otherIncome = this.otherIncomeSection;
// - Gross profit = Total income - COGS.
const grossProfit = this.getSummarySection(income, costOfSales);
// - Operating profit = Gross profit - Expenses.
const operatingProfit = this.getSummarySection(grossProfit, expenses);
// Net other income = Other income - other expenses.
const netOtherIncome = this.getSummarySection(otherIncome, otherExpenses);
// - Net income = (Operating profit + other income) - Other expenses.
const netIncome = this.getSummarySection(
[operatingProfit, netOtherIncome],
[],
);
return {
income,
costOfSales,
grossProfit,
expenses,
otherIncome,
otherExpenses,
operatingProfit,
netOtherIncome,
netIncome,
};
}
/**
* Retrieve profit/loss report columns.
*/
public reportColumns() {
// Date range collection.
return this.query.displayColumnsType === 'date_periods'
? this.dateRangeColumns()
: ['total'];
}
}