feat: journal and general ledger report.

This commit is contained in:
a.bouhuolia
2021-01-21 14:32:31 +02:00
parent da69c333d7
commit 1a89730855
43 changed files with 797 additions and 372 deletions

View File

@@ -71,6 +71,13 @@ export default class JournalPoster implements IJournalPoster {
}
}
/**
*
*/
public isEmpty() {
return this.entries.length === 0;
}
/**
* Writes the credit entry for the given account.
* @param {IJournalEntry} entry -

View File

@@ -184,7 +184,7 @@ export default class BalanceSheetStatement extends FinancialSheet {
const filteredAccounts = accounts
// Filter accounts that associated to the section accounts types.
.filter(
(account) => sectionAccountsTypes.indexOf(account.type.childType) !== -1
(account) => sectionAccountsTypes.indexOf(account.type.key) !== -1
)
.map((account) => this.balanceSheetAccountMapper(account))
// Filter accounts that have no transaction when `noneTransactions` is on.
@@ -258,7 +258,7 @@ export default class BalanceSheetStatement extends FinancialSheet {
type: structure.type,
...(structure.type === 'accounts_section'
? this.structureRelatedAccountsMapper(
structure.accountsTypesRelated,
structure.accountsTypes,
accounts
)
: this.structureSectionMapper(structure, accounts)),

View File

@@ -1,4 +1,4 @@
import { pick } from 'lodash';
import { pick, get, last } from 'lodash';
import {
IGeneralLedgerSheetQuery,
IGeneralLedgerSheetAccount,
@@ -8,9 +8,13 @@ import {
IJournalPoster,
IAccountType,
IJournalEntry,
IContact,
} from 'interfaces';
import FinancialSheet from '../FinancialSheet';
/**
* General ledger sheet.
*/
export default class GeneralLedgerSheet extends FinancialSheet {
tenantId: number;
accounts: IAccount[];
@@ -18,6 +22,7 @@ export default class GeneralLedgerSheet extends FinancialSheet {
openingBalancesJournal: IJournalPoster;
closingBalancesJournal: IJournalPoster;
transactions: IJournalPoster;
contactsMap: Map<number, IContact>;
baseCurrency: string;
/**
@@ -32,6 +37,7 @@ export default class GeneralLedgerSheet extends FinancialSheet {
tenantId: number,
query: IGeneralLedgerSheetQuery,
accounts: IAccount[],
contactsByIdMap: Map<number, IContact>,
transactions: IJournalPoster,
openingBalancesJournal: IJournalPoster,
closingBalancesJournal: IJournalPoster,
@@ -43,48 +49,100 @@ export default class GeneralLedgerSheet extends FinancialSheet {
this.query = query;
this.numberFormat = this.query.numberFormat;
this.accounts = accounts;
this.contactsMap = contactsByIdMap;
this.transactions = transactions;
this.openingBalancesJournal = openingBalancesJournal;
this.closingBalancesJournal = closingBalancesJournal;
this.baseCurrency = baseCurrency;
}
/**
* Retrieve the transaction amount.
* @param {number} credit - Credit amount.
* @param {number} debit - Debit amount.
* @param {string} normal - Credit or debit.
*/
getAmount(credit: number, debit: number, normal: string) {
return normal === 'credit' ? credit - debit : debit - credit;
}
/**
* Entry mapper.
* @param {IJournalEntry} entry -
* @return {IGeneralLedgerSheetAccountTransaction}
*/
entryReducer(
entries: IGeneralLedgerSheetAccountTransaction[],
entry: IJournalEntry,
index: number
): IGeneralLedgerSheetAccountTransaction[] {
const lastEntry = last(entries);
const openingBalance = 0;
const contact = this.contactsMap.get(entry.contactId);
const amount = this.getAmount(
entry.credit,
entry.debit,
entry.accountNormal
);
const runningBalance =
(entries.length === 0
? openingBalance
: lastEntry
? lastEntry.runningBalance
: 0) + amount;
const newEntry = {
date: entry.date,
entryId: entry.id,
referenceType: entry.referenceType,
referenceId: entry.referenceId,
referenceTypeFormatted: entry.referenceTypeFormatted,
contactName: get(contact, 'displayName'),
contactType: get(contact, 'contactService'),
transactionType: entry.transactionType,
index: entry.index,
note: entry.note,
credit: entry.credit,
debit: entry.debit,
amount,
runningBalance,
formattedAmount: this.formatNumber(amount),
formattedCredit: this.formatNumber(entry.credit),
formattedDebit: this.formatNumber(entry.debit),
formattedRunningBalance: this.formatNumber(runningBalance),
currencyCode: this.baseCurrency,
};
entries.push(newEntry);
return entries;
}
/**
* Mapping the account transactions to general ledger transactions of the given account.
* @param {IAccount} account
* @return {IGeneralLedgerSheetAccountTransaction[]}
*/
private accountTransactionsMapper(
account: IAccount & { type: IAccountType }
account: IAccount & { type: IAccountType },
openingBalance: number
): 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',
'referenceTypeFormatted',
'date',
]),
amount,
formattedAmount,
currencyCode: this.baseCurrency,
};
}
return entries.reduce(
(
entries: IGeneralLedgerSheetAccountTransaction[],
entry: IJournalEntry
) => {
return this.entryReducer(entries, entry, openingBalance);
},
[]
);
}
@@ -128,11 +186,21 @@ export default class GeneralLedgerSheet extends FinancialSheet {
private accountMapper(
account: IAccount & { type: IAccountType }
): IGeneralLedgerSheetAccount {
const openingBalance = this.accountOpeningBalance(account);
const closingBalance = this.accountClosingBalance(account);
return {
...pick(account, ['id', 'name', 'code', 'index', 'parentAccountId']),
opening: this.accountOpeningBalance(account),
transactions: this.accountTransactionsMapper(account),
closing: this.accountClosingBalance(account),
id: account.id,
name: account.name,
code: account.code,
index: account.index,
parentAccountId: account.parentAccountId,
openingBalance,
transactions: this.accountTransactionsMapper(
account,
openingBalance.amount
),
closingBalance,
};
}
@@ -149,7 +217,8 @@ export default class GeneralLedgerSheet extends FinancialSheet {
.map((account: IAccount & { type: IAccountType }) =>
this.accountMapper(account)
)
// Filter general ledger accounts that have no transactions when `noneTransactions` is on.
// Filter general ledger accounts that have no transactions
// when`noneTransactions` is on.
.filter(
(generalLedgerAccount: IGeneralLedgerSheetAccount) =>
!(

View File

@@ -7,6 +7,8 @@ import TenancyService from 'services/Tenancy/TenancyService';
import Journal from 'services/Accounting/JournalPoster';
import GeneralLedgerSheet from 'services/FinancialStatements/GeneralLedger/GeneralLedger';
import { transformToMap } from 'utils';
const ERRORS = {
ACCOUNTS_NOT_FOUND: 'ACCOUNTS_NOT_FOUND',
};
@@ -70,6 +72,7 @@ export default class GeneralLedgerService {
const {
accountRepository,
transactionsRepository,
contactRepository
} = this.tenancy.repositories(tenantId);
const settings = this.tenancy.settings(tenantId);
@@ -89,6 +92,10 @@ export default class GeneralLedgerService {
const accounts = await accountRepository.all('type');
const accountsGraph = await accountRepository.getDependencyGraph();
// Retrieve all contacts on the storage.
const contacts = await contactRepository.all();
const contactsByIdMap = transformToMap(contacts, 'id');
// Retreive journal transactions from/to the given date.
const transactions = await transactionsRepository.journal({
fromDate: filter.fromDate,
@@ -127,6 +134,7 @@ export default class GeneralLedgerService {
tenantId,
filter,
accounts,
contactsByIdMap,
transactionsJournal,
openingTransJournal,
closingTransJournal,

View File

@@ -1,19 +1,20 @@
import { sumBy, chain, omit } from 'lodash';
import { sumBy, chain, get, head } from 'lodash';
import {
IJournalEntry,
IJournalPoster,
IJournalReportEntriesGroup,
IJournalReportQuery,
IJournalReport,
IContact,
} from 'interfaces';
import FinancialSheet from '../FinancialSheet';
import { AccountTransaction } from 'models';
export default class JournalSheet extends FinancialSheet {
tenantId: number;
journal: IJournalPoster;
query: IJournalReportQuery;
baseCurrency: string;
readonly contactsById: Map<number | string, IContact>;
/**
* Constructor method.
@@ -24,6 +25,8 @@ export default class JournalSheet extends FinancialSheet {
tenantId: number,
query: IJournalReportQuery,
journal: IJournalPoster,
accountsGraph: any,
contactsById: Map<number | string, IContact>,
baseCurrency: string
) {
super();
@@ -32,22 +35,48 @@ export default class JournalSheet extends FinancialSheet {
this.journal = journal;
this.query = query;
this.numberFormat = this.query.numberFormat;
this.accountsGraph = accountsGraph;
this.contactsById = contactsById;
this.baseCurrency = baseCurrency;
}
/**
* Mappes the journal entries.
* @param {IJournalEntry[]} entries -
* Entry mapper.
* @param {IJournalEntry} entry
*/
entriesMapper(
entries: IJournalEntry[],
) {
return entries.map((entry: IJournalEntry) => {
return {
...omit(entry, 'account'),
currencyCode: this.baseCurrency,
};
})
entryMapper(entry: IJournalEntry) {
const account = this.accountsGraph.getNodeData(entry.accountId);
const contact = this.contactsById.get(entry.contactId);
return {
entryId: entry.id,
index: entry.index,
note: entry.note,
contactName: get(contact, 'displayName'),
contactType: get(contact, 'contactService'),
accountName: account.name,
accountCode: account.code,
transactionNumber: entry.transactionNumber,
currencyCode: this.baseCurrency,
formattedCredit: this.formatNumber(entry.credit),
formattedDebit: this.formatNumber(entry.debit),
credit: entry.credit,
debit: entry.debit,
createdAt: entry.createdAt,
};
}
/**
* Mappes the journal entries.
* @param {IJournalEntry[]} entries -
*/
entriesMapper(entries: IJournalEntry[]) {
return entries.map(this.entryMapper.bind(this));
}
/**
@@ -58,13 +87,17 @@ export default class JournalSheet extends FinancialSheet {
*/
entriesGroupsMapper(
entriesGroup: IJournalEntry[],
key: string
groupEntry: IJournalEntry
): IJournalReportEntriesGroup {
const totalCredit = sumBy(entriesGroup, 'credit');
const totalDebit = sumBy(entriesGroup, 'debit');
return {
id: key,
date: groupEntry.date,
referenceType: groupEntry.referenceType,
referenceId: groupEntry.referenceId,
referenceTypeFormatted: groupEntry.referenceTypeFormatted,
entries: this.entriesMapper(entriesGroup),
currencyCode: this.baseCurrency,
@@ -72,8 +105,8 @@ export default class JournalSheet extends FinancialSheet {
credit: totalCredit,
debit: totalDebit,
formattedCredit: this.formatNumber(totalCredit),
formattedDebit: this.formatNumber(totalDebit),
formattedCredit: this.formatTotalNumber(totalCredit),
formattedDebit: this.formatTotalNumber(totalDebit),
};
}
@@ -85,9 +118,10 @@ export default class JournalSheet extends FinancialSheet {
entriesWalker(entries: IJournalEntry[]): IJournalReportEntriesGroup[] {
return chain(entries)
.groupBy((entry) => `${entry.referenceId}-${entry.referenceType}`)
.map((entriesGroup: IJournalEntry[], key: string) =>
this.entriesGroupsMapper(entriesGroup, key)
)
.map((entriesGroup: IJournalEntry[], key: string) => {
const headEntry = head(entriesGroup);
return this.entriesGroupsMapper(entriesGroup, headEntry);
})
.value();
}

View File

@@ -1,10 +1,13 @@
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';
import { transformToMap } from 'utils';
@Service()
export default class JournalSheetService {
@Inject()
@@ -40,6 +43,7 @@ export default class JournalSheetService {
const {
accountRepository,
transactionsRepository,
contactRepository,
} = this.tenancy.repositories(tenantId);
const filter = {
@@ -50,7 +54,6 @@ export default class JournalSheetService {
tenantId,
filter,
});
// Settings service.
const settings = this.tenancy.settings(tenantId);
const baseCurrency = settings.get({
@@ -60,6 +63,10 @@ export default class JournalSheetService {
// Retrieve all accounts on the storage.
const accountsGraph = await accountRepository.getDependencyGraph();
// Retrieve all contacts on the storage.
const contacts = await contactRepository.all();
const contactsByIdMap = transformToMap(contacts, 'id');
// Retrieve all journal transactions based on the given query.
const transactions = await transactionsRepository.journal({
fromDate: filter.fromDate,
@@ -79,6 +86,8 @@ export default class JournalSheetService {
tenantId,
filter,
transactionsJournal,
accountsGraph,
contactsByIdMap,
baseCurrency
);
// Retrieve journal report columns.

View File

@@ -50,6 +50,10 @@ export default class ProfitLossSheet extends FinancialSheet {
this.initDateRangeCollection();
}
get otherIncomeAccounts() {
return this.accounts.filter((a) => a.type.key === 'other_income');
}
/**
* Filtering income accounts.
* @return {IAccount & { type: IAccountType }[]}
@@ -235,6 +239,14 @@ export default class ProfitLossSheet extends FinancialSheet {
};
}
private get otherIncomeSection(): any {
return {
name: 'Other Income',
entryNormal: 'credit',
...this.sectionMapper(this.otherIncomeAccounts)
}
}
/**
* Retreive expenses section.
* @return {IProfitLossSheetLossSection}
@@ -343,10 +355,14 @@ export default class ProfitLossSheet extends FinancialSheet {
* @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);
@@ -356,7 +372,6 @@ export default class ProfitLossSheet extends FinancialSheet {
expenses,
costOfSales,
]);
// - Net income = Operating profit - Other expenses.
const netIncome = this.getSummarySection(operatingProfit, otherExpenses);
@@ -365,6 +380,7 @@ export default class ProfitLossSheet extends FinancialSheet {
costOfSales,
grossProfit,
expenses,
otherIncome,
otherExpenses,
netIncome,
operatingProfit,

View File

@@ -4,6 +4,7 @@ import {
ITrialBalanceAccount,
IAccount,
ITrialBalanceTotal,
ITrialBalanceSheetData,
IAccountType,
} from 'interfaces';
import FinancialSheet from '../FinancialSheet';
@@ -49,6 +50,7 @@ export default class TrialBalanceSheet extends FinancialSheet {
/**
* Account mapper.
* @param {IAccount} account
* @return {ITrialBalanceAccount}
*/
private accountMapper(
account: IAccount & { type: IAccountType }
@@ -80,6 +82,7 @@ export default class TrialBalanceSheet extends FinancialSheet {
/**
* Accounts walker.
* @param {IAccount[]} accounts
* @return {ITrialBalanceAccount[]}
*/
private accountsWalker(
accounts: IAccount & { type: IAccountType }[]
@@ -136,8 +139,15 @@ export default class TrialBalanceSheet extends FinancialSheet {
/**
* Retrieve trial balance sheet statement data.
* Note: Retruns null in case there is no transactions between the given date periods.
*
* @return {ITrialBalanceSheetData}
*/
public reportData() {
public reportData(): ITrialBalanceSheetData {
// Don't return noting if the journal has no transactions.
if (this.journalFinancial.isEmpty()) {
return null;
}
const accounts = this.accountsWalker(this.accounts);
const total = this.tatalSection(accounts);