WIP: customer balance report.

This commit is contained in:
a.bouhuolia
2021-05-05 02:19:43 +02:00
parent 8275d3d395
commit 8ca3509f03
14 changed files with 843 additions and 1 deletions

View File

@@ -11,6 +11,9 @@ import APAgingSummary from './FinancialStatements/APAgingSummary';
import PurchasesByItemsController from './FinancialStatements/PurchasesByItem';
import SalesByItemsController from './FinancialStatements/SalesByItems';
import InventoryValuationController from './FinancialStatements/InventoryValuationSheet';
import CustomerBalanceSummaryController from './FinancialStatements/CustomerBalanceSummary';
import VendorBalanceSummaryController from './FinancialStatements/VendorBalanceSummary';
import TransactionsByCustomers from './FinancialStatements/TransactionsByCustomers';
@Service()
export default class FinancialStatementsService {
@@ -57,6 +60,18 @@ export default class FinancialStatementsService {
'/inventory-valuation',
Container.get(InventoryValuationController).router()
);
router.use(
'/customer-balance-summary',
Container.get(CustomerBalanceSummaryController).router(),
);
router.use(
'/transactions-by-customers',
Container.get(TransactionsByCustomers).router(),
)
// router.use(
// '/vendor-balance-summary',
// Container.get(VendorBalanceSummaryController).router(),
// )
return router;
}
}

View File

@@ -0,0 +1,74 @@
import { Router, Request, Response, NextFunction } from 'express';
import { query } from 'express-validator';
import { Inject } from 'typedi';
import asyncMiddleware from 'api/middleware/asyncMiddleware';
import CustomerBalanceSummary from 'services/FinancialStatements/CustomerBalanceSummary/CustomerBalanceSummaryService';
import BaseFinancialReportController from '../BaseFinancialReportController';
import CustomerBalanceSummaryTableRows from 'services/FinancialStatements/CustomerBalanceSummary/CustomerBalanceSummaryTableRows';
export default class CustomerBalanceSummaryReportController extends BaseFinancialReportController {
@Inject()
customerBalanceSummaryService: CustomerBalanceSummary;
@Inject()
customerBalanceSummaryTableRows: CustomerBalanceSummaryTableRows;
/**
* Router constructor.
*/
router() {
const router = Router();
router.get(
'/',
this.validationSchema,
asyncMiddleware(this.customerBalanceSummary.bind(this))
);
return router;
}
/**
* Validation schema.
*/
get validationSchema() {
return [
...this.sheetNumberFormatValidationSchema,
query('as_date').optional().isISO8601(),
];
}
/**
* Retrieve payable aging summary report.
* @param {Request} req -
* @param {Response} res -
* @param {NextFunction} next -
*/
async customerBalanceSummary(req: Request, res: Response, next: NextFunction) {
const { tenantId, settings } = req;
const filter = this.matchedQueryData(req);
try {
const {
data,
columns,
query,
} = await this.customerBalanceSummaryService.customerBalanceSummary(
tenantId,
filter
);
const tableRows = this.customerBalanceSummaryTableRows.tableRowsTransformer(
data
);
return res.status(200).send({
table: {
rows: tableRows
},
columns: this.transfromToResponse(columns),
query: this.transfromToResponse(query),
});
} catch (error) {
next(error);
}
}
}

View File

@@ -0,0 +1,72 @@
import { Router, Request, Response, NextFunction } from 'express';
import { query } from 'express-validator';
import { Inject } from 'typedi';
import asyncMiddleware from 'api/middleware/asyncMiddleware';
import BaseFinancialReportController from '../BaseFinancialReportController';
import TransactionsByCustomersService from 'services/FinancialStatements/TransactionsByCustomer/TransactionsByCustomersService';
export default class TransactionsByCustomersReportController extends BaseFinancialReportController {
@Inject()
transactionsByCustomersService: TransactionsByCustomersService;
/**
* Router constructor.
*/
router() {
const router = Router();
router.get(
'/',
this.validationSchema,
asyncMiddleware(this.transactionsByCustomers.bind(this))
);
return router;
}
/**
* Validation schema.
*/
get validationSchema() {
return [
...this.sheetNumberFormatValidationSchema,
query('from_date').optional().isISO8601(),
query('to_date').optional().isISO8601(),
query('none_zero').optional().isBoolean().toBoolean(),
query('none_transactions').optional().isBoolean().toBoolean(),
];
}
/**
* Retrieve payable aging summary report.
* @param {Request} req -
* @param {Response} res -
* @param {NextFunction} next -
*/
async transactionsByCustomers(
req: Request,
res: Response,
next: NextFunction
) {
const { tenantId } = req;
const filter = this.matchedQueryData(req);
try {
const {
data,
columns,
query,
} = await this.transactionsByCustomersService.transactionsByCustomers(
tenantId,
filter
);
return res.status(200).send({
data: this.transfromToResponse(data),
columns: this.transfromToResponse(columns),
query: this.transfromToResponse(query),
});
} catch (error) {
next(error);
}
}
}

