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

@@ -13,9 +13,9 @@ export default class CashflowController {
router() {
const router = Router();
router.use(Container.get(CommandCashflowTransaction).router());
router.use(Container.get(GetCashflowTransaction).router());
router.use(Container.get(GetCashflowAccounts).router());
router.use(Container.get(CommandCashflowTransaction).router());
router.use(Container.get(DeleteCashflowTransaction).router());
return router;

View File

@@ -3,14 +3,14 @@ import { Router, Request, Response, NextFunction } from 'express';
import { param } from 'express-validator';
import BaseController from '../BaseController';
import { ServiceError } from '@/exceptions';
import DeleteCashflowTransactionService from '../../../services/Cashflow/DeleteCashflowTransactionService';
import { DeleteCashflowTransaction } from '../../../services/Cashflow/DeleteCashflowTransactionService';
import CheckPolicies from '@/api/middleware/CheckPolicies';
import { AbilitySubject, CashflowAction } from '@/interfaces';
@Service()
export default class DeleteCashflowTransaction extends BaseController {
export default class DeleteCashflowTransactionController extends BaseController {
@Inject()
deleteCashflowService: DeleteCashflowTransactionService;
private deleteCashflowService: DeleteCashflowTransaction;
/**
* Controller router.

View File

@@ -11,10 +11,10 @@ import { AbilitySubject, CashflowAction } from '@/interfaces';
@Service()
export default class GetCashflowAccounts extends BaseController {
@Inject()
getCashflowAccountsService: GetCashflowAccountsService;
private getCashflowAccountsService: GetCashflowAccountsService;
@Inject()
getCashflowTransactionsService: GetCashflowTransactionsService;
private getCashflowTransactionsService: GetCashflowTransactionsService;
/**
* Controller router.

View File

@@ -10,7 +10,7 @@ import { AbilitySubject, CashflowAction } from '@/interfaces';
@Service()
export default class GetCashflowAccounts extends BaseController {
@Inject()
getCashflowTransactionsService: GetCashflowTransactionsService;
private getCashflowTransactionsService: GetCashflowTransactionsService;
/**
* Controller router.

View File

@@ -6,18 +6,27 @@ import { ServiceError } from '@/exceptions';
import NewCashflowTransactionService from '@/services/Cashflow/NewCashflowTransactionService';
import CheckPolicies from '@/api/middleware/CheckPolicies';
import { AbilitySubject, CashflowAction } from '@/interfaces';
import { CashflowApplication } from '@/services/Cashflow/CashflowApplication';
@Service()
export default class NewCashflowTransactionController extends BaseController {
@Inject()
private newCashflowTranscationService: NewCashflowTransactionService;
@Inject()
private cashflowApplication: CashflowApplication;
/**
* Router constructor.
*/
public router() {
const router = Router();
router.get(
'/transactions/uncategorized',
this.asyncMiddleware(this.getUncategorizedCashflowTransactions),
this.catchServiceErrors
);
router.post(
'/transactions',
CheckPolicies(CashflowAction.Create, AbilitySubject.Cashflow),
@@ -26,13 +35,61 @@ export default class NewCashflowTransactionController extends BaseController {
this.asyncMiddleware(this.newCashflowTransaction),
this.catchServiceErrors
);
router.post(
'/transactions/:id/uncategorize',
this.revertCategorizedCashflowTransaction,
this.catchServiceErrors
);
router.post(
'/transactions/:id/categorize',
this.categorizeCashflowTransactionValidationSchema,
this.validationResult,
this.categorizeCashflowTransaction,
this.catchServiceErrors
);
router.post(
'/transaction/:id/categorize/expense',
this.categorizeAsExpenseValidationSchema,
this.validationResult,
this.categorizesCashflowTransactionAsExpense,
this.catchServiceErrors
);
return router;
}
/**
* Categorize as expense validation schema.
*/
public get categorizeAsExpenseValidationSchema() {
return [
check('expense_account_id').exists(),
check('date').isISO8601().exists(),
check('reference_no').optional(),
check('exchange_rate').optional().isFloat({ gt: 0 }).toFloat(),
];
}
/**
* Categorize cashflow tranasction validation schema.
*/
public get categorizeCashflowTransactionValidationSchema() {
return [
check('date').exists().isISO8601().toDate(),
check('to_account_id').exists().isInt().toInt(),
check('from_account_id').exists().isInt().toInt(),
check('transaction_type').exists(),
check('reference_no').optional(),
check('exchange_rate').optional().isFloat({ gt: 0 }).toFloat(),
check('description').optional(),
];
}
/**
* New cashflow transaction validation schema.
*/
get newTransactionValidationSchema() {
public get newTransactionValidationSchema() {
return [
check('date').exists().isISO8601().toDate(),
check('reference_no').optional({ nullable: true }).trim().escape(),
@@ -48,12 +105,10 @@ export default class NewCashflowTransactionController extends BaseController {
check('credit_account_id').exists().isInt().toInt(),
check('exchange_rate').optional().isFloat({ gt: 0 }).toFloat(),
check('branch_id').optional({ nullable: true }).isNumeric().toInt(),
check('publish').default(false).isBoolean().toBoolean(),
];
}
}
/**
* Creates a new cashflow transaction.
@@ -76,7 +131,6 @@ export default class NewCashflowTransactionController extends BaseController {
ownerContributionDTO,
userId
);
return res.status(200).send({
id: cashflowTransaction.id,
message: 'New cashflow transaction has been created successfully.',
@@ -86,11 +140,118 @@ export default class NewCashflowTransactionController extends BaseController {
}
};
/**
* Revert the categorized cashflow transaction.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
*/
private revertCategorizedCashflowTransaction = async (
req: Request,
res: Response,
next: NextFunction
) => {
const { tenantId } = req;
const { id: cashflowTransactionId } = req.params;
try {
const data= await this.cashflowApplication.uncategorizeTransaction(
tenantId,
cashflowTransactionId
);
return res.status(200).send({ data });
} catch (error) {
next(error);
}
};
/**
* Categorize the cashflow transaction.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
*/
private categorizeCashflowTransaction = async (
req: Request,
res: Response,
next: NextFunction
) => {
const { tenantId } = req;
const { id: cashflowTransactionId } = req.params;
const cashflowTransaction = this.matchedBodyData(req);
try {
await this.cashflowApplication.categorizeTransaction(
tenantId,
cashflowTransactionId,
cashflowTransaction
);
return res.status(200).send({
message: 'The cashflow transaction has been created successfully.',
});
} catch (error) {
next(error);
}
};
/**
* Categorize the transaction as expense transaction.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
*/
private categorizesCashflowTransactionAsExpense = async (
req: Request,
res: Response,
next: NextFunction
) => {
const { tenantId } = req;
const { id: cashflowTransactionId } = req.params;
const cashflowTransaction = this.matchedBodyData(req);
try {
await this.cashflowApplication.categorizeAsExpense(
tenantId,
cashflowTransactionId,
cashflowTransaction
);
return res.status(200).send({
message: 'The cashflow transaction has been created successfully.',
});
} catch (error) {
next(error);
}
};
/**
* Retrieves the uncategorized cashflow transactions.
* @param {Request} req
* @param {Response} res
* @param {NextFunction} next
*/
public getUncategorizedCashflowTransactions = async (
req: Request,
res: Response,
next: NextFunction
) => {
const { tenantId } = req;
try {
const data = await this.cashflowApplication.getUncategorizedTransactions(
tenantId
);
return res.status(200).send(data);
} catch (error) {
next(error);
}
};
/**
* Handle the service errors.
* @param error
* @param req
* @param res
* @param {Request} req
* @param {res
* @param next
* @returns
*/

View File

@@ -0,0 +1,25 @@
exports.up = function (knex) {
return knex.schema.createTable(
'uncategorized_cashflow_transactions',
(table) => {
table.increments('id');
table.date('date').index();
table.decimal('amount');
table.string('reference_no').index();
table
.integer('account_id')
.unsigned()
.references('id')
.inTable('accounts');
table.string('description');
table.string('categorize_ref_type');
table.integer('categorize_ref_id').unsigned();
table.boolean('categorized').defaultTo(false);
table.timestamps();
}
);
};
exports.down = function (knex) {
return knex.schema.dropTableIfExists('uncategorized_cashflow_transactions');
};

View File

@@ -0,0 +1,11 @@
exports.up = function (knex) {
return knex.schema.table('expenses_transactions', (table) => {
table
.integer('categorized_transaction_id')
.unsigned()
.references('id')
.inTable('uncategorized_cashflow_transactions');
});
};
exports.down = function (knex) {};

View File

@@ -233,3 +233,27 @@ export interface ICashflowTransactionSchema {
}
export interface ICashflowTransactionInput extends ICashflowTransactionSchema {}
export interface ICategorizeCashflowTransactioDTO {
fromAccountId: number;
toAccountId: number;
referenceNo: string;
transactionNumber: string;
transactionType: string;
exchangeRate: number;
description: string;
branchId: number;
}
export interface IUncategorizedCashflowTransaction {
id?: number;
amount: number;
date: Date;
currencyCode: string;
accountId: number;
description: string;
referenceNo: string;
categorizeRefType: string;
categorizeRefId: number;
categorized: boolean;
}

View File

@@ -1,5 +1,6 @@
import { Knex } from 'knex';
import { IAccount } from './Account';
import { IUncategorizedCashflowTransaction } from './CashFlow';
export interface ICashflowAccountTransactionsFilter {
page: number;
@@ -124,8 +125,34 @@ export interface ICommandCashflowDeletedPayload {
trx: Knex.Transaction;
}
export interface ICashflowTransactionCategorizedPayload {
tenantId: number;
cashflowTransactionId: number;
cashflowTransaction: ICashflowTransaction;
trx: Knex.Transaction;
}
export interface ICashflowTransactionUncategorizingPayload {
tenantId: number;
uncategorizedTransaction: IUncategorizedCashflowTransaction;
trx: Knex.Transaction;
}
export interface ICashflowTransactionUncategorizedPayload {
tenantId: number;
uncategorizedTransaction: IUncategorizedCashflowTransaction;
oldUncategorizedTransaction: IUncategorizedCashflowTransaction;
trx: Knex.Transaction;
}
export enum CashflowAction {
Create = 'Create',
Delete = 'Delete',
View = 'View',
}
export interface CategorizeTransactionAsExpenseDTO {
expenseAccountId: number;
exchangeRate: number;
referenceNo: string;
description: string;
branchId?: number;
}

View File

@@ -88,6 +88,7 @@ import { PlaidUpdateTransactionsOnItemCreatedSubscriber } from '@/services/Banki
import { InvoiceChangeStatusOnMailSentSubscriber } from '@/services/Sales/Invoices/subscribers/InvoiceChangeStatusOnMailSentSubscriber';
import { SaleReceiptMarkClosedOnMailSentSubcriber } from '@/services/Sales/Receipts/subscribers/SaleReceiptMarkClosedOnMailSentSubcriber';
import { SaleEstimateMarkApprovedOnMailSent } from '@/services/Sales/Estimates/subscribers/SaleEstimateMarkApprovedOnMailSent';
import { DeleteCashflowTransactionOnUncategorize } from '@/services/Cashflow/subscribers/DeleteCashflowTransactionOnUncategorize';
export default () => {
return new EventPublisher();
@@ -212,6 +213,9 @@ export const susbcribers = () => {
SyncItemTaxRateOnEditTaxSubscriber,
// Plaid
PlaidUpdateTransactionsOnItemCreatedSubscriber
PlaidUpdateTransactionsOnItemCreatedSubscriber,
// Cashflow
DeleteCashflowTransactionOnUncategorize,
];
};

View File

@@ -62,6 +62,7 @@ import TaxRate from 'models/TaxRate';
import TaxRateTransaction from 'models/TaxRateTransaction';
import Attachment from 'models/Attachment';
import PlaidItem from 'models/PlaidItem';
import UncategorizedCashflowTransaction from 'models/UncategorizedCashflowTransaction';
export default (knex) => {
const models = {
@@ -126,7 +127,8 @@ export default (knex) => {
TaxRate,
TaxRateTransaction,
Attachment,
PlaidItem
PlaidItem,
UncategorizedCashflowTransaction
};
return mapValues(models, (model) => model.bindKnex(knex));
};

View File

@@ -12,6 +12,7 @@ export default class CashflowTransaction extends TenantModel {
transactionType: string;
amount: number;
exchangeRate: number;
uncategorize: boolean;
/**
* Table name.

View File

@@ -182,6 +182,7 @@ export default class Expense extends mixin(TenantModel, [
const ExpenseCategory = require('models/ExpenseCategory');
const Media = require('models/Media');
const Branch = require('models/Branch');
const UncategorizedCashflowTransaction = require('models/UncategorizedCashflowTransaction');
return {
paymentAccount: {
@@ -215,6 +216,10 @@ export default class Expense extends mixin(TenantModel, [
to: 'branches.id',
},
},
/**
*
*/
media: {
relation: Model.ManyToManyRelation,
modelClass: Media.default,
@@ -230,6 +235,18 @@ export default class Expense extends mixin(TenantModel, [
query.where('model_name', 'Expense');
},
},
/**
* Retrieves the related uncategorized cashflow transaction.
*/
categorized: {
relation: Model.BelongsToOneRelation,
modelClass: UncategorizedCashflowTransaction.default,
join: {
from: 'expenses_transactions.categorizedTransactionId',
to: 'uncategorized_cashflow_transactions.id',
},
}
};
}

View File

@@ -0,0 +1,64 @@
/* eslint-disable global-require */
import TenantModel from 'models/TenantModel';
import { Model } from 'objection';
export default class UncategorizedCashflowTransaction extends TenantModel {
amount: number;
/**
* Table name.
*/
static get tableName() {
return 'uncategorized_cashflow_transactions';
}
/**
* Timestamps columns.
*/
static get timestamps() {
return ['createdAt', 'updatedAt'];
}
/**
* Retrieves the withdrawal amount.
* @returns {number}
*/
public withdrawal() {
return this.amount > 0 ? Math.abs(this.amount) : 0;
}
/**
* Retrieves the deposit amount.
* @returns {number}
*/
public deposit() {
return this.amount < 0 ? Math.abs(this.amount) : 0;
}
/**
* Virtual attributes.
*/
static get virtualAttributes() {
return ['withdrawal', 'deposit'];
}
/**
* Relationship mapping.
*/
static get relationMappings() {
const Account = require('models/Account');
return {
/**
* Transaction may has associated to account.
*/
account: {
relation: Model.BelongsToOneRelation,
modelClass: Account.default,
join: {
from: 'uncategorized_cashflow_transactions.accountId',
to: 'accounts.id',
},
},
};
}
}

View File

@@ -9,7 +9,7 @@ import {
transformPlaidTrxsToCashflowCreate,
} from './utils';
import NewCashflowTransactionService from '@/services/Cashflow/NewCashflowTransactionService';
import DeleteCashflowTransactionService from '@/services/Cashflow/DeleteCashflowTransactionService';
import { DeleteCashflowTransaction } from '@/services/Cashflow/DeleteCashflowTransactionService';
import HasTenancyService from '@/services/Tenancy/TenancyService';
const CONCURRENCY_ASYNC = 10;
@@ -26,7 +26,7 @@ export class PlaidSyncDb {
private createCashflowTransactionService: NewCashflowTransactionService;
@Inject()
private deleteCashflowTransactionService: DeleteCashflowTransactionService;
private deleteCashflowTransactionService: DeleteCashflowTransaction;
/**
* Syncs the plaid accounts to the system accounts.

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,
};
};

View File

@@ -392,6 +392,15 @@ export default {
onTransactionDeleting: 'onCashflowTransactionDeleting',
onTransactionDeleted: 'onCashflowTransactionDeleted',
onTransactionCategorizing: 'onTransactionCategorizing',
onTransactionCategorized: 'onCashflowTransactionCategorized',
onTransactionUncategorizing: 'onTransactionUncategorizing',
onTransactionUncategorized: 'onTransactionUncategorized',
onTransactionCategorizingAsExpense: 'onTransactionCategorizingAsExpense',
onTransactionCategorizedAsExpense: 'onTransactionCategorizedAsExpense',
},
/**