fix: bank transactions report

This commit is contained in:
Ahmed Bouhuolia
2024-07-07 22:11:57 +02:00
parent b7487f19d3
commit 9a5befbee7
28 changed files with 560 additions and 158 deletions

View File

@@ -1,20 +1,20 @@
import R from 'ramda';
import moment from 'moment';
import { first, isEmpty } from 'lodash';
import {
ICashflowAccountTransaction,
ICashflowAccountTransactionsQuery,
INumberFormatQuery,
} from '@/interfaces';
import FinancialSheet from '../FinancialSheet';
import { runningAmount } from 'utils';
import { CashflowAccountTransactionsRepo } from './CashflowAccountTransactionsRepo';
import { BankTransactionStatus } from './constants';
import { formatBankTransactionsStatus } from './utils';
export default class CashflowAccountTransactionReport extends FinancialSheet {
private transactions: any;
private openingBalance: number;
export class CashflowAccountTransactionReport extends FinancialSheet {
private runningBalance: any;
private numberFormat: INumberFormatQuery;
private baseCurrency: string;
private query: ICashflowAccountTransactionsQuery;
private repo: CashflowAccountTransactionsRepo;
/**
* Constructor method.
@@ -23,19 +23,61 @@ export default class CashflowAccountTransactionReport extends FinancialSheet {
* @param {ICashflowAccountTransactionsQuery} query -
*/
constructor(
transactions,
openingBalance: number,
repo: CashflowAccountTransactionsRepo,
query: ICashflowAccountTransactionsQuery
) {
super();
this.transactions = transactions;
this.openingBalance = openingBalance;
this.runningBalance = runningAmount(this.openingBalance);
this.repo = repo;
this.query = query;
this.numberFormat = query.numberFormat;
this.baseCurrency = 'USD';
this.runningBalance = runningAmount(this.repo.openingBalance);
}
/**
* Retrieves the transaction status.
* @param {} transaction
* @returns {BankTransactionStatus}
*/
private getTransactionStatus(transaction: any): BankTransactionStatus {
const categorizedTrans = this.repo.uncategorizedTransactionsMapByRef.get(
`${transaction.referenceType}-${transaction.referenceId}`
);
const matchedTrans = this.repo.matchedBankTransactionsMapByRef.get(
`${transaction.referenceType}-${transaction.referenceId}`
);
if (!isEmpty(categorizedTrans)) {
return BankTransactionStatus.Categorized;
} else if (!isEmpty(matchedTrans)) {
return BankTransactionStatus.Matched;
} else {
return BankTransactionStatus.Manual;
}
}
/**
* Retrieves the uncategoized transaction id from the given transaction.
* @param transaction
* @returns {number|null}
*/
private getUncategorizedTransId(transaction: any): number {
// The given transaction would be categorized, matched or not, so we'd take a look at
// the categorized transaction first to get the id if not exist, then should look at the matched
// transaction if not exist too, so the given transaction has no uncategorized transaction id.
const categorizedTrans = this.repo.uncategorizedTransactionsMapByRef.get(
`${transaction.referenceType}-${transaction.referenceId}`
);
const matchedTrans = this.repo.matchedBankTransactionsMapByRef.get(
`${transaction.referenceType}-${transaction.referenceId}`
);
// Relation between the transaction and matching always been one-to-one.
const firstCategorizedTrans = first(categorizedTrans);
const firstMatchedTrans = first(matchedTrans);
return (
(firstCategorizedTrans?.id ||
firstMatchedTrans?.uncategorizedTransactionId ||
null
);
}
/**
@@ -44,6 +86,10 @@ export default class CashflowAccountTransactionReport extends FinancialSheet {
* @returns {ICashflowAccountTransaction}
*/
private transactionNode = (transaction: any): ICashflowAccountTransaction => {
const status = this.getTransactionStatus(transaction);
const uncategorizedTransactionId =
this.getUncategorizedTransId(transaction);
return {
date: transaction.date,
formattedDate: moment(transaction.date).format('YYYY-MM-DD'),
@@ -67,6 +113,9 @@ export default class CashflowAccountTransactionReport extends FinancialSheet {
balance: 0,
formattedBalance: '',
status,
formattedStatus: formatBankTransactionsStatus(status),
uncategorizedTransactionId,
};
};
@@ -146,6 +195,6 @@ export default class CashflowAccountTransactionReport extends FinancialSheet {
* @returns {ICashflowAccountTransaction[]}
*/
public reportData(): ICashflowAccountTransaction[] {
return this.transactionsNode(this.transactions);
return this.transactionsNode(this.repo.transactions);
}
}

View File

@@ -1,30 +1,59 @@
import { Service, Inject } from 'typedi';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { ICashflowAccountTransactionsQuery, IPaginationMeta } from '@/interfaces';
import * as R from 'ramda';
import { ICashflowAccountTransactionsQuery } from '@/interfaces';
import {
groupMatchedBankTransactions,
groupUncategorizedTransactions,
} from './utils';
@Service()
export default class CashflowAccountTransactionsRepo {
@Inject()
private tenancy: HasTenancyService;
export class CashflowAccountTransactionsRepo {
private models: any;
public query: ICashflowAccountTransactionsQuery;
public transactions: any;
public uncategorizedTransactions: any;
public uncategorizedTransactionsMapByRef: Map<string, any>;
public matchedBankTransactions: any;
public matchedBankTransactionsMapByRef: Map<string, any>;
public pagination: any;
public openingBalance: any;
/**
* Constructor method.
* @param {any} models
* @param {ICashflowAccountTransactionsQuery} query
*/
constructor(models: any, query: ICashflowAccountTransactionsQuery) {
this.models = models;
this.query = query;
}
/**
* Async initalize the resources.
*/
async asyncInit() {
await this.initCashflowAccountTransactions();
await this.initCashflowAccountOpeningBalance();
await this.initCategorizedTransactions();
await this.initMatchedTransactions();
}
/**
* Retrieve the cashflow account transactions.
* @param {number} tenantId -
* @param {ICashflowAccountTransactionsQuery} query -
*/
async getCashflowAccountTransactions(
tenantId: number,
query: ICashflowAccountTransactionsQuery
) {
const { AccountTransaction } = this.tenancy.models(tenantId);
async initCashflowAccountTransactions() {
const { AccountTransaction } = this.models;
return AccountTransaction.query()
.where('account_id', query.accountId)
const { results, pagination } = await AccountTransaction.query()
.where('account_id', this.query.accountId)
.orderBy([
{ column: 'date', order: 'desc' },
{ column: 'created_at', order: 'desc' },
])
.pagination(query.page - 1, query.pageSize);
.pagination(this.query.page - 1, this.query.pageSize);
this.transactions = results;
this.pagination = pagination;
}
/**
@@ -34,22 +63,18 @@ export default class CashflowAccountTransactionsRepo {
* @param {IPaginationMeta} pagination
* @return {Promise<number>}
*/
async getCashflowAccountOpeningBalance(
tenantId: number,
accountId: number,
pagination: IPaginationMeta
): Promise<number> {
const { AccountTransaction } = this.tenancy.models(tenantId);
async initCashflowAccountOpeningBalance(): Promise<void> {
const { AccountTransaction } = this.models;
// Retrieve the opening balance of credit and debit balances.
const openingBalancesSubquery = AccountTransaction.query()
.where('account_id', accountId)
.where('account_id', this.query.accountId)
.orderBy([
{ column: 'date', order: 'desc' },
{ column: 'created_at', order: 'desc' },
])
.limit(pagination.total)
.offset(pagination.pageSize * (pagination.page - 1));
.limit(this.pagination.total)
.offset(this.pagination.pageSize * (this.pagination.page - 1));
// Sumation of credit and debit balance.
const openingBalances = await AccountTransaction.query()
@@ -60,6 +85,43 @@ export default class CashflowAccountTransactionsRepo {
const openingBalance = openingBalances.debit - openingBalances.credit;
return openingBalance;
this.openingBalance = openingBalance;
}
/**
* Initialize the uncategorized transactions of the bank account.
*/
async initCategorizedTransactions() {
const { UncategorizedCashflowTransaction } = this.models;
const refs = this.transactions.map((t) => [t.referenceType, t.referenceId]);
const uncategorizedTransactions =
await UncategorizedCashflowTransaction.query().whereIn(
['categorizeRefType', 'categorizeRefId'],
refs
);
this.uncategorizedTransactions = uncategorizedTransactions;
this.uncategorizedTransactionsMapByRef = groupUncategorizedTransactions(
uncategorizedTransactions
);
}
/**
* Initialize the matched bank transactions of the bank account.
*/
async initMatchedTransactions(): Promise<void> {
const { MatchedBankTransaction } = this.models;
const refs = this.transactions.map((t) => [t.referenceType, t.referenceId]);
const matchedBankTransactions =
await MatchedBankTransaction.query().whereIn(
['referenceType', 'referenceId'],
refs
);
this.matchedBankTransactions = matchedBankTransactions;
this.matchedBankTransactionsMapByRef = groupMatchedBankTransactions(
matchedBankTransactions
);
}
}

View File

@@ -1,26 +1,16 @@
import { Service, Inject } from 'typedi';
import { includes } from 'lodash';
import * as qim from 'qim';
import { ICashflowAccountTransactionsQuery, IAccount } from '@/interfaces';
import TenancyService from '@/services/Tenancy/TenancyService';
import FinancialSheet from '../FinancialSheet';
import CashflowAccountTransactionsRepo from './CashflowAccountTransactionsRepo';
import CashflowAccountTransactionsReport from './CashflowAccountTransactions';
import { ACCOUNT_TYPE } from '@/data/AccountTypes';
import { ServiceError } from '@/exceptions';
import { ERRORS } from './constants';
import { CashflowAccountTransactionReport } from './CashflowAccountTransactions';
import I18nService from '@/services/I18n/I18nService';
import { CashflowAccountTransactionsRepo } from './CashflowAccountTransactionsRepo';
@Service()
export default class CashflowAccountTransactionsService extends FinancialSheet {
@Inject()
tenancy: TenancyService;
@Inject()
cashflowTransactionsRepo: CashflowAccountTransactionsRepo;
@Inject()
i18nService: I18nService;
private tenancy: TenancyService;
/**
* Defaults balance sheet filter query.
@@ -50,59 +40,24 @@ export default class CashflowAccountTransactionsService extends FinancialSheet {
tenantId: number,
query: ICashflowAccountTransactionsQuery
) {
const { Account } = this.tenancy.models(tenantId);
const models = this.tenancy.models(tenantId);
const parsedQuery = { ...this.defaultQuery, ...query };
// Retrieve the given account or throw not found service error.
const account = await Account.query().findById(parsedQuery.accountId);
// Validates the cashflow account type.
this.validateCashflowAccountType(account);
// Retrieve the cashflow account transactions.
const { results: transactions, pagination } =
await this.cashflowTransactionsRepo.getCashflowAccountTransactions(
tenantId,
parsedQuery
);
// Retrieve the cashflow account opening balance.
const openingBalance =
await this.cashflowTransactionsRepo.getCashflowAccountOpeningBalance(
tenantId,
parsedQuery.accountId,
pagination
);
// Retrieve the computed report.
const report = new CashflowAccountTransactionsReport(
transactions,
openingBalance,
// Initalize the bank transactions report repository.
const cashflowTransactionsRepo = new CashflowAccountTransactionsRepo(
models,
parsedQuery
);
const reportTranasctions = report.reportData();
await cashflowTransactionsRepo.asyncInit();
return {
transactions: this.i18nService.i18nApply(
[[qim.$each, 'formattedTransactionType']],
reportTranasctions,
tenantId
),
pagination,
};
}
// Retrieve the computed report.
const report = new CashflowAccountTransactionReport(
cashflowTransactionsRepo,
parsedQuery
);
const transactions = report.reportData();
const pagination = cashflowTransactionsRepo.pagination;
/**
* Validates the cashflow account type.
* @param {IAccount} account -
*/
private validateCashflowAccountType(account: IAccount) {
const cashflowTypes = [
ACCOUNT_TYPE.CASH,
ACCOUNT_TYPE.CREDIT_CARD,
ACCOUNT_TYPE.BANK,
];
if (!includes(cashflowTypes, account.accountType)) {
throw new ServiceError(ERRORS.ACCOUNT_ID_HAS_INVALID_TYPE);
}
return { transactions, pagination };
}
}

View File

@@ -1,3 +1,9 @@
export const ERRORS = {
ACCOUNT_ID_HAS_INVALID_TYPE: 'ACCOUNT_ID_HAS_INVALID_TYPE',
};
export enum BankTransactionStatus {
Categorized = 'categorized',
Matched = 'matched',
Manual = 'manual',
}

View File

@@ -0,0 +1,40 @@
import * as R from 'ramda';
export const groupUncategorizedTransactions = (
uncategorizedTransactions: any
): Map<string, any> => {
return new Map(
R.toPairs(
R.groupBy(
(transaction) =>
`${transaction.categorizeRefType}-${transaction.categorizeRefId}`,
uncategorizedTransactions
)
)
);
};
export const groupMatchedBankTransactions = (
uncategorizedTransactions: any
): Map<string, any> => {
return new Map(
R.toPairs(
R.groupBy(
(transaction) =>
`${transaction.referenceType}-${transaction.referenceId}`,
uncategorizedTransactions
)
)
);
};
export const formatBankTransactionsStatus = (status) => {
switch (status) {
case 'categorized':
return 'Categorized';
case 'matched':
return 'Matched';
case 'manual':
return 'Manual';
}
};