View File

@@ -0,0 +1,50 @@
import { INumberFormatQuery } from './FinancialStatements';
export interface ICustomerBalanceSummaryQuery {
asDate: Date;
numberFormat: INumberFormatQuery;
comparison: {
percentageOfColumn: boolean;
};
noneTransactions: boolean;
noneZero: boolean;
}
export interface ICustomerBalanceSummaryAmount {
amount: number;
formattedAmount: string;
currencyCode: string;
}
export interface ICustomerBalanceSummaryPercentage {
amount: number;
formattedAmount: string;
}
export interface ICustomerBalanceSummaryCustomer {
customerName: string;
total: ICustomerBalanceSummaryAmount;
percentageOfColumn?: ICustomerBalanceSummaryPercentage;
}
export interface ICustomerBalanceSummaryTotal {
total: ICustomerBalanceSummaryAmount;
percentageOfColumn?: ICustomerBalanceSummaryPercentage;
}
export interface ICustomerBalanceSummaryData {
customers: ICustomerBalanceSummaryCustomer[];
total: ICustomerBalanceSummaryTotal;
}
export interface ICustomerBalanceSummaryStatement {
data: ICustomerBalanceSummaryData;
columns: {};
query: ICustomerBalanceSummaryQuery;
}
export interface ICustomerBalanceSummaryService {
customerBalanceSummary(
tenantId: number,
query: ICustomerBalanceSummaryQuery,
): Promise<ICustomerBalanceSummaryStatement>;
}

View File

@@ -0,0 +1,45 @@
import { INumberFormatQuery } from './FinancialStatements';
export interface ITransactionsByCustomersAmount {
amount: number;
formattedAmount: string;
}
export interface ITransactionsByCustomersTransaction {
date: string|Date,
credit: ITransactionsByCustomersAmount;
debit: ITransactionsByCustomersAmount;
runningBalance: ITransactionsByCustomersAmount;
referenceNumber: string;
transactionNumber: string;
}
export interface ITransactionsByCustomersCustomer {
customerName: string;
openingBalance: any;
closingBalance: any;
transactions: ITransactionsByCustomersTransaction[];
}
export interface ITransactionsByCustomersFilter {
fromDate: Date;
toDate: Date;
numberFormat: INumberFormatQuery;
noneTransactions: boolean;
noneZero: boolean;
}
export interface ITransactionsByCustomersData {
customers: ITransactionsByCustomersCustomer[];
}
export interface ITransactionsByCustomersStatement {
data: ITransactionsByCustomersData;
}
export interface ITransactionsByCustomersService {
transactionsByCustomers(
tenantId: number,
filter: ITransactionsByCustomersFilter
): Promise<ITransactionsByCustomersStatement>;
}

View File

@@ -42,4 +42,6 @@ export * from './Mailable';
export * from './InventoryAdjustment';
export * from './Setup'
export * from './IInventoryValuationSheet';
export * from './SalesByItemsSheet';
export * from './SalesByItemsSheet';
export * from './CustomerBalanceSummary';
export * from './TransactionsByCustomers';

View File

