Merge pull request #511 from bigcapitalhq/BIG-208

feat: Bank rules for uncategorized transactions
This commit is contained in:
Ahmed Bouhuolia
2024-07-03 19:43:28 +02:00
committed by GitHub
180 changed files with 8249 additions and 289 deletions

View File

@@ -0,0 +1,55 @@
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { Server } from 'socket.io';
import { Inject, Service } from 'typedi';
@Service()
export class GetBankAccountSummary {
@Inject()
private tenancy: HasTenancyService;
/**
* Retrieves the bank account meta summary
* @param {number} tenantId
* @param {number} bankAccountId
* @returns
*/
public async getBankAccountSummary(tenantId: number, bankAccountId: number) {
const {
Account,
UncategorizedCashflowTransaction,
RecognizedBankTransaction,
} = this.tenancy.models(tenantId);
const bankAccount = await Account.query()
.findById(bankAccountId)
.throwIfNotFound();
// Retrieves the uncategorized transactions count of the given bank account.
const uncategorizedTranasctionsCount =
await UncategorizedCashflowTransaction.query()
.where('accountId', bankAccountId)
.count('id as total')
.first();
// Retrieves the recognized transactions count of the given bank account.
const recognizedTransactionsCount = await RecognizedBankTransaction.query()
.whereExists(
UncategorizedCashflowTransaction.query().where(
'accountId',
bankAccountId
)
)
.count('id as total')
.first();
const totalUncategorizedTransactions =
uncategorizedTranasctionsCount?.total;
const totalRecognizedTransactions = recognizedTransactionsCount?.total;
return {
name: bankAccount.name,
totalUncategorizedTransactions,
totalRecognizedTransactions,
};
}
}

View File

@@ -0,0 +1,41 @@
import HasTenancyService from '@/services/Tenancy/TenancyService';
import UnitOfWork from '@/services/UnitOfWork';
import { Inject, Service } from 'typedi';
import { validateTransactionNotCategorized } from './utils';
@Service()
export class ExcludeBankTransaction {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private uow: UnitOfWork;
/**
* Marks the given bank transaction as excluded.
* @param {number} tenantId
* @param {number} bankTransactionId
* @returns {Promise<void>}
*/
public async excludeBankTransaction(
tenantId: number,
uncategorizedTransactionId: number
) {
const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId);
const oldUncategorizedTransaction =
await UncategorizedCashflowTransaction.query()
.findById(uncategorizedTransactionId)
.throwIfNotFound();
validateTransactionNotCategorized(oldUncategorizedTransaction);
return this.uow.withTransaction(tenantId, async (trx) => {
await UncategorizedCashflowTransaction.query(trx)
.findById(uncategorizedTransactionId)
.patch({
excludedAt: new Date(),
});
});
}
}

View File

@@ -0,0 +1,59 @@
import { Inject, Service } from 'typedi';
import { ExcludeBankTransaction } from './ExcludeBankTransaction';
import { UnexcludeBankTransaction } from './UnexcludeBankTransaction';
import { GetExcludedBankTransactionsService } from './GetExcludedBankTransactions';
import { ExcludedBankTransactionsQuery } from './_types';
@Service()
export class ExcludeBankTransactionsApplication {
@Inject()
private excludeBankTransactionService: ExcludeBankTransaction;
@Inject()
private unexcludeBankTransactionService: UnexcludeBankTransaction;
@Inject()
private getExcludedBankTransactionsService: GetExcludedBankTransactionsService;
/**
* Marks a bank transaction as excluded.
* @param {number} tenantId - The ID of the tenant.
* @param {number} bankTransactionId - The ID of the bank transaction to exclude.
* @returns {Promise<void>}
*/
public excludeBankTransaction(tenantId: number, bankTransactionId: number) {
return this.excludeBankTransactionService.excludeBankTransaction(
tenantId,
bankTransactionId
);
}
/**
* Marks a bank transaction as not excluded.
* @param {number} tenantId - The ID of the tenant.
* @param {number} bankTransactionId - The ID of the bank transaction to exclude.
* @returns {Promise<void>}
*/
public unexcludeBankTransaction(tenantId: number, bankTransactionId: number) {
return this.unexcludeBankTransactionService.unexcludeBankTransaction(
tenantId,
bankTransactionId
);
}
/**
* Retrieves the excluded bank transactions.
* @param {number} tenantId
* @param {ExcludedBankTransactionsQuery} filter
* @returns {}
*/
public getExcludedBankTransactions(
tenantId: number,
filter: ExcludedBankTransactionsQuery
) {
return this.getExcludedBankTransactionsService.getExcludedBankTransactions(
tenantId,
filter
);
}
}

View File

@@ -0,0 +1,52 @@
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { Inject, Service } from 'typedi';
import { ExcludedBankTransactionsQuery } from './_types';
import { UncategorizedTransactionTransformer } from '@/services/Cashflow/UncategorizedTransactionTransformer';
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
@Service()
export class GetExcludedBankTransactionsService {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private transformer: TransformerInjectable;
/**
* Retrieves the excluded uncategorized bank transactions.
* @param {number} tenantId
* @param {ExcludedBankTransactionsQuery} filter
* @returns
*/
public async getExcludedBankTransactions(
tenantId: number,
filter: ExcludedBankTransactionsQuery
) {
const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId);
// Parsed query with default values.
const _query = {
page: 1,
pageSize: 20,
...filter,
};
const { results, pagination } =
await UncategorizedCashflowTransaction.query()
.onBuild((q) => {
q.modify('excluded');
q.orderBy('date', 'DESC');
if (_query.accountId) {
q.where('account_id', _query.accountId);
}
})
.pagination(_query.page - 1, _query.pageSize);
const data = await this.transformer.transform(
tenantId,
results,
new UncategorizedTransactionTransformer()
);
return { data, pagination };
}
}

View File

@@ -0,0 +1,41 @@
import HasTenancyService from '@/services/Tenancy/TenancyService';
import UnitOfWork from '@/services/UnitOfWork';
import { Inject, Service } from 'typedi';
import { validateTransactionNotCategorized } from './utils';
@Service()
export class UnexcludeBankTransaction {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private uow: UnitOfWork;
/**
* Marks the given bank transaction as excluded.
* @param {number} tenantId
* @param {number} bankTransactionId
* @returns {Promise<void>}
*/
public async unexcludeBankTransaction(
tenantId: number,
uncategorizedTransactionId: number
) {
const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId);
const oldUncategorizedTransaction =
await UncategorizedCashflowTransaction.query()
.findById(uncategorizedTransactionId)
.throwIfNotFound();
validateTransactionNotCategorized(oldUncategorizedTransaction);
return this.uow.withTransaction(tenantId, async (trx) => {
await UncategorizedCashflowTransaction.query(trx)
.findById(uncategorizedTransactionId)
.patch({
excludedAt: null,
});
});
}
}

View File

@@ -0,0 +1,6 @@
export interface ExcludedBankTransactionsQuery {
page?: number;
pageSize?: number;
accountId?: number;
}

View File

@@ -0,0 +1,14 @@
import { ServiceError } from '@/exceptions';
import UncategorizedCashflowTransaction from '@/models/UncategorizedCashflowTransaction';
const ERRORS = {
TRANSACTION_ALREADY_CATEGORIZED: 'TRANSACTION_ALREADY_CATEGORIZED',
};
export const validateTransactionNotCategorized = (
transaction: UncategorizedCashflowTransaction
) => {
if (transaction.categorized) {
throw new ServiceError(ERRORS.TRANSACTION_ALREADY_CATEGORIZED);
}
};

View File

@@ -0,0 +1,103 @@
import { Transformer } from '@/lib/Transformer/Transformer';
export class GetMatchedTransactionBillsTransformer extends Transformer {
/**
* Include these attributes to sale credit note object.
* @returns {Array}
*/
public includeAttributes = (): string[] => {
return [
'referenceNo',
'amount',
'amountFormatted',
'transactionNo',
'date',
'dateFormatted',
'transactionId',
'transactionNo',
'transactionType',
'transsactionTypeFormatted',
];
};
/**
* Exclude all attributes.
* @returns {Array<string>}
*/
public excludeAttributes = (): string[] => {
return ['*'];
};
/**
* Retrieve the reference number of the bill.
* @param {Object} bill - The bill object.
* @returns {string}
*/
protected referenceNo(bill) {
return bill.referenceNo;
}
/**
* Retrieve the amount of the bill.
* @param {Object} bill - The bill object.
* @returns {number}
*/
protected amount(bill) {
return bill.amount;
}
/**
* Retrieve the formatted amount of the bill.
* @param {Object} bill - The bill object.
* @returns {string}
*/
protected amountFormatted(bill) {
return this.formatNumber(bill.amount, {
currencyCode: bill.currencyCode,
money: true,
});
}
/**
* Retrieve the date of the bill.
* @param {Object} bill - The bill object.
* @returns {string}
*/
protected date(bill) {
return bill.billDate;
}
/**
* Retrieve the formatted date of the bill.
* @param {Object} bill - The bill object.
* @returns {string}
*/
protected dateFormatted(bill) {
return this.formatDate(bill.billDate);
}
/**
* Retrieve the transcation id of the bill.
* @param {Object} bill - The bill object.
* @returns {number}
*/
protected transactionId(bill) {
return bill.id;
}
/**
* Retrieve the manual journal transaction type.
* @returns {string}
*/
protected transactionType() {
return 'Bill';
}
/**
* Retrieves the manual journal formatted transaction type.
* @returns {string}
*/
protected transsactionTypeFormatted() {
return 'Bill';
}
}

View File

@@ -0,0 +1,114 @@
import { Transformer } from '@/lib/Transformer/Transformer';
export class GetMatchedTransactionExpensesTransformer extends Transformer {
/**
* Include these attributes to sale credit note object.
* @returns {Array}
*/
public includeAttributes = (): string[] => {
return [
'referenceNo',
'amount',
'amountFormatted',
'transactionNo',
'date',
'dateFormatted',
'transactionId',
'transactionNo',
'transactionType',
'transsactionTypeFormatted',
];
};
/**
* Exclude all attributes.
* @returns {Array<string>}
*/
public excludeAttributes = (): string[] => {
return ['*'];
};
/**
* Retrieves the expense reference number.
* @param expense
* @returns {string}
*/
protected referenceNo(expense) {
return expense.referenceNo;
}
/**
* Retrieves the expense amount.
* @param expense
* @returns {number}
*/
protected amount(expense) {
return expense.totalAmount;
}
/**
* Formats the amount of the expense.
* @param expense
* @returns {string}
*/
protected amountFormatted(expense) {
return this.formatNumber(expense.totalAmount, {
currencyCode: expense.currencyCode,
money: true,
});
}
/**
* Retrieves the date of the expense.
* @param expense
* @returns {Date}
*/
protected date(expense) {
return expense.paymentDate;
}
/**
* Formats the date of the expense.
* @param expense
* @returns {string}
*/
protected dateFormatted(expense) {
return this.formatDate(expense.paymentDate);
}
/**
* Retrieves the transaction ID of the expense.
* @param expense
* @returns {number}
*/
protected transactionId(expense) {
return expense.id;
}
/**
* Retrieves the expense transaction number.
* @param expense
* @returns {string}
*/
protected transactionNo(expense) {
return expense.expenseNo;
}
/**
* Retrieves the expense transaction type.
* @param expense
* @returns {String}
*/
protected transactionType() {
return 'Expense';
}
/**
* Retrieves the formatted transaction type of the expense.
* @param expense
* @returns {string}
*/
protected transsactionTypeFormatted() {
return 'Expense';
}
}

View File

