feat: Categorize the bank synced transactions

This commit is contained in:
Ahmed Bouhuolia
2024-02-29 23:53:26 +02:00
parent 0833baabda
commit ea8c5458ff
31 changed files with 901 additions and 35 deletions

View File

@@ -0,0 +1,104 @@
import { Inject, Service } from 'typedi';
import { DeleteCashflowTransaction } from './DeleteCashflowTransactionService';
import { UncategorizeCashflowTransaction } from './UncategorizeCashflowTransaction';
import { CategorizeCashflowTransaction } from './CategorizeCashflowTransaction';
import {
CategorizeTransactionAsExpenseDTO,
ICategorizeCashflowTransactioDTO,
} from '@/interfaces';
import { CategorizeTransactionAsExpense } from './CategorizeTransactionAsExpense';
import { GetUncategorizedTransactions } from './GetUncategorizedTransactions';
@Service()
export class CashflowApplication {
@Inject()
private deleteTransactionService: DeleteCashflowTransaction;
@Inject()
private uncategorizeTransactionService: UncategorizeCashflowTransaction;
@Inject()
private categorizeTransactionService: CategorizeCashflowTransaction;
@Inject()
private categorizeAsExpenseService: CategorizeTransactionAsExpense;
@Inject()
private getUncategorizedTransactionsService: GetUncategorizedTransactions;
/**
* Deletes the given cashflow transaction.
* @param {number} tenantId
* @param {number} cashflowTransactionId
* @returns
*/
public deleteTransaction(tenantId: number, cashflowTransactionId: number) {
return this.deleteTransactionService.deleteCashflowTransaction(
tenantId,
cashflowTransactionId
);
}
/**
* Uncategorize the given cashflow transaction.
* @param {number} tenantId
* @param {number} cashflowTransactionId
* @returns
*/
public uncategorizeTransaction(
tenantId: number,
cashflowTransactionId: number
) {
return this.uncategorizeTransactionService.uncategorize(
tenantId,
cashflowTransactionId
);
}
/**
* Categorize the given cashflow transaction.
* @param {number} tenantId
* @param {number} cashflowTransactionId
* @param {ICategorizeCashflowTransactioDTO} categorizeDTO
* @returns
*/
public categorizeTransaction(
tenantId: number,
cashflowTransactionId: number,
categorizeDTO: ICategorizeCashflowTransactioDTO
) {
return this.categorizeTransactionService.categorize(
tenantId,
cashflowTransactionId,
categorizeDTO
);
}
/**
* Categorizes the given cashflow transaction as expense transaction.
* @param {number} tenantId
* @param {number} cashflowTransactionId
* @param {CategorizeTransactionAsExpenseDTO} transactionDTO
* @returns
*/
public categorizeAsExpense(
tenantId: number,
cashflowTransactionId: number,
transactionDTO: CategorizeTransactionAsExpenseDTO
) {
return this.categorizeAsExpenseService.categorize(
tenantId,
cashflowTransactionId,
transactionDTO
);
}
/**
* Retrieves the uncategorized cashflow transactions.
* @param {number} tenantId
* @returns {}
*/
public getUncategorizedTransactions(tenantId: number) {
return this.getUncategorizedTransactionsService.getTransactions(tenantId);
}
}

View File