@@ -689,6 +689,7 @@ export default class JournalPoster implements IJournalPoster {
});
return balance;
}
getAccountEntries(accountId: number) {
return this.entries.filter((entry) => entry.account === accountId);

View File

@@ -0,0 +1,193 @@
import { sumBy } from 'lodash';
import * as R from 'ramda';
import FinancialSheet from '../FinancialSheet';
import {
IJournalPoster,
ICustomer,
ICustomerBalanceSummaryCustomer,
ICustomerBalanceSummaryQuery,
ICustomerBalanceSummaryData,
ICustomerBalanceSummaryTotal,
} from 'interfaces';
export class CustomerBalanceSummaryReport extends FinancialSheet {
receivableLedger: IJournalPoster;
baseCurrency: string;
customers: ICustomer[];
filter: ICustomerBalanceSummaryQuery;
/**
* Constructor method.
* @param {IJournalPoster} receivableLedger
* @param {ICustomer[]} customers
* @param {ICustomerBalanceSummaryQuery} filter
* @param {string} baseCurrency
*/
constructor(
receivableLedger: IJournalPoster,
customers: ICustomer[],
filter: ICustomerBalanceSummaryQuery,
baseCurrency: string
) {
super();
this.receivableLedger = receivableLedger;
this.baseCurrency = baseCurrency;
this.customers = customers;
this.filter = filter;
}
getAmountMeta(amount: number) {
return {
amount,
formattedAmount: amount,
currencyCode: this.baseCurrency,
};
}
getPercentageMeta(amount: number) {
return {
amount,
formattedAmount: this.formatPercentage(amount),
};
}
/**
* Customer section mapper.
* @param {ICustomer} customer
* @returns {ICustomerBalanceSummaryCustomer}
*/
private customerMapper(customer: ICustomer): ICustomerBalanceSummaryCustomer {
const balance = this.receivableLedger.getContactBalance(null, customer.id);
return {
customerName: customer.displayName,
total: this.getAmountMeta(balance),
};
}
/**
* Retrieve the customer summary section with percentage of column.
* @param {number} total
* @param {ICustomerBalanceSummaryCustomer} customer
* @returns {ICustomerBalanceSummaryCustomer}
*/
private customerCamparsionPercentageOfColumnMapper(
total: number,
customer: ICustomerBalanceSummaryCustomer
): ICustomerBalanceSummaryCustomer {
const amount = this.getCustomerPercentageOfColumn(
total,
customer.total.amount
);
return {
...customer,
percentageOfColumn: this.getPercentageMeta(amount),
};
}
/**
* Mappes the customers summary sections with percentage of column.
* @param {ICustomerBalanceSummaryCustomer[]} customers -
* @return {ICustomerBalanceSummaryCustomer[]}
*/
private customerCamparsionPercentageOfColumn(
customers: ICustomerBalanceSummaryCustomer[]
): ICustomerBalanceSummaryCustomer[] {
const customersTotal = this.getCustomersTotal(customers);
const camparsionPercentageOfColummn = R.curry(
this.customerCamparsionPercentageOfColumnMapper.bind(this)
)(customersTotal);
return customers.map(camparsionPercentageOfColummn);
}
/**
* Mappes the customer model object to customer balance summary section.
* @param {ICustomer[]} customers - Customers.
* @returns {ICustomerBalanceSummaryCustomer[]}
*/
private customersMapper(
customers: ICustomer[]
): ICustomerBalanceSummaryCustomer[] {
return customers.map(this.customerMapper.bind(this));
}
/**
* Retrieve the customers sections of the report.
* @param {ICustomer} customers
* @returns {ICustomerBalanceSummaryCustomer[]}
*/
private getCustomersSection(
customers: ICustomer[]
): ICustomerBalanceSummaryCustomer[] {
return R.compose(
R.when(
R.always(this.filter.comparison.percentageOfColumn),
this.customerCamparsionPercentageOfColumn.bind(this)
),
this.customersMapper.bind(this)
).bind(this)(customers);
}
/**
* Retrieve the customers total.
* @param {ICustomerBalanceSummaryCustomer} customers
* @returns {number}
*/
private getCustomersTotal(
customers: ICustomerBalanceSummaryCustomer[]
): number {
return sumBy(
customers,
(customer: ICustomerBalanceSummaryCustomer) => customer.total.amount
);
}
/**
* Calculates the customer percentage of column.
* @param {number} customerBalance - Customer balance.
* @param {number} totalBalance - Total customers balance.
* @returns {number}
*/
private getCustomerPercentageOfColumn(
customerBalance: number,
totalBalance: number
) {
return customerBalance > 0 ? totalBalance / customerBalance : 0;
}
/**
* Retrieve the customers total section.
* @param {ICustomer[]} customers
* @returns {ICustomerBalanceSummaryTotal}
*/
private customersTotalSection(
customers: ICustomerBalanceSummaryCustomer[]
): ICustomerBalanceSummaryTotal {
const customersTotal = this.getCustomersTotal(customers);
return {
total: this.getAmountMeta(customersTotal),
percentageOfColumn: this.getPercentageMeta(1),
};
}
/**
* Retrieve the report statement data.
* @returns {ICustomerBalanceSummaryData}
*/
public reportData(): ICustomerBalanceSummaryData {
const customersSections = this.getCustomersSection(this.customers);
const customersTotal = this.customersTotalSection(customersSections);
return {
customers: customersSections,
total: customersTotal,
};
}
reportColumns() {
return [];
}
}

View File

@@ -0,0 +1,110 @@
import { Inject } from 'typedi';
import moment from 'moment';
import TenancyService from 'services/Tenancy/TenancyService';
import Journal from 'services/Accounting/JournalPoster';
import {
ICustomerBalanceSummaryService,
ICustomerBalanceSummaryQuery,
ICustomerBalanceSummaryStatement,
} from 'interfaces';
import { CustomerBalanceSummaryReport } from './CustomerBalanceSummary';
export default class CustomerBalanceSummaryService
implements ICustomerBalanceSummaryService {
@Inject()
tenancy: TenancyService;
@Inject('logger')
logger: any;
/**
* Defaults balance sheet filter query.
* @return {ICustomerBalanceSummaryQuery}
*/
get defaultQuery(): ICustomerBalanceSummaryQuery {
return {
asDate: moment().format('YYYY-MM-DD'),
numberFormat: {
precision: 2,
divideOn1000: false,
showZero: false,
formatMoney: 'total',
negativeFormat: 'mines',
},
comparison: {
percentageOfColumn: true,
},
noneZero: false,
noneTransactions: false,
};
}
/**
* Retrieve the statment of customer balance summary report.
* @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',
});
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
);
// Retrieve the customers list ordered by the display name.
const customers = await Customer.query().orderBy('displayName');
// Report instance.
const reportInstance = new CustomerBalanceSummaryReport(
transactionsJournal,
customers,
filter,
baseCurrency,
);
// Retrieve the report statement.
const reportData = reportInstance.reportData();
// Retrieve the report columns.
const reportColumns = reportInstance.reportColumns();
return {
data: reportData,
columns: reportColumns,
};
}
}

View File

@@ -0,0 +1,108 @@
import { get } from 'lodash';
import {
ICustomerBalanceSummaryData,
ICustomerBalanceSummaryCustomer,
ICustomerBalanceSummaryTotal,
} from 'interfaces';
import { Service } from 'typedi';
interface IColumnMapperMeta {
key: string;
accessor?: string;
value?: string;
}
interface ITableCell {
value: string;
key: string;
}
type ITableRow = {
rows: ITableCell[];
};
enum TABLE_ROWS_TYPES {
CUSTOMER = 'CUSTOMER',
TOTAL = 'TOTAL',
}
function tableMapper(
data: Object[],
columns: IColumnMapperMeta[],
rowsMeta
): ITableRow[] {
return data.map((object) => tableRowMapper(object, columns, rowsMeta));
}
function tableRowMapper(
object: Object,
columns: IColumnMapperMeta[],
rowMeta
): ITableRow {
const cells = columns.map((column) => ({
key: column.key,
value: column.value ? column.value : get(object, column.accessor),
}));
return {
cells,
...rowMeta,
};
}
@Service()
export default class CustomerBalanceSummaryTableRows {
/**
* Transformes the customers to table rows.
* @param {ICustomerBalanceSummaryCustomer[]} customers
* @returns {ITableRow[]}
*/
private customersTransformer(
customers: ICustomerBalanceSummaryCustomer[]
): ITableRow[] {
const columns = [
{ key: 'customerName', accessor: 'customerName' },
{ key: 'total', accessor: 'total.formattedAmount' },
{
key: 'percentageOfColumn',
accessor: 'percentageOfColumn.formattedAmount',
},
];
return tableMapper(customers, columns, {
rowTypes: [TABLE_ROWS_TYPES.CUSTOMER],
});
}
/**
* Transformes the total to table row.
* @param {ICustomerBalanceSummaryTotal} total
* @returns {ITableRow}
*/
private totalTransformer(total: ICustomerBalanceSummaryTotal) {
const columns = [
{ key: 'total', value: 'Total' },
{ key: 'total', accessor: 'total.formattedAmount' },
{
key: 'percentageOfColumn',
accessor: 'percentageOfColumn.formattedAmount',
},
];
return tableRowMapper(total, columns, {
rowTypes: [TABLE_ROWS_TYPES.TOTAL],
});
}
/**
* Transformes the customer balance summary to table rows.
* @param {ICustomerBalanceSummaryData} customerBalanceSummary
* @returns {ITableRow[]}
*/
public tableRowsTransformer(
customerBalanceSummary: ICustomerBalanceSummaryData
): ITableRow[] {
return [
...this.customersTransformer(customerBalanceSummary.customers),
this.totalTransformer(customerBalanceSummary.total),
];
}
}

View File

@@ -55,4 +55,11 @@ export default class FinancialSheet {
...settings
});
}
protected formatPercentage(
amount
): string {
return `%${amount * 100}`;
}
}