@@ -0,0 +1,111 @@
import { Transformer } from '@/lib/Transformer/Transformer';
export class GetMatchedTransactionInvoicesTransformer extends Transformer {
/**
* Include these attributes to sale credit note object.
* @returns {Array}
*/
public includeAttributes = (): string[] => {
return [
'referenceNo',
'amount',
'amountFormatted',
'transactionNo',
'date',
'dateFormatted',
'transactionId',
'transactionNo',
'transactionType',
'transsactionTypeFormatted',
];
};
/**
* Exclude all attributes.
* @returns {Array<string>}
*/
public excludeAttributes = (): string[] => {
return ['*'];
};
/**
* Retrieve the invoice reference number.
* @returns {string}
*/
protected referenceNo(invoice) {
return invoice.referenceNo;
}
/**
* Retrieve the invoice amount.
* @param invoice
* @returns {number}
*/
protected amount(invoice) {
return invoice.dueAmount;
}
/**
* Format the amount of the invoice.
* @param invoice
* @returns {string}
*/
protected formatAmount(invoice) {
return this.formatNumber(invoice.dueAmount, {
currencyCode: invoice.currencyCode,
money: true,
});
}
/**
* Retrieve the date of the invoice.
* @param invoice
* @returns {Date}
*/
protected date(invoice) {
return invoice.invoiceDate;
}
/**
* Format the date of the invoice.
* @param invoice
* @returns {string}
*/
protected dateFormatted(invoice) {
return this.formatDate(invoice.invoiceDate);
}
/**
* Retrieve the transaction ID of the invoice.
* @param invoice
* @returns {number}
*/
protected getTransactionId(invoice) {
return invoice.id;
}
/**
* Retrieve the invoice transaction number.
* @param invoice
* @returns {string}
*/
protected transactionNo(invoice) {
return invoice.invoiceNo;
}
/**
* Retrieve the invoice transaction type.
* @param invoice
* @returns {String}
*/
protected transactionType(invoice) {
return 'SaleInvoice';
}
/**
* Retrieve the invoice formatted transaction type.
* @param invoice
* @returns {string}
*/
protected transsactionTypeFormatted(invoice) {
return 'Sale invoice';
}
}

View File

@@ -0,0 +1,111 @@
import { Transformer } from '@/lib/Transformer/Transformer';
export class GetMatchedTransactionManualJournalsTransformer extends Transformer {
/**
* Include these attributes to sale credit note object.
* @returns {Array}
*/
public includeAttributes = (): string[] => {
return [
'referenceNo',
'amount',
'amountFormatted',
'transactionNo',
'date',
'dateFormatted',
'transactionId',
'transactionNo',
'transactionType',
'transsactionTypeFormatted',
];
};
/**
* Exclude all attributes.
* @returns {Array<string>}
*/
public excludeAttributes = (): string[] => {
return ['*'];
};
/**
* Retrieves the manual journal reference no.
* @param manualJournal
* @returns {string}
*/
protected referenceNo(manualJournal) {
return manualJournal.referenceNo;
}
/**
* Retrieves the manual journal amount.
* @param manualJournal
* @returns {number}
*/
protected amount(manualJournal) {
return manualJournal.amount;
}
/**
* Retrieves the manual journal formatted amount.
* @param manualJournal
* @returns {string}
*/
protected amountFormatted(manualJournal) {
return this.formatNumber(manualJournal.amount, {
currencyCode: manualJournal.currencyCode,
money: true,
});
}
/**
* Retreives the manual journal date.
* @param manualJournal
* @returns {Date}
*/
protected date(manualJournal) {
return manualJournal.date;
}
/**
* Retrieves the manual journal formatted date.
* @param manualJournal
* @returns {string}
*/
protected dateFormatted(manualJournal) {
return this.formatDate(manualJournal.date);
}
/**
* Retrieve the manual journal transaction id.
* @returns {number}
*/
protected transactionId(manualJournal) {
return manualJournal.id;
}
/**
* Retrieve the manual journal transaction number.
* @param manualJournal
*/
protected transactionNo(manualJournal) {
return manualJournal.journalNumber;
}
/**
* Retrieve the manual journal transaction type.
* @returns {string}
*/
protected transactionType() {
return 'ManualJournal';
}
/**
* Retrieves the manual journal formatted transaction type.
* @returns {string}
*/
protected transsactionTypeFormatted() {
return 'Manual Journal';
}
}

View File

@@ -0,0 +1,107 @@
import { Inject, Service } from 'typedi';
import * as R from 'ramda';
import moment from 'moment';
import { PromisePool } from '@supercharge/promise-pool';
import { GetMatchedTransactionsFilter, MatchedTransactionsPOJO } from './types';
import { GetMatchedTransactionsByExpenses } from './GetMatchedTransactionsByExpenses';
import { GetMatchedTransactionsByBills } from './GetMatchedTransactionsByBills';
import { GetMatchedTransactionsByManualJournals } from './GetMatchedTransactionsByManualJournals';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { sortClosestMatchTransactions } from './_utils';
@Service()
export class GetMatchedTransactions {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private getMatchedInvoicesService: GetMatchedTransactionsByExpenses;
@Inject()
private getMatchedBillsService: GetMatchedTransactionsByBills;
@Inject()
private getMatchedManualJournalService: GetMatchedTransactionsByManualJournals;
@Inject()
private getMatchedExpensesService: GetMatchedTransactionsByExpenses;
/**
* Registered matched transactions types.
*/
get registered() {
return [
{ type: 'SaleInvoice', service: this.getMatchedInvoicesService },
{ type: 'Bill', service: this.getMatchedBillsService },
{ type: 'Expense', service: this.getMatchedExpensesService },
{ type: 'ManualJournal', service: this.getMatchedManualJournalService },
];
}
/**
* Retrieves the matched transactions.
* @param {number} tenantId -
* @param {GetMatchedTransactionsFilter} filter -
* @returns {Promise<MatchedTransactionsPOJO>}
*/
public async getMatchedTransactions(
tenantId: number,
uncategorizedTransactionId: number,
filter: GetMatchedTransactionsFilter
): Promise<MatchedTransactionsPOJO> {
const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId);
const uncategorizedTransaction =
await UncategorizedCashflowTransaction.query()
.findById(uncategorizedTransactionId)
.throwIfNotFound();
const filtered = filter.transactionType
? this.registered.filter((item) => item.type === filter.transactionType)
: this.registered;
const matchedTransactions = await PromisePool.withConcurrency(2)
.for(filtered)
.process(async ({ type, service }) => {
return service.getMatchedTransactions(tenantId, filter);
});
const { perfectMatches, possibleMatches } = this.groupMatchedResults(
uncategorizedTransaction,
matchedTransactions
);
return {
perfectMatches,
possibleMatches,
};
}
/**
* Groups the given results for getting perfect and possible matches
* based on the given uncategorized transaction.
* @param uncategorizedTransaction
* @param matchedTransactions
* @returns {MatchedTransactionsPOJO}
*/
private groupMatchedResults(
uncategorizedTransaction,
matchedTransactions
): MatchedTransactionsPOJO {
const results = R.compose(R.flatten)(matchedTransactions?.results);
// Sort the results based on amount, date, and transaction type
const closestResullts = sortClosestMatchTransactions(
uncategorizedTransaction,
results
);
const perfectMatches = R.filter(
(match) =>
match.amount === uncategorizedTransaction.amount &&
moment(match.date).isSame(uncategorizedTransaction.date, 'day'),
closestResullts
);
const possibleMatches = R.difference(closestResullts, perfectMatches);
return { perfectMatches, possibleMatches };
}
}

View File

@@ -0,0 +1,58 @@
import { Inject, Service } from 'typedi';
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
import { GetMatchedTransactionBillsTransformer } from './GetMatchedTransactionBillsTransformer';
import { GetMatchedTransactionsFilter, MatchedTransactionPOJO } from './types';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { GetMatchedTransactionsByType } from './GetMatchedTransactionsByType';
@Service()
export class GetMatchedTransactionsByBills extends GetMatchedTransactionsByType {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private transformer: TransformerInjectable;
/**
* Retrieves the matched transactions.
* @param {number} tenantId -
* @param {GetMatchedTransactionsFilter} filter -
*/
public async getMatchedTransactions(
tenantId: number,
filter: GetMatchedTransactionsFilter
) {
const { Bill } = this.tenancy.models(tenantId);
const bills = await Bill.query().onBuild((q) => {
q.whereNotExists(Bill.relatedQuery('matchedBankTransaction'));
});
return this.transformer.transform(
tenantId,
bills,
new GetMatchedTransactionBillsTransformer()
);
}
/**
* Retrieves the given bill matched transaction.
* @param {number} tenantId
* @param {number} transactionId
* @returns {Promise<MatchedTransactionPOJO>}
*/
public async getMatchedTransaction(
tenantId: number,
transactionId: number
): Promise<MatchedTransactionPOJO> {
const { Bill } = this.tenancy.models(tenantId);
const bill = await Bill.query().findById(transactionId).throwIfNotFound();
return this.transformer.transform(
tenantId,
bill,
new GetMatchedTransactionBillsTransformer()
);
}
}

View File

@@ -0,0 +1,72 @@
import { Inject, Service } from 'typedi';
import { GetMatchedTransactionsFilter, MatchedTransactionPOJO } from './types';
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { GetMatchedTransactionsByType } from './GetMatchedTransactionsByType';
import { GetMatchedTransactionExpensesTransformer } from './GetMatchedTransactionExpensesTransformer';
@Service()
export class GetMatchedTransactionsByExpenses extends GetMatchedTransactionsByType {
@Inject()
protected tenancy: HasTenancyService;
@Inject()
protected transformer: TransformerInjectable;
/**
* Retrieves the matched transactions of expenses.
* @param {number} tenantId
* @param {GetMatchedTransactionsFilter} filter
* @returns
*/
async getMatchedTransactions(
tenantId: number,
filter: GetMatchedTransactionsFilter
) {
const { Expense } = this.tenancy.models(tenantId);
const expenses = await Expense.query().onBuild((query) => {
query.whereNotExists(Expense.relatedQuery('matchedBankTransaction'));
if (filter.fromDate) {
query.where('payment_date', '>=', filter.fromDate);
}
if (filter.toDate) {
query.where('payment_date', '<=', filter.toDate);
}
if (filter.minAmount) {
query.where('total_amount', '>=', filter.minAmount);
}
if (filter.maxAmount) {
query.where('total_amount', '<=', filter.maxAmount);
}
});
return this.transformer.transform(
tenantId,
expenses,
new GetMatchedTransactionExpensesTransformer()
);
}
/**
* Retrieves the given matched expense transaction.
* @param {number} tenantId
* @param {number} transactionId
* @returns {GetMatchedTransactionExpensesTransformer-}
*/
public async getMatchedTransaction(
tenantId: number,
transactionId: number
): Promise<MatchedTransactionPOJO> {
const { Expense } = this.tenancy.models(tenantId);
const expense = await Expense.query()
.findById(transactionId)
.throwIfNotFound();
return this.transformer.transform(
tenantId,
expense,
new GetMatchedTransactionExpensesTransformer()
);
}
}

View File

@@ -0,0 +1,63 @@
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
import { GetMatchedTransactionInvoicesTransformer } from './GetMatchedTransactionInvoicesTransformer';
import {
GetMatchedTransactionsFilter,
MatchedTransactionPOJO,
MatchedTransactionsPOJO,
} from './types';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { Inject, Service } from 'typedi';
import { GetMatchedTransactionsByType } from './GetMatchedTransactionsByType';
@Service()
export class GetMatchedTransactionsByInvoices extends GetMatchedTransactionsByType {
@Inject()
protected tenancy: HasTenancyService;
@Inject()
protected transformer: TransformerInjectable;
/**
* Retrieves the matched transactions.
* @param {number} tenantId -
* @param {GetMatchedTransactionsFilter} filter -
* @returns {Promise<MatchedTransactionsPOJO>}
*/
public async getMatchedTransactions(
tenantId: number,
filter: GetMatchedTransactionsFilter
): Promise<MatchedTransactionsPOJO> {
const { SaleInvoice } = this.tenancy.models(tenantId);
const invoices = await SaleInvoice.query().onBuild((q) => {
q.whereNotExists(SaleInvoice.relatedQuery('matchedBankTransaction'));
});
return this.transformer.transform(
tenantId,
invoices,
new GetMatchedTransactionInvoicesTransformer()
);
}
/**
* Retrieves the matched transaction.
* @param {number} tenantId
* @param {number} transactionId
* @returns {Promise<MatchedTransactionPOJO>}
*/
public async getMatchedTransaction(
tenantId: number,
transactionId: number
): Promise<MatchedTransactionPOJO> {
const { SaleInvoice } = this.tenancy.models(tenantId);
const invoice = await SaleInvoice.query().findById(transactionId);
return this.transformer.transform(
tenantId,
invoice,
new GetMatchedTransactionInvoicesTransformer()
);
}
}

