refactoring: balance sheet report.

refactoring: trial balance sheet report.
refactoring: general ledger report.
refactoring: journal report.
refactoring: P&L report.
This commit is contained in:
Ahmed Bouhuolia
2020-12-10 13:04:49 +02:00
parent e8f329e29e
commit d49992a6d7
71 changed files with 3203 additions and 1571 deletions

View File

@@ -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;
}
}

View File

@@ -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<void>}
*/
private async initializeAccountsDepGraph(): Promise<void> {
public async initAccountsDepGraph(): Promise<void> {
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<void>}
*/
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);
}
}

View File

@@ -340,7 +340,7 @@ export default class AccountsService {
* @param {number[]} accountsIds
* @return {IAccount[]}
*/
private async getAccountsOrThrowError(
public async getAccountsOrThrowError(
tenantId: number,
accountsIds: number[]
): Promise<IAccount[]> {
@@ -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 -

View File

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

View File

@@ -0,0 +1,12 @@
import FinancialSheet from "../FinancialSheet";
export default class APAgingSummarySheet extends FinancialSheet {
reportData() {
}
}

View File

@@ -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 };
}
}

View File

@@ -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 []
}
}

View File

@@ -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;
}
}

View File

@@ -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,
)
}
}

View File

@@ -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<IBalanceSheetStatement> {
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,
};
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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,
}
}
}

View File

@@ -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),
};
}
}

View File

@@ -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;
}
}

View File

@@ -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'];
}
}

View File

@@ -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,
};
}
}

View File

@@ -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);
}
}

View File

@@ -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<ITrialBalanceStatement> {
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,
}
}
}

View File

@@ -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;
};

View File

@@ -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.',

View File

@@ -81,7 +81,7 @@ export default class HasTenancyService {
setI18nLocals(tenantId: number, locals: any) {
return this.singletonService(tenantId, 'i18n', () => {
return locals;
})
});
}
/**