@@ -1,11 +1,9 @@
import { Inject, Service } from 'typedi';
import { Knex } from 'knex';
import * as R from 'ramda';
import {
ILedgerEntry,
ICashflowTransaction,
AccountNormal,
ICashflowTransactionLine,
} from '../../interfaces';
import {
transformCashflowTransactionType,

View File

@@ -0,0 +1,93 @@
import { Inject, Service } from 'typedi';
import HasTenancyService from '../Tenancy/TenancyService';
import events from '@/subscribers/events';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import UnitOfWork from '../UnitOfWork';
import {
ICashflowTransactionCategorizedPayload,
ICashflowTransactionUncategorizingPayload,
ICategorizeCashflowTransactioDTO,
} from '@/interfaces';
import { Knex } from 'knex';
import { transformCategorizeTransToCashflow } from './utils';
import { CommandCashflowValidator } from './CommandCasflowValidator';
import NewCashflowTransactionService from './NewCashflowTransactionService';
@Service()
export class CategorizeCashflowTransaction {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private eventPublisher: EventPublisher;
@Inject()
private uow: UnitOfWork;
@Inject()
private commandValidators: CommandCashflowValidator;
@Inject()
private createCashflow: NewCashflowTransactionService;
/**
* Categorize the given cashflow transaction.
* @param {number} tenantId
* @param {ICategorizeCashflowTransactioDTO} categorizeDTO
*/
public async categorize(
tenantId: number,
uncategorizedTransactionId: number,
categorizeDTO: ICategorizeCashflowTransactioDTO
) {
const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId);
// Retrieves the uncategorized transaction or throw an error.
const transaction = await UncategorizedCashflowTransaction.query()
.findById(uncategorizedTransactionId)
.throwIfNotFound();
// Validates the transaction shouldn't be categorized before.
this.commandValidators.validateTransactionShouldNotCategorized(transaction);
// Edits the cashflow transaction under UOW env.
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
// Triggers `onTransactionCategorizing` event.
await this.eventPublisher.emitAsync(
events.cashflow.onTransactionCategorizing,
{
tenantId,
trx,
} as ICashflowTransactionUncategorizingPayload
);
// Transformes the categorize DTO to the cashflow transaction.
const cashflowTransactionDTO = transformCategorizeTransToCashflow(
transaction,
categorizeDTO
);
// Creates a new cashflow transaction.
const cashflowTransaction =
await this.createCashflow.newCashflowTransaction(
tenantId,
cashflowTransactionDTO
);
// Updates the uncategorized transaction as categorized.
await UncategorizedCashflowTransaction.query(trx)
.findById(uncategorizedTransactionId)
.patch({
categorized: true,
categorizeRefType: 'CashflowTransaction',
categorizeRefId: cashflowTransaction.id,
});
// Triggers `onCashflowTransactionCategorized` event.
await this.eventPublisher.emitAsync(
events.cashflow.onTransactionCategorized,
{
tenantId,
// cashflowTransaction,
trx,
} as ICashflowTransactionCategorizedPayload
);
});
}
}

View File

@@ -0,0 +1,80 @@
import {
CategorizeTransactionAsExpenseDTO,
ICashflowTransactionCategorizedPayload,
} from '@/interfaces';
import { Inject, Service } from 'typedi';
import UnitOfWork from '../UnitOfWork';
import events from '@/subscribers/events';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import HasTenancyService from '../Tenancy/TenancyService';
import { Knex } from 'knex';
import { CreateExpense } from '../Expenses/CRUD/CreateExpense';
@Service()
export class CategorizeTransactionAsExpense {
@Inject()
private uow: UnitOfWork;
@Inject()
private tenancy: HasTenancyService;
@Inject()
private eventPublisher: EventPublisher;
@Inject()
private createExpenseService: CreateExpense;
/**
* Categorize the transaction as expense transaction.
* @param {number} tenantId
* @param {number} cashflowTransactionId
* @param {CategorizeTransactionAsExpenseDTO} transactionDTO
*/
public async categorize(
tenantId: number,
cashflowTransactionId: number,
transactionDTO: CategorizeTransactionAsExpenseDTO
) {
const { CashflowTransaction } = this.tenancy.models(tenantId);
const transaction = await CashflowTransaction.query()
.findById(cashflowTransactionId)
.throwIfNotFound();
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
// Triggers `onTransactionUncategorizing` event.
await this.eventPublisher.emitAsync(
events.cashflow.onTransactionCategorizingAsExpense,
{
tenantId,
trx,
} as ICashflowTransactionCategorizedPayload
);
// Creates a new expense transaction.
const expenseTransaction = await this.createExpenseService.newExpense(
tenantId,
{
},
1
);
// Updates the item on the storage and fetches the updated once.
const cashflowTransaction = await CashflowTransaction.query(
trx
).patchAndFetchById(cashflowTransactionId, {
categorizeRefType: 'Expense',
categorizeRefId: expenseTransaction.id,
uncategorized: true,
});
// Triggers `onTransactionUncategorized` event.
await this.eventPublisher.emitAsync(
events.cashflow.onTransactionCategorizedAsExpense,
{
tenantId,
cashflowTransaction,
trx,
} as ICashflowTransactionUncategorizedPayload
);
});
}
}