View File

@@ -0,0 +1,81 @@
import * as R from 'ramda';
import FinancialSheet from '../FinancialSheet';
import { IAccountTransaction, ICustomer } from 'interfaces';
import { transaction } from 'objection';
export default class TransactionsByCustomers extends FinancialSheet {
customers: ICustomer[];
transactionsByContact: any;
baseCurrency: string;
/**
* Constructor method.
* @param {ICustomer} customers
* @param transactionsByContact
* @param {string} baseCurrency
*/
constructor(
customers: ICustomer[],
transactionsByContact: Map<number, IAccountTransaction[]>,
baseCurrency: string
) {
super();
this.customers = customers;
this.transactionsByContact = transactionsByContact;
this.baseCurrency = baseCurrency;
}
/**
*
*/
private customerTransactionMapper(
transaction
): ITransactionsByCustomersTransaction {
return {
credit: transaction.credit,
debit: transaction.debit,
transactionNumber: transaction.transactionNumber,
referenceNumber: transaction.referenceNumber,
date: transaction.date,
createdAt: transaction.createdAt,
};
}
private customerTransactionRunningBalance(
openingBalance: number,
transaction: ITransactionsByCustomersTransaction
): ITransactionsByCustomersTransaction {
}
private customerTransactions(customerId: number) {
const transactions = this.transactionsByContact.get(customerId + '') || [];
return R.compose(
R.map(this.customerTransactionMapper),
R.map(R.curry(this.customerTransactionRunningBalance(0)))
).bind(this)(transactions);
}
private customerMapper(customer: ICustomer) {
return {
customerName: customer.displayName,
openingBalance: {},
closingBalance: {},
transactions: this.customerTransactions(customer.id),
};
}
private customersMapper(customers: ICustomer[]) {
return customers.map(this.customerMapper.bind(this));
}
public reportData() {
return this.customersMapper(this.customers);
}
public reportColumns() {
return [];
}
}

