WIP: transactions by customers.

This commit is contained in:
a.bouhuolia
2021-05-07 23:49:38 +02:00
parent 5f2e90b234
commit b5ed7af7eb
16 changed files with 425 additions and 76 deletions

View File

@@ -6,6 +6,7 @@ import BaseFinancialReportController from '../BaseFinancialReportController';
import TransactionsByVendorsTableRows from 'services/FinancialStatements/TransactionsByVendor/TransactionsByVendorTableRows';
import TransactionsByVendorsService from 'services/FinancialStatements/TransactionsByVendor/TransactionsByVendorService';
import { ITransactionsByVendorsStatement } from 'interfaces';
export default class TransactionsByVendorsReportController extends BaseFinancialReportController {
@Inject()
transactionsByVendorsService: TransactionsByVendorsService;
@@ -44,7 +45,7 @@ export default class TransactionsByVendorsReportController extends BaseFinancial
* Transformes the report statement to table rows.
* @param {ITransactionsByVendorsStatement} statement -
*/
transformToTableRows({ data }: ITransactionsByVendorsStatement) {
private transformToTableRows({ data }: ITransactionsByVendorsStatement) {
return {
table: {
data: this.transactionsByVendorsTableRows.tableRows(data),
@@ -56,7 +57,7 @@ export default class TransactionsByVendorsReportController extends BaseFinancial
* Transformes the report statement to json response.
* @param {ITransactionsByVendorsStatement} statement -
*/
transformToJsonResponse({
private transformToJsonResponse({
data,
columns,
query,

View File

@@ -0,0 +1,17 @@
export interface ILedger {
entries: ILedgerEntry[];
getEntries(): ILedgerEntry[];
whereContactId(contactId: number): ILedger;
whereFromDate(fromDate: Date | string): ILedger;
whereToDate(toDate: Date | string): ILedger;
}
export interface ILedgerEntry {
credit: number;
debit: number;
accountId?: number;
accountNormal: string;
contactId?: number;
date: Date | string;
}

View File

@@ -10,6 +10,7 @@ export interface ITransactionsByContactsTransaction {
date: string|Date,
credit: ITransactionsByContactsAmount;
debit: ITransactionsByContactsAmount;
accountName: string,
runningBalance: ITransactionsByContactsAmount;
currencyCode: string;
referenceNumber: string;

View File

@@ -49,4 +49,5 @@ export * from './ContactBalanceSummary';
export * from './TransactionsByCustomers';
export * from './TransactionsByContacts';
export * from './TransactionsByVendors';
export * from './Table';
export * from './Table';
export * from './Ledger';

View File

@@ -120,6 +120,25 @@ export default class AccountTransaction extends TenantModel {
query.modify('filterDateRange', null, toDate)
query.modify('sumationCreditDebit')
},
contactsOpeningBalance(query, openingDate, receivableAccounts, customersIds) {
// Filter by date.
query.modify('filterDateRange', null, openingDate);
// Filter by customers.
query.whereNot('contactId', null);
query.where('accountId', receivableAccounts);
if (customersIds) {
query.whereIn('contactId', customersIds);
}
// Group by the contact transactions.
query.groupBy('contactId');
query.sum('credit as credit');
query.sum('debit as debit');
query.select('contactId');
}
};
}

View File

@@ -694,4 +694,51 @@ export default class JournalPoster implements IJournalPoster {
getAccountEntries(accountId: number) {
return this.entries.filter((entry) => entry.account === accountId);
}
/**
* Retrieve total balnace of the given customer/vendor contact.
* @param {Number} accountId
* @param {Number} contactId
* @param {String} contactType
* @param {Date} closingDate
*/
getEntriesBalance(
entries
) {
let balance = 0;
entries.forEach((entry) => {
if (entry.credit) {
balance -= entry.credit;
}
if (entry.debit) {
balance += entry.debit;
}
});
return balance;
}
getContactEntries(
contactId: number,
openingDate: Date,
closingDate?: Date,
) {
const momentClosingDate = moment(closingDate);
const momentOpeningDate = moment(openingDate);
return this.entries.filter((entry) => {
if (
(closingDate &&
!momentClosingDate.isAfter(entry.date, 'day') &&
!momentClosingDate.isSame(entry.date, 'day')) ||
(openingDate &&
!momentOpeningDate.isBefore(entry.date, 'day') &&
!momentOpeningDate.isSame(entry.date)) ||
(entry.contactId === contactId)
) {
return true;
}
return false;
});
}
}

View File

@@ -0,0 +1,92 @@
import moment from 'moment';
import {
ILedger,
ILedgerEntry
} from 'interfaces';
export default class Ledger implements ILedger {
readonly entries: ILedgerEntry[];
/**
* Constructor method.
* @param {ILedgerEntry[]} entries
*/
constructor(entries: ILedgerEntry[]) {
this.entries = Ledger.mappingEntries(entries);
}
/**
* Filters the ledegr entries.
* @param callback
* @returns
*/
filter(callback) {
const entries = this.entries.filter(callback);
return new Ledger(entries);
}
getEntries(): ILedgerEntry[] {
return this.entries;
}
whereContactId(contactId: number): ILedger {
return this.filter((entry) => entry.contactId === contactId);
}
whereAccountId(accountId: number): ILedger {
return this.filter((entry) => entry.accountId === accountId);
}
whereFromDate(fromDate: Date | string): ILedger {
const fromDateParsed = moment(fromDate);
return this.filter(
(entry) =>
fromDateParsed.isBefore(entry.date) || fromDateParsed.isSame(entry.date)
);
}
whereToDate(toDate: Date | string): ILedger {
const toDateParsed = moment(toDate);
return this.filter(
(entry) =>
toDateParsed.isAfter(entry.date) || toDateParsed.isSame(entry.date)
);
}
/**
* Retrieve the closing balance of the entries.
* @returns {number}
*/
getClosingBalance() {
let closingBalance = 0;
this.entries.forEach((entry) => {
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;
}
static mappingEntries(entries): ILedgerEntry[] {
return entries.map(this.mapEntry);
}
static mapEntry(entry): ILedgerEntry {
return {
credit: entry.credit,
debit: entry.debit,
accountNormal: entry.accountNormal,
accountId: entry.accountId,
contactId: entry.contactId,
date: entry.date,
transactionNumber: entry.transactionNumber,
referenceNumber: entry.referenceNumber,
}
}
}

View File

@@ -100,7 +100,7 @@ export class ContactBalanceSummaryReport extends FinancialSheet {
): IContactBalanceSummaryAmount {
return {
amount,
formattedAmount: this.formatNumber(amount),
formattedAmount: this.formatNumber(amount, { money: true }),
currencyCode: this.baseCurrency,
};
}
@@ -113,7 +113,7 @@ export class ContactBalanceSummaryReport extends FinancialSheet {
protected getTotalFormat(amount: number): IContactBalanceSummaryAmount {
return {
amount,
formattedAmount: this.formatNumber(amount),
formattedAmount: this.formatNumber(amount, { money: true }),
currencyCode: this.baseCurrency,
};
}

View File

@@ -1,4 +1,4 @@
import { sumBy } from 'lodash';
import { get } from 'lodash';
import * as R from 'ramda';
import {
IJournalPoster,
@@ -45,11 +45,12 @@ export class CustomerBalanceSummaryReport extends ContactBalanceSummaryReport {
* @returns {ICustomerBalanceSummaryCustomer}
*/
private customerMapper(customer: ICustomer): ICustomerBalanceSummaryCustomer {
const balance = this.receivableLedger.getContactBalance(null, customer.id);
const customerBalance = this.receivableLedger.get(customer.id);
const balanceAmount = get(customerBalance, 'balance', 0);
return {
customerName: customer.displayName,
total: this.getContactTotalFormat(balance),
total: this.getContactTotalFormat(balanceAmount),
};
}

View File

@@ -1,20 +1,22 @@
import { Inject } from 'typedi';
import moment from 'moment';
import { map } from 'lodash';
import TenancyService from 'services/Tenancy/TenancyService';
import Journal from 'services/Accounting/JournalPoster';
import * as R from 'ramda';
import { transformToMap } from 'utils';
import {
ICustomerBalanceSummaryService,
ICustomerBalanceSummaryQuery,
ICustomerBalanceSummaryStatement,
} from 'interfaces';
import { CustomerBalanceSummaryReport } from './CustomerBalanceSummary';
import { ACCOUNT_TYPE } from 'data/AccountTypes';
export default class CustomerBalanceSummaryService
implements ICustomerBalanceSummaryService {
@Inject()
tenancy: TenancyService;
@Inject('logger')
logger: any;
@@ -40,61 +42,83 @@ export default class CustomerBalanceSummaryService
};
}
customersBalancesQuery(query) {
query.groupBy('contactId');
query.sum('credit as credit');
query.sum('debit as debit');
query.select('contactId');
}
/**
* Retrieve the customers credit/debit totals
* @param {number} tenantId
* @returns
*/
async getCustomersCreditDebitTotals(tenantId: number) {
const { AccountTransaction, Account } = this.tenancy.models(tenantId);
const receivableAccounts = await Account.query().where(
'accountType',
ACCOUNT_TYPE.ACCOUNTS_RECEIVABLE
);
const receivableAccountsIds = map(receivableAccounts, 'id');
const customersTotals = await AccountTransaction.query().onBuild((query) => {
query.whereIn('accountId', receivableAccountsIds);
this.customersBalancesQuery(query);
});
return R.compose(
(customers) => transformToMap(customers, 'contactId'),
(customers) => customers.map((customer) => ({
...customer,
balance: customer.debit - customer.credit
})),
)(customersTotals);
}
/**
* Retrieve the statment of customer balance summary report.
* @param {number} tenantId
* @param {ICustomerBalanceSummaryQuery} query
* @param {number} tenantId
* @param {ICustomerBalanceSummaryQuery} query
* @return {Promise<ICustomerBalanceSummaryStatement>}
*/
async customerBalanceSummary(
tenantId: number,
query: ICustomerBalanceSummaryQuery
): Promise<ICustomerBalanceSummaryStatement> {
const {
accountRepository,
transactionsRepository,
} = this.tenancy.repositories(tenantId);
const { Customer } = this.tenancy.models(tenantId);
// Settings tenant service.
const settings = this.tenancy.settings(tenantId);
const baseCurrency = settings.get({
group: 'organization', key: 'base_currency',
group: 'organization',
key: 'base_currency',
});
const filter = {
...this.defaultQuery,
...query,
};
this.logger.info('[customer_balance_summary] trying to calculate the report.', {
filter,
tenantId,
});
// Retrieve all accounts on the storage.
const accounts = await accountRepository.all();
const accountsGraph = await accountRepository.getDependencyGraph();
// Retrieve all journal transactions based on the given query.
const transactions = await transactionsRepository.journal({
toDate: query.asDate,
});
// Transform transactions to journal collection.
const transactionsJournal = Journal.fromTransactions(
transactions,
tenantId,
accountsGraph
this.logger.info(
'[customer_balance_summary] trying to calculate the report.',
{
filter,
tenantId,
}
);
// Retrieve the customers list ordered by the display name.
const customers = await Customer.query().orderBy('displayName');
// Retrieve the customers debit/credit totals.
const customersBalances = await this.getCustomersCreditDebitTotals(tenantId);
// Report instance.
const reportInstance = new CustomerBalanceSummaryReport(
transactionsJournal,
customersBalances,
customers,
filter,
baseCurrency,
baseCurrency
);
// Retrieve the report statement.
const reportData = reportInstance.reportData();

View File

@@ -56,10 +56,20 @@ export default class FinancialSheet {
});
}
/**
* Formates the amount to the percentage string.
* @param {number} amount
* @returns {string}
*/
protected formatPercentage(
amount
): string {
return `%${amount * 100}`;
const percentage = amount * 100;
return formatNumber(percentage, {
symbol: '%',
excerptZero: true,
money: false,
})
}
}

View File

@@ -1,14 +1,18 @@
import { sumBy } from 'lodash';
import { sumBy, defaultTo } from 'lodash';
import {
ITransactionsByContactsTransaction,
ITransactionsByContactsAmount,
ITransactionsByContacts,
ITransactionsByContactsFilter,
IContact,
ILedger,
} from 'interfaces';
import FinancialSheet from '../FinancialSheet';
export default class TransactionsByContact extends FinancialSheet {
readonly contacts: IContact[];
readonly ledger: ILedger;
readonly filter: ITransactionsByContactsFilter;
readonly accountsGraph: any;
/**
* Customer transaction mapper.
@@ -18,11 +22,13 @@ export default class TransactionsByContact extends FinancialSheet {
protected contactTransactionMapper(
transaction
): Omit<ITransactionsByContactsTransaction, 'runningBalance'> {
const account = this.accountsGraph.getNodeData(transaction.accountId);
const currencyCode = 'USD';
return {
credit: this.getContactAmount(transaction.credit, currencyCode),
debit: this.getContactAmount(transaction.debit, currencyCode),
accountName: account.name,
currencyCode: 'USD',
transactionNumber: transaction.transactionNumber,
referenceNumber: transaction.referenceNumber,
@@ -69,8 +75,8 @@ export default class TransactionsByContact extends FinancialSheet {
): number {
const closingBalance = openingBalance;
const totalCredit = sumBy(customerTransactions, 'credit');
const totalDebit = sumBy(customerTransactions, 'debit');
const totalCredit = sumBy(customerTransactions, 'credit.amount');
const totalDebit = sumBy(customerTransactions, 'debit.amount');
return closingBalance + (totalDebit - totalCredit);
}
@@ -81,7 +87,14 @@ export default class TransactionsByContact extends FinancialSheet {
* @returns {number}
*/
protected getContactOpeningBalance(customerId: number): number {
return 0;
const openingBalanceLedger = this.ledger
.whereContactId(customerId)
.whereToDate(this.filter.fromDate);
// Retrieve the closing balance of the ledger.
const openingBalance = openingBalanceLedger.getClosingBalance();
return defaultTo(openingBalance, 0);
}
/**
@@ -100,4 +113,21 @@ export default class TransactionsByContact extends FinancialSheet {
currencyCode,
};
}
/**
* Retrieve the contact total amount format meta.
* @param {number} amount - Amount.
* @param {string} currencyCode - Currency code./
* @returns {ITransactionsByContactsAmount}
*/
protected getTotalAmountMeta(
amount: number,
currencyCode: string
) {
return {
amount,
formattedAmount: this.formatTotalNumber(amount, { currencyCode }),
currencyCode,
};
}
}

View File

@@ -1,4 +1,5 @@
import moment from 'moment';
import * as R from 'ramda';
import { tableMapper, tableRowMapper } from 'utils';
import {
ITransactionsByContactsContact,
@@ -28,11 +29,12 @@ export default class TransactionsByContactsTableRows {
): ITableRow[] {
const columns = [
{ key: 'date', accessor: this.dateAccessor },
{ key: 'account', accessor: 'account.name' },
{ key: 'referenceType', accessor: 'referenceType' },
{ key: 'transactionType', accessor: 'transactionType' },
{ key: 'account', accessor: 'accountName' },
{ key: 'referenceNumber', accessor: 'referenceNumber' },
{ key: 'transactionNumber', accessor: 'transactionNumber' },
{ key: 'credit', accessor: 'credit.formattedAmount' },
{ key: 'debit', accessor: 'debit.formattedAmount' },
{ key: 'runningBalance', accessor: 'runningBalance.formattedAmount' },
];
return tableMapper(contact.transactions, columns, {
rowTypes: [ROW_TYPE.TRANSACTION],
@@ -49,6 +51,7 @@ export default class TransactionsByContactsTableRows {
): ITableRow {
const columns = [
{ key: 'openingBalanceLabel', value: 'Opening balance' },
...R.repeat({ key: 'empty', value: '' }, 5),
{
key: 'openingBalanceValue',
accessor: 'openingBalance.formattedAmount',
@@ -69,6 +72,7 @@ export default class TransactionsByContactsTableRows {
): ITableRow {
const columns = [
{ key: 'closingBalanceLabel', value: 'Closing balance' },
...R.repeat({ key: 'empty', value: '' }, 5),
{
key: 'closingBalanceValue',
accessor: 'closingBalance.formattedAmount',

View File

@@ -11,30 +11,34 @@ import {
ICustomer,
} from 'interfaces';
import TransactionsByContact from '../TransactionsByContact/TransactionsByContact';
import Ledger from 'services/Accounting/Ledger';
export default class TransactionsByCustomers extends TransactionsByContact {
readonly customers: ICustomer[];
readonly transactionsByContact: any;
readonly ledger: Ledger;
readonly filter: ITransactionsByCustomersFilter;
readonly baseCurrency: string;
readonly numberFormat: INumberFormatQuery;
readonly accountsGraph: any;
/**
* Constructor method.
* @param {ICustomer} customers
* @param {Map<number, IAccountTransaction[]>} transactionsByContact
* @param {Map<number, IAccountTransaction[]>} transactionsLedger
* @param {string} baseCurrency
*/
constructor(
customers: ICustomer[],
transactionsByContact: Map<number, IAccountTransaction[]>,
accountsGraph: any,
ledger: Ledger,
filter: ITransactionsByCustomersFilter,
baseCurrency: string
) {
super();
this.customers = customers;
this.transactionsByContact = transactionsByContact;
this.accountsGraph = accountsGraph;
this.ledger = ledger;
this.baseCurrency = baseCurrency;
this.filter = filter;
this.numberFormat = this.filter.numberFormat;
@@ -50,12 +54,17 @@ export default class TransactionsByCustomers extends TransactionsByContact {
customerId: number,
openingBalance: number
): ITransactionsByCustomersTransaction[] {
const transactions = this.transactionsByContact.get(customerId + '') || [];
const ledger = this.ledger
.whereContactId(customerId)
.whereFromDate(this.filter.fromDate)
.whereToDate(this.filter.toDate);
return R.compose(
const ledgerEntries = ledger.getEntries();
return R.compose(
R.curry(this.contactTransactionRunningBalance)(openingBalance),
R.map(this.contactTransactionMapper.bind(this))
).bind(this)(transactions);
).bind(this)(ledgerEntries);
}
/**
@@ -66,17 +75,17 @@ export default class TransactionsByCustomers extends TransactionsByContact {
private customerMapper(
customer: ICustomer
): ITransactionsByCustomersCustomer {
const openingBalance = this.getContactOpeningBalance(1);
const openingBalance = this.getContactOpeningBalance(customer.id);
const transactions = this.customerTransactions(customer.id, openingBalance);
const closingBalance = this.getContactClosingBalance(transactions, 0);
const closingBalance = this.getContactClosingBalance(transactions, openingBalance);
return {
customerName: customer.displayName,
openingBalance: this.getContactAmount(
openingBalance: this.getTotalAmountMeta(
openingBalance,
customer.currencyCode
),
closingBalance: this.getContactAmount(
closingBalance: this.getTotalAmountMeta(
closingBalance,
customer.currencyCode
),

View File

@@ -1,6 +1,7 @@
import { Inject } from 'typedi';
import * as R from 'ramda';
import moment from 'moment';
import { groupBy } from 'lodash';
import { map } from 'lodash';
import TenancyService from 'services/Tenancy/TenancyService';
import {
ITransactionsByCustomersService,
@@ -8,6 +9,9 @@ import {
ITransactionsByCustomersStatement,
} from 'interfaces';
import TransactionsByCustomers from './TransactionsByCustomers';
import Ledger from 'services/Accounting/Ledger';
import { ACCOUNT_TYPE } from 'data/AccountTypes';
import AccountRepository from 'repositories/AccountRepository';
export default class TransactionsByCustomersService
implements ITransactionsByCustomersService {
@@ -23,8 +27,8 @@ export default class TransactionsByCustomersService
*/
get defaultQuery(): ITransactionsByCustomersFilter {
return {
fromDate: moment().format('YYYY-MM-DD'),
toDate: moment().format('YYYY-MM-DD'),
fromDate: moment().startOf('year').format('YYYY-MM-DD'),
toDate: moment().endOf('year').format('YYYY-MM-DD'),
numberFormat: {
precision: 2,
divideOn1000: false,
@@ -40,6 +44,80 @@ export default class TransactionsByCustomersService
};
}
/**
* Retrieve the accounts receivable.
* @param {number} tenantId
* @returns
*/
async getReceivableAccounts(tenantId: number) {
const { Account } = this.tenancy.models(tenantId);
const accounts = await Account.query().where(
'accountType',
ACCOUNT_TYPE.ACCOUNTS_RECEIVABLE
);
return accounts;
}
/**
* Retrieve the customers opening balance transactions.
* @param {number} tenantId
* @param {number} openingDate
* @param {number} customersIds
* @returns {}
*/
async getCustomersOpeningBalance(
tenantId: number,
openingDate: Date,
customersIds?: number[]
): Promise<ILedgerEntry[]> {
const { AccountTransaction } = this.tenancy.models(tenantId);
const receivableAccounts = await this.getReceivableAccounts(tenantId);
const receivableAccountsIds = map(receivableAccounts, 'id');
const openingTransactions = await AccountTransaction.query().modify(
'contactsOpeningBalance',
openingDate,
receivableAccountsIds,
customersIds
);
return R.compose(
R.map(R.assoc('date', openingDate)),
R.map(R.assoc('accountNormal', 'debit'))
)(openingTransactions);
}
/**
*
* @param {number} tenantId
* @param {Date|string} openingDate
* @param {number[]} customersIds
*/
async getCustomersPeriodTransactions(
tenantId: number,
fromDate: Date,
toDate: Date,
): Promise<ILedgerEntry[]> {
const { AccountTransaction } = this.tenancy.models(tenantId);
const receivableAccounts = await this.getReceivableAccounts(tenantId);
const receivableAccountsIds = map(receivableAccounts, 'id');
const transactions = await AccountTransaction.query().onBuild((query) => {
// Filter by date.
query.modify('filterDateRange', fromDate, toDate);
// Filter by customers.
query.whereNot('contactId', null);
// Filter by accounts.
query.whereIn('accountId', receivableAccountsIds);
});
return R.compose(R.map(R.assoc('accountNormal', 'debit')))(transactions);
}
/**
* Retrieve transactions by by the customers.
* @param {number} tenantId
@@ -50,8 +128,8 @@ export default class TransactionsByCustomersService
tenantId: number,
query: ITransactionsByCustomersFilter
): Promise<ITransactionsByCustomersStatement> {
const { transactionsRepository } = this.tenancy.repositories(tenantId);
const { Customer } = this.tenancy.models(tenantId);
const { accountRepository } = this.tenancy.repositories(tenantId);
// Settings tenant service.
const settings = this.tenancy.settings(tenantId);
@@ -64,21 +142,35 @@ export default class TransactionsByCustomersService
...this.defaultQuery,
...query,
};
const accountsGraph = await accountRepository.getDependencyGraph();
const customers = await Customer.query().orderBy('displayName');
// Retrieve all journal transactions based on the given query.
const transactions = await transactionsRepository.journal({
fromDate: query.fromDate,
toDate: query.toDate,
});
// Transactions map by contact id.
const transactionsMap = new Map(
Object.entries(groupBy(transactions, 'contactId'))
const openingBalanceDate = moment(filter.fromDate).subtract(1, 'days').toDate();
// Retrieve all ledger transactions of the opening balance of.
const openingBalanceTransactions = await this.getCustomersOpeningBalance(
tenantId,
openingBalanceDate,
);
// Retrieve all ledger transactions between opeing and closing period.
const customersTransactions = await this.getCustomersPeriodTransactions(
tenantId,
query.fromDate,
query.toDate,
);
// Concats the opening balance and period customer ledger transactions.
const journalTransactions = [
...openingBalanceTransactions,
...customersTransactions,
];
const journal = new Ledger(journalTransactions);
// Transactions by customers data mapper.
const reportInstance = new TransactionsByCustomers(
customers,
transactionsMap,
accountsGraph,
journal,
filter,
baseCurrency
);

View File

@@ -242,10 +242,11 @@ const formatNumber = (
decimal = '.',
zeroSign = '',
money = true,
currencyCode
currencyCode,
symbol
}
) => {
const symbol = getCurrencySign(currencyCode);
const formattedSymbol = getCurrencySign(currencyCode);
const negForamt = getNegativeFormat(negativeFormat);
const format = '%s%v';
@@ -256,7 +257,7 @@ const formatNumber = (
}
return accounting.formatMoney(
formattedBalance,
money ? symbol : '',
money ? formattedSymbol : symbol ? symbol : '',
precision,
thousand,
decimal,