View File

@@ -4,6 +4,7 @@ import { IAccount } from '@/interfaces';
import { getCashflowTransactionType } from './utils';
import { ServiceError } from '@/exceptions';
import { CASHFLOW_TRANSACTION_TYPE, ERRORS } from './constants';
import CashflowTransaction from '@/models/CashflowTransaction';
@Service()
export class CommandCashflowValidator {
@@ -46,4 +47,28 @@ export class CommandCashflowValidator {
}
return transformedType;
};
/**
* Validate the given transaction should be categorized.
* @param {CashflowTransaction} cashflowTransaction
*/
public validateTransactionShouldCategorized(
cashflowTransaction: CashflowTransaction
) {
if (!cashflowTransaction.uncategorize) {
throw new ServiceError(ERRORS.TRANSACTION_ALREADY_CATEGORIZED);
}
}
/**
* Validate the given transcation shouldn't be categorized.
* @param {CashflowTransaction} cashflowTransaction
*/
public validateTransactionShouldNotCategorized(
cashflowTransaction: CashflowTransaction
) {
if (cashflowTransaction.uncategorize) {
throw new ServiceError(ERRORS.TRANSACTION_ALREADY_CATEGORIZED);
}
}
}

View File

@@ -13,15 +13,15 @@ import UnitOfWork from '@/services/UnitOfWork';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
@Service()
export default class CommandCashflowTransactionService {
export class DeleteCashflowTransaction {
@Inject()
tenancy: HasTenancyService;
private tenancy: HasTenancyService;
@Inject()
eventPublisher: EventPublisher;
private eventPublisher: EventPublisher;
@Inject()
uow: UnitOfWork;
private uow: UnitOfWork;
/**
* Deletes the cashflow transaction with associated journal entries.

View File

@@ -4,7 +4,6 @@ import { CashflowTransactionTransformer } from './CashflowTransactionTransformer
import { ERRORS } from './constants';
import { ICashflowTransaction } from '@/interfaces';
import { ServiceError } from '@/exceptions';
import I18nService from '@/services/I18n/I18nService';
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
@Service()
@@ -12,9 +11,6 @@ export default class GetCashflowTransactionsService {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private i18nService: I18nService;
@Inject()
private transfromer: TransformerInjectable;

View File

@@ -0,0 +1,37 @@
import { Inject, Service } from 'typedi';
import HasTenancyService from '../Tenancy/TenancyService';
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
import { UncategorizedTransactionTransformer } from './UncategorizedTransactionTransformer';
@Service()
export class GetUncategorizedTransactions {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private transformer: TransformerInjectable;
/**
* Retrieves the uncategorized cashflow transactions.
* @param {number} tenantId
*/
public async getTransactions(tenantId: number) {
const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId);
const { results, pagination } =
await UncategorizedCashflowTransaction.query()
.where('categorized', false)
.withGraphFetched('account')
.pagination(0, 10);
const data = await this.transformer.transform(
tenantId,
results,
new UncategorizedTransactionTransformer()
);
return {
data,
pagination,
};
}
}

View File

@@ -1,11 +1,10 @@
import { Service, Inject } from 'typedi';
import { isEmpty, pick } from 'lodash';
import { pick } from 'lodash';
import { Knex } from 'knex';
import * as R from 'ramda';
import {
ICashflowNewCommandDTO,
ICashflowTransaction,
ICashflowTransactionLine,
ICommandCashflowCreatedPayload,
ICommandCashflowCreatingPayload,
ICashflowTransactionInput,
@@ -126,7 +125,7 @@ export default class NewCashflowTransactionService {
tenantId: number,
newTransactionDTO: ICashflowNewCommandDTO,
userId?: number
): Promise<{ cashflowTransaction: ICashflowTransaction }> => {
): Promise<ICashflowTransaction> => {
const { CashflowTransaction, Account } = this.tenancy.models(tenantId);
// Retrieves the cashflow account or throw not found error.
@@ -175,7 +174,7 @@ export default class NewCashflowTransactionService {
trx,
} as ICommandCashflowCreatedPayload
);
return { cashflowTransaction };
return cashflowTransaction;
});
};
}