View File

@@ -0,0 +1,68 @@
import { Inject, Service } from 'typedi';
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
import { GetMatchedTransactionManualJournalsTransformer } from './GetMatchedTransactionManualJournalsTransformer';
import { GetMatchedTransactionsByType } from './GetMatchedTransactionsByType';
import { GetMatchedTransactionsFilter } from './types';
@Service()
export class GetMatchedTransactionsByManualJournals extends GetMatchedTransactionsByType {
@Inject()
private transformer: TransformerInjectable;
/**
* Retrieve the matched transactions of manual journals.
* @param {number} tenantId
* @param {GetMatchedTransactionsFilter} filter
* @returns
*/
async getMatchedTransactions(
tenantId: number,
filter: Omit<GetMatchedTransactionsFilter, 'transactionType'>
) {
const { ManualJournal } = this.tenancy.models(tenantId);
const manualJournals = await ManualJournal.query().onBuild((query) => {
query.whereNotExists(
ManualJournal.relatedQuery('matchedBankTransaction')
);
if (filter.fromDate) {
query.where('date', '>=', filter.fromDate);
}
if (filter.toDate) {
query.where('date', '<=', filter.toDate);
}
if (filter.minAmount) {
query.where('amount', '>=', filter.minAmount);
}
if (filter.maxAmount) {
query.where('amount', '<=', filter.maxAmount);
}
});
return this.transformer.transform(
tenantId,
manualJournals,
new GetMatchedTransactionManualJournalsTransformer()
);
}
/**
* Retrieves the matched transaction of manual journals.
* @param {number} tenantId
* @param {number} transactionId
* @returns
*/
async getMatchedTransaction(tenantId: number, transactionId: number) {
const { ManualJournal } = this.tenancy.models(tenantId);
const manualJournal = await ManualJournal.query()
.findById(transactionId)
.whereNotExists(ManualJournal.relatedQuery('matchedBankTransaction'))
.throwIfNotFound();
return this.transformer.transform(
tenantId,
manualJournal,
new GetMatchedTransactionManualJournalsTransformer()
);
}
}

View File

@@ -0,0 +1,66 @@
import { Knex } from 'knex';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import {
GetMatchedTransactionsFilter,
IMatchTransactionDTO,
MatchedTransactionPOJO,
MatchedTransactionsPOJO,
} from './types';
import { Inject, Service } from 'typedi';
export abstract class GetMatchedTransactionsByType {
@Inject()
protected tenancy: HasTenancyService;
/**
* Retrieves the matched transactions.
* @param {number} tenantId -
* @param {GetMatchedTransactionsFilter} filter -
* @returns {Promise<MatchedTransactionsPOJO>}
*/
public async getMatchedTransactions(
tenantId: number,
filter: GetMatchedTransactionsFilter
): Promise<MatchedTransactionsPOJO> {
throw new Error(
'The `getMatchedTransactions` method is not defined for the transaction type.'
);
}
/**
* Retrieves the matched transaction details.
* @param {number} tenantId -
* @param {number} transactionId -
* @returns {Promise<MatchedTransactionPOJO>}
*/
public async getMatchedTransaction(
tenantId: number,
transactionId: number
): Promise<MatchedTransactionPOJO> {
throw new Error(
'The `getMatchedTransaction` method is not defined for the transaction type.'
);
}
/**
*
* @param {number} tenantId
* @param {number} uncategorizedTransactionId
* @param {IMatchTransactionDTO} matchTransactionDTO
* @param {Knex.Transaction} trx
*/
public async createMatchedTransaction(
tenantId: number,
uncategorizedTransactionId: number,
matchTransactionDTO: IMatchTransactionDTO,
trx?: Knex.Transaction
) {
const { MatchedBankTransaction } = this.tenancy.models(tenantId);
await MatchedBankTransaction.query(trx).insert({
uncategorizedTransactionId,
referenceType: matchTransactionDTO.referenceType,
referenceId: matchTransactionDTO.referenceId,
});
}
}

View File

@@ -0,0 +1,70 @@
import { Inject, Service } from 'typedi';
import { GetMatchedTransactions } from './GetMatchedTransactions';
import { MatchBankTransactions } from './MatchTransactions';
import { UnmatchMatchedBankTransaction } from './UnmatchMatchedTransaction';
import { GetMatchedTransactionsFilter, IMatchTransactionsDTO } from './types';
@Service()
export class MatchBankTransactionsApplication {
@Inject()
private getMatchedTransactionsService: GetMatchedTransactions;
@Inject()
private matchTransactionService: MatchBankTransactions;
@Inject()
private unmatchMatchedTransactionService: UnmatchMatchedBankTransaction;
/**
* Retrieves the matched transactions.
* @param {number} tenantId -
* @param {GetMatchedTransactionsFilter} filter -
* @returns
*/
public getMatchedTransactions(
tenantId: number,
uncategorizedTransactionId: number,
filter: GetMatchedTransactionsFilter
) {
return this.getMatchedTransactionsService.getMatchedTransactions(
tenantId,
uncategorizedTransactionId,
filter
);
}
/**
* Matches the given uncategorized transaction with the given system transaction.
* @param {number} tenantId
* @param {number} uncategorizedTransactionId
* @param {IMatchTransactionDTO} matchTransactionsDTO
* @returns {Promise<void>}
*/
public matchTransaction(
tenantId: number,
uncategorizedTransactionId: number,
matchTransactionsDTO: IMatchTransactionsDTO
): Promise<void> {
return this.matchTransactionService.matchTransaction(
tenantId,
uncategorizedTransactionId,
matchTransactionsDTO
);
}
/**
* Unmatch the given matched transaction.
* @param {number} tenantId
* @param {number} uncategorizedTransactionId
* @returns {Promise<void>}
*/
public unmatchMatchedTransaction(
tenantId: number,
uncategorizedTransactionId: number
) {
return this.unmatchMatchedTransactionService.unmatchMatchedTransaction(
tenantId,
uncategorizedTransactionId
);
}
}

View File

@@ -0,0 +1,157 @@
import { isEmpty, sumBy } from 'lodash';
import { Knex } from 'knex';
import { Inject, Service } from 'typedi';
import { PromisePool } from '@supercharge/promise-pool';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import UnitOfWork from '@/services/UnitOfWork';
import events from '@/subscribers/events';
import {
ERRORS,
IBankTransactionMatchedEventPayload,
IBankTransactionMatchingEventPayload,
IMatchTransactionsDTO,
} from './types';
import { MatchTransactionsTypes } from './MatchTransactionsTypes';
import { ServiceError } from '@/exceptions';
@Service()
export class MatchBankTransactions {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private uow: UnitOfWork;
@Inject()
private eventPublisher: EventPublisher;
@Inject()
private matchedBankTransactions: MatchTransactionsTypes;
/**
* Validates the match bank transactions DTO.
* @param {number} tenantId
* @param {number} uncategorizedTransactionId
* @param {IMatchTransactionsDTO} matchTransactionsDTO
* @returns {Promise<void>}
*/
async validate(
tenantId: number,
uncategorizedTransactionId: number,
matchTransactionsDTO: IMatchTransactionsDTO
) {
const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId);
const { matchedTransactions } = matchTransactionsDTO;
// Validates the uncategorized transaction existance.
const uncategorizedTransaction =
await UncategorizedCashflowTransaction.query()
.findById(uncategorizedTransactionId)
.withGraphFetched('matchedBankTransactions')
.throwIfNotFound();
// Validates the uncategorized transaction is not already matched.
if (!isEmpty(uncategorizedTransaction.matchedBankTransactions)) {
throw new ServiceError(ERRORS.TRANSACTION_ALREADY_MATCHED);
}
// Validate the uncategorized transaction is not excluded.
if (uncategorizedTransaction.excluded) {
throw new ServiceError(ERRORS.CANNOT_MATCH_EXCLUDED_TRANSACTION);
}
// Validates the given matched transaction.
const validateMatchedTransaction = async (matchedTransaction) => {
const getMatchedTransactionsService =
this.matchedBankTransactions.registry.get(
matchedTransaction.referenceType
);
if (!getMatchedTransactionsService) {
throw new ServiceError(
ERRORS.RESOURCE_TYPE_MATCHING_TRANSACTION_INVALID
);
}
const foundMatchedTransaction =
await getMatchedTransactionsService.getMatchedTransaction(
tenantId,
matchedTransaction.referenceId
);
if (!foundMatchedTransaction) {
throw new ServiceError(ERRORS.RESOURCE_ID_MATCHING_TRANSACTION_INVALID);
}
return foundMatchedTransaction;
};
// Matches the given transactions under promise pool concurrency controlling.
const validatationResult = await PromisePool.withConcurrency(10)
.for(matchedTransactions)
.process(validateMatchedTransaction);
if (validatationResult.errors?.length > 0) {
const error = validatationResult.errors.map((er) => er.raw)[0];
throw new ServiceError(error);
}
// Calculate the total given matching transactions.
const totalMatchedTranasctions = sumBy(
validatationResult.results,
'amount'
);
// Validates the total given matching transcations whether is not equal
// uncategorized transaction amount.
if (totalMatchedTranasctions !== uncategorizedTransaction.amount) {
throw new ServiceError(ERRORS.TOTAL_MATCHING_TRANSACTIONS_INVALID);
}
}
/**
* Matches the given uncategorized transaction to the given references.
* @param {number} tenantId
* @param {number} uncategorizedTransactionId
* @returns {Promise<void>}
*/
public async matchTransaction(
tenantId: number,
uncategorizedTransactionId: number,
matchTransactionsDTO: IMatchTransactionsDTO
): Promise<void> {
const { matchedTransactions } = matchTransactionsDTO;
// Validates the given matching transactions DTO.
await this.validate(
tenantId,
uncategorizedTransactionId,
matchTransactionsDTO
);
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
// Triggers the event `onBankTransactionMatching`.
await this.eventPublisher.emitAsync(events.bankMatch.onMatching, {
tenantId,
uncategorizedTransactionId,
matchTransactionsDTO,
trx,
} as IBankTransactionMatchingEventPayload);
// Matches the given transactions under promise pool concurrency controlling.
await PromisePool.withConcurrency(10)
.for(matchedTransactions)
.process(async (matchedTransaction) => {
const getMatchedTransactionsService =
this.matchedBankTransactions.registry.get(
matchedTransaction.referenceType
);
await getMatchedTransactionsService.createMatchedTransaction(
tenantId,
uncategorizedTransactionId,
matchedTransaction,
trx
);
});
// Triggers the event `onBankTransactionMatched`.
await this.eventPublisher.emitAsync(events.bankMatch.onMatched, {
tenantId,
uncategorizedTransactionId,
matchTransactionsDTO,
trx,
} as IBankTransactionMatchedEventPayload);
});
}
}

View File