View File

@@ -0,0 +1,84 @@
import { Inject } from 'typedi';
import moment from 'moment';
import { groupBy } from 'lodash';
import TenancyService from 'services/Tenancy/TenancyService';
import {
ITransactionsByCustomersService,
ITransactionsByCustomersFilter,
ITransactionsByCustomersStatement,
} from 'interfaces';
import TransactionsByCustomers from './TransactionsByCustomers';
export default class TransactionsByCustomersService implements ITransactionsByCustomersService {
@Inject()
tenancy: TenancyService;
@Inject('logger')
logger: any;
/**
* Defaults balance sheet filter query.
* @return {ICustomerBalanceSummaryQuery}
*/
get defaultQuery(): ITransactionsByCustomersFilter {
return {
fromDate: moment().format('YYYY-MM-DD'),
toDate: moment().format('YYYY-MM-DD'),
numberFormat: {
precision: 2,
divideOn1000: false,
showZero: false,
formatMoney: 'total',
negativeFormat: 'mines',
},
comparison: {
percentageOfColumn: true,
},
noneZero: false,
noneTransactions: false,
};
}
/**
* Retrieve transactions by by the customers.
* @param {number} tenantId
* @param {ITransactionsByCustomersFilter} query
* @return {Promise<ITransactionsByCustomersStatement>}
*/
public async transactionsByCustomers(
tenantId: number,
query: ITransactionsByCustomersFilter
): Promise<ITransactionsByCustomersStatement> {
const { 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',
});
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 by customers data mapper.
const reportInstance = new TransactionsByCustomers(
customers,
new Map(Object.entries(groupBy(transactions, 'contactId'))),
baseCurrency
);
const reportData = reportInstance.reportData();
const reportColumns = reportInstance.reportColumns();
return {
data: reportData,
columns: reportColumns,
};
}
}