View File

@@ -0,0 +1,72 @@
import { Knex } from 'knex';
import { Inject, Service } from 'typedi';
import HasTenancyService from '../Tenancy/TenancyService';
import UnitOfWork from '../UnitOfWork';
import events from '@/subscribers/events';
import { EventPublisher } from '@/lib/EventPublisher/EventPublisher';
import {
ICashflowTransactionUncategorizedPayload,
ICashflowTransactionUncategorizingPayload,
} from '@/interfaces';
@Service()
export class UncategorizeCashflowTransaction {
@Inject()
private tenancy: HasTenancyService;
@Inject()
private eventPublisher: EventPublisher;
@Inject()
private uow: UnitOfWork;
/**
* Uncategorizes the given cashflow transaction.
* @param {number} tenantId
* @param {number} cashflowTransactionId
*/
public async uncategorize(
tenantId: number,
uncategorizedTransactionId: number
) {
const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId);
const oldUncategorizedTransaction =
await UncategorizedCashflowTransaction.query()
.findById(uncategorizedTransactionId)
.throwIfNotFound();
// Updates the transaction under UOW.
return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => {
// Triggers `onTransactionUncategorizing` event.
await this.eventPublisher.emitAsync(
events.cashflow.onTransactionUncategorizing,
{
tenantId,
trx,
} as ICashflowTransactionUncategorizingPayload
);
// Removes the ref relation with the related transaction.
const uncategorizedTransaction =
await UncategorizedCashflowTransaction.query(trx).updateAndFetchById(
uncategorizedTransactionId,
{
categorized: false,
categorizeRefId: null,
categorizeRefType: null,
}
);
// Triggers `onTransactionUncategorized` event.
await this.eventPublisher.emitAsync(
events.cashflow.onTransactionUncategorized,
{
tenantId,
uncategorizedTransaction,
oldUncategorizedTransaction,
trx,
} as ICashflowTransactionUncategorizedPayload
);
return uncategorizedTransaction;
});
}
}

View File

@@ -0,0 +1,9 @@
import { Service } from 'typedi';
import { UncategorizeCashflowTransaction } from './UncategorizeCashflowTransaction';
@Service()
export class UncategorizeTransactionByRef {
private uncategorizeTransactionService: UncategorizeCashflowTransaction;
public uncategorize(tenantId: number, refId: number, refType: string) {}
}

View File

@@ -0,0 +1,34 @@
import { Transformer } from '@/lib/Transformer/Transformer';
import { formatNumber } from '@/utils';
export class UncategorizedTransactionTransformer extends Transformer {
/**
* Include these attributes to sale invoice object.
* @returns {string[]}
*/
public includeAttributes = (): string[] => {
return ['formattetDepositAmount', 'formattedWithdrawalAmount'];
};
/**
* Formatted deposit amount.
* @param transaction
* @returns {string}
*/
protected formattetDepositAmount(transaction) {
return formatNumber(transaction.deposit, {
currencyCode: transaction.currencyCode,
});
}
/**
* Formatted withdrawal amount.
* @param transaction
* @returns {string}
*/
protected formattedWithdrawalAmount(transaction) {
return formatNumber(transaction.withdrawal, {
currencyCode: transaction.currencyCode,
});
}
}

View File