@@ -0,0 +1,57 @@
import Container, { Service } from 'typedi';
import { GetMatchedTransactionsByExpenses } from './GetMatchedTransactionsByExpenses';
import { GetMatchedTransactionsByBills } from './GetMatchedTransactionsByBills';
import { GetMatchedTransactionsByManualJournals } from './GetMatchedTransactionsByManualJournals';
import { MatchTransactionsTypesRegistry } from './MatchTransactionsTypesRegistry';
import { GetMatchedTransactionsByInvoices } from './GetMatchedTransactionsByInvoices';
@Service()
export class MatchTransactionsTypes {
private static registry: MatchTransactionsTypesRegistry;
/**
* Consttuctor method.
*/
constructor() {
this.boot();
}
get registered() {
return [
{ type: 'SaleInvoice', service: GetMatchedTransactionsByInvoices },
{ type: 'Bill', service: GetMatchedTransactionsByBills },
{ type: 'Expense', service: GetMatchedTransactionsByExpenses },
{
type: 'ManualJournal',
service: GetMatchedTransactionsByManualJournals,
},
];
}
/**
* Importable instances.
*/
private types = [];
/**
*
*/
public get registry() {
return MatchTransactionsTypes.registry;
}
/**
* Boots all the registered importables.
*/
public boot() {
if (!MatchTransactionsTypes.registry) {
const instance = MatchTransactionsTypesRegistry.getInstance();
this.registered.forEach((registered) => {
const serviceInstanace = Container.get(registered.service);
instance.register(registered.type, serviceInstanace);
});
MatchTransactionsTypes.registry = instance;
}
}
}

View File

@@ -0,0 +1,50 @@
import { camelCase, upperFirst } from 'lodash';
import { GetMatchedTransactionsByType } from './GetMatchedTransactionsByType';
export class MatchTransactionsTypesRegistry {
private static instance: MatchTransactionsTypesRegistry;
private importables: Record<string, GetMatchedTransactionsByType>;
constructor() {
this.importables = {};
}
/**
* Gets singleton instance of registry.
* @returns {MatchTransactionsTypesRegistry}
*/
public static getInstance(): MatchTransactionsTypesRegistry {
if (!MatchTransactionsTypesRegistry.instance) {
MatchTransactionsTypesRegistry.instance =
new MatchTransactionsTypesRegistry();
}
return MatchTransactionsTypesRegistry.instance;
}
/**
* Registers the given importable service.
* @param {string} resource
* @param {GetMatchedTransactionsByType} importable
*/
public register(
resource: string,
importable: GetMatchedTransactionsByType
): void {
const _resource = this.sanitizeResourceName(resource);
this.importables[_resource] = importable;
}
/**
* Retrieves the importable service instance of the given resource name.
* @param {string} name
* @returns {GetMatchedTransactionsByType}
*/
public get(name: string): GetMatchedTransactionsByType {
const _name = this.sanitizeResourceName(name);
return this.importables[_name];
}
private sanitizeResourceName(resource: string) {
return upperFirst(camelCase(resource));
}
}

View File

@@ -0,0 +1,47 @@
import { Inject, Service } from 'typedi';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import UnitOfWork from '@/services/UnitOfWork';
import events from '@/subscribers/events';
import { IBankTransactionUnmatchingEventPayload } from './types';
@Service()
export class UnmatchMatchedBankTransaction {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private uow: UnitOfWork;
@Inject()
private eventPublisher: EventPublisher;
/**
* Unmatch the matched the given uncategorized bank transaction.
* @param {number} tenantId
* @param {number} uncategorizedTransactionId
* @returns {Promise<void>}
*/
public unmatchMatchedTransaction(
tenantId: number,
uncategorizedTransactionId: number
): Promise<void> {
const { MatchedBankTransaction } = this.tenancy.models(tenantId);
return this.uow.withTransaction(tenantId, async (trx) => {
await this.eventPublisher.emitAsync(events.bankMatch.onUnmatching, {
tenantId,
trx,
} as IBankTransactionUnmatchingEventPayload);
await MatchedBankTransaction.query(trx)
.where('uncategorizedTransactionId', uncategorizedTransactionId)
.delete();
await this.eventPublisher.emitAsync(events.bankMatch.onUnmatched, {
tenantId,
trx,
} as IBankTransactionUnmatchingEventPayload);
});
}
}

View File

@@ -0,0 +1,34 @@
import { ServiceError } from '@/exceptions';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { Inject, Service } from 'typedi';
import { ERRORS } from './types';
@Service()
export class ValidateTransactionMatched {
@Inject()
private tenancy: HasTenancyService;
/**
* Validate the given transaction whether is matched with bank transactions.
* @param {number} tenantId
* @param {string} referenceType - Transaction reference type.
* @param {number} referenceId - Transaction reference id.
* @returns {Promise<void>}
*/
public async validateTransactionNoMatchLinking(
tenantId: number,
referenceType: string,
referenceId: number
) {
const { MatchedBankTransaction } = this.tenancy.models(tenantId);
const foundMatchedTransaction =
await MatchedBankTransaction.query().findOne({
referenceType,
referenceId,
});
if (foundMatchedTransaction) {
throw new ServiceError(ERRORS.CANNOT_DELETE_TRANSACTION_MATCHED);
}
}
}

View File

@@ -0,0 +1,22 @@
import moment from 'moment';
import * as R from 'ramda';
import UncategorizedCashflowTransaction from '@/models/UncategorizedCashflowTransaction';
import { MatchedTransactionPOJO } from './types';
export const sortClosestMatchTransactions = (
uncategorizedTransaction: UncategorizedCashflowTransaction,
matches: MatchedTransactionPOJO[]
) => {
return R.sortWith([
// Sort by amount difference (closest to uncategorized transaction amount first)
R.ascend((match: MatchedTransactionPOJO) =>
Math.abs(match.amount - uncategorizedTransaction.amount)
),
// Sort by date difference (closest to uncategorized transaction date first)
R.ascend((match: MatchedTransactionPOJO) =>
Math.abs(
moment(match.date).diff(moment(uncategorizedTransaction.date), 'days')
)
),
])(matches);
};

View File

@@ -0,0 +1,36 @@
import { Inject, Service } from 'typedi';
import { IManualJournalDeletingPayload } from '@/interfaces';
import events from '@/subscribers/events';
import { ValidateTransactionMatched } from '../ValidateTransactionsMatched';
@Service()
export class ValidateMatchingOnCashflowDelete {
@Inject()
private validateNoMatchingLinkedService: ValidateTransactionMatched;
/**
* Constructor method.
*/
public attach(bus) {
bus.subscribe(
events.cashflow.onTransactionDeleting,
this.validateMatchingOnCashflowDeleting.bind(this)
);
}
/**
* Validates the cashflow transaction whether matched with bank transaction on deleting.
* @param {IManualJournalDeletingPayload}
*/
public async validateMatchingOnCashflowDeleting({
tenantId,
oldManualJournal,
trx,
}: IManualJournalDeletingPayload) {
await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking(
tenantId,
'ManualJournal',
oldManualJournal.id
);
}
}

View File

@@ -0,0 +1,36 @@
import { Inject, Service } from 'typedi';
import { IExpenseEventDeletePayload } from '@/interfaces';
import events from '@/subscribers/events';
import { ValidateTransactionMatched } from '../ValidateTransactionsMatched';
@Service()
export class ValidateMatchingOnExpenseDelete {
@Inject()
private validateNoMatchingLinkedService: ValidateTransactionMatched;
/**
* Constructor method.
*/
public attach(bus) {
bus.subscribe(
events.expenses.onDeleting,
this.validateMatchingOnExpenseDeleting.bind(this)
);
}
/**
* Validates the expense transaction whether matched with bank transaction on deleting.
* @param {IExpenseEventDeletePayload}
*/
public async validateMatchingOnExpenseDeleting({
tenantId,
oldExpense,
trx,
}: IExpenseEventDeletePayload) {
await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking(
tenantId,
'Expense',
oldExpense.id
);
}
}

View File

@@ -0,0 +1,36 @@
import { Inject, Service } from 'typedi';
import { IManualJournalDeletingPayload } from '@/interfaces';
import events from '@/subscribers/events';
import { ValidateTransactionMatched } from '../ValidateTransactionsMatched';
@Service()
export class ValidateMatchingOnManualJournalDelete {
@Inject()
private validateNoMatchingLinkedService: ValidateTransactionMatched;
/**
* Constructor method.
*/
public attach(bus) {
bus.subscribe(
events.manualJournals.onDeleting,
this.validateMatchingOnManualJournalDeleting.bind(this)
);
}
/**
* Validates the manual journal transaction whether matched with bank transaction on deleting.
* @param {IManualJournalDeletingPayload}
*/
public async validateMatchingOnManualJournalDeleting({
tenantId,
oldManualJournal,
trx,
}: IManualJournalDeletingPayload) {
await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking(
tenantId,
'ManualJournal',
oldManualJournal.id
);
}
}

View File

@@ -0,0 +1,39 @@
import { Inject, Service } from 'typedi';
import {
IBillPaymentEventDeletedPayload,
IPaymentReceiveDeletedPayload,
} from '@/interfaces';
import { ValidateTransactionMatched } from '../ValidateTransactionsMatched';
import events from '@/subscribers/events';
@Service()
export class ValidateMatchingOnPaymentMadeDelete {
@Inject()
private validateNoMatchingLinkedService: ValidateTransactionMatched;
/**
* Constructor method.
*/
public attach(bus) {
bus.subscribe(
events.billPayment.onDeleting,
this.validateMatchingOnPaymentMadeDeleting.bind(this)
);
}
/**
* Validates the payment made transaction whether matched with bank transaction on deleting.
* @param {IPaymentReceiveDeletedPayload}
*/
public async validateMatchingOnPaymentMadeDeleting({
tenantId,
oldBillPayment,
trx,
}: IBillPaymentEventDeletedPayload) {
await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking(
tenantId,
'PaymentMade',
oldBillPayment.id
);
}
}

View File

@@ -0,0 +1,36 @@
import { Inject, Service } from 'typedi';
import { IPaymentReceiveDeletedPayload } from '@/interfaces';
import { ValidateTransactionMatched } from '../ValidateTransactionsMatched';
import events from '@/subscribers/events';
@Service()
export class ValidateMatchingOnPaymentReceivedDelete {
@Inject()
private validateNoMatchingLinkedService: ValidateTransactionMatched;
/**
* Constructor method.
*/
public attach(bus) {
bus.subscribe(
events.paymentReceive.onDeleting,
this.validateMatchingOnPaymentReceivedDeleting.bind(this)
);
}
/**
* Validates the payment received transaction whether matched with bank transaction on deleting.
* @param {IPaymentReceiveDeletedPayload}
*/
public async validateMatchingOnPaymentReceivedDeleting({
tenantId,
oldPaymentReceive,
trx,
}: IPaymentReceiveDeletedPayload) {
await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking(
tenantId,
'PaymentReceive',
oldPaymentReceive.id
);
}
}

View File

@@ -0,0 +1,67 @@
import { Knex } from 'knex';
export interface IBankTransactionMatchingEventPayload {
tenantId: number;
uncategorizedTransactionId: number;
matchTransactionsDTO: IMatchTransactionsDTO;
trx?: Knex.Transaction;
}
export interface IBankTransactionMatchedEventPayload {
tenantId: number;
uncategorizedTransactionId: number;
matchTransactionsDTO: IMatchTransactionsDTO;
trx?: Knex.Transaction;
}
export interface IBankTransactionUnmatchingEventPayload {
tenantId: number;
}
export interface IBankTransactionUnmatchedEventPayload {
tenantId: number;
}
export interface IMatchTransactionDTO {
referenceType: string;
referenceId: number;
}
export interface IMatchTransactionsDTO {
matchedTransactions: Array<IMatchTransactionDTO>;
}
export interface GetMatchedTransactionsFilter {
fromDate: string;
toDate: string;
minAmount: number;
maxAmount: number;
transactionType: string;
}
export interface MatchedTransactionPOJO {
amount: number;
amountFormatted: string;
date: string;
dateFormatted: string;
referenceNo: string;
transactionNo: string;
transactionId: number;
transactionType: string;
}
export type MatchedTransactionsPOJO = {
perfectMatches: Array<MatchedTransactionPOJO>;
possibleMatches: Array<MatchedTransactionPOJO>;
};
export const ERRORS = {
RESOURCE_TYPE_MATCHING_TRANSACTION_INVALID:
'RESOURCE_TYPE_MATCHING_TRANSACTION_INVALID',
RESOURCE_ID_MATCHING_TRANSACTION_INVALID:
'RESOURCE_ID_MATCHING_TRANSACTION_INVALID',
TOTAL_MATCHING_TRANSACTIONS_INVALID: 'TOTAL_MATCHING_TRANSACTIONS_INVALID',
TRANSACTION_ALREADY_MATCHED: 'TRANSACTION_ALREADY_MATCHED',
CANNOT_MATCH_EXCLUDED_TRANSACTION: 'CANNOT_MATCH_EXCLUDED_TRANSACTION',
CANNOT_DELETE_TRANSACTION_MATCHED: 'CANNOT_DELETE_TRANSACTION_MATCHED',
};