@@ -8,7 +8,9 @@ export const ERRORS = {
CREDIT_ACCOUNTS_IDS_NOT_FOUND: 'CREDIT_ACCOUNTS_IDS_NOT_FOUND',
CREDIT_ACCOUNTS_HAS_INVALID_TYPE: 'CREDIT_ACCOUNTS_HAS_INVALID_TYPE',
ACCOUNT_ID_HAS_INVALID_TYPE: 'ACCOUNT_ID_HAS_INVALID_TYPE',
ACCOUNT_HAS_ASSOCIATED_TRANSACTIONS: 'account_has_associated_transactions'
ACCOUNT_HAS_ASSOCIATED_TRANSACTIONS: 'account_has_associated_transactions',
TRANSACTION_ALREADY_CATEGORIZED: 'TRANSACTION_ALREADY_CATEGORIZED',
TRANSACTION_ALREADY_UNCATEGORIZED: 'TRANSACTION_ALREADY_UNCATEGORIZED'
};
export enum CASHFLOW_DIRECTION {

View File

@@ -0,0 +1,40 @@
import { Inject, Service } from 'typedi';
import events from '@/subscribers/events';
import { ICashflowTransactionUncategorizedPayload } from '@/interfaces';
import { DeleteCashflowTransaction } from '../DeleteCashflowTransactionService';
@Service()
export class DeleteCashflowTransactionOnUncategorize {
@Inject()
private deleteCashflowTransactionService: DeleteCashflowTransaction;
/**
* Attaches events with handlers.
*/
public attach = (bus) => {
bus.subscribe(
events.cashflow.onTransactionUncategorized,
this.deleteCashflowTransactionOnUncategorize.bind(this)
);
};
/**
* Deletes the cashflow transaction on uncategorize transaction.
* @param {ICashflowTransactionUncategorizedPayload} payload
*/
public async deleteCashflowTransactionOnUncategorize({
tenantId,
oldUncategorizedTransaction,
trx,
}: ICashflowTransactionUncategorizedPayload) {
// Deletes the cashflow transaction.
if (
oldUncategorizedTransaction.categorizeRefType === 'CashflowTransaction'
) {
await this.deleteCashflowTransactionService.deleteCashflowTransaction(
tenantId,
oldUncategorizedTransaction.categorizeRefId
);
}
}
}

View File

@@ -1,13 +1,19 @@
import { upperFirst, camelCase } from 'lodash';
import { upperFirst, camelCase, omit } from 'lodash';
import {
CASHFLOW_TRANSACTION_TYPE,
CASHFLOW_TRANSACTION_TYPE_META,
ICashflowTransactionTypeMeta,
} from './constants';
import {
ICashflowNewCommandDTO,
ICashflowTransaction,
ICategorizeCashflowTransactioDTO,
IUncategorizedCashflowTransaction,
} from '@/interfaces';
/**
* Ensures the given transaction type to transformed to appropriate format.
* @param {string} type
* @param {string} type
* @returns {string}
*/
export const transformCashflowTransactionType = (type) => {
@@ -32,3 +38,29 @@ export function getCashflowTransactionType(
export const getCashflowAccountTransactionsTypes = () => {
return Object.values(CASHFLOW_TRANSACTION_TYPE_META).map((meta) => meta.type);
};
/**
* Tranasformes the given uncategorized transaction and categorized DTO
* to cashflow create DTO.
* @param {IUncategorizedCashflowTransaction} uncategorizeModel
* @param {ICategorizeCashflowTransactioDTO} categorizeDTO
* @returns {ICashflowNewCommandDTO}
*/
export const transformCategorizeTransToCashflow = (
uncategorizeModel: IUncategorizedCashflowTransaction,
categorizeDTO: ICategorizeCashflowTransactioDTO
): ICashflowNewCommandDTO => {
return {
date: uncategorizeModel.date,
referenceNo: categorizeDTO.referenceNo || uncategorizeModel.referenceNo,
description: categorizeDTO.description || uncategorizeModel.description,
cashflowAccountId: uncategorizeModel.accountId,
creditAccountId: categorizeDTO.fromAccountId || categorizeDTO.toAccountId,
exchangeRate: categorizeDTO.exchangeRate || 1,
currencyCode: uncategorizeModel.currencyCode,
amount: uncategorizeModel.amount,
transactionNumber: categorizeDTO.transactionNumber,
transactionType: categorizeDTO.transactionType,
publish: true,
};
};