View File

@@ -5,6 +5,7 @@ import { entries, groupBy } from 'lodash';
import { CreateAccount } from '@/services/Accounts/CreateAccount';
import {
IAccountCreateDTO,
\ IPlaidTransactionsSyncedEventPayload,
PlaidAccount,
PlaidTransaction,
} from '@/interfaces';
@@ -16,6 +17,9 @@ import { DeleteCashflowTransaction } from '@/services/Cashflow/DeleteCashflowTra
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { CashflowApplication } from '@/services/Cashflow/CashflowApplication';
import { Knex } from 'knex';
import { uniqid } from 'uniqid';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import events from '@/subscribers/events';
const CONCURRENCY_ASYNC = 10;
@@ -33,6 +37,9 @@ export class PlaidSyncDb {
@Inject()
private deleteCashflowTransactionService: DeleteCashflowTransaction;
@Inject()
private eventPublisher: EventPublisher;
/**
* Syncs the Plaid bank account.
* @param {number} tenantId
@@ -92,6 +99,7 @@ export class PlaidSyncDb {
* @param {number} tenantId - Tenant ID.
* @param {number} plaidAccountId - Plaid account ID.
* @param {PlaidTransaction[]} plaidTranasctions - Plaid transactions
* @return {Promise<void>}
*/
public async syncAccountTranactions(
tenantId: number,
@@ -101,18 +109,14 @@ export class PlaidSyncDb {
): Promise<void> {
const { Account } = this.tenancy.models(tenantId);
const batch = uniqid();
const cashflowAccount = await Account.query(trx)
.findOne({ plaidAccountId })
.throwIfNotFound();
const openingEquityBalance = await Account.query(trx).findOne(
'slug',
'opening-balance-equity'
);
// Transformes the Plaid transactions to cashflow create DTOs.
const transformTransaction = transformPlaidTrxsToCashflowCreate(
cashflowAccount.id,
openingEquityBalance.id
cashflowAccount.id
);
const uncategorizedTransDTOs =
R.map(transformTransaction)(plaidTranasctions);
@@ -123,20 +127,28 @@ export class PlaidSyncDb {
(uncategoriedDTO) =>
this.cashflowApp.createUncategorizedTransaction(
tenantId,
uncategoriedDTO,
{ ...uncategoriedDTO, batch },
trx
),
{ concurrency: 1 }
);
// Triggers `onPlaidTransactionsSynced` event.
await this.eventPublisher.emitAsync(events.plaid.onTransactionsSynced, {
tenantId,
plaidAccountId,
batch,
} as IPlaidTransactionsSyncedEventPayload);
}
/**
* Syncs the accounts transactions in paraller under controlled concurrency.
* @param {number} tenantId
* @param {PlaidTransaction[]} plaidTransactions
* @return {Promise<void>}
*/
public async syncAccountsTransactions(
tenantId: number,
batchNo: string,
plaidAccountsTransactions: PlaidTransaction[],
trx?: Knex.Transaction
): Promise<void> {
@@ -149,6 +161,7 @@ export class PlaidSyncDb {
return this.syncAccountTranactions(
tenantId,
plaidAccountId,
batchNo,
plaidTransactions,
trx
);
@@ -192,13 +205,14 @@ export class PlaidSyncDb {
* @param {number} tenantId - Tenant ID.
* @param {string} itemId - Plaid item ID.
* @param {string} lastCursor - Last transaction cursor.
* @return {Promise<void>}
*/
public async syncTransactionsCursor(
tenantId: number,
plaidItemId: string,
lastCursor: string,
trx?: Knex.Transaction
) {
): Promise<void> {
const { PlaidItem } = this.tenancy.models(tenantId);
await PlaidItem.query(trx).findOne({ plaidItemId }).patch({ lastCursor });
@@ -208,12 +222,13 @@ export class PlaidSyncDb {
* Updates the last feeds updated at of the given Plaid accounts ids.
* @param {number} tenantId
* @param {string[]} plaidAccountIds
* @return {Promise<void>}
*/
public async updateLastFeedsUpdatedAt(
tenantId: number,
plaidAccountIds: string[],
trx?: Knex.Transaction
) {
): Promise<void> {
const { Account } = this.tenancy.models(tenantId);
await Account.query(trx)
@@ -228,13 +243,14 @@ export class PlaidSyncDb {
* @param {number} tenantId
* @param {number[]} plaidAccountIds
* @param {boolean} isFeedsActive
* @returns {Promise<void>}
*/
public async updateAccountsFeedsActive(
tenantId: number,
plaidAccountIds: string[],
isFeedsActive: boolean = true,
trx?: Knex.Transaction
) {
): Promise<void> {
const { Account } = this.tenancy.models(tenantId);
await Account.query(trx)

View File

@@ -0,0 +1,42 @@
import { Inject, Service } from 'typedi';
import { EventSubscriber } from '@/lib/EventPublisher/EventPublisher';
import {
IPlaidItemCreatedEventPayload,
IPlaidTransactionsSyncedEventPayload,
} from '@/interfaces/Plaid';
import events from '@/subscribers/events';
import { RecognizeTranasctionsService } from '../../RegonizeTranasctions/RecognizeTranasctionsService';
import { runAfterTransaction } from '@/services/UnitOfWork/TransactionsHooks';
@Service()
export class RecognizeSyncedBankTranasctions extends EventSubscriber {
@Inject()
private recognizeTranasctionsService: RecognizeTranasctionsService;
/**
* Constructor method.
*/
public attach(bus) {
bus.subscribe(
events.plaid.onTransactionsSynced,
this.handleRecognizeSyncedBankTransactions.bind(this)
);
}
/**
* Updates the Plaid item transactions
* @param {IPlaidItemCreatedEventPayload} payload - Event payload.
*/
private handleRecognizeSyncedBankTransactions = async ({
tenantId,
batch,
trx,
}: IPlaidTransactionsSyncedEventPayload) => {
runAfterTransaction(trx, async () => {
await this.recognizeTranasctionsService.recognizeTransactions(
tenantId,
batch
);
});
};
}

View File

@@ -0,0 +1,110 @@
import { Knex } from 'knex';
import UncategorizedCashflowTransaction from '@/models/UncategorizedCashflowTransaction';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { transformToMapBy } from '@/utils';
import { Inject, Service } from 'typedi';
import { PromisePool } from '@supercharge/promise-pool';
import { BankRule } from '@/models/BankRule';
import { bankRulesMatchTransaction } from './_utils';
@Service()
export class RecognizeTranasctionsService {
@Inject()
private tenancy: HasTenancyService;
/**
* Marks the uncategorized transaction as recognized from the given bank rule.
* @param {number} tenantId -
* @param {BankRule} bankRule -
* @param {UncategorizedCashflowTransaction} transaction -
* @param {Knex.Transaction} trx -
*/
private markBankRuleAsRecognized = async (
tenantId: number,
bankRule: BankRule,
transaction: UncategorizedCashflowTransaction,
trx?: Knex.Transaction
) => {
const { RecognizedBankTransaction, UncategorizedCashflowTransaction } =
this.tenancy.models(tenantId);
const recognizedTransaction = await RecognizedBankTransaction.query(
trx
).insert({
bankRuleId: bankRule.id,
uncategorizedTransactionId: transaction.id,
assignedCategory: bankRule.assignCategory,
assignedAccountId: bankRule.assignAccountId,
assignedPayee: bankRule.assignPayee,
assignedMemo: bankRule.assignMemo,
});
await UncategorizedCashflowTransaction.query(trx)
.findById(transaction.id)
.patch({
recognizedTransactionId: recognizedTransaction.id,
});
};
/**
* Regonized the uncategorized transactions.
* @param {number} tenantId -
* @param {Knex.Transaction} trx -
*/
public async recognizeTransactions(
tenantId: number,
batch: string = '',
trx?: Knex.Transaction
) {
const { UncategorizedCashflowTransaction, BankRule } =
this.tenancy.models(tenantId);
const uncategorizedTranasctions =
await UncategorizedCashflowTransaction.query().onBuild((query) => {
query.where('recognized_transaction_id', null);
query.where('categorized', false);
if (batch) query.where('batch', batch);
});
const bankRules = await BankRule.query().withGraphFetched('conditions');
const bankRulesByAccountId = transformToMapBy(
bankRules,
'applyIfAccountId'
);
// Try to recognize the transaction.
const regonizeTransaction = async (
transaction: UncategorizedCashflowTransaction
) => {
const allAccountsBankRules = bankRulesByAccountId.get(`null`);
const accountBankRules = bankRulesByAccountId.get(
`${transaction.accountId}`
);
const recognizedBankRule = bankRulesMatchTransaction(
transaction,
accountBankRules
);
if (recognizedBankRule) {
await this.markBankRuleAsRecognized(
tenantId,
recognizedBankRule,
transaction,
trx
);
}
};
const result = await PromisePool.withConcurrency(MIGRATION_CONCURRENCY)
.for(uncategorizedTranasctions)
.process((transaction: UncategorizedCashflowTransaction, index, pool) => {
return regonizeTransaction(transaction);
});
}
/**
*
* @param {number} uncategorizedTransaction
*/
public async regonizeTransaction(
uncategorizedTransaction: UncategorizedCashflowTransaction
) {}
}
const MIGRATION_CONCURRENCY = 10;

View File

@@ -0,0 +1,32 @@
import Container, { Service } from 'typedi';
import { RecognizeTranasctionsService } from './RecognizeTranasctionsService';
@Service()
export class RegonizeTransactionsJob {
/**
* Constructor method.
*/
constructor(agenda) {
agenda.define(
'recognize-uncategorized-transactions-job',
{ priority: 'high', concurrency: 2 },
this.handler
);
}
/**
* Triggers sending invoice mail.
*/
private handler = async (job, done: Function) => {
const { tenantId } = job.attrs.data;
const regonizeTransactions = Container.get(RecognizeTranasctionsService);
try {
await regonizeTransactions.recognizeTransactions(tenantId);
done();
} catch (error) {
console.log(error);
done(error);
}
};
}

View File

@@ -0,0 +1,104 @@
import UncategorizedCashflowTransaction from '@/models/UncategorizedCashflowTransaction';
import {
BankRuleApplyIfTransactionType,
BankRuleConditionComparator,
BankRuleConditionType,
IBankRule,
IBankRuleCondition,
} from '../Rules/types';
import { BankRule } from '@/models/BankRule';
const conditionsMatch = (
transaction: UncategorizedCashflowTransaction,
conditions: IBankRuleCondition[],
conditionsType: BankRuleConditionType = BankRuleConditionType.And
) => {
const method =
conditionsType === BankRuleConditionType.And ? 'every' : 'some';
return conditions[method]((condition) => {
switch (determineFieldType(condition.field)) {
case 'number':
return matchNumberCondition(transaction, condition);
case 'text':
return matchTextCondition(transaction, condition);
default:
return false;
}
});
};
const matchNumberCondition = (
transaction: UncategorizedCashflowTransaction,
condition: IBankRuleCondition
) => {
switch (condition.comparator) {
case BankRuleConditionComparator.Equals:
return transaction[condition.field] === condition.value;
case BankRuleConditionComparator.Contains:
return transaction[condition.field]
?.toString()
.includes(condition.value.toString());
case BankRuleConditionComparator.NotContain:
return !transaction[condition.field]
?.toString()
.includes(condition.value.toString());
default:
return false;
}
};
const matchTextCondition = (
transaction: UncategorizedCashflowTransaction,
condition: IBankRuleCondition
) => {
switch (condition.comparator) {
case BankRuleConditionComparator.Equals:
return transaction[condition.field] === condition.value;
case BankRuleConditionComparator.Contains:
return transaction[condition.field]?.includes(condition.value.toString());
case BankRuleConditionComparator.NotContain:
return !transaction[condition.field]?.includes(
condition.value.toString()
);
default:
return false;
}
};
const matchTransactionType = (
bankRule: BankRule,
transaction: UncategorizedCashflowTransaction
): boolean => {
return (
(transaction.isDepositTransaction &&
bankRule.applyIfTransactionType ===
BankRuleApplyIfTransactionType.Deposit) ||
(transaction.isWithdrawalTransaction &&
bankRule.applyIfTransactionType ===
BankRuleApplyIfTransactionType.Withdrawal)
);
};
export const bankRulesMatchTransaction = (
transaction: UncategorizedCashflowTransaction,
bankRules: IBankRule[]
) => {
return bankRules.find((rule) => {
return (
matchTransactionType(rule, transaction) &&
conditionsMatch(transaction, rule.conditions, rule.conditionsType)
);
});
};
const determineFieldType = (field: string): string => {
switch (field) {
case 'amount':
return 'number';
case 'description':
return 'text';
default:
return 'unknown';
}
};

View File

@@ -0,0 +1,76 @@
import { Inject, Service } from 'typedi';
import events from '@/subscribers/events';
import {
IBankRuleEventCreatedPayload,
IBankRuleEventDeletedPayload,
IBankRuleEventEditedPayload,
} from '../../Rules/types';
@Service()
export class TriggerRecognizedTransactions {
@Inject('agenda')
private agenda: any;
/**
* Constructor method.
*/
public attach(bus) {
bus.subscribe(
events.bankRules.onCreated,
this.recognizedTransactionsOnRuleCreated.bind(this)
);
bus.subscribe(
events.bankRules.onEdited,
this.recognizedTransactionsOnRuleEdited.bind(this)
);
bus.subscribe(
events.bankRules.onDeleted,
this.recognizedTransactionsOnRuleDeleted.bind(this)
);
}
/**
* Triggers the recognize uncategorized transactions job on rule created.
* @param {IBankRuleEventCreatedPayload} payload -
*/
private async recognizedTransactionsOnRuleCreated({
tenantId,
createRuleDTO,
}: IBankRuleEventCreatedPayload) {
const payload = { tenantId };
// Cannot run recognition if the option is not enabled.
if (createRuleDTO.recognition) {
return;
}
await this.agenda.now('recognize-uncategorized-transactions-job', payload);
}
/**
* Triggers the recognize uncategorized transactions job on rule edited.
* @param {IBankRuleEventEditedPayload} payload -
*/
private async recognizedTransactionsOnRuleEdited({
tenantId,
editRuleDTO,
}: IBankRuleEventEditedPayload) {
const payload = { tenantId };
// Cannot run recognition if the option is not enabled.
if (!editRuleDTO.recognition) {
return;
}
await this.agenda.now('recognize-uncategorized-transactions-job', payload);
}
/**
* Triggers the recognize uncategorized transactions job on rule deleted.
* @param {IBankRuleEventDeletedPayload} payload -
*/
private async recognizedTransactionsOnRuleDeleted({
tenantId,
}: IBankRuleEventDeletedPayload) {
const payload = { tenantId };
await this.agenda.now('recognize-uncategorized-transactions-job', payload);
}
}

View File

@@ -0,0 +1,82 @@
import { Inject, Service } from 'typedi';
import { CreateBankRuleService } from './CreateBankRule';
import { DeleteBankRuleSerivce } from './DeleteBankRule';
import { EditBankRuleService } from './EditBankRule';
import { GetBankRuleService } from './GetBankRule';
import { GetBankRulesService } from './GetBankRules';
import { ICreateBankRuleDTO, IEditBankRuleDTO } from './types';
@Service()
export class BankRulesApplication {
@Inject()
private createBankRuleService: CreateBankRuleService;
@Inject()
private editBankRuleService: EditBankRuleService;
@Inject()
private deleteBankRuleService: DeleteBankRuleSerivce;
@Inject()
private getBankRuleService: GetBankRuleService;
@Inject()
private getBankRulesService: GetBankRulesService;
/**
* Creates new bank rule.
* @param {number} tenantId
* @param {ICreateBankRuleDTO} createRuleDTO
* @returns {Promise<void>}
*/
public createBankRule(
tenantId: number,
createRuleDTO: ICreateBankRuleDTO
): Promise<void> {
return this.createBankRuleService.createBankRule(tenantId, createRuleDTO);
}
/**
* Edits the given bank rule.
* @param {number} tenantId
* @param {IEditBankRuleDTO} editRuleDTO
* @returns {Promise<void>}
*/
public editBankRule(
tenantId: number,
ruleId: number,
editRuleDTO: IEditBankRuleDTO
): Promise<void> {
return this.editBankRuleService.editBankRule(tenantId, ruleId, editRuleDTO);
}
/**
* Deletes the given bank rule.
* @param {number} tenantId
* @param {number} ruleId
* @returns {Promise<void>}
*/
public deleteBankRule(tenantId: number, ruleId: number): Promise<void> {
return this.deleteBankRuleService.deleteBankRule(tenantId, ruleId);
}
/**
* Retrieves the given bank rule.
* @param {number} tenantId
* @param {number} ruleId
* @returns {Promise<any>}
*/
public getBankRule(tenantId: number, ruleId: number): Promise<any> {
return this.getBankRuleService.getBankRule(tenantId, ruleId);
}
/**
* Retrieves the bank rules of the given account.
* @param {number} tenantId
* @param {number} accountId
* @returns {Promise<any>}
*/
public getBankRules(tenantId: number): Promise<any> {
return this.getBankRulesService.getBankRules(tenantId);
}
}

View File

@@ -0,0 +1,71 @@
import { Knex } from 'knex';
import { Inject, Service } from 'typedi';
import {
IBankRuleEventCreatedPayload,
IBankRuleEventCreatingPayload,
ICreateBankRuleDTO,
} from './types';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import UnitOfWork from '@/services/UnitOfWork';
import events from '@/subscribers/events';
@Service()
export class CreateBankRuleService {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private uow: UnitOfWork;
@Inject()
private eventPublisher: EventPublisher;
/**
* Transformes the DTO to model.
* @param {ICreateBankRuleDTO} createDTO
* @returns
*/
private transformDTO(createDTO: ICreateBankRuleDTO) {
return {
...createDTO,
};
}
/**
* Creates a new bank rule.
* @param {number} tenantId
* @param {ICreateBankRuleDTO} createRuleDTO
* @returns {Promise<void>}
*/
public createBankRule(
tenantId: number,
createRuleDTO: ICreateBankRuleDTO
): Promise<void> {
const { BankRule } = this.tenancy.models(tenantId);
const transformDTO = this.transformDTO(createRuleDTO);
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
// Triggers `onBankRuleCreating` event.
await this.eventPublisher.emitAsync(events.bankRules.onCreating, {
tenantId,
createRuleDTO,
trx,
} as IBankRuleEventCreatingPayload);
const bankRule = await BankRule.query(trx).upsertGraph({
...transformDTO,
});
// Triggers `onBankRuleCreated` event.
await this.eventPublisher.emitAsync(events.bankRules.onCreated, {
tenantId,
createRuleDTO,
trx,
} as IBankRuleEventCreatedPayload);
return bankRule;
});
}
}

View File

@@ -0,0 +1,56 @@
import { Knex } from 'knex';
import { Inject, Service } from 'typedi';
import UnitOfWork from '@/services/UnitOfWork';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import events from '@/subscribers/events';
import {
IBankRuleEventDeletedPayload,
IBankRuleEventDeletingPayload,
} from './types';
import HasTenancyService from '@/services/Tenancy/TenancyService';
@Service()
export class DeleteBankRuleSerivce {
@Inject()
private uow: UnitOfWork;
@Inject()
private eventPublisher: EventPublisher;
@Inject()
private tenancy: HasTenancyService;
/**
* Deletes the given bank rule.
* @param {number} tenantId
* @param {number} ruleId
* @returns {Promise<void>}
*/
public async deleteBankRule(tenantId: number, ruleId: number): Promise<void> {
const { BankRule, BankRuleCondition } = this.tenancy.models(tenantId);
const oldBankRule = await BankRule.query()
.findById(ruleId)
.throwIfNotFound();
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
// Triggers `onBankRuleDeleting` event.
await this.eventPublisher.emitAsync(events.bankRules.onDeleting, {
tenantId,
oldBankRule,
ruleId,
trx,
} as IBankRuleEventDeletingPayload);
await BankRuleCondition.query(trx).where('ruleId', ruleId).delete();
await BankRule.query(trx).findById(ruleId).delete();
// Triggers `onBankRuleDeleted` event.
await await this.eventPublisher.emitAsync(events.bankRules.onDeleted, {
tenantId,
ruleId,
trx,
} as IBankRuleEventDeletedPayload);
});
}
}

View File

@@ -0,0 +1,82 @@
import { Knex } from 'knex';
import { Inject, Service } from 'typedi';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import UnitOfWork from '@/services/UnitOfWork';
import events from '@/subscribers/events';
import {
IBankRuleEventEditedPayload,
IBankRuleEventEditingPayload,
IEditBankRuleDTO,
} from './types';
@Service()
export class EditBankRuleService {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private uow: UnitOfWork;
@Inject()
private eventPublisher: EventPublisher;
/**
*
* @param createDTO
* @returns
*/
private transformDTO(createDTO: IEditBankRuleDTO) {
return {
...createDTO,
};
}
/**
* Edits the given bank rule.
* @param {number} tenantId
* @param {number} ruleId -
* @param {IEditBankRuleDTO} editBankDTO
*/
public async editBankRule(
tenantId: number,
ruleId: number,
editRuleDTO: IEditBankRuleDTO
) {
const { BankRule } = this.tenancy.models(tenantId);
const oldBankRule = await BankRule.query()
.findById(ruleId)
.throwIfNotFound();
const tranformDTO = this.transformDTO(editRuleDTO);
return this.uow.withTransaction(
tenantId,
async (trx?: Knex.Transaction) => {
// Triggers `onBankRuleEditing` event.
await this.eventPublisher.emitAsync(events.bankRules.onEditing, {
tenantId,
oldBankRule,
ruleId,
editRuleDTO,
trx,
} as IBankRuleEventEditingPayload);
// Updates the given bank rule.
await BankRule.query(trx)
.findById(ruleId)
.patch({ ...tranformDTO });
// Triggers `onBankRuleEdited` event.
await this.eventPublisher.emitAsync(events.bankRules.onEdited, {
tenantId,
oldBankRule,
ruleId,
editRuleDTO,
trx,
} as IBankRuleEventEditedPayload);
}
);
}
}

View File

@@ -0,0 +1,34 @@
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
import { BankRule } from '@/models/BankRule';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { Inject, Service } from 'typedi';
import { GetBankRuleTransformer } from './GetBankRuleTransformer';
@Service()
export class GetBankRuleService {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private transformer: TransformerInjectable;
/**
* Retrieves the bank rule.
* @param {number} tenantId
* @param {number} ruleId
* @returns {Promise<any>}
*/
async getBankRule(tenantId: number, ruleId: number): Promise<any> {
const { BankRule } = this.tenancy.models(tenantId);
const bankRule = await BankRule.query()
.findById(ruleId)
.withGraphFetched('conditions');
return this.transformer.transform(
tenantId,
bankRule,
new GetBankRuleTransformer()
);
}
}

View File

@@ -0,0 +1,11 @@
import { Transformer } from '@/lib/Transformer/Transformer';
export class GetBankRuleTransformer extends Transformer {
/**
* Include these attributes to sale invoice object.
* @returns {Array}
*/
public includeAttributes = (): string[] => {
return [];
};
}

View File

@@ -0,0 +1,33 @@
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import { Inject, Service } from 'typedi';
import { GetBankRulesTransformer } from './GetBankRulesTransformer';
@Service()
export class GetBankRulesService {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private transformer: TransformerInjectable;
/**
* Retrieves the bank rules of the given account.
* @param {number} tenantId
* @param {number} accountId
* @returns {Promise<any>}
*/
public async getBankRules(tenantId: number): Promise<any> {
const { BankRule } = this.tenancy.models(tenantId);
const bankRule = await BankRule.query()
.withGraphFetched('conditions')
.withGraphFetched('assignAccount');
return this.transformer.transform(
tenantId,
bankRule,
new GetBankRulesTransformer()
);
}
}

View File

@@ -0,0 +1,51 @@
import { upperFirst, camelCase } from 'lodash';
import { Transformer } from '@/lib/Transformer/Transformer';
import { getTransactionTypeLabel } from '@/utils/transactions-types';
export class GetBankRulesTransformer extends Transformer {
/**
* Include these attributes to sale invoice object.
* @returns {Array}
*/
public includeAttributes = (): string[] => {
return [
'assignAccountName',
'assignCategoryFormatted',
'conditionsFormatted',
];
};
/**
* Get the assign account name.
* @param bankRule
* @returns {string}
*/
protected assignAccountName(bankRule: any) {
return bankRule.assignAccount.name;
}
/**
* Assigned category formatted.
* @returns {string}
*/
protected assignCategoryFormatted(bankRule: any) {
const assignCategory = upperFirst(camelCase(bankRule.assignCategory));
return getTransactionTypeLabel(assignCategory);
}
/**
* Get the bank rule formatted conditions.
* @param bankRule
* @returns {string}
*/
protected conditionsFormatted(bankRule: any) {
return bankRule.conditions
.map((condition) => {
const field =
condition.field.charAt(0).toUpperCase() + condition.field.slice(1);
return `${field} ${condition.comparator} ${condition.value}`;
})
.join(bankRule.conditionsType === 'and' ? ' and ' : ' or ');
}
}

View File

@@ -0,0 +1,54 @@
import { Inject, Service } from 'typedi';
import { Knex } from 'knex';
import HasTenancyService from '@/services/Tenancy/TenancyService';
import UnitOfWork from '@/services/UnitOfWork';
@Service()
export class UnlinkBankRuleRecognizedTransactions {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private uow: UnitOfWork;
/**
* Unlinks the given bank rule out of recognized transactions.
* @param {number} tenantId - Tenant id.
* @param {number} bankRuleId - Bank rule id.
* @param {Knex.Transaction} trx - Knex transaction.
* @returns {Promise<void>}
*/
public async unlinkBankRuleOutRecognizedTransactions(
tenantId: number,
bankRuleId: number,
trx?: Knex.Transaction
): Promise<void> {
const { UncategorizedCashflowTransaction, RecognizedBankTransaction } =
this.tenancy.models(tenantId);
return this.uow.withTransaction(
tenantId,
async (trx: Knex.Transaction) => {
// Retrieves all the recognized transactions of the banbk rule.
const recognizedTransactions = await RecognizedBankTransaction.query(
trx
).where('bankRuleId', bankRuleId);
const uncategorizedTransactionIds = recognizedTransactions.map(
(r) => r.uncategorizedTransactionId
);
// Unlink the recongized transactions out of uncategorized transactions.
await UncategorizedCashflowTransaction.query(trx)
.whereIn('id', uncategorizedTransactionIds)
.patch({
recognizedTransactionId: null,
});
// Delete the recognized bank transactions that assocaited to bank rule.
await RecognizedBankTransaction.query(trx)
.where({ bankRuleId })
.delete();
},
trx
);
}
}

View File

@@ -0,0 +1,34 @@
import { Inject, Service } from 'typedi';
import events from '@/subscribers/events';
import { UnlinkBankRuleRecognizedTransactions } from '../UnlinkBankRuleRecognizedTransactions';
import { IBankRuleEventDeletingPayload } from '../types';
@Service()
export class UnlinkBankRuleOnDeleteBankRule {
@Inject()
private unlinkBankRule: UnlinkBankRuleRecognizedTransactions;
/**
* Constructor method.
*/
public attach(bus) {
bus.subscribe(
events.bankRules.onDeleting,
this.unlinkBankRuleOutRecognizedTransactionsOnRuleDeleting.bind(this)
);
}
/**
* Unlinks the bank rule out of recognized transactions.
* @param {IBankRuleEventDeletingPayload} payload -
*/
private async unlinkBankRuleOutRecognizedTransactionsOnRuleDeleting({
tenantId,
ruleId,
}: IBankRuleEventDeletingPayload) {
await this.unlinkBankRule.unlinkBankRuleOutRecognizedTransactions(
tenantId,
ruleId
);
}
}

View File

@@ -0,0 +1,116 @@
import { Knex } from 'knex';
export enum BankRuleConditionField {
Amount = 'Amount',
Description = 'Description',
Payee = 'Payee'
}
export enum BankRuleConditionComparator {
Contains = 'contains',
Equals = 'equals',
NotContain = 'not_contain';
}
export interface IBankRuleCondition {
id?: number;
field: BankRuleConditionField;
comparator: BankRuleConditionComparator;
value: number;
}
export enum BankRuleConditionType {
Or = 'or',
And = 'and'
}
export enum BankRuleApplyIfTransactionType {
Deposit = 'deposit',
Withdrawal = 'withdrawal',
}
export interface IBankRule {
name: string;
order?: number;
applyIfAccountId: number;
applyIfTransactionType: BankRuleApplyIfTransactionType;
conditionsType: BankRuleConditionType;
conditions: IBankRuleCondition[];
assignCategory: BankRuleAssignCategory;
assignAccountId: number;
assignPayee?: string;
assignMemo?: string;
}
export enum BankRuleAssignCategory {
InterestIncome = 'InterestIncome',
OtherIncome = 'OtherIncome',
Deposit = 'Deposit',
Expense = 'Expense',
OwnerDrawings = 'OwnerDrawings',
}
export interface IBankRuleConditionDTO {
id?: number;
field: string;
comparator: string;
value: number;
}
export interface IBankRuleCommonDTO {
name: string;
order?: number;
applyIfAccountId: number;
applyIfTransactionType: string;
conditions: IBankRuleConditionDTO[];
assignCategory: BankRuleAssignCategory;
assignAccountId: number;
assignPayee?: string;
assignMemo?: string;
recognition?: boolean;
}
export interface ICreateBankRuleDTO extends IBankRuleCommonDTO {}
export interface IEditBankRuleDTO extends IBankRuleCommonDTO {}
export interface IBankRuleEventCreatingPayload {
tenantId: number;
createRuleDTO: ICreateBankRuleDTO;
trx?: Knex.Transaction;
}
export interface IBankRuleEventCreatedPayload {
tenantId: number;
createRuleDTO: ICreateBankRuleDTO;
trx?: Knex.Transaction;
}
export interface IBankRuleEventEditingPayload {
tenantId: number;
ruleId: number;
oldBankRule: any;
editRuleDTO: IEditBankRuleDTO;
trx?: Knex.Transaction;
}
export interface IBankRuleEventEditedPayload {
tenantId: number;
ruleId: number;
editRuleDTO: IEditBankRuleDTO;
trx?: Knex.Transaction;
}
export interface IBankRuleEventDeletingPayload {
tenantId: number;
oldBankRule: any;
ruleId: number;
trx?: Knex.Transaction;
}
export interface IBankRuleEventDeletedPayload {
tenantId: number;
ruleId: number;
trx?: Knex.Transaction;
}

View File

@@ -9,6 +9,7 @@ import {
ICashflowAccountsFilter,
ICashflowNewCommandDTO,
ICategorizeCashflowTransactioDTO,
IGetRecognizedTransactionsQuery,
IGetUncategorizedTransactionsQuery,
} from '@/interfaces';
import { CategorizeTransactionAsExpense } from './CategorizeTransactionAsExpense';
@@ -18,6 +19,8 @@ import { GetUncategorizedTransaction } from './GetUncategorizedTransaction';
import NewCashflowTransactionService from './NewCashflowTransactionService';
import GetCashflowAccountsService from './GetCashflowAccountsService';
import { GetCashflowTransactionService } from './GetCashflowTransactionsService';
import { GetRecognizedTransactionsService } from './GetRecongizedTransactions';
import { GetRecognizedTransactionService } from './GetRecognizedTransaction';
@Service()
export class CashflowApplication {
@@ -51,6 +54,12 @@ export class CashflowApplication {
@Inject()
private createUncategorizedTransactionService: CreateUncategorizedTransaction;
@Inject()
private getRecognizedTranasctionsService: GetRecognizedTransactionsService;
@Inject()
private getRecognizedTransactionService: GetRecognizedTransactionService;
/**
* Creates a new cashflow transaction.
* @param {number} tenantId
@@ -213,4 +222,36 @@ export class CashflowApplication {
uncategorizedTransactionId
);
}
/**
* Retrieves the recognized bank transactions.
* @param {number} tenantId
* @param {number} accountId
* @returns
*/
public getRecognizedTransactions(
tenantId: number,
filter?: IGetRecognizedTransactionsQuery
) {
return this.getRecognizedTranasctionsService.getRecognizedTranactions(
tenantId,
filter
);
}
/**
* Retrieves the recognized transaction of the given uncategorized transaction.
* @param {number} tenantId
* @param {number} uncategorizedTransactionId
* @returns
*/
public getRecognizedTransaction(
tenantId: number,
uncategorizedTransactionId: number
) {
return this.getRecognizedTransactionService.getRecognizedTransaction(
tenantId,
uncategorizedTransactionId
);
}
}

View File

@@ -12,6 +12,8 @@ import { Knex } from 'knex';
import { transformCategorizeTransToCashflow } from './utils';
import { CommandCashflowValidator } from './CommandCasflowValidator';
import NewCashflowTransactionService from './NewCashflowTransactionService';
import { ServiceError } from '@/exceptions';
import { ERRORS } from './constants';
@Service()
export class CategorizeCashflowTransaction {
@@ -47,6 +49,10 @@ export class CategorizeCashflowTransaction {
.findById(uncategorizedTransactionId)
.throwIfNotFound();
// Validate cannot categorize excluded transaction.
if (transaction.excluded) {
throw new ServiceError(ERRORS.CANNOT_CATEGORIZE_EXCLUDED_TRANSACTION);
}
// Validates the transaction shouldn't be categorized before.
this.commandValidators.validateTransactionShouldNotCategorized(transaction);

View File

@@ -0,0 +1,8 @@
import { Service } from "typedi";
@Service()
export class CategorizeRecognizedTransactionService {
}

View File

@@ -0,0 +1,40 @@
import { Inject, Service } from 'typedi';
import HasTenancyService from '../Tenancy/TenancyService';
import { GetRecognizedTransactionTransformer } from './GetRecognizedTransactionTransformer';
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
@Service()
export class GetRecognizedTransactionService {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private transformer: TransformerInjectable;
/**
* Retrieves the recognized transaction of the given uncategorized transaction.
* @param {number} tenantId
* @param {number} uncategorizedTransactionId
*/
public async getRecognizedTransaction(
tenantId: number,
uncategorizedTransactionId: number
) {
const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId);
const uncategorizedTransaction =
await UncategorizedCashflowTransaction.query()
.findById(uncategorizedTransactionId)
.withGraphFetched('matchedBankTransactions')
.withGraphFetched('recognizedTransaction.assignAccount')
.withGraphFetched('recognizedTransaction.bankRule')
.withGraphFetched('account')
.throwIfNotFound();
return this.transformer.transform(
tenantId,
uncategorizedTransaction,
new GetRecognizedTransactionTransformer()
);
}
}

View File

@@ -0,0 +1,262 @@
import { Transformer } from '@/lib/Transformer/Transformer';
import { formatNumber } from '@/utils';
export class GetRecognizedTransactionTransformer extends Transformer {
/**
* Include these attributes to sale credit note object.
* @returns {Array}
*/
public includeAttributes = (): string[] => {
return [
'uncategorizedTransactionId',
'referenceNo',
'description',
'payee',
'amount',
'formattedAmount',
'date',
'formattedDate',
'assignedAccountId',
'assignedAccountName',
'assignedAccountCode',
'assignedPayee',
'assignedMemo',
'assignedCategory',
'assignedCategoryFormatted',
'withdrawal',
'deposit',
'isDepositTransaction',
'isWithdrawalTransaction',
'formattedDepositAmount',
'formattedWithdrawalAmount',
'bankRuleId',
'bankRuleName',
];
};
/**
* Exclude all attributes.
* @returns {Array<string>}
*/
public excludeAttributes = (): string[] => {
return ['*'];
};
/**
* Get the uncategorized transaction id.
* @param transaction
* @returns {number}
*/
public uncategorizedTransactionId = (transaction): number => {
return transaction.id;
}
/**
* Get the reference number of the transaction.
* @param {object} transaction
* @returns {string}
*/
public referenceNo(transaction: any): string {
return transaction.referenceNo;
}
/**
* Get the description of the transaction.
* @param {object} transaction
* @returns {string}
*/
public description(transaction: any): string {
return transaction.description;
}
/**
* Get the payee of the transaction.
* @param {object} transaction
* @returns {string}
*/
public payee(transaction: any): string {
return transaction.payee;
}
/**
* Get the amount of the transaction.
* @param {object} transaction
* @returns {number}
*/
public amount(transaction: any): number {
return transaction.amount;
}
/**
* Get the formatted amount of the transaction.
* @param {object} transaction
* @returns {string}
*/
public formattedAmount(transaction: any): string {
return this.formatNumber(transaction.formattedAmount, {
money: true,
});
}
/**
* Get the date of the transaction.
* @param {object} transaction
* @returns {string}
*/
public date(transaction: any): string {
return transaction.date;
}
/**
* Get the formatted date of the transaction.
* @param {object} transaction
* @returns {string}
*/
public formattedDate(transaction: any): string {
return this.formatDate(transaction.date);
}
/**
* Get the assigned account ID of the transaction.
* @param {object} transaction
* @returns {number}
*/
public assignedAccountId(transaction: any): number {
return transaction.recognizedTransaction.assignedAccountId;
}
/**
* Get the assigned account name of the transaction.
* @param {object} transaction
* @returns {string}
*/
public assignedAccountName(transaction: any): string {
return transaction.recognizedTransaction.assignAccount.name;
}
/**
* Get the assigned account code of the transaction.
* @param {object} transaction
* @returns {string}
*/
public assignedAccountCode(transaction: any): string {
return transaction.recognizedTransaction.assignAccount.code;
}
/**
* Get the assigned payee of the transaction.
* @param {object} transaction
* @returns {string}
*/
public getAssignedPayee(transaction: any): string {
return transaction.recognizedTransaction.assignedPayee;
}
/**
* Get the assigned memo of the transaction.
* @param {object} transaction
* @returns {string}
*/
public assignedMemo(transaction: any): string {
return transaction.recognizedTransaction.assignedMemo;
}
/**
* Get the assigned category of the transaction.
* @param {object} transaction
* @returns {string}
*/
public assignedCategory(transaction: any): string {
return transaction.recognizedTransaction.assignedCategory;
}
/**
*
* @returns {string}
*/
public assignedCategoryFormatted() {
return 'Other Income'
}
/**
* Check if the transaction is a withdrawal.
* @param {object} transaction
* @returns {boolean}
*/
public isWithdrawal(transaction: any): boolean {
return transaction.withdrawal;
}
/**
* Check if the transaction is a deposit.
* @param {object} transaction
* @returns {boolean}
*/
public isDeposit(transaction: any): boolean {
return transaction.deposit;
}
/**
* Check if the transaction is a deposit transaction.
* @param {object} transaction
* @returns {boolean}
*/
public isDepositTransaction(transaction: any): boolean {
return transaction.isDepositTransaction;
}
/**
* Check if the transaction is a withdrawal transaction.
* @param {object} transaction
* @returns {boolean}
*/
public isWithdrawalTransaction(transaction: any): boolean {
return transaction.isWithdrawalTransaction;
}
/**
* Get formatted deposit amount.
* @param {any} transaction
* @returns {string}
*/
protected formattedDepositAmount(transaction) {
if (transaction.isDepositTransaction) {
return formatNumber(transaction.deposit, {
currencyCode: transaction.currencyCode,
});
}
return '';
}
/**
* Get formatted withdrawal amount.
* @param transaction
* @returns {string}
*/
protected formattedWithdrawalAmount(transaction) {
if (transaction.isWithdrawalTransaction) {
return formatNumber(transaction.withdrawal, {
currencyCode: transaction.currencyCode,
});
}
return '';
}
/**
* Get the transaction bank rule id.
* @param transaction
* @returns {string}
*/
protected bankRuleId(transaction) {
return transaction.recognizedTransaction.bankRuleId;
}
/**
* Get the transaction bank rule name.
* @param transaction
* @returns {string}
*/
protected bankRuleName(transaction) {
return transaction.recognizedTransaction.bankRule.name;
}
}

View File

@@ -0,0 +1,51 @@
import { Inject, Service } from 'typedi';
import HasTenancyService from '../Tenancy/TenancyService';
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
import { GetRecognizedTransactionTransformer } from './GetRecognizedTransactionTransformer';
import { IGetRecognizedTransactionsQuery } from '@/interfaces';
@Service()
export class GetRecognizedTransactionsService {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private transformer: TransformerInjectable;
/**
* Retrieves the recognized transactions of the given account.
* @param {number} tenantId
* @param {IGetRecognizedTransactionsQuery} filter -
*/
async getRecognizedTranactions(
tenantId: number,
filter?: IGetRecognizedTransactionsQuery
) {
const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId);
const _filter = {
page: 1,
pageSize: 20,
...filter,
};
const { results, pagination } =
await UncategorizedCashflowTransaction.query()
.onBuild((q) => {
q.withGraphFetched('recognizedTransaction.assignAccount');
q.withGraphFetched('recognizedTransaction.bankRule');
q.whereNotNull('recognizedTransactionId');
if (_filter.accountId) {
q.where('accountId', _filter.accountId);
}
})
.pagination(_filter.page - 1, _filter.pageSize);
const data = await this.transformer.transform(
tenantId,
results,
new GetRecognizedTransactionTransformer()
);
return { data, pagination };
}
}

View File

@@ -1,4 +1,5 @@
import { Inject, Service } from 'typedi';
import { initialize } from 'objection';
import HasTenancyService from '../Tenancy/TenancyService';
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
import { UncategorizedTransactionTransformer } from './UncategorizedTransactionTransformer';
@@ -22,7 +23,13 @@ export class GetUncategorizedTransactions {
accountId: number,
query: IGetUncategorizedTransactionsQuery
) {
const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId);
const {
UncategorizedCashflowTransaction,
RecognizedBankTransaction,
MatchedBankTransaction,
Account,
} = this.tenancy.models(tenantId);
const knex = this.tenancy.knex(tenantId);
// Parsed query with default values.
const _query = {
@@ -30,12 +37,30 @@ export class GetUncategorizedTransactions {
pageSize: 20,
...query,
};
// Initialize the ORM models metadata.
await initialize(knex, [
UncategorizedCashflowTransaction,
MatchedBankTransaction,
RecognizedBankTransaction,
Account,
]);
const { results, pagination } =
await UncategorizedCashflowTransaction.query()
.where('accountId', accountId)
.where('categorized', false)
.withGraphFetched('account')
.orderBy('date', 'DESC')
.onBuild((q) => {
q.where('accountId', accountId);
q.where('categorized', false);
q.modify('notExcluded');
q.withGraphFetched('account');
q.withGraphFetched('recognizedTransaction.assignAccount');
q.withGraphJoined('matchedBankTransactions');
q.whereNull('matchedBankTransactions.id');
q.orderBy('date', 'DESC');
})
.pagination(_query.page - 1, _query.pageSize);
const data = await this.transformer.transform(

View File

@@ -10,11 +10,27 @@ export class UncategorizedTransactionTransformer extends Transformer {
return [
'formattedAmount',
'formattedDate',
'formattetDepositAmount',
'formattedDepositAmount',
'formattedWithdrawalAmount',
'assignedAccountId',
'assignedAccountName',
'assignedAccountCode',
'assignedPayee',
'assignedMemo',
'assignedCategory',
'assignedCategoryFormatted',
];
};
/**
* Exclude all attributes.
* @returns {Array<string>}
*/
public excludeAttributes = (): string[] => {
return ['recognizedTransaction'];
};
/**
* Formattes the transaction date.
* @param transaction
@@ -26,7 +42,7 @@ export class UncategorizedTransactionTransformer extends Transformer {
/**
* Formatted amount.
* @param transaction
* @param transaction
* @returns {string}
*/
public formattedAmount(transaction) {
@@ -40,7 +56,7 @@ export class UncategorizedTransactionTransformer extends Transformer {
* @param transaction
* @returns {string}
*/
protected formattetDepositAmount(transaction) {
protected formattedDepositAmount(transaction) {
if (transaction.isDepositTransaction) {
return formatNumber(transaction.deposit, {
currencyCode: transaction.currencyCode,
@@ -62,4 +78,69 @@ export class UncategorizedTransactionTransformer extends Transformer {
}
return '';
}
// --------------------------------------------------------
// # Recgonized transaction
// --------------------------------------------------------
/**
* Get the assigned account ID of the transaction.
* @param {object} transaction
* @returns {number}
*/
public assignedAccountId(transaction: any): number {
return transaction.recognizedTransaction?.assignedAccountId;
}
/**
* Get the assigned account name of the transaction.
* @param {object} transaction
* @returns {string}
*/
public assignedAccountName(transaction: any): string {
return transaction.recognizedTransaction?.assignAccount?.name;
}
/**
* Get the assigned account code of the transaction.
* @param {object} transaction
* @returns {string}
*/
public assignedAccountCode(transaction: any): string {
return transaction.recognizedTransaction?.assignAccount?.code;
}
/**
* Get the assigned payee of the transaction.
* @param {object} transaction
* @returns {string}
*/
public getAssignedPayee(transaction: any): string {
return transaction.recognizedTransaction?.assignedPayee;
}
/**
* Get the assigned memo of the transaction.
* @param {object} transaction
* @returns {string}
*/
public assignedMemo(transaction: any): string {
return transaction.recognizedTransaction?.assignedMemo;
}
/**
* Get the assigned category of the transaction.
* @param {object} transaction
* @returns {string}
*/
public assignedCategory(transaction: any): string {
return transaction.recognizedTransaction?.assignedCategory;
}
/**
* Get the assigned formatted category.
* @returns {string}
*/
public assignedCategoryFormatted() {
return 'Other Income';
}
}

View File

@@ -15,6 +15,8 @@ export const ERRORS = {
'UNCATEGORIZED_TRANSACTION_TYPE_INVALID',
CANNOT_DELETE_TRANSACTION_CONVERTED_FROM_UNCATEGORIZED:
'CANNOT_DELETE_TRANSACTION_CONVERTED_FROM_UNCATEGORIZED',
CANNOT_CATEGORIZE_EXCLUDED_TRANSACTION: 'CANNOT_CATEGORIZE_EXCLUDED_TRANSACTION'
};
export enum CASHFLOW_DIRECTION {