From 906835c396e5815295c9650130698b95153a77bf Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Tue, 18 Jun 2024 17:14:30 +0200 Subject: [PATCH 01/42] feat: bank rules for uncategorized transactions --- .../controllers/Banking/BankingController.ts | 2 + .../Banking/BankingRulesController.ts | 202 ++++++++++++++++++ .../20240618100137_create_bank_rules_table.js | 33 +++ packages/server/src/loaders/tenantModels.ts | 4 + packages/server/src/models/BankRule.ts | 46 ++++ .../server/src/models/BankRuleCondition.ts | 24 +++ .../Banking/Rules/BankRulesApplication.ts | 79 +++++++ .../services/Banking/Rules/CreateBankRule.ts | 65 ++++++ .../services/Banking/Rules/DeleteBankRule.ts | 53 +++++ .../services/Banking/Rules/EditBankRule.ts | 80 +++++++ .../src/services/Banking/Rules/GetBankRule.ts | 34 +++ .../Banking/Rules/GetBankRuleTransformer.ts | 11 + .../services/Banking/Rules/GetBankRules.ts | 31 +++ .../Banking/Rules/GetBankRulesTransformer.ts | 11 + .../src/services/Banking/Rules/types.ts | 64 ++++++ packages/server/src/subscribers/events.ts | 15 +- 16 files changed, 752 insertions(+), 2 deletions(-) create mode 100644 packages/server/src/api/controllers/Banking/BankingRulesController.ts create mode 100644 packages/server/src/database/migrations/20240618100137_create_bank_rules_table.js create mode 100644 packages/server/src/models/BankRule.ts create mode 100644 packages/server/src/models/BankRuleCondition.ts create mode 100644 packages/server/src/services/Banking/Rules/BankRulesApplication.ts create mode 100644 packages/server/src/services/Banking/Rules/CreateBankRule.ts create mode 100644 packages/server/src/services/Banking/Rules/DeleteBankRule.ts create mode 100644 packages/server/src/services/Banking/Rules/EditBankRule.ts create mode 100644 packages/server/src/services/Banking/Rules/GetBankRule.ts create mode 100644 packages/server/src/services/Banking/Rules/GetBankRuleTransformer.ts create mode 100644 packages/server/src/services/Banking/Rules/GetBankRules.ts create mode 100644 packages/server/src/services/Banking/Rules/GetBankRulesTransformer.ts create mode 100644 packages/server/src/services/Banking/Rules/types.ts diff --git a/packages/server/src/api/controllers/Banking/BankingController.ts b/packages/server/src/api/controllers/Banking/BankingController.ts index 27838a285..109371311 100644 --- a/packages/server/src/api/controllers/Banking/BankingController.ts +++ b/packages/server/src/api/controllers/Banking/BankingController.ts @@ -2,6 +2,7 @@ import Container, { Inject, Service } from 'typedi'; import { Router } from 'express'; import BaseController from '@/api/controllers/BaseController'; import { PlaidBankingController } from './PlaidBankingController'; +import { BankingRulesController } from './BankingRulesController'; @Service() export class BankingController extends BaseController { @@ -12,6 +13,7 @@ export class BankingController extends BaseController { const router = Router(); router.use('/plaid', Container.get(PlaidBankingController).router()); + router.use('/rules', Container.get(BankingRulesController).router()); return router; } diff --git a/packages/server/src/api/controllers/Banking/BankingRulesController.ts b/packages/server/src/api/controllers/Banking/BankingRulesController.ts new file mode 100644 index 000000000..e4b5ea26c --- /dev/null +++ b/packages/server/src/api/controllers/Banking/BankingRulesController.ts @@ -0,0 +1,202 @@ +import { Inject, Service } from 'typedi'; +import { NextFunction, Request, Response, Router } from 'express'; +import BaseController from '@/api/controllers/BaseController'; +import { BankRulesApplication } from '@/services/Banking/Rules/BankRulesApplication'; +import { body, param } from 'express-validator'; +import { + ICreateBankRuleDTO, + IEditBankRuleDTO, +} from '@/services/Banking/Rules/types'; + +@Service() +export class BankingRulesController extends BaseController { + @Inject() + private bankRulesApplication: BankRulesApplication; + + /** + * Bank rule DTO validation schema. + */ + private get bankRuleValidationSchema() { + return [ + body('name').isString().exists(), + body('order').isInt({ min: 0 }), + + // Apply to if transaction is. + body('apply_if_account_id') + .isInt({ min: 0 }) + .optional({ nullable: true }), + body('apply_if_transaction_type').isIn(['deposit', 'withdrawal']), + + // Conditions + body('conditions_type').isString().isIn(['and', 'or']).default('and'), + body('conditions').isArray({ min: 1 }), + body('conditions.*.field').exists().isIn(['description', 'amount']), + body('conditions.*.comparator') + .exists() + .isIn(['equals', 'contains', 'not_contain']) + .default('contain'), + body('conditions.*.value').exists(), + + // Assign + body('assign_category') + .isString() + .isIn([ + 'interest_income', + 'other_income', + 'deposit', + 'expense', + 'owner_drawings', + ]), + body('assign_account_id').isInt({ min: 0 }), + body('assign_payee').isString().optional({ nullable: true }), + body('assign_memo').isString().optional({ nullable: true }), + ]; + } + + /** + * Router constructor. + */ + public router() { + const router = Router(); + + router.post( + '/', + [...this.bankRuleValidationSchema], + this.validationResult, + this.createBankRule.bind(this) + ); + router.post( + '/:id', + [param('id').toInt().exists(), ...this.bankRuleValidationSchema], + this.validationResult, + this.editBankRule.bind(this) + ); + router.delete( + '/:id', + [param('id').toInt().exists()], + this.validationResult, + this.deleteBankRule.bind(this) + ); + router.get( + '/:id', + [param('id').toInt().exists()], + this.validationResult, + this.getBankRule.bind(this) + ); + router.get( + '/', + [param('id').toInt().exists()], + this.validationResult, + this.getBankRules.bind(this) + ); + return router; + } + + /** + * Creates a new bank rule. + * @param {Request} req + * @param {Response} res + * @param next + */ + public async createBankRule(req: Request, res: Response, next: NextFunction) { + const { tenantId } = req; + const createBankRuleDTO = this.matchedBodyData(req) as ICreateBankRuleDTO; + + try { + const bankRule = await this.bankRulesApplication.createBankRule( + tenantId, + createBankRuleDTO + ); + return res.status(200).send({ + id: bankRule.id, + message: 'The bank rule has been created successfully.', + }); + } catch (error) { + next(error); + } + } + + /** + * Edits the given bank rule. + * @param req + * @param res + * @param next + */ + public async editBankRule(req: Request, res: Response, next: NextFunction) { + const { tenantId } = req; + const { id: ruleId } = req.params; + const editBankRuleDTO = this.matchedBodyData(req) as IEditBankRuleDTO; + + try { + await this.bankRulesApplication.editBankRule( + tenantId, + ruleId, + editBankRuleDTO + ); + return res.status(200).send({ + id: ruleId, + message: 'The bank rule has been updated successfully.', + }); + } catch (error) { + next(error); + } + } + + /** + * Deletes the given bank rule. + * @param req + * @param res + * @param next + */ + public async deleteBankRule(req: Request, res: Response, next: NextFunction) { + const { id: ruleId } = req.params; + try { + await this.bankRulesApplication.deleteBankRule(tenantId, ruleId); + + return res + .status(200) + .send({ message: 'The bank rule has been deleted.' }); + } catch (error) { + next(error); + } + } + + /** + * Retrieve the given bank rule. + * @param req + * @param res + * @param next + */ + public async getBankRule(req: Request, res: Response, next: NextFunction) { + const { id: ruleId } = req.params; + const { tenantId } = req; + + try { + const bankRule = await this.bankRulesApplication.getBankRule( + tenantId, + ruleId + ); + + return res.status(200).send({ bankRule }); + } catch (error) { + next(error); + } + } + + /** + * Retrieves the bank rules. + * @param req + * @param res + * @param next + */ + public async getBankRules(req: Request, res: Response, next: NextFunction) { + const { tenantId } = req; + + try { + const bankRules = await this.bankRulesApplication.getBankRules(tenantId); + return res.status(200).send({ bankRules }); + } catch (error) { + next(error); + } + } +} diff --git a/packages/server/src/database/migrations/20240618100137_create_bank_rules_table.js b/packages/server/src/database/migrations/20240618100137_create_bank_rules_table.js new file mode 100644 index 000000000..ec0136117 --- /dev/null +++ b/packages/server/src/database/migrations/20240618100137_create_bank_rules_table.js @@ -0,0 +1,33 @@ +exports.up = function (knex) { + return knex.schema + .createTable('bank_rules', (table) => { + table.increments('id').primary(); + table.string('name'); + table.integer('order').unsigned(); + + table.integer('apply_if_account_id').unsigned(); + table.string('apply_if_transaction_type'); + + table.string('assign_category'); + table.integer('assign_account_id').unsigned(); + table.string('assign_payee'); + table.string('assign_memo'); + + table.string('conditions_type'); + + table.timestamps(); + }) + .createTable('bank_rule_conditions', (table) => { + table.increments('id').primary(); + table.integer('rule_id').unsigned(); + table.string('field'); + table.string('comparator'); + table.string('value'); + }); +}; + +exports.down = function (knex) { + return knex.schema + .dropTableIfExists('bank_rules') + .dropTableIfExists('bank_rule_conditions'); +}; diff --git a/packages/server/src/loaders/tenantModels.ts b/packages/server/src/loaders/tenantModels.ts index 5183e85ae..0debb29b5 100644 --- a/packages/server/src/loaders/tenantModels.ts +++ b/packages/server/src/loaders/tenantModels.ts @@ -64,6 +64,8 @@ import PlaidItem from 'models/PlaidItem'; import UncategorizedCashflowTransaction from 'models/UncategorizedCashflowTransaction'; import Document from '@/models/Document'; import DocumentLink from '@/models/DocumentLink'; +import { BankRule } from '@/models/BankRule'; +import { BankRuleCondition } from '@/models/BankRuleCondition'; export default (knex) => { const models = { @@ -131,6 +133,8 @@ export default (knex) => { DocumentLink, PlaidItem, UncategorizedCashflowTransaction, + BankRule, + BankRuleCondition, }; return mapValues(models, (model) => model.bindKnex(knex)); }; diff --git a/packages/server/src/models/BankRule.ts b/packages/server/src/models/BankRule.ts new file mode 100644 index 000000000..1f93a3415 --- /dev/null +++ b/packages/server/src/models/BankRule.ts @@ -0,0 +1,46 @@ +import TenantModel from 'models/TenantModel'; +import { Model } from 'objection'; + +export class BankRule extends TenantModel { + /** + * Table name + */ + static get tableName() { + return 'bank_rules'; + } + + /** + * Timestamps columns. + */ + get timestamps() { + return ['created_at', 'updated_at']; + } + + /** + * Virtual attributes. + */ + static get virtualAttributes() { + return []; + } + + /** + * Relationship mapping. + */ + static get relationMappings() { + const { BankRuleCondition } = require('models/BankRuleCondition'); + + return { + /** + * Sale invoice associated entries. + */ + conditions: { + relation: Model.HasManyRelation, + modelClass: BankRuleCondition, + join: { + from: 'bank_rules.id', + to: 'bank_rule_conditions.ruleId', + }, + }, + }; + } +} diff --git a/packages/server/src/models/BankRuleCondition.ts b/packages/server/src/models/BankRuleCondition.ts new file mode 100644 index 000000000..ff0fa7a06 --- /dev/null +++ b/packages/server/src/models/BankRuleCondition.ts @@ -0,0 +1,24 @@ +import TenantModel from 'models/TenantModel'; + +export class BankRuleCondition extends TenantModel { + /** + * Table name. + */ + static get tableName() { + return 'bank_rule_conditions'; + } + + /** + * Timestamps columns. + */ + get timestamps() { + return []; + } + + /** + * Virtual attributes. + */ + static get virtualAttributes() { + return []; + } +} diff --git a/packages/server/src/services/Banking/Rules/BankRulesApplication.ts b/packages/server/src/services/Banking/Rules/BankRulesApplication.ts new file mode 100644 index 000000000..c24f47c32 --- /dev/null +++ b/packages/server/src/services/Banking/Rules/BankRulesApplication.ts @@ -0,0 +1,79 @@ +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 + */ + public createBankRule(tenantId: number, createRuleDTO: ICreateBankRuleDTO) { + return this.createBankRuleService.createBankRule(tenantId, createRuleDTO); + } + + /** + * Edits the given bank rule. + * @param {number} tenantId + * @param {IEditBankRuleDTO} editRuleDTO + * @returns + */ + public editBankRule( + tenantId: number, + ruleId: number, + editRuleDTO: IEditBankRuleDTO + ) { + return this.editBankRuleService.editBankRule(tenantId, ruleId, editRuleDTO); + } + + /** + * Deletes the given bank rule. + * @param {number} tenantId + * @param {number} ruleId + * @returns + */ + public deleteBankRule(tenantId: number, ruleId: number) { + return this.deleteBankRuleService.deleteBankRule(tenantId, ruleId); + } + + /** + * Retrieves the given bank rule. + * @param {number} tenantId + * @param {number} ruleId + * @returns + */ + public getBankRule(tenantId: number, ruleId: number) { + return this.getBankRuleService.getBankRule(tenantId, ruleId); + } + + /** + * Retrieves the bank rules of the given account. + * @param {number} tenantId + * @param {number} accountId + * @returns + */ + public getBankRules(tenantId: number) { + return this.getBankRulesService.getBankRules(tenantId); + } +} diff --git a/packages/server/src/services/Banking/Rules/CreateBankRule.ts b/packages/server/src/services/Banking/Rules/CreateBankRule.ts new file mode 100644 index 000000000..9385d0a79 --- /dev/null +++ b/packages/server/src/services/Banking/Rules/CreateBankRule.ts @@ -0,0 +1,65 @@ +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 + */ + public createBankRule(tenantId: number, createRuleDTO: ICreateBankRuleDTO) { + 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, { + createRuleDTO, + trx, + } as IBankRuleEventCreatingPayload); + + const bankRule = await BankRule.query(trx).upsertGraph({ + ...transformDTO, + }); + + // Triggers `onBankRuleCreated` event. + await this.eventPublisher.emitAsync(events.bankRules.onCreated, { + createRuleDTO, + trx, + } as IBankRuleEventCreatedPayload); + + return bankRule; + }); + } +} diff --git a/packages/server/src/services/Banking/Rules/DeleteBankRule.ts b/packages/server/src/services/Banking/Rules/DeleteBankRule.ts new file mode 100644 index 000000000..9045f2a9b --- /dev/null +++ b/packages/server/src/services/Banking/Rules/DeleteBankRule.ts @@ -0,0 +1,53 @@ +import { Knex } from 'knex'; +import UnitOfWork from '@/services/UnitOfWork'; +import { Inject, Service } from 'typedi'; +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} + */ + public async deleteBankRule(tenantId: number, ruleId: number) { + const { BankRule } = 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, { + oldBankRule, + ruleId, + trx, + } as IBankRuleEventDeletingPayload); + + await BankRule.query(trx).findById(ruleId).delete(); + + // Triggers `onBankRuleDeleted` event. + await await this.eventPublisher.emitAsync(events.bankRules.onDeleted, { + ruleId, + trx, + } as IBankRuleEventDeletedPayload); + }); + } +} diff --git a/packages/server/src/services/Banking/Rules/EditBankRule.ts b/packages/server/src/services/Banking/Rules/EditBankRule.ts new file mode 100644 index 000000000..346541d62 --- /dev/null +++ b/packages/server/src/services/Banking/Rules/EditBankRule.ts @@ -0,0 +1,80 @@ +import { Knex } from 'knex'; +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import UnitOfWork from '@/services/UnitOfWork'; +import events from '@/subscribers/events'; +import { Inject, Service } from 'typedi'; +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, { + oldBankRule, + ruleId, + editRuleDTO, + trx, + } as IBankRuleEventEditingPayload); + + // Updates the given bank rule. + await BankRule.query() + .findById(ruleId) + .patch({ ...tranformDTO }); + + // Triggers `onBankRuleEdited` event. + await this.eventPublisher.emitAsync(events.bankRules.onEdited, { + oldBankRule, + ruleId, + editRuleDTO, + trx, + } as IBankRuleEventEditedPayload); + } + ); + } +} diff --git a/packages/server/src/services/Banking/Rules/GetBankRule.ts b/packages/server/src/services/Banking/Rules/GetBankRule.ts new file mode 100644 index 000000000..82e141f4f --- /dev/null +++ b/packages/server/src/services/Banking/Rules/GetBankRule.ts @@ -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 + */ + async getBankRule(tenantId: number, ruleId: number) { + const { BankRule } = this.tenancy.models(tenantId); + + const bankRule = await BankRule.query() + .findById(ruleId) + .withGraphFetched('conditions'); + + return this.transformer.transform( + tenantId, + bankRule, + new GetBankRuleTransformer() + ); + } +} diff --git a/packages/server/src/services/Banking/Rules/GetBankRuleTransformer.ts b/packages/server/src/services/Banking/Rules/GetBankRuleTransformer.ts new file mode 100644 index 000000000..93cb6cab5 --- /dev/null +++ b/packages/server/src/services/Banking/Rules/GetBankRuleTransformer.ts @@ -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 []; + }; +} diff --git a/packages/server/src/services/Banking/Rules/GetBankRules.ts b/packages/server/src/services/Banking/Rules/GetBankRules.ts new file mode 100644 index 000000000..d543956cb --- /dev/null +++ b/packages/server/src/services/Banking/Rules/GetBankRules.ts @@ -0,0 +1,31 @@ +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 + */ + public async getBankRules(tenantId: number) { + const { BankRule } = this.tenancy.models(tenantId); + + const bankRule = await BankRule.query(); + + return this.transformer.transform( + tenantId, + bankRule, + new GetBankRulesTransformer() + ); + } +} diff --git a/packages/server/src/services/Banking/Rules/GetBankRulesTransformer.ts b/packages/server/src/services/Banking/Rules/GetBankRulesTransformer.ts new file mode 100644 index 000000000..634798826 --- /dev/null +++ b/packages/server/src/services/Banking/Rules/GetBankRulesTransformer.ts @@ -0,0 +1,11 @@ +import { Transformer } from '@/lib/Transformer/Transformer'; + +export class GetBankRulesTransformer extends Transformer { + /** + * Include these attributes to sale invoice object. + * @returns {Array} + */ + public includeAttributes = (): string[] => { + return []; + }; +} diff --git a/packages/server/src/services/Banking/Rules/types.ts b/packages/server/src/services/Banking/Rules/types.ts new file mode 100644 index 000000000..4ed5aff21 --- /dev/null +++ b/packages/server/src/services/Banking/Rules/types.ts @@ -0,0 +1,64 @@ +import { Knex } from 'knex'; + +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; +} + +export interface ICreateBankRuleDTO extends IBankRuleCommonDTO {} +export interface IEditBankRuleDTO extends IBankRuleCommonDTO {} + +export interface IBankRuleEventCreatingPayload { + createRuleDTO: ICreateBankRuleDTO; + trx?: Knex.Transaction; +} +export interface IBankRuleEventCreatedPayload { + createRuleDTO: ICreateBankRuleDTO; + trx?: Knex.Transaction; +} + +export interface IBankRuleEventEditingPayload { + ruleId: number; + oldBankRule: any; + editRuleDTO: IEditBankRuleDTO; + trx?: Knex.Transaction; +} +export interface IBankRuleEventEditedPayload { + ruleId: number; + editRuleDTO: IEditBankRuleDTO; + trx?: Knex.Transaction; +} + +export interface IBankRuleEventDeletingPayload { + oldBankRule: any; + ruleId: number; + trx?: Knex.Transaction; +} +export interface IBankRuleEventDeletedPayload { + ruleId: number; + trx?: Knex.Transaction; +} diff --git a/packages/server/src/subscribers/events.ts b/packages/server/src/subscribers/events.ts index b96cadf95..c57941f1e 100644 --- a/packages/server/src/subscribers/events.ts +++ b/packages/server/src/subscribers/events.ts @@ -205,7 +205,7 @@ export default { onPreMailSend: 'onSaleReceiptPreMailSend', onMailSend: 'onSaleReceiptMailSend', - onMailSent: 'onSaleReceiptMailSent', + onMailSent: 'onSaleReceiptMailSent', }, /** @@ -229,7 +229,7 @@ export default { onPreMailSend: 'onPaymentReceivePreMailSend', onMailSend: 'onPaymentReceiveMailSend', - onMailSent: 'onPaymentReceiveMailSent', + onMailSent: 'onPaymentReceiveMailSent', }, /** @@ -617,4 +617,15 @@ export default { plaid: { onItemCreated: 'onPlaidItemCreated', }, + + bankRules: { + onCreating: 'onBankRuleCreating', + onCreated: 'onBankRuleCreated', + + onEditing: 'onBankRuleEditing', + onEdited: 'onBankRuleEdited', + + onDeleting: 'onBankRuleDeleting', + onDeleted: 'onBankRuleDeleted', + }, }; From 0b5cee070ab7f14bcdef44c42bd50be508b909fe Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Tue, 18 Jun 2024 21:43:54 +0200 Subject: [PATCH 02/42] feat: recognize uncategorized transactions --- .../Banking/BankingRulesController.ts | 44 ++++++++++------- ...eate_recognized_bank_transactions_table.js | 18 +++++++ ...n_id_to_uncategorized_transactins_table.js | 11 +++++ packages/server/src/loaders/eventEmitter.ts | 4 ++ packages/server/src/loaders/jobs.ts | 2 + packages/server/src/models/BankRule.ts | 11 +++++ .../RecognizeTranasctionsService.ts | 47 +++++++++++++++++++ .../RecognizeTransactionsJob.ts | 32 +++++++++++++ .../events/TriggerRecognizedTransactions.ts | 37 +++++++++++++++ .../Banking/Rules/BankRulesApplication.ts | 23 +++++---- .../services/Banking/Rules/CreateBankRule.ts | 8 +++- .../services/Banking/Rules/DeleteBankRule.ts | 6 ++- .../services/Banking/Rules/EditBankRule.ts | 6 ++- .../src/services/Banking/Rules/GetBankRule.ts | 4 +- .../services/Banking/Rules/GetBankRules.ts | 4 +- .../src/services/Banking/Rules/types.ts | 6 +++ .../CategorizeRecognizedTransaction.ts | 8 ++++ 17 files changed, 234 insertions(+), 37 deletions(-) create mode 100644 packages/server/src/database/migrations/20240618171553_create_recognized_bank_transactions_table.js create mode 100644 packages/server/src/database/migrations/20240618175241_add_recognized_transaction_id_to_uncategorized_transactins_table.js create mode 100644 packages/server/src/services/Banking/RegonizeTranasctions/RecognizeTranasctionsService.ts create mode 100644 packages/server/src/services/Banking/RegonizeTranasctions/RecognizeTransactionsJob.ts create mode 100644 packages/server/src/services/Banking/RegonizeTranasctions/events/TriggerRecognizedTransactions.ts create mode 100644 packages/server/src/services/Cashflow/CategorizeRecognizedTransaction.ts diff --git a/packages/server/src/api/controllers/Banking/BankingRulesController.ts b/packages/server/src/api/controllers/Banking/BankingRulesController.ts index e4b5ea26c..360dbcad6 100644 --- a/packages/server/src/api/controllers/Banking/BankingRulesController.ts +++ b/packages/server/src/api/controllers/Banking/BankingRulesController.ts @@ -96,9 +96,13 @@ export class BankingRulesController extends BaseController { * Creates a new bank rule. * @param {Request} req * @param {Response} res - * @param next + * @param {NextFunction} next */ - public async createBankRule(req: Request, res: Response, next: NextFunction) { + private async createBankRule( + req: Request, + res: Response, + next: NextFunction + ) { const { tenantId } = req; const createBankRuleDTO = this.matchedBodyData(req) as ICreateBankRuleDTO; @@ -118,11 +122,11 @@ export class BankingRulesController extends BaseController { /** * Edits the given bank rule. - * @param req - * @param res - * @param next + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next */ - public async editBankRule(req: Request, res: Response, next: NextFunction) { + private async editBankRule(req: Request, res: Response, next: NextFunction) { const { tenantId } = req; const { id: ruleId } = req.params; const editBankRuleDTO = this.matchedBodyData(req) as IEditBankRuleDTO; @@ -144,11 +148,15 @@ export class BankingRulesController extends BaseController { /** * Deletes the given bank rule. - * @param req - * @param res - * @param next + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next */ - public async deleteBankRule(req: Request, res: Response, next: NextFunction) { + private async deleteBankRule( + req: Request, + res: Response, + next: NextFunction + ) { const { id: ruleId } = req.params; try { await this.bankRulesApplication.deleteBankRule(tenantId, ruleId); @@ -163,11 +171,11 @@ export class BankingRulesController extends BaseController { /** * Retrieve the given bank rule. - * @param req - * @param res - * @param next + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next */ - public async getBankRule(req: Request, res: Response, next: NextFunction) { + private async getBankRule(req: Request, res: Response, next: NextFunction) { const { id: ruleId } = req.params; const { tenantId } = req; @@ -185,11 +193,11 @@ export class BankingRulesController extends BaseController { /** * Retrieves the bank rules. - * @param req - * @param res - * @param next + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next */ - public async getBankRules(req: Request, res: Response, next: NextFunction) { + private async getBankRules(req: Request, res: Response, next: NextFunction) { const { tenantId } = req; try { diff --git a/packages/server/src/database/migrations/20240618171553_create_recognized_bank_transactions_table.js b/packages/server/src/database/migrations/20240618171553_create_recognized_bank_transactions_table.js new file mode 100644 index 000000000..6fab718a1 --- /dev/null +++ b/packages/server/src/database/migrations/20240618171553_create_recognized_bank_transactions_table.js @@ -0,0 +1,18 @@ +exports.up = function (knex) { + return knex.schema.createTable('recognized_bank_transactions', (table) => { + table.increments('id'); + table.integer('cashflow_transaction_id').unsigned(); + table.inteegr('bank_rule_id').unsigned(); + + table.string('assigned_category'); + table.integer('assigned_account_id').unsigned(); + table.string('assigned_payee'); + table.string('assigned_memo'); + + table.timestamps(); + }); +}; + +exports.down = function (knex) { + return knex.schema.dropTableIfExists('recognized_bank_transactions'); +}; diff --git a/packages/server/src/database/migrations/20240618175241_add_recognized_transaction_id_to_uncategorized_transactins_table.js b/packages/server/src/database/migrations/20240618175241_add_recognized_transaction_id_to_uncategorized_transactins_table.js new file mode 100644 index 000000000..f50a13473 --- /dev/null +++ b/packages/server/src/database/migrations/20240618175241_add_recognized_transaction_id_to_uncategorized_transactins_table.js @@ -0,0 +1,11 @@ +exports.up = function (knex) { + return knex.schema.table('uncategorized_cashflow_transactions', (table) => { + table.integer('recognized_transaction_id').unsigned(); + }); +}; + +exports.down = function (knex) { + return knex.schema.table('uncategorized_cashflow_transactions', (table) => { + table.dropColumn('recognized_transaction_id'); + }); +}; diff --git a/packages/server/src/loaders/eventEmitter.ts b/packages/server/src/loaders/eventEmitter.ts index ad83f6dec..22a5e1988 100644 --- a/packages/server/src/loaders/eventEmitter.ts +++ b/packages/server/src/loaders/eventEmitter.ts @@ -102,6 +102,7 @@ import { AttachmentsOnVendorCredits } from '@/services/Attachments/events/Attach import { AttachmentsOnCreditNote } from '@/services/Attachments/events/AttachmentsOnCreditNote'; import { AttachmentsOnBillPayments } from '@/services/Attachments/events/AttachmentsOnPaymentsMade'; import { AttachmentsOnSaleEstimates } from '@/services/Attachments/events/AttachmentsOnSaleEstimates'; +import { TriggerRecognizedTransactions } from '@/services/Banking/RegonizeTranasctions/events/TriggerRecognizedTransactions'; export default () => { return new EventPublisher(); @@ -246,5 +247,8 @@ export const susbcribers = () => { AttachmentsOnBillPayments, AttachmentsOnManualJournals, AttachmentsOnExpenses, + + // Bank Rules + TriggerRecognizedTransactions, ]; }; diff --git a/packages/server/src/loaders/jobs.ts b/packages/server/src/loaders/jobs.ts index 231149f48..c64a8fe72 100644 --- a/packages/server/src/loaders/jobs.ts +++ b/packages/server/src/loaders/jobs.ts @@ -13,6 +13,7 @@ import { PaymentReceiveMailNotificationJob } from '@/services/Sales/PaymentRecei import { PlaidFetchTransactionsJob } from '@/services/Banking/Plaid/PlaidFetchTransactionsJob'; import { ImportDeleteExpiredFilesJobs } from '@/services/Import/jobs/ImportDeleteExpiredFilesJob'; import { SendVerifyMailJob } from '@/services/Authentication/jobs/SendVerifyMailJob'; +import { RegonizeTransactionsJob } from '@/services/Banking/RegonizeTranasctions/RecognizeTransactionsJob'; export default ({ agenda }: { agenda: Agenda }) => { new ResetPasswordMailJob(agenda); @@ -29,6 +30,7 @@ export default ({ agenda }: { agenda: Agenda }) => { new PlaidFetchTransactionsJob(agenda); new ImportDeleteExpiredFilesJobs(agenda); new SendVerifyMailJob(agenda); + new RegonizeTransactionsJob(agenda); agenda.start().then(() => { agenda.every('1 hours', 'delete-expired-imported-files', {}); diff --git a/packages/server/src/models/BankRule.ts b/packages/server/src/models/BankRule.ts index 1f93a3415..a65b3bb27 100644 --- a/packages/server/src/models/BankRule.ts +++ b/packages/server/src/models/BankRule.ts @@ -2,6 +2,17 @@ import TenantModel from 'models/TenantModel'; import { Model } from 'objection'; export class BankRule extends TenantModel { + id!: number; + name!: string; + order!: number; + applyIfAccountId!: number; + applyIfTransactionType!: string; + assignCategory!: string; + assignAccountId!: number; + assignPayee!: string; + assignMemo!: string; + conditionsType!: string; + /** * Table name */ diff --git a/packages/server/src/services/Banking/RegonizeTranasctions/RecognizeTranasctionsService.ts b/packages/server/src/services/Banking/RegonizeTranasctions/RecognizeTranasctionsService.ts new file mode 100644 index 000000000..1e0bb8c58 --- /dev/null +++ b/packages/server/src/services/Banking/RegonizeTranasctions/RecognizeTranasctionsService.ts @@ -0,0 +1,47 @@ +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'; + +@Service() +export class RecognizeedTranasctionsService { + @Inject() + private tenancy: HasTenancyService; + + /** + * Regonized the uncategorized transactions. + * @param {number} tenantId + */ + public async recognizeTransactions(tenantId: number) { + const { UncategorizedCashflowTransaction, BankRule } = + this.tenancy.models(tenantId); + + const uncategorizedTranasctions = + await UncategorizedCashflowTransaction.query().where( + 'regonized_transaction_id', + null + ); + + const bankRules = await BankRule.query(); + const bankRulesByAccountId = transformToMapBy(bankRules, 'accountId'); + + console.log(bankRulesByAccountId); + + const regonizeTransaction = ( + transaction: UncategorizedCashflowTransaction + ) => {}; + + await PromisePool.withConcurrency(MIGRATION_CONCURRENCY) + .for(uncategorizedTranasctions) + .process((transaction: UncategorizedCashflowTransaction, index, pool) => { + return regonizeTransaction(transaction); + }); + } + + public async regonizeTransaction( + uncategorizedTransaction: UncategorizedCashflowTransaction + ) {} +} + +const MIGRATION_CONCURRENCY = 10; diff --git a/packages/server/src/services/Banking/RegonizeTranasctions/RecognizeTransactionsJob.ts b/packages/server/src/services/Banking/RegonizeTranasctions/RecognizeTransactionsJob.ts new file mode 100644 index 000000000..2278b6505 --- /dev/null +++ b/packages/server/src/services/Banking/RegonizeTranasctions/RecognizeTransactionsJob.ts @@ -0,0 +1,32 @@ +import Container, { Service } from 'typedi'; +import { RegonizeTranasctionsService } from './RecognizeTranasctionsService'; + +@Service() +export class RegonizeTransactionsJob { + /** + * Constructor method. + */ + constructor(agenda) { + agenda.define( + 'regonize-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(RegonizeTranasctionsService); + + try { + await regonizeTransactions.regonizeTransactions(tenantId); + done(); + } catch (error) { + console.log(error); + done(error); + } + }; +} diff --git a/packages/server/src/services/Banking/RegonizeTranasctions/events/TriggerRecognizedTransactions.ts b/packages/server/src/services/Banking/RegonizeTranasctions/events/TriggerRecognizedTransactions.ts new file mode 100644 index 000000000..3094cc520 --- /dev/null +++ b/packages/server/src/services/Banking/RegonizeTranasctions/events/TriggerRecognizedTransactions.ts @@ -0,0 +1,37 @@ +import { Inject, Service } from 'typedi'; +import events from '@/subscribers/events'; +import { + IBankRuleEventCreatedPayload, + 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.recognizedTransactionsOnRuleCreated.bind(this) + ); + } + + /** + * Triggers the recognize uncategorized transactions job. + * @param {IBankRuleEventEditedPayload | IBankRuleEventCreatedPayload} payload - + */ + private async recognizedTransactionsOnRuleCreated({ + tenantId, + }: IBankRuleEventEditedPayload | IBankRuleEventCreatedPayload) { + const payload = { tenantId }; + await this.agenda.now('recognize-uncategorized-transactions-job', payload); + } +} diff --git a/packages/server/src/services/Banking/Rules/BankRulesApplication.ts b/packages/server/src/services/Banking/Rules/BankRulesApplication.ts index c24f47c32..7beb5e1f4 100644 --- a/packages/server/src/services/Banking/Rules/BankRulesApplication.ts +++ b/packages/server/src/services/Banking/Rules/BankRulesApplication.ts @@ -27,9 +27,12 @@ export class BankRulesApplication { * Creates new bank rule. * @param {number} tenantId * @param {ICreateBankRuleDTO} createRuleDTO - * @returns + * @returns {Promise} */ - public createBankRule(tenantId: number, createRuleDTO: ICreateBankRuleDTO) { + public createBankRule( + tenantId: number, + createRuleDTO: ICreateBankRuleDTO + ): Promise { return this.createBankRuleService.createBankRule(tenantId, createRuleDTO); } @@ -37,13 +40,13 @@ export class BankRulesApplication { * Edits the given bank rule. * @param {number} tenantId * @param {IEditBankRuleDTO} editRuleDTO - * @returns + * @returns {Promise} */ public editBankRule( tenantId: number, ruleId: number, editRuleDTO: IEditBankRuleDTO - ) { + ): Promise { return this.editBankRuleService.editBankRule(tenantId, ruleId, editRuleDTO); } @@ -51,9 +54,9 @@ export class BankRulesApplication { * Deletes the given bank rule. * @param {number} tenantId * @param {number} ruleId - * @returns + * @returns {Promise} */ - public deleteBankRule(tenantId: number, ruleId: number) { + public deleteBankRule(tenantId: number, ruleId: number): Promise { return this.deleteBankRuleService.deleteBankRule(tenantId, ruleId); } @@ -61,9 +64,9 @@ export class BankRulesApplication { * Retrieves the given bank rule. * @param {number} tenantId * @param {number} ruleId - * @returns + * @returns {Promise} */ - public getBankRule(tenantId: number, ruleId: number) { + public getBankRule(tenantId: number, ruleId: number): Promise { return this.getBankRuleService.getBankRule(tenantId, ruleId); } @@ -71,9 +74,9 @@ export class BankRulesApplication { * Retrieves the bank rules of the given account. * @param {number} tenantId * @param {number} accountId - * @returns + * @returns {Promise} */ - public getBankRules(tenantId: number) { + public getBankRules(tenantId: number): Promise { return this.getBankRulesService.getBankRules(tenantId); } } diff --git a/packages/server/src/services/Banking/Rules/CreateBankRule.ts b/packages/server/src/services/Banking/Rules/CreateBankRule.ts index 9385d0a79..ccca113e3 100644 --- a/packages/server/src/services/Banking/Rules/CreateBankRule.ts +++ b/packages/server/src/services/Banking/Rules/CreateBankRule.ts @@ -36,8 +36,12 @@ export class CreateBankRuleService { * Creates a new bank rule. * @param {number} tenantId * @param {ICreateBankRuleDTO} createRuleDTO + * @returns {Promise} */ - public createBankRule(tenantId: number, createRuleDTO: ICreateBankRuleDTO) { + public createBankRule( + tenantId: number, + createRuleDTO: ICreateBankRuleDTO + ): Promise { const { BankRule } = this.tenancy.models(tenantId); const transformDTO = this.transformDTO(createRuleDTO); @@ -45,6 +49,7 @@ export class CreateBankRuleService { return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => { // Triggers `onBankRuleCreating` event. await this.eventPublisher.emitAsync(events.bankRules.onCreating, { + tenantId, createRuleDTO, trx, } as IBankRuleEventCreatingPayload); @@ -55,6 +60,7 @@ export class CreateBankRuleService { // Triggers `onBankRuleCreated` event. await this.eventPublisher.emitAsync(events.bankRules.onCreated, { + tenantId, createRuleDTO, trx, } as IBankRuleEventCreatedPayload); diff --git a/packages/server/src/services/Banking/Rules/DeleteBankRule.ts b/packages/server/src/services/Banking/Rules/DeleteBankRule.ts index 9045f2a9b..9d6ce0167 100644 --- a/packages/server/src/services/Banking/Rules/DeleteBankRule.ts +++ b/packages/server/src/services/Banking/Rules/DeleteBankRule.ts @@ -1,6 +1,6 @@ import { Knex } from 'knex'; -import UnitOfWork from '@/services/UnitOfWork'; import { Inject, Service } from 'typedi'; +import UnitOfWork from '@/services/UnitOfWork'; import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; import events from '@/subscribers/events'; import { @@ -26,7 +26,7 @@ export class DeleteBankRuleSerivce { * @param {number} ruleId * @returns {Promise} */ - public async deleteBankRule(tenantId: number, ruleId: number) { + public async deleteBankRule(tenantId: number, ruleId: number): Promise { const { BankRule } = this.tenancy.models(tenantId); const oldBankRule = await BankRule.query() @@ -36,6 +36,7 @@ export class DeleteBankRuleSerivce { return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => { // Triggers `onBankRuleDeleting` event. await this.eventPublisher.emitAsync(events.bankRules.onDeleting, { + tenantId, oldBankRule, ruleId, trx, @@ -45,6 +46,7 @@ export class DeleteBankRuleSerivce { // Triggers `onBankRuleDeleted` event. await await this.eventPublisher.emitAsync(events.bankRules.onDeleted, { + tenantId, ruleId, trx, } as IBankRuleEventDeletedPayload); diff --git a/packages/server/src/services/Banking/Rules/EditBankRule.ts b/packages/server/src/services/Banking/Rules/EditBankRule.ts index 346541d62..5073e1c59 100644 --- a/packages/server/src/services/Banking/Rules/EditBankRule.ts +++ b/packages/server/src/services/Banking/Rules/EditBankRule.ts @@ -1,9 +1,9 @@ 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 { Inject, Service } from 'typedi'; import { IBankRuleEventEditedPayload, IBankRuleEventEditingPayload, @@ -56,6 +56,7 @@ export class EditBankRuleService { async (trx?: Knex.Transaction) => { // Triggers `onBankRuleEditing` event. await this.eventPublisher.emitAsync(events.bankRules.onEditing, { + tenantId, oldBankRule, ruleId, editRuleDTO, @@ -63,12 +64,13 @@ export class EditBankRuleService { } as IBankRuleEventEditingPayload); // Updates the given bank rule. - await BankRule.query() + await BankRule.query(trx) .findById(ruleId) .patch({ ...tranformDTO }); // Triggers `onBankRuleEdited` event. await this.eventPublisher.emitAsync(events.bankRules.onEdited, { + tenantId, oldBankRule, ruleId, editRuleDTO, diff --git a/packages/server/src/services/Banking/Rules/GetBankRule.ts b/packages/server/src/services/Banking/Rules/GetBankRule.ts index 82e141f4f..67a26e5a1 100644 --- a/packages/server/src/services/Banking/Rules/GetBankRule.ts +++ b/packages/server/src/services/Banking/Rules/GetBankRule.ts @@ -16,9 +16,9 @@ export class GetBankRuleService { * Retrieves the bank rule. * @param {number} tenantId * @param {number} ruleId - * @returns + * @returns {Promise} */ - async getBankRule(tenantId: number, ruleId: number) { + async getBankRule(tenantId: number, ruleId: number): Promise { const { BankRule } = this.tenancy.models(tenantId); const bankRule = await BankRule.query() diff --git a/packages/server/src/services/Banking/Rules/GetBankRules.ts b/packages/server/src/services/Banking/Rules/GetBankRules.ts index d543956cb..6eeb2c215 100644 --- a/packages/server/src/services/Banking/Rules/GetBankRules.ts +++ b/packages/server/src/services/Banking/Rules/GetBankRules.ts @@ -15,9 +15,9 @@ export class GetBankRulesService { * Retrieves the bank rules of the given account. * @param {number} tenantId * @param {number} accountId - * @returns + * @returns {Promise} */ - public async getBankRules(tenantId: number) { + public async getBankRules(tenantId: number): Promise { const { BankRule } = this.tenancy.models(tenantId); const bankRule = await BankRule.query(); diff --git a/packages/server/src/services/Banking/Rules/types.ts b/packages/server/src/services/Banking/Rules/types.ts index 4ed5aff21..ae1151254 100644 --- a/packages/server/src/services/Banking/Rules/types.ts +++ b/packages/server/src/services/Banking/Rules/types.ts @@ -33,32 +33,38 @@ 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; } diff --git a/packages/server/src/services/Cashflow/CategorizeRecognizedTransaction.ts b/packages/server/src/services/Cashflow/CategorizeRecognizedTransaction.ts new file mode 100644 index 000000000..68c2e7fac --- /dev/null +++ b/packages/server/src/services/Cashflow/CategorizeRecognizedTransaction.ts @@ -0,0 +1,8 @@ +import { Service } from "typedi"; + + +@Service() +export class CategorizeRecognizedTransactionService { + + +} \ No newline at end of file From 6c4b0cdac51cb5b2bf26f02b16f7fb15c2cca720 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Wed, 19 Jun 2024 13:49:12 +0200 Subject: [PATCH 03/42] feat: auto recognize uncategorized transactions --- .../20240618100137_create_bank_rules_table.js | 18 ++- ...eate_recognized_bank_transactions_table.js | 18 ++- packages/server/src/loaders/tenantModels.ts | 2 + .../src/models/RecognizedBankTransaction.ts | 24 ++++ .../UncategorizedCashflowTransaction.ts | 28 +++++ .../RecognizeTranasctionsService.ts | 89 ++++++++++++--- .../RecognizeTransactionsJob.ts | 8 +- .../Banking/RegonizeTranasctions/_utils.ts | 104 ++++++++++++++++++ .../src/services/Banking/Rules/types.ts | 43 ++++++++ 9 files changed, 308 insertions(+), 26 deletions(-) create mode 100644 packages/server/src/models/RecognizedBankTransaction.ts create mode 100644 packages/server/src/services/Banking/RegonizeTranasctions/_utils.ts diff --git a/packages/server/src/database/migrations/20240618100137_create_bank_rules_table.js b/packages/server/src/database/migrations/20240618100137_create_bank_rules_table.js index ec0136117..c11ce89f2 100644 --- a/packages/server/src/database/migrations/20240618100137_create_bank_rules_table.js +++ b/packages/server/src/database/migrations/20240618100137_create_bank_rules_table.js @@ -5,11 +5,19 @@ exports.up = function (knex) { table.string('name'); table.integer('order').unsigned(); - table.integer('apply_if_account_id').unsigned(); + table + .integer('apply_if_account_id') + .unsigned() + .references('id') + .inTable('accounts'); table.string('apply_if_transaction_type'); table.string('assign_category'); - table.integer('assign_account_id').unsigned(); + table + .integer('assign_account_id') + .unsigned() + .references('id') + .inTable('accounts'); table.string('assign_payee'); table.string('assign_memo'); @@ -19,7 +27,11 @@ exports.up = function (knex) { }) .createTable('bank_rule_conditions', (table) => { table.increments('id').primary(); - table.integer('rule_id').unsigned(); + table + .integer('rule_id') + .unsigned() + .references('id') + .inTable('bank_rules'); table.string('field'); table.string('comparator'); table.string('value'); diff --git a/packages/server/src/database/migrations/20240618171553_create_recognized_bank_transactions_table.js b/packages/server/src/database/migrations/20240618171553_create_recognized_bank_transactions_table.js index 6fab718a1..223ff403e 100644 --- a/packages/server/src/database/migrations/20240618171553_create_recognized_bank_transactions_table.js +++ b/packages/server/src/database/migrations/20240618171553_create_recognized_bank_transactions_table.js @@ -1,11 +1,23 @@ exports.up = function (knex) { return knex.schema.createTable('recognized_bank_transactions', (table) => { table.increments('id'); - table.integer('cashflow_transaction_id').unsigned(); - table.inteegr('bank_rule_id').unsigned(); + table + .integer('cashflow_transaction_id') + .unsigned() + .references('id') + .inTable('uncategorized_cashflow_transactions'); + table + .integer('bank_rule_id') + .unsigned() + .references('id') + .inTable('bank_rules'); table.string('assigned_category'); - table.integer('assigned_account_id').unsigned(); + table + .integer('assigned_account_id') + .unsigned() + .references('id') + .inTable('accounts'); table.string('assigned_payee'); table.string('assigned_memo'); diff --git a/packages/server/src/loaders/tenantModels.ts b/packages/server/src/loaders/tenantModels.ts index 0debb29b5..f119da226 100644 --- a/packages/server/src/loaders/tenantModels.ts +++ b/packages/server/src/loaders/tenantModels.ts @@ -66,6 +66,7 @@ import Document from '@/models/Document'; import DocumentLink from '@/models/DocumentLink'; import { BankRule } from '@/models/BankRule'; import { BankRuleCondition } from '@/models/BankRuleCondition'; +import { RecognizedBankTransaction } from '@/models/RecognizedBankTransaction'; export default (knex) => { const models = { @@ -135,6 +136,7 @@ export default (knex) => { UncategorizedCashflowTransaction, BankRule, BankRuleCondition, + RecognizedBankTransaction, }; return mapValues(models, (model) => model.bindKnex(knex)); }; diff --git a/packages/server/src/models/RecognizedBankTransaction.ts b/packages/server/src/models/RecognizedBankTransaction.ts new file mode 100644 index 000000000..4ddf47c06 --- /dev/null +++ b/packages/server/src/models/RecognizedBankTransaction.ts @@ -0,0 +1,24 @@ +import TenantModel from 'models/TenantModel'; + +export class RecognizedBankTransaction extends TenantModel { + /** + * Table name. + */ + static get tableName() { + return 'recognized_bank_transactions'; + } + + /** + * Timestamps columns. + */ + get timestamps() { + return []; + } + + /** + * Virtual attributes. + */ + static get virtualAttributes() { + return []; + } +} diff --git a/packages/server/src/models/UncategorizedCashflowTransaction.ts b/packages/server/src/models/UncategorizedCashflowTransaction.ts index bcd4a2770..272458892 100644 --- a/packages/server/src/models/UncategorizedCashflowTransaction.ts +++ b/packages/server/src/models/UncategorizedCashflowTransaction.ts @@ -11,9 +11,15 @@ export default class UncategorizedCashflowTransaction extends mixin( [ModelSettings] ) { id!: number; + date!: Date | string; amount!: number; categorized!: boolean; accountId!: number; + referenceNo!: string; + payee!: string; + description!: string; + plaidTransactionId!: string; + recognizedTransactionId!: number; /** * Table name. @@ -75,11 +81,21 @@ export default class UncategorizedCashflowTransaction extends mixin( return 0 < this.withdrawal; } + /** + * Detarmines whether the transaction is recognized. + */ + public get isRecognized(): boolean { + return !!this.recognizedTransactionId; + } + /** * Relationship mapping. */ static get relationMappings() { const Account = require('models/Account'); + const { + RecognizedBankTransaction, + } = require('models/RecognizedBankTransaction'); return { /** @@ -93,6 +109,18 @@ export default class UncategorizedCashflowTransaction extends mixin( to: 'accounts.id', }, }, + + /** + * Transaction may has association to recognized transaction. + */ + recognizedTransaction: { + relation: Model.HasOneRelation, + modelClass: RecognizedBankTransaction, + join: { + from: 'uncategorized_cashflow_transactions.recognizedTransactionId', + to: 'recognized_bank_transactions.id', + }, + }, }; } diff --git a/packages/server/src/services/Banking/RegonizeTranasctions/RecognizeTranasctionsService.ts b/packages/server/src/services/Banking/RegonizeTranasctions/RecognizeTranasctionsService.ts index 1e0bb8c58..74832fbbb 100644 --- a/packages/server/src/services/Banking/RegonizeTranasctions/RecognizeTranasctionsService.ts +++ b/packages/server/src/services/Banking/RegonizeTranasctions/RecognizeTranasctionsService.ts @@ -1,37 +1,90 @@ +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 RecognizeedTranasctionsService { +export class RecognizeTranasctionsService { @Inject() private tenancy: HasTenancyService; /** - * Regonized the uncategorized transactions. - * @param {number} tenantId + * Marks the uncategorized transaction as recognized from the given bank rule. + * @param {number} tenantId - + * @param {BankRule} bankRule - + * @param {UncategorizedCashflowTransaction} transaction - + * @param {Knex.Transaction} trx - */ - public async recognizeTransactions(tenantId: number) { + 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, + cashflowTransactionId: 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, trx?: Knex.Transaction) { const { UncategorizedCashflowTransaction, BankRule } = this.tenancy.models(tenantId); const uncategorizedTranasctions = - await UncategorizedCashflowTransaction.query().where( - 'regonized_transaction_id', - null - ); + await UncategorizedCashflowTransaction.query() + .where('recognized_transaction_id', null) + .where('categorized', false); - const bankRules = await BankRule.query(); - const bankRulesByAccountId = transformToMapBy(bankRules, 'accountId'); - - console.log(bankRulesByAccountId); - - const regonizeTransaction = ( + 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 + ); + } + }; await PromisePool.withConcurrency(MIGRATION_CONCURRENCY) .for(uncategorizedTranasctions) .process((transaction: UncategorizedCashflowTransaction, index, pool) => { @@ -39,6 +92,10 @@ export class RecognizeedTranasctionsService { }); } + /** + * + * @param {number} uncategorizedTransaction + */ public async regonizeTransaction( uncategorizedTransaction: UncategorizedCashflowTransaction ) {} diff --git a/packages/server/src/services/Banking/RegonizeTranasctions/RecognizeTransactionsJob.ts b/packages/server/src/services/Banking/RegonizeTranasctions/RecognizeTransactionsJob.ts index 2278b6505..2eee22f53 100644 --- a/packages/server/src/services/Banking/RegonizeTranasctions/RecognizeTransactionsJob.ts +++ b/packages/server/src/services/Banking/RegonizeTranasctions/RecognizeTransactionsJob.ts @@ -1,5 +1,5 @@ import Container, { Service } from 'typedi'; -import { RegonizeTranasctionsService } from './RecognizeTranasctionsService'; +import { RecognizeTranasctionsService } from './RecognizeTranasctionsService'; @Service() export class RegonizeTransactionsJob { @@ -8,7 +8,7 @@ export class RegonizeTransactionsJob { */ constructor(agenda) { agenda.define( - 'regonize-uncategorized-transactions-job', + 'recognize-uncategorized-transactions-job', { priority: 'high', concurrency: 2 }, this.handler ); @@ -19,10 +19,10 @@ export class RegonizeTransactionsJob { */ private handler = async (job, done: Function) => { const { tenantId } = job.attrs.data; - const regonizeTransactions = Container.get(RegonizeTranasctionsService); + const regonizeTransactions = Container.get(RecognizeTranasctionsService); try { - await regonizeTransactions.regonizeTransactions(tenantId); + await regonizeTransactions.recognizeTransactions(tenantId); done(); } catch (error) { console.log(error); diff --git a/packages/server/src/services/Banking/RegonizeTranasctions/_utils.ts b/packages/server/src/services/Banking/RegonizeTranasctions/_utils.ts new file mode 100644 index 000000000..2a030b799 --- /dev/null +++ b/packages/server/src/services/Banking/RegonizeTranasctions/_utils.ts @@ -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'; + } +}; diff --git a/packages/server/src/services/Banking/Rules/types.ts b/packages/server/src/services/Banking/Rules/types.ts index ae1151254..0701345f0 100644 --- a/packages/server/src/services/Banking/Rules/types.ts +++ b/packages/server/src/services/Banking/Rules/types.ts @@ -1,5 +1,48 @@ import { Knex } from 'knex'; +export enum BankRuleConditionField { + Amount = 'Amount', + Description = 'Description', +} + +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', From d3230767dda51eeeb687febb747a3fcc9bcf6075 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Wed, 19 Jun 2024 22:40:10 +0200 Subject: [PATCH 04/42] feat: matching uncategorized transactions --- .../BankTransactionsMatchingController.ts | 129 ++++++++++++++++ .../controllers/Banking/BankingController.ts | 5 + ..._create_matched_bank_transactions_table.js | 14 ++ packages/server/src/loaders/tenantModels.ts | 2 + .../src/models/MatchedBankTransaction.ts | 24 +++ .../GetMatchedTransactionBillsTransformer.ts | 40 +++++ ...etMatchedTransactionExpensesTransformer.ts | 32 ++++ ...etMatchedTransactionInvoicesTransformer.ts | 23 +++ ...hedTransactionManualJournalsTransformer.ts | 32 ++++ .../Matching/GetMatchedTransactions.ts | 144 ++++++++++++++++++ .../MatchBankTransactionsApplication.ts | 68 +++++++++ .../Banking/Matching/MatchTransactions.ts | 69 +++++++++ .../Matching/UnmatchMatchedTransaction.ts | 41 +++++ .../src/services/Banking/Matching/types.ts | 39 +++++ packages/server/src/subscribers/events.ts | 10 ++ 15 files changed, 672 insertions(+) create mode 100644 packages/server/src/api/controllers/Banking/BankTransactionsMatchingController.ts create mode 100644 packages/server/src/database/migrations/20240619133733_create_matched_bank_transactions_table.js create mode 100644 packages/server/src/models/MatchedBankTransaction.ts create mode 100644 packages/server/src/services/Banking/Matching/GetMatchedTransactionBillsTransformer.ts create mode 100644 packages/server/src/services/Banking/Matching/GetMatchedTransactionExpensesTransformer.ts create mode 100644 packages/server/src/services/Banking/Matching/GetMatchedTransactionInvoicesTransformer.ts create mode 100644 packages/server/src/services/Banking/Matching/GetMatchedTransactionManualJournalsTransformer.ts create mode 100644 packages/server/src/services/Banking/Matching/GetMatchedTransactions.ts create mode 100644 packages/server/src/services/Banking/Matching/MatchBankTransactionsApplication.ts create mode 100644 packages/server/src/services/Banking/Matching/MatchTransactions.ts create mode 100644 packages/server/src/services/Banking/Matching/UnmatchMatchedTransaction.ts create mode 100644 packages/server/src/services/Banking/Matching/types.ts diff --git a/packages/server/src/api/controllers/Banking/BankTransactionsMatchingController.ts b/packages/server/src/api/controllers/Banking/BankTransactionsMatchingController.ts new file mode 100644 index 000000000..98e000cb8 --- /dev/null +++ b/packages/server/src/api/controllers/Banking/BankTransactionsMatchingController.ts @@ -0,0 +1,129 @@ +import { Inject, Service } from 'typedi'; +import { NextFunction, Request, Response, Router } from 'express'; +import BaseController from '@/api/controllers/BaseController'; +import { MatchBankTransactionsApplication } from '@/services/Banking/Matching/MatchBankTransactionsApplication'; +import { param } from 'express-validator'; +import { + GetMatchedTransactionsFilter, + IMatchTransactionDTO, +} from '@/services/Banking/Matching/types'; + +@Service() +export class BankTransactionsMatchingController extends BaseController { + @Inject() + private bankTransactionsMatchingApp: MatchBankTransactionsApplication; + + /** + * Router constructor. + */ + public router() { + const router = Router(); + + router.post( + '/:transactionId', + [param('transactionId').exists()], + this.validationResult, + this.matchBankTransaction.bind(this) + ); + router.post( + '/unmatch/:transactionId', + [param('transactionId').exists()], + this.validationResult, + this.unmatchMatchedBankTransaction.bind(this) + ); + router.get('/', this.getMatchedTransactions.bind(this)); + + return router; + } + + /** + * Matches the given bank transaction. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + * @returns + */ + private async matchBankTransaction( + req: Request, + res: Response, + next: NextFunction + ) { + const { tenantId } = req; + const { transactionId } = req.params; + const matchTransactionDTO = this.matchedBodyData( + req + ) as IMatchTransactionDTO; + + try { + await this.bankTransactionsMatchingApp.matchTransaction( + tenantId, + transactionId, + matchTransactionDTO + ); + + return res.status(200).send({ + message: 'The bank transaction has been matched.', + }); + } catch (error) { + next(error); + } + } + + /** + * + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + * @returns + */ + private async unmatchMatchedBankTransaction( + req: Request, + res: Response, + next: NextFunction + ) { + const { tenantId } = req; + const transactionId = req.params?.transactionId; + + try { + await this.bankTransactionsMatchingApp.unmatchMatchedTransaction( + tenantId, + transactionId + ); + + return res.status(200).send({ + message: 'The bank matched transaction has been unmatched.', + }); + } catch (error) { + next(error); + } + } + + /** + * Retrieves the matched transactions. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + private async getMatchedTransactions( + req: Request, + res: Response, + next: NextFunction + ) { + const { tenantId } = req; + const filter = this.matchedQueryData(req) as GetMatchedTransactionsFilter; + + console.log('test'); + + try { + const matchedTransactions = + await this.bankTransactionsMatchingApp.getMatchedTransactions( + tenantId, + filter + ); + + return res.status(200).send({ data: matchedTransactions }); + } catch (error) { + next(error); + } + } +} diff --git a/packages/server/src/api/controllers/Banking/BankingController.ts b/packages/server/src/api/controllers/Banking/BankingController.ts index 109371311..679073661 100644 --- a/packages/server/src/api/controllers/Banking/BankingController.ts +++ b/packages/server/src/api/controllers/Banking/BankingController.ts @@ -3,6 +3,7 @@ import { Router } from 'express'; import BaseController from '@/api/controllers/BaseController'; import { PlaidBankingController } from './PlaidBankingController'; import { BankingRulesController } from './BankingRulesController'; +import { BankTransactionsMatchingController } from './BankTransactionsMatchingController'; @Service() export class BankingController extends BaseController { @@ -14,6 +15,10 @@ export class BankingController extends BaseController { router.use('/plaid', Container.get(PlaidBankingController).router()); router.use('/rules', Container.get(BankingRulesController).router()); + router.use( + '/matches', + Container.get(BankTransactionsMatchingController).router() + ); return router; } diff --git a/packages/server/src/database/migrations/20240619133733_create_matched_bank_transactions_table.js b/packages/server/src/database/migrations/20240619133733_create_matched_bank_transactions_table.js new file mode 100644 index 000000000..1ed36e10c --- /dev/null +++ b/packages/server/src/database/migrations/20240619133733_create_matched_bank_transactions_table.js @@ -0,0 +1,14 @@ +exports.up = function (knex) { + return knex.schema.createTable('matched_bank_transactions', (table) => { + table.increments('id'); + table.integer('uncategorized_transaction_id').unsigned(); + table.string('reference_type'); + table.integer('reference_id'); + table.decimal('amount'); + table.timestamps(); + }); +}; + +exports.down = function (knex) { + return knex.schema.dropTableIfExists('matched_bank_transactions'); +}; diff --git a/packages/server/src/loaders/tenantModels.ts b/packages/server/src/loaders/tenantModels.ts index f119da226..02877491a 100644 --- a/packages/server/src/loaders/tenantModels.ts +++ b/packages/server/src/loaders/tenantModels.ts @@ -67,6 +67,7 @@ import DocumentLink from '@/models/DocumentLink'; import { BankRule } from '@/models/BankRule'; import { BankRuleCondition } from '@/models/BankRuleCondition'; import { RecognizedBankTransaction } from '@/models/RecognizedBankTransaction'; +import { MatchedBankTransaction } from '@/models/MatchedBankTransaction'; export default (knex) => { const models = { @@ -137,6 +138,7 @@ export default (knex) => { BankRule, BankRuleCondition, RecognizedBankTransaction, + MatchedBankTransaction, }; return mapValues(models, (model) => model.bindKnex(knex)); }; diff --git a/packages/server/src/models/MatchedBankTransaction.ts b/packages/server/src/models/MatchedBankTransaction.ts new file mode 100644 index 000000000..5d025cbbb --- /dev/null +++ b/packages/server/src/models/MatchedBankTransaction.ts @@ -0,0 +1,24 @@ +import TenantModel from 'models/TenantModel'; + +export class MatchedBankTransaction extends TenantModel { + /** + * Table name. + */ + static get tableName() { + return 'matched_bank_transactions'; + } + + /** + * Timestamps columns. + */ + get timestamps() { + return []; + } + + /** + * Virtual attributes. + */ + static get virtualAttributes() { + return []; + } +} diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionBillsTransformer.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionBillsTransformer.ts new file mode 100644 index 000000000..fdc2a38ed --- /dev/null +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionBillsTransformer.ts @@ -0,0 +1,40 @@ +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']; + }; + + public excludeAttributes = (): string[] => { + return ['*']; + }; + + protected referenceNo(invoice) { + return invoice.referenceNo; + } + + amount(invoice) { + return 1; + } + amountFormatted() { + + } + date() { + + } + dateFromatted() { + + } + transactionId(invoice) { + return invoice.id; + } + transactionNo() { + + } + transactionType() {} + transsactionTypeFormatted() {} +} diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionExpensesTransformer.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionExpensesTransformer.ts new file mode 100644 index 000000000..b8f16ae7a --- /dev/null +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionExpensesTransformer.ts @@ -0,0 +1,32 @@ +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']; + }; + + public excludeAttributes = (): string[] => { + return ['*']; + }; + + protected referenceNo(invoice) { + return invoice.referenceNo; + } + + amount(invoice) { + return 1; + } + amountFormatted() {} + date() {} + dateFromatted() {} + transactionId(invoice) { + return invoice.id; + } + transactionNo() {} + transactionType() {} + transsactionTypeFormatted() {} +} diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionInvoicesTransformer.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionInvoicesTransformer.ts new file mode 100644 index 000000000..e3326a873 --- /dev/null +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionInvoicesTransformer.ts @@ -0,0 +1,23 @@ +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', 'transactionNo']; + }; + + public excludeAttributes = (): string[] => { + return ['*']; + }; + + protected referenceNo(invoice) { + return invoice.referenceNo; + } + + protected transactionNo(invoice) { + return invoice.invoiceNo; + } +} diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionManualJournalsTransformer.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionManualJournalsTransformer.ts new file mode 100644 index 000000000..530dfbfa7 --- /dev/null +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionManualJournalsTransformer.ts @@ -0,0 +1,32 @@ +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']; + }; + + public excludeAttributes = (): string[] => { + return ['*']; + }; + + protected referenceNo(invoice) { + return invoice.referenceNo; + } + + amount(invoice) { + return 1; + } + amountFormatted() {} + date() {} + dateFromatted() {} + transactionId(invoice) { + return invoice.id; + } + transactionNo() {} + transactionType() {} + transsactionTypeFormatted() {} +} diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactions.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactions.ts new file mode 100644 index 000000000..b98d2cef1 --- /dev/null +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactions.ts @@ -0,0 +1,144 @@ +import { Inject, Service } from 'typedi'; +import * as R from 'ramda'; +import { PromisePool } from '@supercharge/promise-pool'; +import { GetMatchedTransactionsFilter } from './types'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; +import { GetMatchedTransactionInvoicesTransformer } from './GetMatchedTransactionInvoicesTransformer'; +import { GetMatchedTransactionBillsTransformer } from './GetMatchedTransactionBillsTransformer'; +import { GetMatchedTransactionExpensesTransformer } from './GetMatchedTransactionExpensesTransformer'; +import { GetMatchedTransactionManualJournalsTransformer } from './GetMatchedTransactionManualJournalsTransformer'; + +@Service() +export class GetMatchedTransactions { + @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 registered = [ + { + type: 'SaleInvoice', + callback: this.getSaleInvoicesMatchedTransactions.bind(this), + }, + { + type: 'Bill', + callback: this.getBillsMatchedTransactions.bind(this), + }, + { + type: 'Expense', + callback: this.getExpensesMatchedTransactions.bind(this), + }, + { + type: 'ManualJournal', + callback: this.getManualJournalsMatchedTransactions.bind(this), + }, + ]; + const filtered = filter.transactionType + ? registered.filter((item) => item.type === filter.transactionType) + : registered; + + const matchedTransactions = await PromisePool.withConcurrency(2) + .for(filtered) + .process(async ({ type, callback }) => { + return callback(tenantId, filter); + }); + return R.compose(R.flatten)(matchedTransactions?.results); + } + + /** + * + * @param {number} tenantId - + * @param {GetMatchedTransactionsFilter} filter - + */ + async getSaleInvoicesMatchedTransactions( + tenantId: number, + filter: GetMatchedTransactionsFilter + ) { + const { SaleInvoice } = this.tenancy.models(tenantId); + + const invoices = await SaleInvoice.query(); + + return this.transformer.transform( + tenantId, + invoices, + new GetMatchedTransactionInvoicesTransformer() + ); + } + + /** + * + * @param {number} tenantId - + * @param {GetMatchedTransactionsFilter} filter - + */ + async getBillsMatchedTransactions( + tenantId: number, + filter: GetMatchedTransactionsFilter + ) { + const { Bill } = this.tenancy.models(tenantId); + + const bills = await Bill.query(); + + return this.transformer.transform( + tenantId, + bills, + new GetMatchedTransactionBillsTransformer() + ); + } + + /** + * + * @param {number} tenantId + * @param {GetMatchedTransactionsFilter} filter + * @returns + */ + async getExpensesMatchedTransactions( + tenantId: number, + filter: GetMatchedTransactionsFilter + ) { + const { Expense } = this.tenancy.models(tenantId); + + const expenses = await Expense.query(); + + return this.transformer.transform( + tenantId, + expenses, + new GetMatchedTransactionManualJournalsTransformer() + ); + } + + async getManualJournalsMatchedTransactions( + tenantId: number, + filter: GetMatchedTransactionsFilter + ) { + const { ManualJournal } = this.tenancy.models(tenantId); + + const manualJournals = await ManualJournal.query(); + + return this.transformer.transform( + tenantId, + manualJournals, + new GetMatchedTransactionManualJournalsTransformer() + ); + } +} + +interface MatchedTransaction { + amount: number; + amountFormatted: string; + date: string; + dateFormatted: string; + referenceNo: string; + transactionNo: string; + transactionId: number; +} diff --git a/packages/server/src/services/Banking/Matching/MatchBankTransactionsApplication.ts b/packages/server/src/services/Banking/Matching/MatchBankTransactionsApplication.ts new file mode 100644 index 000000000..b065d2bc3 --- /dev/null +++ b/packages/server/src/services/Banking/Matching/MatchBankTransactionsApplication.ts @@ -0,0 +1,68 @@ +import { Inject, Service } from 'typedi'; +import { GetMatchedTransactions } from './GetMatchedTransactions'; +import { MatchBankTransactions } from './MatchTransactions'; +import { UnmatchMatchedBankTransaction } from './UnmatchMatchedTransaction'; +import { GetMatchedTransactionsFilter, IMatchTransactionDTO } 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, + filter: GetMatchedTransactionsFilter + ) { + return this.getMatchedTransactionsService.getMatchedTransactions( + tenantId, + filter + ); + } + + /** + * Matches the given uncategorized transaction with the given system transaction. + * @param {number} tenantId + * @param {number} uncategorizedTransactionId + * @param {IMatchTransactionDTO} matchTransactionsDTO + * @returns {Promise} + */ + public matchTransaction( + tenantId: number, + uncategorizedTransactionId: number, + matchTransactionsDTO: IMatchTransactionDTO + ): Promise { + return this.matchTransactionService.matchTransaction( + tenantId, + uncategorizedTransactionId, + matchTransactionsDTO + ); + } + + /** + * Unmatch the given matched transaction. + * @param {number} tenantId + * @param {number} uncategorizedTransactionId + * @returns {Promise} + */ + public unmatchMatchedTransaction( + tenantId: number, + uncategorizedTransactionId: number + ) { + return this.unmatchMatchedTransactionService.unmatchMatchedTransaction( + tenantId, + uncategorizedTransactionId + ); + } +} diff --git a/packages/server/src/services/Banking/Matching/MatchTransactions.ts b/packages/server/src/services/Banking/Matching/MatchTransactions.ts new file mode 100644 index 000000000..496ba204e --- /dev/null +++ b/packages/server/src/services/Banking/Matching/MatchTransactions.ts @@ -0,0 +1,69 @@ +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 { Knex } from 'knex'; +import { Inject, Service } from 'typedi'; +import { + IBankTransactionMatchedEventPayload, + IBankTransactionMatchingEventPayload, + IMatchTransactionDTO, +} from './types'; + +@Service() +export class MatchBankTransactions { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private uow: UnitOfWork; + + @Inject() + private eventPublisher: EventPublisher; + + /** + * Matches the given uncategorized transaction to the given references. + * @param {number} tenantId + * @param {number} uncategorizedTransactionId + */ + public matchTransaction( + tenantId: number, + uncategorizedTransactionId: number, + matchTransactionsDTO: IMatchTransactionDTO + ) { + const { matchedTransactions } = matchTransactionsDTO; + const { MatchBankTransaction } = this.tenancy.models(tenantId); + + // + return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => { + // Triggers the event `onSaleInvoiceCreated`. + await this.eventPublisher.emitAsync(events.bankMatch.onMatching, { + tenantId, + uncategorizedTransactionId, + matchTransactionsDTO, + trx, + } as IBankTransactionMatchingEventPayload); + + // + await PromisePool.withConcurrency(10) + .for(matchedTransactions) + .process(async (matchedTransaction) => { + await MatchBankTransaction.query(trx).insert({ + uncategorizedTransactionId, + referenceType: matchedTransaction.referenceType, + referenceId: matchedTransaction.referenceId, + amount: matchedTransaction.amount, + }); + }); + + // Triggers the event `onSaleInvoiceCreated`. + await this.eventPublisher.emitAsync(events.bankMatch.onMatched, { + tenantId, + uncategorizedTransactionId, + matchTransactionsDTO, + trx, + } as IBankTransactionMatchedEventPayload); + }); + } +} diff --git a/packages/server/src/services/Banking/Matching/UnmatchMatchedTransaction.ts b/packages/server/src/services/Banking/Matching/UnmatchMatchedTransaction.ts new file mode 100644 index 000000000..98c86d6e6 --- /dev/null +++ b/packages/server/src/services/Banking/Matching/UnmatchMatchedTransaction.ts @@ -0,0 +1,41 @@ +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import UnitOfWork from '@/services/UnitOfWork'; +import events from '@/subscribers/events'; +import { Inject, Service } from 'typedi'; +import { IBankTransactionUnmatchingEventPayload } from './types'; + +@Service() +export class UnmatchMatchedBankTransaction { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private uow: UnitOfWork; + + @Inject() + private eventPublisher: EventPublisher; + + public unmatchMatchedTransaction( + tenantId: number, + uncategorizedTransactionId: number + ) { + 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); + }); + } +} diff --git a/packages/server/src/services/Banking/Matching/types.ts b/packages/server/src/services/Banking/Matching/types.ts new file mode 100644 index 000000000..c26e273ec --- /dev/null +++ b/packages/server/src/services/Banking/Matching/types.ts @@ -0,0 +1,39 @@ +import { Knex } from 'knex'; + +export interface IBankTransactionMatchingEventPayload { + tenantId: number; + uncategorizedTransactionId: number; + matchTransactionsDTO: IMatchTransactionDTO; + trx?: Knex.Transaction; +} + +export interface IBankTransactionMatchedEventPayload { + tenantId: number; + uncategorizedTransactionId: number; + matchTransactionsDTO: IMatchTransactionDTO; + trx?: Knex.Transaction; +} + +export interface IBankTransactionUnmatchingEventPayload { + tenantId: number; +} + +export interface IBankTransactionUnmatchedEventPayload { + tenantId: number; +} + +export interface IMatchTransactionDTO { + matchedTransactions: Array<{ + referenceType: string; + referenceId: number; + amount: number; + }>; +} + +export interface GetMatchedTransactionsFilter { + fromDate: string; + toDate: string; + minAmount: number; + maxAmount: number; + transactionType: string; +} diff --git a/packages/server/src/subscribers/events.ts b/packages/server/src/subscribers/events.ts index c57941f1e..e139b2b41 100644 --- a/packages/server/src/subscribers/events.ts +++ b/packages/server/src/subscribers/events.ts @@ -618,6 +618,7 @@ export default { onItemCreated: 'onPlaidItemCreated', }, + // Bank rules. bankRules: { onCreating: 'onBankRuleCreating', onCreated: 'onBankRuleCreated', @@ -628,4 +629,13 @@ export default { onDeleting: 'onBankRuleDeleting', onDeleted: 'onBankRuleDeleted', }, + + // Bank matching. + bankMatch: { + onMatching: 'onBankTransactionMatching', + onMatched: 'onBankTransactionMatched', + + onUnmatching: 'onBankTransactionUnmathcing', + onUnmatched: 'onBankTransactionUnmathced', + } }; From b6deb842ff04369f37b8eef2fad97a7c7e07c0a0 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Thu, 20 Jun 2024 10:20:18 +0200 Subject: [PATCH 05/42] feat: retrieve the matching transactions --- packages/server/src/models/ManualJournal.ts | 16 +++ .../GetMatchedTransactionBillsTransformer.ts | 76 ++++++++-- ...etMatchedTransactionExpensesTransformer.ts | 107 ++++++++++++-- ...etMatchedTransactionInvoicesTransformer.ts | 89 +++++++++++- ...hedTransactionManualJournalsTransformer.ts | 103 ++++++++++++-- .../Matching/GetMatchedTransactions.ts | 130 ++++-------------- .../Matching/GetMatchedTransactionsByBills.ts | 35 +++++ .../GetMatchedTransactionsByExpenses.ts | 35 +++++ .../GetMatchedTransactionsByInvoices.ts | 34 +++++ .../GetMatchedTransactionsByManualJournals.ts | 29 ++++ .../Banking/Matching/MatchTransactions.ts | 2 +- 11 files changed, 507 insertions(+), 149 deletions(-) create mode 100644 packages/server/src/services/Banking/Matching/GetMatchedTransactionsByBills.ts create mode 100644 packages/server/src/services/Banking/Matching/GetMatchedTransactionsByExpenses.ts create mode 100644 packages/server/src/services/Banking/Matching/GetMatchedTransactionsByInvoices.ts create mode 100644 packages/server/src/services/Banking/Matching/GetMatchedTransactionsByManualJournals.ts diff --git a/packages/server/src/models/ManualJournal.ts b/packages/server/src/models/ManualJournal.ts index 3d25b377f..945af2aad 100644 --- a/packages/server/src/models/ManualJournal.ts +++ b/packages/server/src/models/ManualJournal.ts @@ -97,6 +97,7 @@ export default class ManualJournal extends mixin(TenantModel, [ const AccountTransaction = require('models/AccountTransaction'); const ManualJournalEntry = require('models/ManualJournalEntry'); const Document = require('models/Document'); + const { MatchedBankTransaction } = require('models/MatchedBankTransaction'); return { entries: { @@ -140,6 +141,21 @@ export default class ManualJournal extends mixin(TenantModel, [ query.where('model_ref', 'ManualJournal'); }, }, + + /** + * Manual journal may belongs to matched bank transaction. + */ + matchedBankTransaction: { + relation: Model.BelongsToOneRelation, + modelClass: MatchedBankTransaction, + join: { + from: 'manual_journals.id', + to: 'matched_bank_transactions.referenceId', + }, + filter(query) { + query.where('reference_type', 'ManualJournal'); + }, + }, }; } diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionBillsTransformer.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionBillsTransformer.ts index fdc2a38ed..1bb71ffa0 100644 --- a/packages/server/src/services/Banking/Matching/GetMatchedTransactionBillsTransformer.ts +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionBillsTransformer.ts @@ -6,35 +6,81 @@ export class GetMatchedTransactionBillsTransformer extends Transformer { * @returns {Array} */ public includeAttributes = (): string[] => { - return ['referenceNo']; + return [ + 'referenceNo', + 'amount', + 'amountFormatted', + 'transactionNo', + 'date', + 'dateFromatted', + 'transactionId', + 'transactionNo', + 'transactionType', + 'transsactionTypeFormatted', + ]; }; + /** + * Exclude all attributes. + * @returns {Array} + */ public excludeAttributes = (): string[] => { return ['*']; }; - protected referenceNo(invoice) { - return invoice.referenceNo; + /** + * Retrieve the reference number of the bill. + * @param {Object} bill - The bill object. + * @returns {string} + */ + protected referenceNo(bill) { + return bill.referenceNo; } - amount(invoice) { - return 1; + /** + * Retrieve the amount of the bill. + * @param {Object} bill - The bill object. + * @returns {number} + */ + protected amount(bill) { + return bill.amount; } - amountFormatted() { + /** + * Retrieve the formatted amount of the bill. + * @param {Object} bill - The bill object. + * @returns {string} + */ + protected amountFormatted(bill) { + return this.formatNumber(bill.totalAmount, { + currencyCode: bill.currencyCode, + }); } - date() { + /** + * Retrieve the date of the bill. + * @param {Object} bill - The bill object. + * @returns {string} + */ + protected date(bill) { + return bill.date; } - dateFromatted() { + /** + * Retrieve the formatted date of the bill. + * @param {Object} bill - The bill object. + * @returns {string} + */ + protected dateFromatted(bill) { + return this.formatDate(bill.date); } - transactionId(invoice) { - return invoice.id; + + /** + * Retrieve the transcation id of the bill. + * @param {Object} bill - The bill object. + * @returns {number} + */ + protected transactionId(bill) { + return bill.id; } - transactionNo() { - - } - transactionType() {} - transsactionTypeFormatted() {} } diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionExpensesTransformer.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionExpensesTransformer.ts index b8f16ae7a..a00dd6d65 100644 --- a/packages/server/src/services/Banking/Matching/GetMatchedTransactionExpensesTransformer.ts +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionExpensesTransformer.ts @@ -6,27 +6,108 @@ export class GetMatchedTransactionExpensesTransformer extends Transformer { * @returns {Array} */ public includeAttributes = (): string[] => { - return ['referenceNo']; + return [ + 'referenceNo', + 'amount', + 'amountFormatted', + 'transactionNo', + 'date', + 'dateFromatted', + 'transactionId', + 'transactionNo', + 'transactionType', + 'transsactionTypeFormatted', + ]; }; + /** + * Exclude all attributes. + * @returns {Array} + */ public excludeAttributes = (): string[] => { return ['*']; }; - protected referenceNo(invoice) { - return invoice.referenceNo; + /** + * Retrieves the expense reference number. + * @param expense + * @returns {string} + */ + protected referenceNo(expense) { + return expense.referenceNo; } - amount(invoice) { - return 1; + /** + * Retrieves the expense amount. + * @param expense + * @returns {number} + */ + protected amount(expense) { + return expense.totalAmount; } - amountFormatted() {} - date() {} - dateFromatted() {} - transactionId(invoice) { - return invoice.id; + + /** + * Formats the amount of the expense. + * @param expense + * @returns {string} + */ + protected amountFormatted(expense) { + return this.formatNumber(expense.totalAmount, { + currencyCode: expense.currencyCode, + }); + } + + /** + * 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 dateFromatted(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'; } - transactionNo() {} - transactionType() {} - transsactionTypeFormatted() {} } diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionInvoicesTransformer.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionInvoicesTransformer.ts index e3326a873..dd49e3eba 100644 --- a/packages/server/src/services/Banking/Matching/GetMatchedTransactionInvoicesTransformer.ts +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionInvoicesTransformer.ts @@ -6,18 +6,105 @@ export class GetMatchedTransactionInvoicesTransformer extends Transformer { * @returns {Array} */ public includeAttributes = (): string[] => { - return ['referenceNo', 'transactionNo']; + return [ + 'referenceNo', + 'amount', + 'amountFormatted', + 'transactionNo', + 'date', + 'dateFromatted', + 'transactionId', + 'transactionNo', + 'transactionType', + 'transsactionTypeFormatted', + ]; }; + /** + * Exclude all attributes. + * @returns {Array} + */ 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, + }); + } + + /** + * Retrieve the date of the invoice. + * @param invoice + * @returns {Date} + */ + protected getDate(invoice) { + return invoice.invoiceDate; + } + + /** + * Format the date of the invoice. + * @param invoice + * @returns {string} + */ + protected formatDate(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'; + } } diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionManualJournalsTransformer.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionManualJournalsTransformer.ts index 530dfbfa7..d43307700 100644 --- a/packages/server/src/services/Banking/Matching/GetMatchedTransactionManualJournalsTransformer.ts +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionManualJournalsTransformer.ts @@ -6,27 +6,104 @@ export class GetMatchedTransactionManualJournalsTransformer extends Transformer * @returns {Array} */ public includeAttributes = (): string[] => { - return ['referenceNo']; + return [ + 'referenceNo', + 'amount', + 'amountFormatted', + 'transactionNo', + 'date', + 'dateFromatted', + 'transactionId', + 'transactionNo', + 'transactionType', + 'transsactionTypeFormatted', + ]; }; + /** + * Exclude all attributes. + * @returns {Array} + */ public excludeAttributes = (): string[] => { return ['*']; }; - protected referenceNo(invoice) { - return invoice.referenceNo; + /** + * Retrieves the manual journal reference no. + * @param manualJournal + * @returns {string} + */ + protected referenceNo(manualJournal) { + return manualJournal.referenceNo; } - amount(invoice) { - return 1; + /** + * Retrieves the manual journal amount. + * @param manualJournal + * @returns {number} + */ + protected amount(manualJournal) { + return manualJournal.totalAmount; } - amountFormatted() {} - date() {} - dateFromatted() {} - transactionId(invoice) { - return invoice.id; + + /** + * Retrieves the manual journal formatted amount. + * @param manualJournal + * @returns {string} + */ + protected amountFormatted(manualJournal) { + return this.formatNumber(manualJournal.totalAmount, { + currencyCode: manualJournal.currencyCode, + }); + } + + /** + * 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 dateFromatted(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'; } - transactionNo() {} - transactionType() {} - transsactionTypeFormatted() {} } diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactions.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactions.ts index b98d2cef1..ead394c97 100644 --- a/packages/server/src/services/Banking/Matching/GetMatchedTransactions.ts +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactions.ts @@ -2,20 +2,32 @@ import { Inject, Service } from 'typedi'; import * as R from 'ramda'; import { PromisePool } from '@supercharge/promise-pool'; import { GetMatchedTransactionsFilter } from './types'; -import HasTenancyService from '@/services/Tenancy/TenancyService'; -import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; -import { GetMatchedTransactionInvoicesTransformer } from './GetMatchedTransactionInvoicesTransformer'; -import { GetMatchedTransactionBillsTransformer } from './GetMatchedTransactionBillsTransformer'; -import { GetMatchedTransactionExpensesTransformer } from './GetMatchedTransactionExpensesTransformer'; -import { GetMatchedTransactionManualJournalsTransformer } from './GetMatchedTransactionManualJournalsTransformer'; +import { GetMatchedTransactionsByExpenses } from './GetMatchedTransactionsByExpenses'; +import { GetMatchedTransactionsByBills } from './GetMatchedTransactionsByBills'; +import { GetMatchedTransactionsByManualJournals } from './GetMatchedTransactionsByManualJournals'; @Service() export class GetMatchedTransactions { @Inject() - private tenancy: HasTenancyService; + private getMatchedInvoicesService: GetMatchedTransactionsByExpenses; @Inject() - private transformer: TransformerInjectable; + private getMatchedBillsService: GetMatchedTransactionsByBills; + + @Inject() + private getMatchedManualJournalService: GetMatchedTransactionsByManualJournals; + + @Inject() + private getMatchedExpensesService: GetMatchedTransactionsByExpenses; + + 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. @@ -26,111 +38,17 @@ export class GetMatchedTransactions { tenantId: number, filter: GetMatchedTransactionsFilter ) { - const registered = [ - { - type: 'SaleInvoice', - callback: this.getSaleInvoicesMatchedTransactions.bind(this), - }, - { - type: 'Bill', - callback: this.getBillsMatchedTransactions.bind(this), - }, - { - type: 'Expense', - callback: this.getExpensesMatchedTransactions.bind(this), - }, - { - type: 'ManualJournal', - callback: this.getManualJournalsMatchedTransactions.bind(this), - }, - ]; const filtered = filter.transactionType - ? registered.filter((item) => item.type === filter.transactionType) - : registered; + ? this.registered.filter((item) => item.type === filter.transactionType) + : this.registered; const matchedTransactions = await PromisePool.withConcurrency(2) .for(filtered) - .process(async ({ type, callback }) => { - return callback(tenantId, filter); + .process(async ({ type, service }) => { + return service.getMatchedTransactions(tenantId, filter); }); return R.compose(R.flatten)(matchedTransactions?.results); } - - /** - * - * @param {number} tenantId - - * @param {GetMatchedTransactionsFilter} filter - - */ - async getSaleInvoicesMatchedTransactions( - tenantId: number, - filter: GetMatchedTransactionsFilter - ) { - const { SaleInvoice } = this.tenancy.models(tenantId); - - const invoices = await SaleInvoice.query(); - - return this.transformer.transform( - tenantId, - invoices, - new GetMatchedTransactionInvoicesTransformer() - ); - } - - /** - * - * @param {number} tenantId - - * @param {GetMatchedTransactionsFilter} filter - - */ - async getBillsMatchedTransactions( - tenantId: number, - filter: GetMatchedTransactionsFilter - ) { - const { Bill } = this.tenancy.models(tenantId); - - const bills = await Bill.query(); - - return this.transformer.transform( - tenantId, - bills, - new GetMatchedTransactionBillsTransformer() - ); - } - - /** - * - * @param {number} tenantId - * @param {GetMatchedTransactionsFilter} filter - * @returns - */ - async getExpensesMatchedTransactions( - tenantId: number, - filter: GetMatchedTransactionsFilter - ) { - const { Expense } = this.tenancy.models(tenantId); - - const expenses = await Expense.query(); - - return this.transformer.transform( - tenantId, - expenses, - new GetMatchedTransactionManualJournalsTransformer() - ); - } - - async getManualJournalsMatchedTransactions( - tenantId: number, - filter: GetMatchedTransactionsFilter - ) { - const { ManualJournal } = this.tenancy.models(tenantId); - - const manualJournals = await ManualJournal.query(); - - return this.transformer.transform( - tenantId, - manualJournals, - new GetMatchedTransactionManualJournalsTransformer() - ); - } } interface MatchedTransaction { diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByBills.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByBills.ts new file mode 100644 index 000000000..3e62bf319 --- /dev/null +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByBills.ts @@ -0,0 +1,35 @@ +import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; +import { GetMatchedTransactionBillsTransformer } from './GetMatchedTransactionBillsTransformer'; +import { GetMatchedTransactionInvoicesTransformer } from './GetMatchedTransactionInvoicesTransformer'; +import { GetMatchedTransactionsFilter } from './types'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { Inject, Service } from 'typedi'; + +@Service() +export class GetMatchedTransactionsByBills { + @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(); + + return this.transformer.transform( + tenantId, + bills, + new GetMatchedTransactionBillsTransformer() + ); + } +} diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByExpenses.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByExpenses.ts new file mode 100644 index 000000000..c28ce6900 --- /dev/null +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByExpenses.ts @@ -0,0 +1,35 @@ +import { Inject, Service } from 'typedi'; +import { GetMatchedTransactionManualJournalsTransformer } from './GetMatchedTransactionManualJournalsTransformer'; +import { GetMatchedTransactionsFilter } from './types'; +import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; + +@Service() +export class GetMatchedTransactionsByExpenses { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private transformer: TransformerInjectable; + + /** + * + * @param {number} tenantId + * @param {GetMatchedTransactionsFilter} filter + * @returns + */ + async getMatchedTransactions( + tenantId: number, + filter: GetMatchedTransactionsFilter + ) { + const { Expense } = this.tenancy.models(tenantId); + + const expenses = await Expense.query(); + + return this.transformer.transform( + tenantId, + expenses, + new GetMatchedTransactionManualJournalsTransformer() + ); + } +} diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByInvoices.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByInvoices.ts new file mode 100644 index 000000000..73185e776 --- /dev/null +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByInvoices.ts @@ -0,0 +1,34 @@ +import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; +import { GetMatchedTransactionInvoicesTransformer } from './GetMatchedTransactionInvoicesTransformer'; +import { GetMatchedTransactionsFilter } from './types'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { Inject, Service } from 'typedi'; + +@Service() +export class GetMatchedTransactionsByInvoices { + @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 { SaleInvoice } = this.tenancy.models(tenantId); + + const invoices = await SaleInvoice.query(); + + return this.transformer.transform( + tenantId, + invoices, + new GetMatchedTransactionInvoicesTransformer() + ); + } +} diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByManualJournals.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByManualJournals.ts new file mode 100644 index 000000000..ec33732eb --- /dev/null +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByManualJournals.ts @@ -0,0 +1,29 @@ +import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; +import { GetMatchedTransactionManualJournalsTransformer } from './GetMatchedTransactionManualJournalsTransformer'; +import { Inject, Service } from 'typedi'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { GetMatchedTransactionsFilter } from './types'; + +@Service() +export class GetMatchedTransactionsByManualJournals { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private transformer: TransformerInjectable; + + async getMatchedTransactions( + tenantId: number, + filter: GetMatchedTransactionsFilter + ) { + const { ManualJournal } = this.tenancy.models(tenantId); + + const manualJournals = await ManualJournal.query(); + + return this.transformer.transform( + tenantId, + manualJournals, + new GetMatchedTransactionManualJournalsTransformer() + ); + } +} diff --git a/packages/server/src/services/Banking/Matching/MatchTransactions.ts b/packages/server/src/services/Banking/Matching/MatchTransactions.ts index 496ba204e..8f9ac6098 100644 --- a/packages/server/src/services/Banking/Matching/MatchTransactions.ts +++ b/packages/server/src/services/Banking/Matching/MatchTransactions.ts @@ -45,7 +45,7 @@ export class MatchBankTransactions { trx, } as IBankTransactionMatchingEventPayload); - // + // Matches the given transactions under promise pool concurrency controlling. await PromisePool.withConcurrency(10) .for(matchedTransactions) .process(async (matchedTransaction) => { From b37002bea6efedc2df50207390fabdfda4add39a Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Thu, 20 Jun 2024 13:50:29 +0200 Subject: [PATCH 06/42] feat: exclude/unexclude the uncategorized transactions --- .../ExcludeBankTransactionsController.ts | 90 +++++++++++++++++++ .../Cashflow/CashflowController.ts | 2 + ...categorized_cashflow_transactions_table.js | 11 +++ .../UncategorizedCashflowTransaction.ts | 1 + .../Banking/Exclude/ExcludeBankTransaction.ts | 41 +++++++++ .../ExcludeBankTransactionsApplication.ts | 38 ++++++++ .../Exclude/UnexcludeBankTransaction.ts | 41 +++++++++ .../src/services/Banking/Exclude/utils.ts | 14 +++ 8 files changed, 238 insertions(+) create mode 100644 packages/server/src/api/controllers/Banking/ExcludeBankTransactionsController.ts create mode 100644 packages/server/src/database/migrations/20240620111308_add_excluded_column_to_uncategorized_cashflow_transactions_table.js create mode 100644 packages/server/src/services/Banking/Exclude/ExcludeBankTransaction.ts create mode 100644 packages/server/src/services/Banking/Exclude/ExcludeBankTransactionsApplication.ts create mode 100644 packages/server/src/services/Banking/Exclude/UnexcludeBankTransaction.ts create mode 100644 packages/server/src/services/Banking/Exclude/utils.ts diff --git a/packages/server/src/api/controllers/Banking/ExcludeBankTransactionsController.ts b/packages/server/src/api/controllers/Banking/ExcludeBankTransactionsController.ts new file mode 100644 index 000000000..6cf3a92d3 --- /dev/null +++ b/packages/server/src/api/controllers/Banking/ExcludeBankTransactionsController.ts @@ -0,0 +1,90 @@ +import { Inject, Service } from 'typedi'; +import { param } from 'express-validator'; +import { NextFunction, Request, Response, Router } from 'express'; +import BaseController from '../BaseController'; +import { ExcludeBankTransactionsApplication } from '@/services/Banking/Exclude/ExcludeBankTransactionsApplication'; + +@Service() +export class ExcludeBankTransactionsController extends BaseController { + @Inject() + prviate excludeBankTransactionApp: ExcludeBankTransactionsApplication; + + /** + * Router constructor. + */ + public router() { + const router = Router(); + + router.put( + '/transactions/:transactionId/exclude', + [param('transactionId').exists()], + this.validationResult, + this.excludeBankTransaction.bind(this) + ); + router.put( + '/transactions/:transactionId/unexclude', + [param('transactionId').exists()], + this.validationResult, + this.unexcludeBankTransaction.bind(this) + ); + return router; + } + + /** + * Marks a bank transaction as excluded. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + * @returns + */ + private async excludeBankTransaction( + req: Request, + res: Response, + next: NextFunction + ): Promise { + const { tenantId } = req; + const { transactionId } = req.params; + + try { + await this.excludeBankTransactionApp.excludeBankTransaction( + tenantId, + transactionId + ); + return res.status(200).send({ + message: 'The bank transaction has been excluded.', + id: transactionId, + }); + } catch (error) { + next(error); + } + } + + /** + * Marks a bank transaction as not excluded. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + * @returns {Promise} + */ + private async unexcludeBankTransaction( + req: Request, + res: Response, + next: NextFunction + ): Promise { + const { tenantId } = req; + const { transactionId } = req.params; + + try { + await this.excludeBankTransactionApp.unexcludeBankTransaction( + tenantId, + transactionId + ); + return res.status(200).send({ + message: 'The bank transaction has been unexcluded.', + id: transactionId, + }); + } catch (error) { + next(error); + } + } +} diff --git a/packages/server/src/api/controllers/Cashflow/CashflowController.ts b/packages/server/src/api/controllers/Cashflow/CashflowController.ts index 42efa4c48..4ef98d267 100644 --- a/packages/server/src/api/controllers/Cashflow/CashflowController.ts +++ b/packages/server/src/api/controllers/Cashflow/CashflowController.ts @@ -4,6 +4,7 @@ import CommandCashflowTransaction from './NewCashflowTransaction'; import DeleteCashflowTransaction from './DeleteCashflowTransaction'; import GetCashflowTransaction from './GetCashflowTransaction'; import GetCashflowAccounts from './GetCashflowAccounts'; +import { ExcludeBankTransactionsController } from '../Banking/ExcludeBankTransactionsController'; @Service() export default class CashflowController { @@ -14,6 +15,7 @@ export default class CashflowController { const router = Router(); router.use(Container.get(CommandCashflowTransaction).router()); + router.use(Container.get(ExcludeBankTransactionsController).router()); router.use(Container.get(GetCashflowTransaction).router()); router.use(Container.get(GetCashflowAccounts).router()); router.use(Container.get(DeleteCashflowTransaction).router()); diff --git a/packages/server/src/database/migrations/20240620111308_add_excluded_column_to_uncategorized_cashflow_transactions_table.js b/packages/server/src/database/migrations/20240620111308_add_excluded_column_to_uncategorized_cashflow_transactions_table.js new file mode 100644 index 000000000..735b817ed --- /dev/null +++ b/packages/server/src/database/migrations/20240620111308_add_excluded_column_to_uncategorized_cashflow_transactions_table.js @@ -0,0 +1,11 @@ +exports.up = function (knex) { + return knex.schema.table('uncategorized_cashflow_transactions', (table) => { + table.boolean('excluded'); + }); +}; + +exports.down = function (knex) { + return knex.schema.table('uncategorized_cashflow_transactions', (table) => { + table.dropColumn('excluded'); + }); +}; diff --git a/packages/server/src/models/UncategorizedCashflowTransaction.ts b/packages/server/src/models/UncategorizedCashflowTransaction.ts index 272458892..373da0a27 100644 --- a/packages/server/src/models/UncategorizedCashflowTransaction.ts +++ b/packages/server/src/models/UncategorizedCashflowTransaction.ts @@ -44,6 +44,7 @@ export default class UncategorizedCashflowTransaction extends mixin( 'deposit', 'isDepositTransaction', 'isWithdrawalTransaction', + 'isRecognized', ]; } diff --git a/packages/server/src/services/Banking/Exclude/ExcludeBankTransaction.ts b/packages/server/src/services/Banking/Exclude/ExcludeBankTransaction.ts new file mode 100644 index 000000000..7d23a0f22 --- /dev/null +++ b/packages/server/src/services/Banking/Exclude/ExcludeBankTransaction.ts @@ -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} + */ + public async excludeBankTransaction( + tenantId: number, + bankTransactionId: number + ) { + const { UncategorizeCashflowTransaction } = this.tenancy.models(tenantId); + + const oldUncategorizedTransaction = + await UncategorizeCashflowTransaction.query() + .findById(bankTransactionId) + .throwIfNotFound(); + + validateTransactionNotCategorized(oldUncategorizedTransaction); + + return this.uow.withTransaction(tenantId, async (trx) => { + await UncategorizeCashflowTransaction.query(trx) + .findById(bankTransactionId) + .patch({ + excluded: true, + }); + }); + } +} diff --git a/packages/server/src/services/Banking/Exclude/ExcludeBankTransactionsApplication.ts b/packages/server/src/services/Banking/Exclude/ExcludeBankTransactionsApplication.ts new file mode 100644 index 000000000..3ee664a64 --- /dev/null +++ b/packages/server/src/services/Banking/Exclude/ExcludeBankTransactionsApplication.ts @@ -0,0 +1,38 @@ +import { Inject, Service } from 'typedi'; +import { ExcludeBankTransaction } from './ExcludeBankTransaction'; +import { UnexcludeBankTransaction } from './UnexcludeBankTransaction'; + +@Service() +export class ExcludeBankTransactionsApplication { + @Inject() + private excludeBankTransactionService: ExcludeBankTransaction; + + @Inject() + private unexcludeBankTransactionService: UnexcludeBankTransaction; + + /** + * 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} + */ + 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} + */ + public unexcludeBankTransaction(tenantId: number, bankTransactionId: number) { + return this.unexcludeBankTransactionService.unexcludeBankTransaction( + tenantId, + bankTransactionId + ); + } +} diff --git a/packages/server/src/services/Banking/Exclude/UnexcludeBankTransaction.ts b/packages/server/src/services/Banking/Exclude/UnexcludeBankTransaction.ts new file mode 100644 index 000000000..159fe373a --- /dev/null +++ b/packages/server/src/services/Banking/Exclude/UnexcludeBankTransaction.ts @@ -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} + */ + public async unexcludeBankTransaction( + tenantId: number, + bankTransactionId: number + ) { + const { UncategorizeCashflowTransaction } = this.tenancy.models(tenantId); + + const oldUncategorizedTransaction = + await UncategorizeCashflowTransaction.query() + .findById(bankTransactionId) + .throwIfNotFound(); + + validateTransactionNotCategorized(oldUncategorizedTransaction); + + return this.uow.withTransaction(tenantId, async (trx) => { + await UncategorizeCashflowTransaction.query(trx) + .findById(bankTransactionId) + .patch({ + excluded: false, + }); + }); + } +} diff --git a/packages/server/src/services/Banking/Exclude/utils.ts b/packages/server/src/services/Banking/Exclude/utils.ts new file mode 100644 index 000000000..6d4f02a9a --- /dev/null +++ b/packages/server/src/services/Banking/Exclude/utils.ts @@ -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); + } +}; From 738a84bb4b0d1e2aed92c4444eb8d73b6f270dc3 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Thu, 20 Jun 2024 23:31:46 +0200 Subject: [PATCH 07/42] feat: match bank transaction --- .../BankTransactionsMatchingController.ts | 12 ++- .../ExcludeBankTransactionsController.ts | 2 +- .../Banking/ReconcileBankController.ts | 30 ++++++ packages/server/src/models/Bill.ts | 16 +++ .../server/src/models/CashflowTransaction.ts | 17 +++ packages/server/src/models/Expense.ts | 16 +++ .../src/models/MatchedBankTransaction.ts | 10 +- packages/server/src/models/SaleInvoice.ts | 16 +++ .../UncategorizedCashflowTransaction.ts | 13 +++ ...hedTransactionManualJournalsTransformer.ts | 4 +- .../Matching/GetMatchedTransactions.ts | 17 +-- .../Matching/GetMatchedTransactionsByBills.ts | 6 +- .../GetMatchedTransactionsByExpenses.ts | 7 +- .../GetMatchedTransactionsByInvoices.ts | 38 ++++++- .../GetMatchedTransactionsByManualJournals.ts | 33 +++++- .../Matching/GetMatchedTransactionsByType.ts | 65 +++++++++++ .../Banking/Matching/MatchTransactions.ts | 102 +++++++++++++++--- .../Matching/MatchTransactionsTypes.ts | 57 ++++++++++ .../MatchTransactionsTypesRegistry.ts | 50 +++++++++ .../src/services/Banking/Matching/types.ts | 36 +++++-- 20 files changed, 492 insertions(+), 55 deletions(-) create mode 100644 packages/server/src/api/controllers/Banking/ReconcileBankController.ts create mode 100644 packages/server/src/services/Banking/Matching/GetMatchedTransactionsByType.ts create mode 100644 packages/server/src/services/Banking/Matching/MatchTransactionsTypes.ts create mode 100644 packages/server/src/services/Banking/Matching/MatchTransactionsTypesRegistry.ts diff --git a/packages/server/src/api/controllers/Banking/BankTransactionsMatchingController.ts b/packages/server/src/api/controllers/Banking/BankTransactionsMatchingController.ts index 98e000cb8..d49fef2ef 100644 --- a/packages/server/src/api/controllers/Banking/BankTransactionsMatchingController.ts +++ b/packages/server/src/api/controllers/Banking/BankTransactionsMatchingController.ts @@ -2,7 +2,7 @@ import { Inject, Service } from 'typedi'; import { NextFunction, Request, Response, Router } from 'express'; import BaseController from '@/api/controllers/BaseController'; import { MatchBankTransactionsApplication } from '@/services/Banking/Matching/MatchBankTransactionsApplication'; -import { param } from 'express-validator'; +import { body, param } from 'express-validator'; import { GetMatchedTransactionsFilter, IMatchTransactionDTO, @@ -21,7 +21,14 @@ export class BankTransactionsMatchingController extends BaseController { router.post( '/:transactionId', - [param('transactionId').exists()], + [ + param('transactionId').exists(), + + body('matchedTransactions').isArray({ min: 1 }), + body('matchedTransactions.*.reference_type').exists(), + body('matchedTransactions.*.reference_id').isNumeric().toInt(), + body('matchedTransactions.*.amount').exists().isNumeric().toFloat(), + ], this.validationResult, this.matchBankTransaction.bind(this) ); @@ -60,7 +67,6 @@ export class BankTransactionsMatchingController extends BaseController { transactionId, matchTransactionDTO ); - return res.status(200).send({ message: 'The bank transaction has been matched.', }); diff --git a/packages/server/src/api/controllers/Banking/ExcludeBankTransactionsController.ts b/packages/server/src/api/controllers/Banking/ExcludeBankTransactionsController.ts index 6cf3a92d3..880bb4705 100644 --- a/packages/server/src/api/controllers/Banking/ExcludeBankTransactionsController.ts +++ b/packages/server/src/api/controllers/Banking/ExcludeBankTransactionsController.ts @@ -7,7 +7,7 @@ import { ExcludeBankTransactionsApplication } from '@/services/Banking/Exclude/E @Service() export class ExcludeBankTransactionsController extends BaseController { @Inject() - prviate excludeBankTransactionApp: ExcludeBankTransactionsApplication; + private excludeBankTransactionApp: ExcludeBankTransactionsApplication; /** * Router constructor. diff --git a/packages/server/src/api/controllers/Banking/ReconcileBankController.ts b/packages/server/src/api/controllers/Banking/ReconcileBankController.ts new file mode 100644 index 000000000..4aa08a295 --- /dev/null +++ b/packages/server/src/api/controllers/Banking/ReconcileBankController.ts @@ -0,0 +1,30 @@ +import { body } from 'express-validator'; +import BaseController from '../BaseController'; +import { Router } from 'express'; +import { Service } from 'typedi'; + +@Service() +export class BankReconcileController extends BaseController { + /** + * Router constructor. + */ + public router() { + const router = Router(); + + router.post( + '/', + [ + body('amount').exists(), + body('date').exists(), + body('fromAccountId').exists(), + body('toAccountId').exists(), + body('reference').optional({ nullable: true }), + ], + this.validationResult, + this.createBankReconcileTransaction.bind(this) + ); + return router; + } + + createBankReconcileTransaction() {} +} diff --git a/packages/server/src/models/Bill.ts b/packages/server/src/models/Bill.ts index 439669ad5..422865cb0 100644 --- a/packages/server/src/models/Bill.ts +++ b/packages/server/src/models/Bill.ts @@ -404,6 +404,7 @@ export default class Bill extends mixin(TenantModel, [ const Branch = require('models/Branch'); const TaxRateTransaction = require('models/TaxRateTransaction'); const Document = require('models/Document'); + const { MatchedBankTransaction } = require('models/MatchedBankTransaction'); return { vendor: { @@ -485,6 +486,21 @@ export default class Bill extends mixin(TenantModel, [ query.where('model_ref', 'Bill'); }, }, + + /** + * Bill may belongs to matched bank transaction. + */ + matchedBankTransaction: { + relation: Model.HasManyRelation, + modelClass: MatchedBankTransaction, + join: { + from: 'bills.id', + to: 'matched_bank_transactions.referenceId', + }, + filter(query) { + query.where('reference_type', 'Bill'); + }, + }, }; } diff --git a/packages/server/src/models/CashflowTransaction.ts b/packages/server/src/models/CashflowTransaction.ts index 3cc2baba7..c5aadbccb 100644 --- a/packages/server/src/models/CashflowTransaction.ts +++ b/packages/server/src/models/CashflowTransaction.ts @@ -102,6 +102,7 @@ export default class CashflowTransaction extends TenantModel { const CashflowTransactionLine = require('models/CashflowTransactionLine'); const AccountTransaction = require('models/AccountTransaction'); const Account = require('models/Account'); + const { MatchedBankTransaction } = require('models/MatchedBankTransaction'); return { /** @@ -158,6 +159,22 @@ export default class CashflowTransaction extends TenantModel { to: 'accounts.id', }, }, + + /** + * Cashflow transaction may belongs to matched bank transaction. + */ + matchedBankTransaction: { + relation: Model.HasManyRelation, + modelClass: MatchedBankTransaction, + join: { + from: 'cashflow_transactions.id', + to: 'matched_bank_transactions.referenceId', + }, + filter: (query) => { + const referenceTypes = getCashflowAccountTransactionsTypes(); + query.whereIn('reference_type', referenceTypes); + }, + }, }; } } diff --git a/packages/server/src/models/Expense.ts b/packages/server/src/models/Expense.ts index 21fa0ac9c..17e0b273d 100644 --- a/packages/server/src/models/Expense.ts +++ b/packages/server/src/models/Expense.ts @@ -182,6 +182,7 @@ export default class Expense extends mixin(TenantModel, [ const ExpenseCategory = require('models/ExpenseCategory'); const Document = require('models/Document'); const Branch = require('models/Branch'); + const { MatchedBankTransaction } = require('models/MatchedBankTransaction'); return { paymentAccount: { @@ -234,6 +235,21 @@ export default class Expense extends mixin(TenantModel, [ query.where('model_ref', 'Expense'); }, }, + + /** + * Expense may belongs to matched bank transaction. + */ + matchedBankTransaction: { + relation: Model.HasManyRelation, + modelClass: MatchedBankTransaction, + join: { + from: 'expenses_transactions.id', + to: 'matched_bank_transactions.referenceId', + }, + filter(query) { + query.where('reference_type', 'Expense'); + }, + }, }; } diff --git a/packages/server/src/models/MatchedBankTransaction.ts b/packages/server/src/models/MatchedBankTransaction.ts index 5d025cbbb..4d5df6315 100644 --- a/packages/server/src/models/MatchedBankTransaction.ts +++ b/packages/server/src/models/MatchedBankTransaction.ts @@ -1,4 +1,5 @@ import TenantModel from 'models/TenantModel'; +import { Model } from 'objection'; export class MatchedBankTransaction extends TenantModel { /** @@ -12,7 +13,7 @@ export class MatchedBankTransaction extends TenantModel { * Timestamps columns. */ get timestamps() { - return []; + return ['createdAt', 'updatedAt']; } /** @@ -21,4 +22,11 @@ export class MatchedBankTransaction extends TenantModel { static get virtualAttributes() { return []; } + + /** + * Relationship mapping. + */ + static get relationMappings() { + return {}; + } } diff --git a/packages/server/src/models/SaleInvoice.ts b/packages/server/src/models/SaleInvoice.ts index 76a3b437e..49ceb1302 100644 --- a/packages/server/src/models/SaleInvoice.ts +++ b/packages/server/src/models/SaleInvoice.ts @@ -411,6 +411,7 @@ export default class SaleInvoice extends mixin(TenantModel, [ const Account = require('models/Account'); const TaxRateTransaction = require('models/TaxRateTransaction'); const Document = require('models/Document'); + const { MatchedBankTransaction } = require('models/MatchedBankTransaction'); return { /** @@ -543,6 +544,21 @@ export default class SaleInvoice extends mixin(TenantModel, [ query.where('model_ref', 'SaleInvoice'); }, }, + + /** + * Sale invocie may belongs to matched bank transaction. + */ + matchedBankTransaction: { + relation: Model.HasManyRelation, + modelClass: MatchedBankTransaction, + join: { + from: 'sales_invoices.id', + to: "matched_bank_transactions.referenceId", + }, + filter(query) { + query.where('reference_type', 'SaleInvoice'); + }, + }, }; } diff --git a/packages/server/src/models/UncategorizedCashflowTransaction.ts b/packages/server/src/models/UncategorizedCashflowTransaction.ts index 373da0a27..97da9eea1 100644 --- a/packages/server/src/models/UncategorizedCashflowTransaction.ts +++ b/packages/server/src/models/UncategorizedCashflowTransaction.ts @@ -97,6 +97,7 @@ export default class UncategorizedCashflowTransaction extends mixin( const { RecognizedBankTransaction, } = require('models/RecognizedBankTransaction'); + const { MatchedBankTransaction } = require('models/MatchedBankTransaction'); return { /** @@ -122,6 +123,18 @@ export default class UncategorizedCashflowTransaction extends mixin( to: 'recognized_bank_transactions.id', }, }, + + /** + * Uncategorized transaction may has association to matched transaction. + */ + matchedBankTransaction: { + relation: Model.BelongsToOneRelation, + modelClass: MatchedBankTransaction, + join: { + from: 'uncategorized_cashflow_transactions.id', + to: 'matched_bank_transactions.uncategorizedTransactionId', + }, + }, }; } diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionManualJournalsTransformer.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionManualJournalsTransformer.ts index d43307700..60fda4226 100644 --- a/packages/server/src/services/Banking/Matching/GetMatchedTransactionManualJournalsTransformer.ts +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionManualJournalsTransformer.ts @@ -43,7 +43,7 @@ export class GetMatchedTransactionManualJournalsTransformer extends Transformer * @returns {number} */ protected amount(manualJournal) { - return manualJournal.totalAmount; + return manualJournal.amount; } /** @@ -52,7 +52,7 @@ export class GetMatchedTransactionManualJournalsTransformer extends Transformer * @returns {string} */ protected amountFormatted(manualJournal) { - return this.formatNumber(manualJournal.totalAmount, { + return this.formatNumber(manualJournal.amount, { currencyCode: manualJournal.currencyCode, }); } diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactions.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactions.ts index ead394c97..0dc71e02a 100644 --- a/packages/server/src/services/Banking/Matching/GetMatchedTransactions.ts +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactions.ts @@ -1,7 +1,7 @@ import { Inject, Service } from 'typedi'; import * as R from 'ramda'; import { PromisePool } from '@supercharge/promise-pool'; -import { GetMatchedTransactionsFilter } from './types'; +import { GetMatchedTransactionsFilter, MatchedTransactionsPOJO } from './types'; import { GetMatchedTransactionsByExpenses } from './GetMatchedTransactionsByExpenses'; import { GetMatchedTransactionsByBills } from './GetMatchedTransactionsByBills'; import { GetMatchedTransactionsByManualJournals } from './GetMatchedTransactionsByManualJournals'; @@ -20,6 +20,9 @@ export class GetMatchedTransactions { @Inject() private getMatchedExpensesService: GetMatchedTransactionsByExpenses; + /** + * Registered matched transactions types. + */ get registered() { return [ { type: 'SaleInvoice', service: this.getMatchedInvoicesService }, @@ -37,7 +40,7 @@ export class GetMatchedTransactions { public async getMatchedTransactions( tenantId: number, filter: GetMatchedTransactionsFilter - ) { + ): Promise { const filtered = filter.transactionType ? this.registered.filter((item) => item.type === filter.transactionType) : this.registered; @@ -50,13 +53,3 @@ export class GetMatchedTransactions { return R.compose(R.flatten)(matchedTransactions?.results); } } - -interface MatchedTransaction { - amount: number; - amountFormatted: string; - date: string; - dateFormatted: string; - referenceNo: string; - transactionNo: string; - transactionId: number; -} diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByBills.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByBills.ts index 3e62bf319..7ac246bce 100644 --- a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByBills.ts +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByBills.ts @@ -1,12 +1,12 @@ +import { Inject, Service } from 'typedi'; import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; import { GetMatchedTransactionBillsTransformer } from './GetMatchedTransactionBillsTransformer'; -import { GetMatchedTransactionInvoicesTransformer } from './GetMatchedTransactionInvoicesTransformer'; import { GetMatchedTransactionsFilter } from './types'; import HasTenancyService from '@/services/Tenancy/TenancyService'; -import { Inject, Service } from 'typedi'; +import { GetMatchedTransactionsByType } from './GetMatchedTransactionsByType'; @Service() -export class GetMatchedTransactionsByBills { +export class GetMatchedTransactionsByBills extends GetMatchedTransactionsByType { @Inject() private tenancy: HasTenancyService; diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByExpenses.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByExpenses.ts index c28ce6900..8480e29b2 100644 --- a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByExpenses.ts +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByExpenses.ts @@ -3,14 +3,15 @@ import { GetMatchedTransactionManualJournalsTransformer } from './GetMatchedTran import { GetMatchedTransactionsFilter } from './types'; import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { GetMatchedTransactionsByType } from './GetMatchedTransactionsByType'; @Service() -export class GetMatchedTransactionsByExpenses { +export class GetMatchedTransactionsByExpenses extends GetMatchedTransactionsByType { @Inject() - private tenancy: HasTenancyService; + protected tenancy: HasTenancyService; @Inject() - private transformer: TransformerInjectable; + protected transformer: TransformerInjectable; /** * diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByInvoices.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByInvoices.ts index 73185e776..3e67a2180 100644 --- a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByInvoices.ts +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByInvoices.ts @@ -1,16 +1,21 @@ import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; import { GetMatchedTransactionInvoicesTransformer } from './GetMatchedTransactionInvoicesTransformer'; -import { GetMatchedTransactionsFilter } from './types'; +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 { +export class GetMatchedTransactionsByInvoices extends GetMatchedTransactionsByType { @Inject() - private tenancy: HasTenancyService; + protected tenancy: HasTenancyService; @Inject() - private transformer: TransformerInjectable; + protected transformer: TransformerInjectable; /** * Retrieves the matched transactions. @@ -20,7 +25,7 @@ export class GetMatchedTransactionsByInvoices { public async getMatchedTransactions( tenantId: number, filter: GetMatchedTransactionsFilter - ) { + ): Promise { const { SaleInvoice } = this.tenancy.models(tenantId); const invoices = await SaleInvoice.query(); @@ -31,4 +36,27 @@ export class GetMatchedTransactionsByInvoices { new GetMatchedTransactionInvoicesTransformer() ); } + + /** + * + * @param {number} tenantId + * @param {number} transactionId + * @returns + */ + public async getMatchedTransaction( + tenantId: number, + transactionId: number + ): Promise { + const { SaleInvoice } = this.tenancy.models(tenantId); + + console.log(transactionId); + + const invoice = await SaleInvoice.query().findById(transactionId); + + return this.transformer.transform( + tenantId, + invoice, + new GetMatchedTransactionInvoicesTransformer() + ); + } } diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByManualJournals.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByManualJournals.ts index ec33732eb..252ff1c82 100644 --- a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByManualJournals.ts +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByManualJournals.ts @@ -1,17 +1,20 @@ import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; import { GetMatchedTransactionManualJournalsTransformer } from './GetMatchedTransactionManualJournalsTransformer'; import { Inject, Service } from 'typedi'; -import HasTenancyService from '@/services/Tenancy/TenancyService'; import { GetMatchedTransactionsFilter } from './types'; +import { GetMatchedTransactionsByType } from './GetMatchedTransactionsByType'; @Service() -export class GetMatchedTransactionsByManualJournals { - @Inject() - private tenancy: HasTenancyService; - +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: GetMatchedTransactionsFilter @@ -26,4 +29,24 @@ export class GetMatchedTransactionsByManualJournals { 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) + .throwIfNotFound(); + + return this.transformer.transform( + tenantId, + manualJournal, + new GetMatchedTransactionManualJournalsTransformer() + ); + } } diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByType.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByType.ts new file mode 100644 index 000000000..2d3ed07f8 --- /dev/null +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByType.ts @@ -0,0 +1,65 @@ +import { Knex } from 'knex'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { + GetMatchedTransactionsFilter, + IMatchTransactionDTO, + MatchedTransactionPOJO, + MatchedTransactionsPOJO, +} from './types'; +import { Inject, Service } from 'typedi'; + +// @Service() +export abstract class GetMatchedTransactionsByType { + @Inject() + protected tenancy: HasTenancyService; + + /** + * Retrieves the matched transactions. + * @param {number} tenantId - + * @param {GetMatchedTransactionsFilter} filter - + */ + public async getMatchedTransactions( + tenantId: number, + filter: GetMatchedTransactionsFilter + ): Promise { + throw new Error( + 'The `getMatchedTransactions` method is not defined for the transaction type.' + ); + } + + /** + * Retrieves the matched transaction details. + * @param {number} tenantId - + * @param {number} transactionId - + */ + public async getMatchedTransaction( + tenantId: number, + transactionId: number + ): Promise { + 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, + }); + } +} diff --git a/packages/server/src/services/Banking/Matching/MatchTransactions.ts b/packages/server/src/services/Banking/Matching/MatchTransactions.ts index 8f9ac6098..eedbd80d7 100644 --- a/packages/server/src/services/Banking/Matching/MatchTransactions.ts +++ b/packages/server/src/services/Banking/Matching/MatchTransactions.ts @@ -1,3 +1,4 @@ +import { sumBy } from 'lodash'; import { PromisePool } from '@supercharge/promise-pool'; import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; import HasTenancyService from '@/services/Tenancy/TenancyService'; @@ -6,10 +7,13 @@ import events from '@/subscribers/events'; import { Knex } from 'knex'; import { Inject, Service } from 'typedi'; import { + ERRORS, IBankTransactionMatchedEventPayload, IBankTransactionMatchingEventPayload, - IMatchTransactionDTO, + IMatchTransactionsDTO, } from './types'; +import { MatchTransactionsTypes } from './MatchTransactionsTypes'; +import { ServiceError } from '@/exceptions'; @Service() export class MatchBankTransactions { @@ -22,22 +26,90 @@ export class MatchBankTransactions { @Inject() private eventPublisher: EventPublisher; + @Inject() + private matchedBankTransactions: MatchTransactionsTypes; + + /** + * Validates the match bank transactions DTO. + * @param {number} tenantId + * @param {number} uncategorizedTransactionId + * @param {IMatchTransactionsDTO} matchTransactionsDTO + */ + async validate( + tenantId: number, + uncategorizedTransactionId: number, + matchTransactionsDTO: IMatchTransactionsDTO + ) { + const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId); + const { matchedTransactions } = matchTransactionsDTO; + + const uncategorizedTransaction = + await UncategorizedCashflowTransaction.query() + .findById(uncategorizedTransactionId) + .throwIfNotFound(); + + // 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 */ - public matchTransaction( + public async matchTransaction( tenantId: number, uncategorizedTransactionId: number, - matchTransactionsDTO: IMatchTransactionDTO + matchTransactionsDTO: IMatchTransactionsDTO ) { const { matchedTransactions } = matchTransactionsDTO; - const { MatchBankTransaction } = this.tenancy.models(tenantId); - // + // Validates the given matching transactions DTO. + await this.validate( + tenantId, + uncategorizedTransactionId, + matchTransactionsDTO + ); return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => { - // Triggers the event `onSaleInvoiceCreated`. + // Triggers the event `onBankTransactionMatching`. await this.eventPublisher.emitAsync(events.bankMatch.onMatching, { tenantId, uncategorizedTransactionId, @@ -45,19 +117,23 @@ export class MatchBankTransactions { trx, } as IBankTransactionMatchingEventPayload); - // Matches the given transactions under promise pool concurrency controlling. + // Matches the given transactions under promise pool concurrency controlling. await PromisePool.withConcurrency(10) .for(matchedTransactions) .process(async (matchedTransaction) => { - await MatchBankTransaction.query(trx).insert({ + const getMatchedTransactionsService = + this.matchedBankTransactions.registry.get( + matchedTransaction.referenceType + ); + await getMatchedTransactionsService.createMatchedTransaction( + tenantId, uncategorizedTransactionId, - referenceType: matchedTransaction.referenceType, - referenceId: matchedTransaction.referenceId, - amount: matchedTransaction.amount, - }); + matchedTransaction, + trx + ); }); - // Triggers the event `onSaleInvoiceCreated`. + // Triggers the event `onBankTransactionMatched`. await this.eventPublisher.emitAsync(events.bankMatch.onMatched, { tenantId, uncategorizedTransactionId, diff --git a/packages/server/src/services/Banking/Matching/MatchTransactionsTypes.ts b/packages/server/src/services/Banking/Matching/MatchTransactionsTypes.ts new file mode 100644 index 000000000..6c5c938d4 --- /dev/null +++ b/packages/server/src/services/Banking/Matching/MatchTransactionsTypes.ts @@ -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; + } + } +} diff --git a/packages/server/src/services/Banking/Matching/MatchTransactionsTypesRegistry.ts b/packages/server/src/services/Banking/Matching/MatchTransactionsTypesRegistry.ts new file mode 100644 index 000000000..a64ff0d6b --- /dev/null +++ b/packages/server/src/services/Banking/Matching/MatchTransactionsTypesRegistry.ts @@ -0,0 +1,50 @@ +import { camelCase, upperFirst } from 'lodash'; +import { GetMatchedTransactionsByType } from './GetMatchedTransactionsByType'; + +export class MatchTransactionsTypesRegistry { + private static instance: MatchTransactionsTypesRegistry; + private importables: Record; + + 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)); + } +} diff --git a/packages/server/src/services/Banking/Matching/types.ts b/packages/server/src/services/Banking/Matching/types.ts index c26e273ec..6fedf862f 100644 --- a/packages/server/src/services/Banking/Matching/types.ts +++ b/packages/server/src/services/Banking/Matching/types.ts @@ -3,14 +3,14 @@ import { Knex } from 'knex'; export interface IBankTransactionMatchingEventPayload { tenantId: number; uncategorizedTransactionId: number; - matchTransactionsDTO: IMatchTransactionDTO; + matchTransactionsDTO: IMatchTransactionsDTO; trx?: Knex.Transaction; } export interface IBankTransactionMatchedEventPayload { tenantId: number; uncategorizedTransactionId: number; - matchTransactionsDTO: IMatchTransactionDTO; + matchTransactionsDTO: IMatchTransactionsDTO; trx?: Knex.Transaction; } @@ -23,11 +23,12 @@ export interface IBankTransactionUnmatchedEventPayload { } export interface IMatchTransactionDTO { - matchedTransactions: Array<{ - referenceType: string; - referenceId: number; - amount: number; - }>; + referenceType: string; + referenceId: number; +} + +export interface IMatchTransactionsDTO { + matchedTransactions: Array; } export interface GetMatchedTransactionsFilter { @@ -37,3 +38,24 @@ export interface GetMatchedTransactionsFilter { maxAmount: number; transactionType: string; } + +export interface MatchedTransactionPOJO { + amount: number; + amountFormatted: string; + date: string; + dateFormatted: string; + referenceNo: string; + transactionNo: string; + transactionId: number; +} + +export type MatchedTransactionsPOJO = Array; + +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', +}; From ca403872b3e7b07848d17a8d5bd0833da0bf862c Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Fri, 21 Jun 2024 11:33:03 +0200 Subject: [PATCH 08/42] feat: exclude bank transaction --- .../Cashflow/GetCashflowAccounts.ts | 19 ------------------- .../Banking/Exclude/ExcludeBankTransaction.ts | 12 ++++++------ .../Exclude/UnexcludeBankTransaction.ts | 12 ++++++------ 3 files changed, 12 insertions(+), 31 deletions(-) diff --git a/packages/server/src/api/controllers/Cashflow/GetCashflowAccounts.ts b/packages/server/src/api/controllers/Cashflow/GetCashflowAccounts.ts index 559a5f4f2..6e0d2dfec 100644 --- a/packages/server/src/api/controllers/Cashflow/GetCashflowAccounts.ts +++ b/packages/server/src/api/controllers/Cashflow/GetCashflowAccounts.ts @@ -31,7 +31,6 @@ export default class GetCashflowAccounts extends BaseController { query('search_keyword').optional({ nullable: true }).isString().trim(), ], this.asyncMiddleware(this.getCashflowAccounts), - this.catchServiceErrors ); return router; } @@ -67,22 +66,4 @@ export default class GetCashflowAccounts extends BaseController { next(error); } }; - - /** - * Catches the service errors. - * @param {Error} error - Error. - * @param {Request} req - Request. - * @param {Response} res - Response. - * @param {NextFunction} next - - */ - private catchServiceErrors( - error, - req: Request, - res: Response, - next: NextFunction - ) { - if (error instanceof ServiceError) { - } - next(error); - } } diff --git a/packages/server/src/services/Banking/Exclude/ExcludeBankTransaction.ts b/packages/server/src/services/Banking/Exclude/ExcludeBankTransaction.ts index 7d23a0f22..39d53d6cd 100644 --- a/packages/server/src/services/Banking/Exclude/ExcludeBankTransaction.ts +++ b/packages/server/src/services/Banking/Exclude/ExcludeBankTransaction.ts @@ -19,20 +19,20 @@ export class ExcludeBankTransaction { */ public async excludeBankTransaction( tenantId: number, - bankTransactionId: number + uncategorizedTransactionId: number ) { - const { UncategorizeCashflowTransaction } = this.tenancy.models(tenantId); + const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId); const oldUncategorizedTransaction = - await UncategorizeCashflowTransaction.query() - .findById(bankTransactionId) + await UncategorizedCashflowTransaction.query() + .findById(uncategorizedTransactionId) .throwIfNotFound(); validateTransactionNotCategorized(oldUncategorizedTransaction); return this.uow.withTransaction(tenantId, async (trx) => { - await UncategorizeCashflowTransaction.query(trx) - .findById(bankTransactionId) + await UncategorizedCashflowTransaction.query(trx) + .findById(uncategorizedTransactionId) .patch({ excluded: true, }); diff --git a/packages/server/src/services/Banking/Exclude/UnexcludeBankTransaction.ts b/packages/server/src/services/Banking/Exclude/UnexcludeBankTransaction.ts index 159fe373a..9dd23c1a6 100644 --- a/packages/server/src/services/Banking/Exclude/UnexcludeBankTransaction.ts +++ b/packages/server/src/services/Banking/Exclude/UnexcludeBankTransaction.ts @@ -19,20 +19,20 @@ export class UnexcludeBankTransaction { */ public async unexcludeBankTransaction( tenantId: number, - bankTransactionId: number + uncategorizedTransactionId: number ) { - const { UncategorizeCashflowTransaction } = this.tenancy.models(tenantId); + const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId); const oldUncategorizedTransaction = - await UncategorizeCashflowTransaction.query() - .findById(bankTransactionId) + await UncategorizedCashflowTransaction.query() + .findById(uncategorizedTransactionId) .throwIfNotFound(); validateTransactionNotCategorized(oldUncategorizedTransaction); return this.uow.withTransaction(tenantId, async (trx) => { - await UncategorizeCashflowTransaction.query(trx) - .findById(bankTransactionId) + await UncategorizedCashflowTransaction.query(trx) + .findById(uncategorizedTransactionId) .patch({ excluded: false, }); From 589b29bbdd1886b8457d70b6d6a0a26d144c61dd Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sun, 23 Jun 2024 14:34:40 +0200 Subject: [PATCH 09/42] feat: validate the matched linked transacation on deleting. --- .../BankTransactionsMatchingController.ts | 2 - .../Banking/BankingRulesController.ts | 2 + ...eate_recognized_bank_transactions_table.js | 2 +- packages/server/src/loaders/eventEmitter.ts | 12 +++++ .../UncategorizedCashflowTransaction.ts | 4 +- .../GetMatchedTransactionsByInvoices.ts | 7 ++- .../Banking/Matching/MatchTransactions.ts | 17 +++++-- .../Matching/UnmatchMatchedTransaction.ts | 10 +++- .../Matching/ValidateTransactionsMatched.ts | 33 +++++++++++++ .../ValidateMatchingOnCashflowDelete.ts | 36 ++++++++++++++ .../events/ValidateMatchingOnExpenseDelete.ts | 36 ++++++++++++++ .../ValidateMatchingOnManualJournalDelete.ts | 36 ++++++++++++++ .../ValidateMatchingOnPaymentMadeDelete.ts | 39 +++++++++++++++ ...ValidateMatchingOnPaymentReceivedDelete.ts | 36 ++++++++++++++ .../src/services/Banking/Matching/types.ts | 4 +- .../RecognizeTranasctionsService.ts | 2 +- .../events/TriggerRecognizedTransactions.ts | 47 +++++++++++++++++-- .../src/services/Banking/Rules/types.ts | 2 + 18 files changed, 307 insertions(+), 20 deletions(-) create mode 100644 packages/server/src/services/Banking/Matching/ValidateTransactionsMatched.ts create mode 100644 packages/server/src/services/Banking/Matching/events/ValidateMatchingOnCashflowDelete.ts create mode 100644 packages/server/src/services/Banking/Matching/events/ValidateMatchingOnExpenseDelete.ts create mode 100644 packages/server/src/services/Banking/Matching/events/ValidateMatchingOnManualJournalDelete.ts create mode 100644 packages/server/src/services/Banking/Matching/events/ValidateMatchingOnPaymentMadeDelete.ts create mode 100644 packages/server/src/services/Banking/Matching/events/ValidateMatchingOnPaymentReceivedDelete.ts diff --git a/packages/server/src/api/controllers/Banking/BankTransactionsMatchingController.ts b/packages/server/src/api/controllers/Banking/BankTransactionsMatchingController.ts index d49fef2ef..437152140 100644 --- a/packages/server/src/api/controllers/Banking/BankTransactionsMatchingController.ts +++ b/packages/server/src/api/controllers/Banking/BankTransactionsMatchingController.ts @@ -23,11 +23,9 @@ export class BankTransactionsMatchingController extends BaseController { '/:transactionId', [ param('transactionId').exists(), - body('matchedTransactions').isArray({ min: 1 }), body('matchedTransactions.*.reference_type').exists(), body('matchedTransactions.*.reference_id').isNumeric().toInt(), - body('matchedTransactions.*.amount').exists().isNumeric().toFloat(), ], this.validationResult, this.matchBankTransaction.bind(this) diff --git a/packages/server/src/api/controllers/Banking/BankingRulesController.ts b/packages/server/src/api/controllers/Banking/BankingRulesController.ts index 360dbcad6..0fb91c0b1 100644 --- a/packages/server/src/api/controllers/Banking/BankingRulesController.ts +++ b/packages/server/src/api/controllers/Banking/BankingRulesController.ts @@ -50,6 +50,8 @@ export class BankingRulesController extends BaseController { body('assign_account_id').isInt({ min: 0 }), body('assign_payee').isString().optional({ nullable: true }), body('assign_memo').isString().optional({ nullable: true }), + + body('recognition').isBoolean().toBoolean().optional({ nullable: true }), ]; } diff --git a/packages/server/src/database/migrations/20240618171553_create_recognized_bank_transactions_table.js b/packages/server/src/database/migrations/20240618171553_create_recognized_bank_transactions_table.js index 223ff403e..aed658e65 100644 --- a/packages/server/src/database/migrations/20240618171553_create_recognized_bank_transactions_table.js +++ b/packages/server/src/database/migrations/20240618171553_create_recognized_bank_transactions_table.js @@ -2,7 +2,7 @@ exports.up = function (knex) { return knex.schema.createTable('recognized_bank_transactions', (table) => { table.increments('id'); table - .integer('cashflow_transaction_id') + .integer('uncategorized_transaction_id') .unsigned() .references('id') .inTable('uncategorized_cashflow_transactions'); diff --git a/packages/server/src/loaders/eventEmitter.ts b/packages/server/src/loaders/eventEmitter.ts index 22a5e1988..fd1586db1 100644 --- a/packages/server/src/loaders/eventEmitter.ts +++ b/packages/server/src/loaders/eventEmitter.ts @@ -103,6 +103,11 @@ import { AttachmentsOnCreditNote } from '@/services/Attachments/events/Attachmen import { AttachmentsOnBillPayments } from '@/services/Attachments/events/AttachmentsOnPaymentsMade'; import { AttachmentsOnSaleEstimates } from '@/services/Attachments/events/AttachmentsOnSaleEstimates'; import { TriggerRecognizedTransactions } from '@/services/Banking/RegonizeTranasctions/events/TriggerRecognizedTransactions'; +import { ValidateMatchingOnExpenseDelete } from '@/services/Banking/Matching/events/ValidateMatchingOnExpenseDelete'; +import { ValidateMatchingOnManualJournalDelete } from '@/services/Banking/Matching/events/ValidateMatchingOnManualJournalDelete'; +import { ValidateMatchingOnPaymentReceivedDelete } from '@/services/Banking/Matching/events/ValidateMatchingOnPaymentReceivedDelete'; +import { ValidateMatchingOnPaymentMadeDelete } from '@/services/Banking/Matching/events/ValidateMatchingOnPaymentMadeDelete'; +import { ValidateMatchingOnCashflowDelete } from '@/services/Banking/Matching/events/ValidateMatchingOnCashflowDelete'; export default () => { return new EventPublisher(); @@ -250,5 +255,12 @@ export const susbcribers = () => { // Bank Rules TriggerRecognizedTransactions, + + // Validate matching + ValidateMatchingOnCashflowDelete, + ValidateMatchingOnExpenseDelete, + ValidateMatchingOnManualJournalDelete, + ValidateMatchingOnPaymentReceivedDelete, + ValidateMatchingOnPaymentMadeDelete, ]; }; diff --git a/packages/server/src/models/UncategorizedCashflowTransaction.ts b/packages/server/src/models/UncategorizedCashflowTransaction.ts index 97da9eea1..3d70ff7d0 100644 --- a/packages/server/src/models/UncategorizedCashflowTransaction.ts +++ b/packages/server/src/models/UncategorizedCashflowTransaction.ts @@ -127,8 +127,8 @@ export default class UncategorizedCashflowTransaction extends mixin( /** * Uncategorized transaction may has association to matched transaction. */ - matchedBankTransaction: { - relation: Model.BelongsToOneRelation, + matchedBankTransactions: { + relation: Model.HasManyRelation, modelClass: MatchedBankTransaction, join: { from: 'uncategorized_cashflow_transactions.id', diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByInvoices.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByInvoices.ts index 3e67a2180..89f1c3307 100644 --- a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByInvoices.ts +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByInvoices.ts @@ -21,6 +21,7 @@ export class GetMatchedTransactionsByInvoices extends GetMatchedTransactionsByTy * Retrieves the matched transactions. * @param {number} tenantId - * @param {GetMatchedTransactionsFilter} filter - + * @returns {Promise} */ public async getMatchedTransactions( tenantId: number, @@ -38,10 +39,10 @@ export class GetMatchedTransactionsByInvoices extends GetMatchedTransactionsByTy } /** - * + * Retrieves the matched transaction. * @param {number} tenantId * @param {number} transactionId - * @returns + * @returns {Promise} */ public async getMatchedTransaction( tenantId: number, @@ -49,8 +50,6 @@ export class GetMatchedTransactionsByInvoices extends GetMatchedTransactionsByTy ): Promise { const { SaleInvoice } = this.tenancy.models(tenantId); - console.log(transactionId); - const invoice = await SaleInvoice.query().findById(transactionId); return this.transformer.transform( diff --git a/packages/server/src/services/Banking/Matching/MatchTransactions.ts b/packages/server/src/services/Banking/Matching/MatchTransactions.ts index eedbd80d7..a86a17953 100644 --- a/packages/server/src/services/Banking/Matching/MatchTransactions.ts +++ b/packages/server/src/services/Banking/Matching/MatchTransactions.ts @@ -1,11 +1,11 @@ -import { sumBy } from 'lodash'; +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 { Knex } from 'knex'; -import { Inject, Service } from 'typedi'; import { ERRORS, IBankTransactionMatchedEventPayload, @@ -34,6 +34,7 @@ export class MatchBankTransactions { * @param {number} tenantId * @param {number} uncategorizedTransactionId * @param {IMatchTransactionsDTO} matchTransactionsDTO + * @returns {Promise} */ async validate( tenantId: number, @@ -43,11 +44,21 @@ export class MatchBankTransactions { 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 = diff --git a/packages/server/src/services/Banking/Matching/UnmatchMatchedTransaction.ts b/packages/server/src/services/Banking/Matching/UnmatchMatchedTransaction.ts index 98c86d6e6..0f4a1def7 100644 --- a/packages/server/src/services/Banking/Matching/UnmatchMatchedTransaction.ts +++ b/packages/server/src/services/Banking/Matching/UnmatchMatchedTransaction.ts @@ -1,8 +1,8 @@ +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 { Inject, Service } from 'typedi'; import { IBankTransactionUnmatchingEventPayload } from './types'; @Service() @@ -16,10 +16,16 @@ export class UnmatchMatchedBankTransaction { @Inject() private eventPublisher: EventPublisher; + /** + * Unmatch the matched the given uncategorized bank transaction. + * @param {number} tenantId + * @param {number} uncategorizedTransactionId + * @returns {Promise} + */ public unmatchMatchedTransaction( tenantId: number, uncategorizedTransactionId: number - ) { + ): Promise { const { MatchedBankTransaction } = this.tenancy.models(tenantId); return this.uow.withTransaction(tenantId, async (trx) => { diff --git a/packages/server/src/services/Banking/Matching/ValidateTransactionsMatched.ts b/packages/server/src/services/Banking/Matching/ValidateTransactionsMatched.ts new file mode 100644 index 000000000..372ae424c --- /dev/null +++ b/packages/server/src/services/Banking/Matching/ValidateTransactionsMatched.ts @@ -0,0 +1,33 @@ +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; + + /** + * + * @param {number} tenantId + * @param {string} referenceType + * @param {number} referenceId + */ + 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); + } + } +} diff --git a/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnCashflowDelete.ts b/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnCashflowDelete.ts new file mode 100644 index 000000000..d41f2be11 --- /dev/null +++ b/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnCashflowDelete.ts @@ -0,0 +1,36 @@ +import { IManualJournalDeletingPayload } from '@/interfaces'; +import events from '@/subscribers/events'; +import { ValidateTransactionMatched } from '../ValidateTransactionsMatched'; +import { Inject, Service } from 'typedi'; + +@Service() +export class ValidateMatchingOnCashflowDelete { + @Inject() + private validateNoMatchingLinkedService: ValidateTransactionMatched; + + /** + * Constructor method. + */ + public attach(bus) { + bus.subscribe( + events.cashflow.onTransactionDeleting, + this.validateMatchingOnCashflowDelete.bind(this) + ); + } + + /** + * + * @param {IManualJournalDeletingPayload} + */ + public async validateMatchingOnCashflowDelete({ + tenantId, + oldManualJournal, + trx, + }: IManualJournalDeletingPayload) { + await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking( + tenantId, + 'ManualJournal', + oldManualJournal.id + ); + } +} diff --git a/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnExpenseDelete.ts b/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnExpenseDelete.ts new file mode 100644 index 000000000..2e45c0159 --- /dev/null +++ b/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnExpenseDelete.ts @@ -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.validateMatchingOnExpenseDelete.bind(this) + ); + } + + /** + * + * @param {IExpenseEventDeletePayload} + */ + public async validateMatchingOnExpenseDelete({ + tenantId, + oldExpense, + trx, + }: IExpenseEventDeletePayload) { + await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking( + tenantId, + 'Expense', + oldExpense.id + ); + } +} diff --git a/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnManualJournalDelete.ts b/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnManualJournalDelete.ts new file mode 100644 index 000000000..f4ebfbeca --- /dev/null +++ b/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnManualJournalDelete.ts @@ -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.validateMatchingOnManualJournalDelete.bind(this) + ); + } + + /** + * + * @param {IManualJournalDeletingPayload} + */ + public async validateMatchingOnManualJournalDelete({ + tenantId, + oldManualJournal, + trx, + }: IManualJournalDeletingPayload) { + await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking( + tenantId, + 'ManualJournal', + oldManualJournal.id + ); + } +} diff --git a/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnPaymentMadeDelete.ts b/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnPaymentMadeDelete.ts new file mode 100644 index 000000000..0188ed9dd --- /dev/null +++ b/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnPaymentMadeDelete.ts @@ -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.validateMatchingOnPaymentMadeDelete.bind(this) + ); + } + + /** + * + * @param {IPaymentReceiveDeletedPayload} + */ + public async validateMatchingOnPaymentMadeDelete({ + tenantId, + oldBillPayment, + trx, + }: IBillPaymentEventDeletedPayload) { + await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking( + tenantId, + 'PaymentMade', + oldBillPayment.id + ); + } +} diff --git a/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnPaymentReceivedDelete.ts b/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnPaymentReceivedDelete.ts new file mode 100644 index 000000000..e94020bb3 --- /dev/null +++ b/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnPaymentReceivedDelete.ts @@ -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.validateMatchingOnPaymentReceivedDelete.bind(this) + ); + } + + /** + * + * @param {IPaymentReceiveDeletedPayload} + */ + public async validateMatchingOnPaymentReceivedDelete({ + tenantId, + oldPaymentReceive, + trx, + }: IPaymentReceiveDeletedPayload) { + await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking( + tenantId, + 'PaymentReceive', + oldPaymentReceive.id + ); + } +} diff --git a/packages/server/src/services/Banking/Matching/types.ts b/packages/server/src/services/Banking/Matching/types.ts index 6fedf862f..74b64d0a7 100644 --- a/packages/server/src/services/Banking/Matching/types.ts +++ b/packages/server/src/services/Banking/Matching/types.ts @@ -56,6 +56,8 @@ export const ERRORS = { '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' }; diff --git a/packages/server/src/services/Banking/RegonizeTranasctions/RecognizeTranasctionsService.ts b/packages/server/src/services/Banking/RegonizeTranasctions/RecognizeTranasctionsService.ts index 74832fbbb..32b8a2542 100644 --- a/packages/server/src/services/Banking/RegonizeTranasctions/RecognizeTranasctionsService.ts +++ b/packages/server/src/services/Banking/RegonizeTranasctions/RecognizeTranasctionsService.ts @@ -32,7 +32,7 @@ export class RecognizeTranasctionsService { trx ).insert({ bankRuleId: bankRule.id, - cashflowTransactionId: transaction.id, + uncategorizedTransactionId: transaction.id, assignedCategory: bankRule.assignCategory, assignedAccountId: bankRule.assignAccountId, assignedPayee: bankRule.assignPayee, diff --git a/packages/server/src/services/Banking/RegonizeTranasctions/events/TriggerRecognizedTransactions.ts b/packages/server/src/services/Banking/RegonizeTranasctions/events/TriggerRecognizedTransactions.ts index 3094cc520..bb8c87b43 100644 --- a/packages/server/src/services/Banking/RegonizeTranasctions/events/TriggerRecognizedTransactions.ts +++ b/packages/server/src/services/Banking/RegonizeTranasctions/events/TriggerRecognizedTransactions.ts @@ -2,6 +2,7 @@ import { Inject, Service } from 'typedi'; import events from '@/subscribers/events'; import { IBankRuleEventCreatedPayload, + IBankRuleEventDeletedPayload, IBankRuleEventEditedPayload, } from '../../Rules/types'; @@ -20,17 +21,55 @@ export class TriggerRecognizedTransactions { ); bus.subscribe( events.bankRules.onEdited, - this.recognizedTransactionsOnRuleCreated.bind(this) + this.recognizedTransactionsOnRuleEdited.bind(this) + ); + bus.subscribe( + events.bankRules.onDeleted, + this.recognizedTransactionsOnRuleDeleted.bind(this) ); } /** - * Triggers the recognize uncategorized transactions job. - * @param {IBankRuleEventEditedPayload | IBankRuleEventCreatedPayload} payload - + * Triggers the recognize uncategorized transactions job on rule created. + * @param {IBankRuleEventCreatedPayload} payload - */ private async recognizedTransactionsOnRuleCreated({ tenantId, - }: IBankRuleEventEditedPayload | IBankRuleEventCreatedPayload) { + 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); } diff --git a/packages/server/src/services/Banking/Rules/types.ts b/packages/server/src/services/Banking/Rules/types.ts index 0701345f0..a2435204a 100644 --- a/packages/server/src/services/Banking/Rules/types.ts +++ b/packages/server/src/services/Banking/Rules/types.ts @@ -70,6 +70,8 @@ export interface IBankRuleCommonDTO { assignAccountId: number; assignPayee?: string; assignMemo?: string; + + recognition?: boolean; } export interface ICreateBankRuleDTO extends IBankRuleCommonDTO {} From 8dc2b187077bd4c6a705d76f90c6a38dc38c621c Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sun, 23 Jun 2024 18:49:46 +0200 Subject: [PATCH 10/42] feat: recognize the syncd bank transactions --- ...categorized_cashflow_transactions_table.js | 11 +++++ packages/server/src/interfaces/CashFlow.ts | 1 + packages/server/src/interfaces/Plaid.ts | 9 ++++ packages/server/src/loaders/eventEmitter.ts | 4 ++ .../GetMatchedTransactionsByExpenses.ts | 23 +++++++--- .../GetMatchedTransactionsByManualJournals.ts | 26 +++++++++--- .../ValidateMatchingOnCashflowDelete.ts | 2 +- .../src/services/Banking/Plaid/PlaidSyncDB.ts | 36 +++++++++++----- .../RecognizeSyncedBankTransactions.ts | 42 +++++++++++++++++++ .../RecognizeTranasctionsService.ts | 19 ++++++--- .../services/Banking/Rules/DeleteBankRule.ts | 3 +- packages/server/src/subscribers/events.ts | 3 +- 12 files changed, 150 insertions(+), 29 deletions(-) create mode 100644 packages/server/src/database/migrations/20240623154149_add_batch_column_to_uncategorized_cashflow_transactions_table.js create mode 100644 packages/server/src/services/Banking/Plaid/subscribers/RecognizeSyncedBankTransactions.ts diff --git a/packages/server/src/database/migrations/20240623154149_add_batch_column_to_uncategorized_cashflow_transactions_table.js b/packages/server/src/database/migrations/20240623154149_add_batch_column_to_uncategorized_cashflow_transactions_table.js new file mode 100644 index 000000000..7641da878 --- /dev/null +++ b/packages/server/src/database/migrations/20240623154149_add_batch_column_to_uncategorized_cashflow_transactions_table.js @@ -0,0 +1,11 @@ +exports.up = function (knex) { + return knex.schema.table('uncategorized_cashflow_transactions', (table) => { + table.string('batch'); + }); +}; + +exports.down = function (knex) { + return knex.schema.table('uncategorized_cashflow_transactions', (table) => { + table.dropColumn('batch'); + }); +}; diff --git a/packages/server/src/interfaces/CashFlow.ts b/packages/server/src/interfaces/CashFlow.ts index 499c526b0..ce9bbf5b2 100644 --- a/packages/server/src/interfaces/CashFlow.ts +++ b/packages/server/src/interfaces/CashFlow.ts @@ -267,4 +267,5 @@ export interface CreateUncategorizedTransactionDTO { description?: string; referenceNo?: string | null; plaidTransactionId?: string | null; + batch?: string; } diff --git a/packages/server/src/interfaces/Plaid.ts b/packages/server/src/interfaces/Plaid.ts index a8ad469df..6142dab93 100644 --- a/packages/server/src/interfaces/Plaid.ts +++ b/packages/server/src/interfaces/Plaid.ts @@ -1,3 +1,5 @@ +import { Knex } from "knex"; + export interface IPlaidItemCreatedEventPayload { tenantId: number; plaidAccessToken: string; @@ -54,3 +56,10 @@ export interface SyncAccountsTransactionsTask { plaidAccountId: number; plaidTransactions: PlaidTransaction[]; } + +export interface IPlaidTransactionsSyncedEventPayload { + tenantId: number; + plaidAccountId: number; + batch: string; + trx?: Knex.Transaction +} diff --git a/packages/server/src/loaders/eventEmitter.ts b/packages/server/src/loaders/eventEmitter.ts index fd1586db1..2e5f46c3f 100644 --- a/packages/server/src/loaders/eventEmitter.ts +++ b/packages/server/src/loaders/eventEmitter.ts @@ -108,6 +108,7 @@ import { ValidateMatchingOnManualJournalDelete } from '@/services/Banking/Matchi import { ValidateMatchingOnPaymentReceivedDelete } from '@/services/Banking/Matching/events/ValidateMatchingOnPaymentReceivedDelete'; import { ValidateMatchingOnPaymentMadeDelete } from '@/services/Banking/Matching/events/ValidateMatchingOnPaymentMadeDelete'; import { ValidateMatchingOnCashflowDelete } from '@/services/Banking/Matching/events/ValidateMatchingOnCashflowDelete'; +import { RecognizeSyncedBankTranasctions } from '@/services/Banking/Plaid/subscribers/RecognizeSyncedBankTransactions'; export default () => { return new EventPublisher(); @@ -262,5 +263,8 @@ export const susbcribers = () => { ValidateMatchingOnManualJournalDelete, ValidateMatchingOnPaymentReceivedDelete, ValidateMatchingOnPaymentMadeDelete, + + // Plaid + RecognizeSyncedBankTranasctions, ]; }; diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByExpenses.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByExpenses.ts index 8480e29b2..9205ce2e8 100644 --- a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByExpenses.ts +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByExpenses.ts @@ -1,9 +1,9 @@ import { Inject, Service } from 'typedi'; -import { GetMatchedTransactionManualJournalsTransformer } from './GetMatchedTransactionManualJournalsTransformer'; import { GetMatchedTransactionsFilter } 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 { @@ -14,7 +14,7 @@ export class GetMatchedTransactionsByExpenses extends GetMatchedTransactionsByTy protected transformer: TransformerInjectable; /** - * + * Retrieves the matched transactions of expenses. * @param {number} tenantId * @param {GetMatchedTransactionsFilter} filter * @returns @@ -25,12 +25,25 @@ export class GetMatchedTransactionsByExpenses extends GetMatchedTransactionsByTy ) { const { Expense } = this.tenancy.models(tenantId); - const expenses = await Expense.query(); - + 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 GetMatchedTransactionManualJournalsTransformer() + new GetMatchedTransactionExpensesTransformer() ); } } diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByManualJournals.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByManualJournals.ts index 252ff1c82..2aa6341af 100644 --- a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByManualJournals.ts +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByManualJournals.ts @@ -1,8 +1,8 @@ +import { Inject, Service } from 'typedi'; import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; import { GetMatchedTransactionManualJournalsTransformer } from './GetMatchedTransactionManualJournalsTransformer'; -import { Inject, Service } from 'typedi'; -import { GetMatchedTransactionsFilter } from './types'; import { GetMatchedTransactionsByType } from './GetMatchedTransactionsByType'; +import { GetMatchedTransactionsFilter } from './types'; @Service() export class GetMatchedTransactionsByManualJournals extends GetMatchedTransactionsByType { @@ -17,12 +17,27 @@ export class GetMatchedTransactionsByManualJournals extends GetMatchedTransactio */ async getMatchedTransactions( tenantId: number, - filter: GetMatchedTransactionsFilter + filter: Omit ) { const { ManualJournal } = this.tenancy.models(tenantId); - const manualJournals = await ManualJournal.query(); - + 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, @@ -41,6 +56,7 @@ export class GetMatchedTransactionsByManualJournals extends GetMatchedTransactio const manualJournal = await ManualJournal.query() .findById(transactionId) + .whereNotExists(ManualJournal.relatedQuery('matchedBankTransaction')) .throwIfNotFound(); return this.transformer.transform( diff --git a/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnCashflowDelete.ts b/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnCashflowDelete.ts index d41f2be11..9d9a8e965 100644 --- a/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnCashflowDelete.ts +++ b/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnCashflowDelete.ts @@ -1,7 +1,7 @@ +import { Inject, Service } from 'typedi'; import { IManualJournalDeletingPayload } from '@/interfaces'; import events from '@/subscribers/events'; import { ValidateTransactionMatched } from '../ValidateTransactionsMatched'; -import { Inject, Service } from 'typedi'; @Service() export class ValidateMatchingOnCashflowDelete { diff --git a/packages/server/src/services/Banking/Plaid/PlaidSyncDB.ts b/packages/server/src/services/Banking/Plaid/PlaidSyncDB.ts index 004662cfd..3070c22f9 100644 --- a/packages/server/src/services/Banking/Plaid/PlaidSyncDB.ts +++ b/packages/server/src/services/Banking/Plaid/PlaidSyncDB.ts @@ -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} */ public async syncAccountTranactions( tenantId: number, @@ -101,18 +109,14 @@ export class PlaidSyncDb { ): Promise { 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} */ public async syncAccountsTransactions( tenantId: number, + batchNo: string, plaidAccountsTransactions: PlaidTransaction[], trx?: Knex.Transaction ): Promise { @@ -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} */ public async syncTransactionsCursor( tenantId: number, plaidItemId: string, lastCursor: string, trx?: Knex.Transaction - ) { + ): Promise { 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} */ public async updateLastFeedsUpdatedAt( tenantId: number, plaidAccountIds: string[], trx?: Knex.Transaction - ) { + ): Promise { 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} */ public async updateAccountsFeedsActive( tenantId: number, plaidAccountIds: string[], isFeedsActive: boolean = true, trx?: Knex.Transaction - ) { + ): Promise { const { Account } = this.tenancy.models(tenantId); await Account.query(trx) diff --git a/packages/server/src/services/Banking/Plaid/subscribers/RecognizeSyncedBankTransactions.ts b/packages/server/src/services/Banking/Plaid/subscribers/RecognizeSyncedBankTransactions.ts new file mode 100644 index 000000000..42104aafc --- /dev/null +++ b/packages/server/src/services/Banking/Plaid/subscribers/RecognizeSyncedBankTransactions.ts @@ -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 + ); + }); + }; +} diff --git a/packages/server/src/services/Banking/RegonizeTranasctions/RecognizeTranasctionsService.ts b/packages/server/src/services/Banking/RegonizeTranasctions/RecognizeTranasctionsService.ts index 32b8a2542..77055ad34 100644 --- a/packages/server/src/services/Banking/RegonizeTranasctions/RecognizeTranasctionsService.ts +++ b/packages/server/src/services/Banking/RegonizeTranasctions/RecognizeTranasctionsService.ts @@ -50,14 +50,21 @@ export class RecognizeTranasctionsService { * @param {number} tenantId - * @param {Knex.Transaction} trx - */ - public async recognizeTransactions(tenantId: number, trx?: Knex.Transaction) { + public async recognizeTransactions( + tenantId: number, + batch: string = '', + trx?: Knex.Transaction + ) { const { UncategorizedCashflowTransaction, BankRule } = this.tenancy.models(tenantId); const uncategorizedTranasctions = - await UncategorizedCashflowTransaction.query() - .where('recognized_transaction_id', null) - .where('categorized', false); + 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( @@ -93,8 +100,8 @@ export class RecognizeTranasctionsService { } /** - * - * @param {number} uncategorizedTransaction + * + * @param {number} uncategorizedTransaction */ public async regonizeTransaction( uncategorizedTransaction: UncategorizedCashflowTransaction diff --git a/packages/server/src/services/Banking/Rules/DeleteBankRule.ts b/packages/server/src/services/Banking/Rules/DeleteBankRule.ts index 9d6ce0167..c02ab6686 100644 --- a/packages/server/src/services/Banking/Rules/DeleteBankRule.ts +++ b/packages/server/src/services/Banking/Rules/DeleteBankRule.ts @@ -27,7 +27,7 @@ export class DeleteBankRuleSerivce { * @returns {Promise} */ public async deleteBankRule(tenantId: number, ruleId: number): Promise { - const { BankRule } = this.tenancy.models(tenantId); + const { BankRule, BankRuleCondition } = this.tenancy.models(tenantId); const oldBankRule = await BankRule.query() .findById(ruleId) @@ -42,6 +42,7 @@ export class DeleteBankRuleSerivce { trx, } as IBankRuleEventDeletingPayload); + await BankRuleCondition.query(trx).where('ruleId', ruleId).delete(); await BankRule.query(trx).findById(ruleId).delete(); // Triggers `onBankRuleDeleted` event. diff --git a/packages/server/src/subscribers/events.ts b/packages/server/src/subscribers/events.ts index e139b2b41..507fe9e52 100644 --- a/packages/server/src/subscribers/events.ts +++ b/packages/server/src/subscribers/events.ts @@ -616,6 +616,7 @@ export default { plaid: { onItemCreated: 'onPlaidItemCreated', + onTransactionsSynced: 'onPlaidTransactionsSynced', }, // Bank rules. @@ -637,5 +638,5 @@ export default { onUnmatching: 'onBankTransactionUnmathcing', onUnmatched: 'onBankTransactionUnmathced', - } + }, }; From 66d2d6a612a509cb960b2997fbef26a2cf0fb9ac Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sun, 23 Jun 2024 23:30:32 +0200 Subject: [PATCH 11/42] feat: avoid categorize excluded transaction --- .../src/services/Cashflow/CategorizeCashflowTransaction.ts | 6 ++++++ packages/server/src/services/Cashflow/constants.ts | 2 ++ 2 files changed, 8 insertions(+) diff --git a/packages/server/src/services/Cashflow/CategorizeCashflowTransaction.ts b/packages/server/src/services/Cashflow/CategorizeCashflowTransaction.ts index 9ec711881..ba8f7c078 100644 --- a/packages/server/src/services/Cashflow/CategorizeCashflowTransaction.ts +++ b/packages/server/src/services/Cashflow/CategorizeCashflowTransaction.ts @@ -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); diff --git a/packages/server/src/services/Cashflow/constants.ts b/packages/server/src/services/Cashflow/constants.ts index a0d52f817..28768c85f 100644 --- a/packages/server/src/services/Cashflow/constants.ts +++ b/packages/server/src/services/Cashflow/constants.ts @@ -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 { From 5aae45c8a8a1f0454d5ce6fa069439cd24d3e933 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Tue, 25 Jun 2024 11:57:02 +0200 Subject: [PATCH 12/42] feat(webapp): bank rules --- .../Dashboard/DashboardActionsBar.tsx | 10 +- .../webapp/src/constants/abilityOption.tsx | 11 +- packages/webapp/src/constants/sidebarMenu.tsx | 5 + .../Banking/Rules/RuleFormDialog/RuleForm.tsx | 0 .../Rules/RuleFormDialog/RuleFormBoot.tsx | 32 +++++ .../Rules/RuleFormDialog/RuleFormContent.tsx | 19 +++ .../RuleFormContentForm.schema.ts | 13 ++ .../RuleFormDialog/RuleFormContentForm.tsx | 134 ++++++++++++++++++ .../Rules/RuleFormDialog/RuleFormDialog.tsx | 33 +++++ .../RulesList/BankRulesLandingEmptyState.tsx | 39 +++++ .../Banking/Rules/RulesList/RulesList.tsx | 20 +++ .../Rules/RulesList/RulesListActionsBar.tsx | 10 ++ .../Banking/Rules/RulesList/RulesListBoot.tsx | 30 ++++ .../Banking/Rules/RulesList/RulesTable.tsx | 81 +++++++++++ .../Banking/Rules/RulesList/_components.tsx | 38 +++++ .../Banking/Rules/RulesList/hooks.ts | 3 + packages/webapp/src/routes/dashboard.tsx | 8 ++ 17 files changed, 483 insertions(+), 3 deletions(-) create mode 100644 packages/webapp/src/containers/Banking/Rules/RuleFormDialog/RuleForm.tsx create mode 100644 packages/webapp/src/containers/Banking/Rules/RuleFormDialog/RuleFormBoot.tsx create mode 100644 packages/webapp/src/containers/Banking/Rules/RuleFormDialog/RuleFormContent.tsx create mode 100644 packages/webapp/src/containers/Banking/Rules/RuleFormDialog/RuleFormContentForm.schema.ts create mode 100644 packages/webapp/src/containers/Banking/Rules/RuleFormDialog/RuleFormContentForm.tsx create mode 100644 packages/webapp/src/containers/Banking/Rules/RuleFormDialog/RuleFormDialog.tsx create mode 100644 packages/webapp/src/containers/Banking/Rules/RulesList/BankRulesLandingEmptyState.tsx create mode 100644 packages/webapp/src/containers/Banking/Rules/RulesList/RulesList.tsx create mode 100644 packages/webapp/src/containers/Banking/Rules/RulesList/RulesListActionsBar.tsx create mode 100644 packages/webapp/src/containers/Banking/Rules/RulesList/RulesListBoot.tsx create mode 100644 packages/webapp/src/containers/Banking/Rules/RulesList/RulesTable.tsx create mode 100644 packages/webapp/src/containers/Banking/Rules/RulesList/_components.tsx create mode 100644 packages/webapp/src/containers/Banking/Rules/RulesList/hooks.ts diff --git a/packages/webapp/src/components/Dashboard/DashboardActionsBar.tsx b/packages/webapp/src/components/Dashboard/DashboardActionsBar.tsx index 29a2f3bb4..f78398442 100644 --- a/packages/webapp/src/components/Dashboard/DashboardActionsBar.tsx +++ b/packages/webapp/src/components/Dashboard/DashboardActionsBar.tsx @@ -3,7 +3,15 @@ import React from 'react'; import clsx from 'classnames'; import { Navbar } from '@blueprintjs/core'; -export function DashboardActionsBar({ className, children, name }) { +interface DashboardActionsBarProps { + children?: React.ReactNode; +} + +export function DashboardActionsBar({ + className, + children, + name, +}: DashboardActionsBarProps) { return (
( + {} as RuleFormBootValues, +); + +interface RuleFormBootProps { + bankRuleId?: number; + children: React.ReactNode; +} + +function RuleFormBoot({ bankRuleId, ...props }: RuleFormBootProps) { + const provider = {} as RuleFormBootValues; + + return ( + + + + ); +} + +const useRuleFormDialogBoot = () => + React.useContext(RuleFormBootContext); + +export { RuleFormBoot, useRuleFormDialogBoot }; diff --git a/packages/webapp/src/containers/Banking/Rules/RuleFormDialog/RuleFormContent.tsx b/packages/webapp/src/containers/Banking/Rules/RuleFormDialog/RuleFormContent.tsx new file mode 100644 index 000000000..e96b1d500 --- /dev/null +++ b/packages/webapp/src/containers/Banking/Rules/RuleFormDialog/RuleFormContent.tsx @@ -0,0 +1,19 @@ +import { RuleFormBoot } from "./RuleFormBoot"; + + +interface RuleFormContentProps { + dialogName: string; + bankRuleId?: number; +} +export function RuleFormContent({ + dialogName, + bankRuleId, +}: RuleFormContentProps) { + return ( + + + + ); +} diff --git a/packages/webapp/src/containers/Banking/Rules/RuleFormDialog/RuleFormContentForm.schema.ts b/packages/webapp/src/containers/Banking/Rules/RuleFormDialog/RuleFormContentForm.schema.ts new file mode 100644 index 000000000..3cde8f265 --- /dev/null +++ b/packages/webapp/src/containers/Banking/Rules/RuleFormDialog/RuleFormContentForm.schema.ts @@ -0,0 +1,13 @@ +// @ts-nocheck +import * as Yup from 'yup'; + +const Schema = Yup.object().shape({ + name: Yup.string().required().label('Rule name'), + applyIfAccountId: Yup.number().required().label(''), + applyIfTransactionType: Yup.string().required().label(''), + conditionsType: Yup.string().required(), + assignCategory: Yup.string().required(), + assignAccountId: Yup.string().required(), +}); + +export const CreateRuleFormSchema = Schema; diff --git a/packages/webapp/src/containers/Banking/Rules/RuleFormDialog/RuleFormContentForm.tsx b/packages/webapp/src/containers/Banking/Rules/RuleFormDialog/RuleFormContentForm.tsx new file mode 100644 index 000000000..a018fe9df --- /dev/null +++ b/packages/webapp/src/containers/Banking/Rules/RuleFormDialog/RuleFormContentForm.tsx @@ -0,0 +1,134 @@ +import { Form, Formik, useFormikContext } from 'formik'; +import { Button, Radio } from '@blueprintjs/core'; +import { CreateRuleFormSchema } from './RuleFormContentForm.schema'; +import { + Box, + FFormGroup, + FInputGroup, + FRadioGroup, + FSelect, + Group, +} from '@/components'; + +const initialValues = { + name: '', + order: 0, + applyIfAccountId: '', + applyIfTransactionType: '', + conditionsType: '', + conditions: [ + { + field: 'description', + comparator: 'contains', + value: 'payment', + }, + ], + assignCategory: '', + assignAccountId: '', +}; + +interface RuleFormValues { + name: string; + order: number; + applyIfAccountId: string; + applyIfTransactionType: string; + conditionsType: string; + conditions: Array<{ + field: string; + comparator: string; + value: string; + }>; + assignCategory: string; + assignAccountId: string; +} + +export function RuleFormContentForm() { + const validationSchema = CreateRuleFormSchema; + const handleSubmit = () => {}; + + return ( + + initialValues={initialValues} + validationSchema={validationSchema} + onSubmit={handleSubmit} + > +
+ + + + + + + + + + + + + + + + + +

Then Assign

+ + + + + + + + + + + + + + + ); +} + +function RuleFormConditions() { + const { values } = useFormikContext(); + + const handleAddConditionBtnClick = () => { + values.conditions.push({ + field: '', + comparator: '', + value: '', + }); + }; + + return ( + + {values?.conditions?.map((condition, index) => ( + + + + + + + + + + + + + + ))} + + + + ); +} diff --git a/packages/webapp/src/containers/Banking/Rules/RuleFormDialog/RuleFormDialog.tsx b/packages/webapp/src/containers/Banking/Rules/RuleFormDialog/RuleFormDialog.tsx new file mode 100644 index 000000000..5fa6929f7 --- /dev/null +++ b/packages/webapp/src/containers/Banking/Rules/RuleFormDialog/RuleFormDialog.tsx @@ -0,0 +1,33 @@ +// @ts-nocheck +import React from 'react'; +import { Dialog, DialogSuspense } from '@/components'; +import withDialogRedux from '@/components/DialogReduxConnect'; +import { compose } from '@/utils'; + +const RuleFormContent = React.lazy(() => import('./RuleFormContent')); + +/** + * Payment mail dialog. + */ +function RuleFormDialog({ + dialogName, + payload: { bankRuleId = null }, + isOpen, +}) { + return ( + + + + + + ); +} + +export default compose(withDialogRedux())(RuleFormDialog); diff --git a/packages/webapp/src/containers/Banking/Rules/RulesList/BankRulesLandingEmptyState.tsx b/packages/webapp/src/containers/Banking/Rules/RulesList/BankRulesLandingEmptyState.tsx new file mode 100644 index 000000000..4ad67f0b6 --- /dev/null +++ b/packages/webapp/src/containers/Banking/Rules/RulesList/BankRulesLandingEmptyState.tsx @@ -0,0 +1,39 @@ +// @ts-nocheck +import * as R from 'ramda'; +import { Button, Intent } from '@blueprintjs/core'; +import { EmptyStatus, Can, FormattedMessage as T } from '@/components'; +import { AbilitySubject, BankRuleAction } from '@/constants/abilityOption'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; + +function BankRulesLandingEmptyStateRoot({ + // #withDialogAction + openDialog, +}) { + return ( + + Setup the organization taxes to start tracking taxes on sales + transactions. +

+ } + action={ + <> + + + + + + } + /> + ); +} + +export const BankRulesLandingEmptyState = R.compose(withDialogActions)( + BankRulesLandingEmptyStateRoot, +); diff --git a/packages/webapp/src/containers/Banking/Rules/RulesList/RulesList.tsx b/packages/webapp/src/containers/Banking/Rules/RulesList/RulesList.tsx new file mode 100644 index 000000000..8c35a99ae --- /dev/null +++ b/packages/webapp/src/containers/Banking/Rules/RulesList/RulesList.tsx @@ -0,0 +1,20 @@ +// @ts-nocheck +import { DashboardPageContent } from '@/components'; +import { RulesListBoot } from './RulesListBoot'; +import { RulesListActionsBar } from './RulesListActionsBar'; +import { BankRulesTable } from './RulesTable'; + +/** + * + */ +export function RulesList() { + return ( + + + + + + + + ); +} diff --git a/packages/webapp/src/containers/Banking/Rules/RulesList/RulesListActionsBar.tsx b/packages/webapp/src/containers/Banking/Rules/RulesList/RulesListActionsBar.tsx new file mode 100644 index 000000000..2f538afef --- /dev/null +++ b/packages/webapp/src/containers/Banking/Rules/RulesList/RulesListActionsBar.tsx @@ -0,0 +1,10 @@ +import { DashboardActionsBar } from '@/components'; +import { NavbarGroup } from '@blueprintjs/core'; + +export function RulesListActionsBar() { + return ( + + + + ); +} diff --git a/packages/webapp/src/containers/Banking/Rules/RulesList/RulesListBoot.tsx b/packages/webapp/src/containers/Banking/Rules/RulesList/RulesListBoot.tsx new file mode 100644 index 000000000..71035fd60 --- /dev/null +++ b/packages/webapp/src/containers/Banking/Rules/RulesList/RulesListBoot.tsx @@ -0,0 +1,30 @@ +import React, { createContext } from 'react'; +import { DialogContent } from '@/components'; + +interface RulesListBootValues { + rules: any; + isRulesLoading: boolean; +} + +const RulesListBootContext = createContext( + {} as RulesListBootValues, +); + +interface RulesListBootProps { + children: React.ReactNode; +} + +function RulesListBoot({ ...props }: RulesListBootProps) { + const provider = {} as RulesListBootValues; + + return ( + + + + ); +} + +const useRulesListBoot = () => + React.useContext(RulesListBootContext); + +export { RulesListBoot, useRulesListBoot }; diff --git a/packages/webapp/src/containers/Banking/Rules/RulesList/RulesTable.tsx b/packages/webapp/src/containers/Banking/Rules/RulesList/RulesTable.tsx new file mode 100644 index 000000000..966b86bc8 --- /dev/null +++ b/packages/webapp/src/containers/Banking/Rules/RulesList/RulesTable.tsx @@ -0,0 +1,81 @@ +// @ts-nocheck +import * as R from 'ramda'; +import { + DataTable, + DashboardContentTable, + TableSkeletonHeader, + TableSkeletonRows, +} from '@/components'; + +import withAlertsActions from '@/containers/Alert/withAlertActions'; +import withDrawerActions from '@/containers/Drawer/withDrawerActions'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; +import withDashboardActions from '@/containers/Dashboard/withDashboardActions'; + +import { useBankRulesTableColumns } from './hooks'; +import { BankRulesTableActionsMenu } from './_components'; +import { BankRulesLandingEmptyState } from './BankRulesLandingEmptyState'; + +/** + * Invoices datatable. + */ +function RulesTable({ + // #withAlertsActions + openAlert, + + // #withDrawerActions + openDrawer, + + // #withDialogAction + openDialog, +}) { + // Invoices table columns. + const columns = useBankRulesTableColumns(); + + // Handle edit bank rule. + const handleDeleteBankRule = ({ id }) => {}; + + // Handle delete bank rule. + const handleEditBankRule = () => {}; + + // Display invoice empty status instead of the table. + if (isEmptyStatus) { + return ; + } + + return ( + + + + ); +} + +export const BankRulesTable = R.compose( + withDashboardActions, + withAlertsActions, + withDrawerActions, + withDialogActions, +)(RulesTable); diff --git a/packages/webapp/src/containers/Banking/Rules/RulesList/_components.tsx b/packages/webapp/src/containers/Banking/Rules/RulesList/_components.tsx new file mode 100644 index 000000000..a33c86dde --- /dev/null +++ b/packages/webapp/src/containers/Banking/Rules/RulesList/_components.tsx @@ -0,0 +1,38 @@ +// @ts-nocheck +import React from 'react'; +import { Intent, Menu, MenuDivider, MenuItem } from '@blueprintjs/core'; +import { Can, Icon } from '@/components'; +import { AbilitySubject, BankRuleAction } from '@/constants/abilityOption'; +import { safeCallback } from '@/utils'; + +/** + * Tax rates table actions menu. + * @returns {JSX.Element} + */ +export function BankRulesTableActionsMenu({ + payload: { onEdit, onDelete }, + row: { original }, +}) { + return ( + + + + } + text={'Edit Rule'} + onClick={safeCallback(onEdit, original)} + /> + + + + + } + /> + + + ); +} diff --git a/packages/webapp/src/containers/Banking/Rules/RulesList/hooks.ts b/packages/webapp/src/containers/Banking/Rules/RulesList/hooks.ts new file mode 100644 index 000000000..3ba4e2106 --- /dev/null +++ b/packages/webapp/src/containers/Banking/Rules/RulesList/hooks.ts @@ -0,0 +1,3 @@ +export const useBankRulesTableColumns = () => { + return []; +}; diff --git a/packages/webapp/src/routes/dashboard.tsx b/packages/webapp/src/routes/dashboard.tsx index 8f1122bae..afd35824d 100644 --- a/packages/webapp/src/routes/dashboard.tsx +++ b/packages/webapp/src/routes/dashboard.tsx @@ -1221,6 +1221,14 @@ export const getDashboardRoutes = () => [ pageTitle: 'Tax Rates', subscriptionActive: [SUBSCRIPTION_TYPE.MAIN], }, + // Bank Rules + { + path: '/bank-rules', + component: lazy( + () => import('@/containers/Banking/Rules/RulesList/RulesList'), + ), + pageTitle: 'Bank Rules', + }, // Homepage { path: `/`, From dad8aeaff190aeed0f5af5c4c125227c3040ec59 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Tue, 25 Jun 2024 13:42:19 +0200 Subject: [PATCH 13/42] feat(webapp): rule form --- .../src/components/DialogsContainer.tsx | 2 ++ packages/webapp/src/constants/dialogs.ts | 1 + .../Banking/Rules/RuleFormDialog/RuleForm.tsx | 0 .../Rules/RuleFormDialog/RuleFormContent.tsx | 17 ++++----- .../RuleFormDialog/RuleFormContentForm.tsx | 32 +++++++++++++++-- .../Rules/RuleFormDialog/RuleFormDialog.tsx | 8 +++-- .../Rules/RulesList/RulesLandingPage.ts | 3 ++ .../Rules/RulesList/RulesListActionsBar.tsx | 35 ++++++++++++++++--- .../Banking/Rules/RulesList/RulesTable.tsx | 10 +++--- packages/webapp/src/routes/dashboard.tsx | 2 +- 10 files changed, 87 insertions(+), 23 deletions(-) delete mode 100644 packages/webapp/src/containers/Banking/Rules/RuleFormDialog/RuleForm.tsx create mode 100644 packages/webapp/src/containers/Banking/Rules/RulesList/RulesLandingPage.ts diff --git a/packages/webapp/src/components/DialogsContainer.tsx b/packages/webapp/src/components/DialogsContainer.tsx index c603a624f..5c753c213 100644 --- a/packages/webapp/src/components/DialogsContainer.tsx +++ b/packages/webapp/src/components/DialogsContainer.tsx @@ -51,6 +51,7 @@ import EstimateMailDialog from '@/containers/Sales/Estimates/EstimateMailDialog/ import ReceiptMailDialog from '@/containers/Sales/Receipts/ReceiptMailDialog/ReceiptMailDialog'; import PaymentMailDialog from '@/containers/Sales/PaymentReceives/PaymentMailDialog/PaymentMailDialog'; import { ExportDialog } from '@/containers/Dialogs/ExportDialog'; +import { RuleFormDialog } from '@/containers/Banking/Rules/RuleFormDialog/RuleFormDialog'; /** * Dialogs container. @@ -147,6 +148,7 @@ export default function DialogsContainer() { +
); } diff --git a/packages/webapp/src/constants/dialogs.ts b/packages/webapp/src/constants/dialogs.ts index cd425ce58..07ed83d67 100644 --- a/packages/webapp/src/constants/dialogs.ts +++ b/packages/webapp/src/constants/dialogs.ts @@ -75,4 +75,5 @@ export enum DialogsName { GeneralLedgerPdfPreview = 'GeneralLedgerPdfPreview', SalesTaxLiabilitySummaryPdfPreview = 'SalesTaxLiabilitySummaryPdfPreview', Export = 'Export', + BankRuleForm = 'BankRuleForm' } diff --git a/packages/webapp/src/containers/Banking/Rules/RuleFormDialog/RuleForm.tsx b/packages/webapp/src/containers/Banking/Rules/RuleFormDialog/RuleForm.tsx deleted file mode 100644 index e69de29bb..000000000 diff --git a/packages/webapp/src/containers/Banking/Rules/RuleFormDialog/RuleFormContent.tsx b/packages/webapp/src/containers/Banking/Rules/RuleFormDialog/RuleFormContent.tsx index e96b1d500..6f8e6d9b1 100644 --- a/packages/webapp/src/containers/Banking/Rules/RuleFormDialog/RuleFormContent.tsx +++ b/packages/webapp/src/containers/Banking/Rules/RuleFormDialog/RuleFormContent.tsx @@ -1,19 +1,20 @@ -import { RuleFormBoot } from "./RuleFormBoot"; - +import { RuleFormBoot } from './RuleFormBoot'; +import { RuleFormContentForm } from './RuleFormContentForm'; interface RuleFormContentProps { dialogName: string; bankRuleId?: number; } -export function RuleFormContent({ + +export default function RuleFormContent({ dialogName, bankRuleId, }: RuleFormContentProps) { return ( - - - +
+ + + +
); } diff --git a/packages/webapp/src/containers/Banking/Rules/RuleFormDialog/RuleFormContentForm.tsx b/packages/webapp/src/containers/Banking/Rules/RuleFormDialog/RuleFormContentForm.tsx index a018fe9df..230f0ed74 100644 --- a/packages/webapp/src/containers/Banking/Rules/RuleFormDialog/RuleFormContentForm.tsx +++ b/packages/webapp/src/containers/Banking/Rules/RuleFormDialog/RuleFormContentForm.tsx @@ -1,5 +1,5 @@ import { Form, Formik, useFormikContext } from 'formik'; -import { Button, Radio } from '@blueprintjs/core'; +import { Button, Classes, Intent, Radio } from '@blueprintjs/core'; import { CreateRuleFormSchema } from './RuleFormContentForm.schema'; import { Box, @@ -61,7 +61,10 @@ export function RuleFormContentForm() { - + -

Then Assign

@@ -86,6 +88,8 @@ export function RuleFormContentForm() { + + ); @@ -132,3 +136,25 @@ function RuleFormConditions() { ); } + +function RuleFormActions() { + const { isSubmitting, submitForm } = useFormikContext(); + + const handleSaveBtnClick = () => { + submitForm(); + }; + const handleCancelBtnClick = () => {}; + + return ( + + + + + ); +} diff --git a/packages/webapp/src/containers/Banking/Rules/RuleFormDialog/RuleFormDialog.tsx b/packages/webapp/src/containers/Banking/Rules/RuleFormDialog/RuleFormDialog.tsx index 5fa6929f7..262f1576a 100644 --- a/packages/webapp/src/containers/Banking/Rules/RuleFormDialog/RuleFormDialog.tsx +++ b/packages/webapp/src/containers/Banking/Rules/RuleFormDialog/RuleFormDialog.tsx @@ -9,14 +9,14 @@ const RuleFormContent = React.lazy(() => import('./RuleFormContent')); /** * Payment mail dialog. */ -function RuleFormDialog({ +function RuleFormDialogRoot({ dialogName, payload: { bankRuleId = null }, isOpen, }) { return ( { + openDialog(DialogsName.BankRuleForm); + }; -export function RulesListActionsBar() { return ( - + + + ); } -function RuleFormActions() { +function RuleFormActionsRoot({ + // #withDialogActions + closeDialog, +}) { const { isSubmitting, submitForm } = useFormikContext(); const handleSaveBtnClick = () => { submitForm(); }; - const handleCancelBtnClick = () => {}; + const handleCancelBtnClick = () => { + closeDialog(DialogsName.BankRuleForm); + }; return ( - - + + + + ); } + +const RuleFormActions = R.compose(withDialogActions)(RuleFormActionsRoot); diff --git a/packages/webapp/src/containers/Banking/Rules/RuleFormDialog/_utils.ts b/packages/webapp/src/containers/Banking/Rules/RuleFormDialog/_utils.ts index d88136c9b..696464b87 100644 --- a/packages/webapp/src/containers/Banking/Rules/RuleFormDialog/_utils.ts +++ b/packages/webapp/src/containers/Banking/Rules/RuleFormDialog/_utils.ts @@ -3,12 +3,12 @@ export const initialValues = { order: 0, applyIfAccountId: '', applyIfTransactionType: '', - conditionsType: '', + conditionsType: 'and', conditions: [ { field: 'description', comparator: 'contains', - value: 'payment', + value: '', }, ], assignCategory: '', @@ -41,7 +41,7 @@ export const Fields = [ ]; export const FieldCondition = [ { value: 'contains', text: 'Contains' }, - { value: 'equals', text: 'equals' }, + { value: 'equals', text: 'Equals' }, { value: 'not_contains', text: 'Not Contains' }, ]; export const AssignTransactionTypeOptions = [ diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsList.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsList.tsx index 43b4b706d..a6db2db7e 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsList.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsList.tsx @@ -14,6 +14,8 @@ import { import { AccountTransactionsDetailsBar } from './AccountTransactionsDetailsBar'; import { AccountTransactionsProgressBar } from './components'; import { AccountTransactionsFilterTabs } from './AccountTransactionsFilterTabs'; +import { AppShell } from '@/components/AppShell/AppShell'; +import { CategorizeTransactionAside } from '../CategorizeTransactionAside/CategorizeTransactionAside'; /** * Account transactions list. @@ -21,17 +23,25 @@ import { AccountTransactionsFilterTabs } from './AccountTransactionsFilterTabs'; function AccountTransactionsList() { return ( - - - + + + + + - - + + - }> - - - + }> + + + + + + + + + ); } diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionAside.module.scss b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionAside.module.scss new file mode 100644 index 000000000..a52cd188c --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionAside.module.scss @@ -0,0 +1,61 @@ + + +.transaction { + background: #fff; + border-radius: 5px; + border: 1px solid #D6DBE3; + padding: 12px 16px; +} + +.asideHeader { + align-items: center; + background: #fff; + border-bottom: 1px solid #E1E2E9; + display: flex; + flex: 0 0 auto; + min-height: 40px; + padding: 5px 5px 5px 15px; + z-index: 0; +} + +.tabs :global .bp4-tab-panel{ + margin-top: 0; +} +.tabs :global .bp4-tab-list{ + background: #fff; + border-bottom: 1px solid #c7d5db; + padding: 0 22px; +} + +.tabs :global .bp4-large > .bp4-tab{ + font-size: 14px; +} + +.matchBar{ + padding: 16px 18px; + background: #fff; + border-bottom: 1px solid #E1E2E9; + border-top: 1px solid #E1E2E9; +} +.matchBarTitle { + font-size: 14px; + font-weight: 500; +} + +.footerActions { + padding: 14px 16px; + border-top: 1px solid #E1E2E9; +} + +.footerTotal { + padding: 8px 16px; + border: 1px solid #E1E2E9; +} + +.checkbox:global(.bp4-control.bp4-checkbox){ + margin: 0; +} +.checkbox:global(.bp4-control.bp4-checkbox) :global .bp4-control-indicator{ + border-color: #CBCBCB; +} + diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionAside.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionAside.tsx new file mode 100644 index 000000000..c4ef09a04 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionAside.tsx @@ -0,0 +1,145 @@ +import { Box, Group, Icon, Stack } from '@/components'; +import { + AnchorButton, + Button, + Checkbox, + Classes, + Intent, + Tab, + Tabs, + Tag, + Text, +} from '@blueprintjs/core'; +import styles from './CategorizeTransactionAside.module.scss'; + +interface AsideProps { + title?: string; + onClose?: () => void; + children?: React.ReactNode; +} + +function Aside({ title, onClose, children }: AsideProps) { + const handleClose = () => { + onClose && onClose(); + }; + return ( + + + {title} + + + + + + ); +} diff --git a/packages/webapp/src/static/json/icons.tsx b/packages/webapp/src/static/json/icons.tsx index 0e6d27c62..26bef2707 100644 --- a/packages/webapp/src/static/json/icons.tsx +++ b/packages/webapp/src/static/json/icons.tsx @@ -605,4 +605,10 @@ export default { ], viewBox: '0 0 16 16', }, + smallCross: { + path: [ + 'M9.41,8l3.29-3.29C12.89,4.53,13,4.28,13,4c0-0.55-0.45-1-1-1c-0.28,0-0.53,0.11-0.71,0.29L8,6.59L4.71,3.29C4.53,3.11,4.28,3,4,3C3.45,3,3,3.45,3,4c0,0.28,0.11,0.53,0.29,0.71L6.59,8l-3.29,3.29C3.11,11.47,3,11.72,3,12c0,0.55,0.45,1,1,1c0.28,0,0.53-0.11,0.71-0.29L8,9.41l3.29,3.29C11.47,12.89,11.72,13,12,13c0.55,0,1-0.45,1-1c0-0.28-0.11-0.53-0.29-0.71L9.41,8z', + ], + viewBox: '0 0 16 16', + }, }; From 8c2888fcd82b1c6a97b1f9c5fe2839dc0da5e9f8 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Tue, 25 Jun 2024 23:44:57 +0200 Subject: [PATCH 16/42] fix(server): delete bank rule --- .../src/api/controllers/Banking/BankingRulesController.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/server/src/api/controllers/Banking/BankingRulesController.ts b/packages/server/src/api/controllers/Banking/BankingRulesController.ts index 0fb91c0b1..f005a0da0 100644 --- a/packages/server/src/api/controllers/Banking/BankingRulesController.ts +++ b/packages/server/src/api/controllers/Banking/BankingRulesController.ts @@ -155,17 +155,19 @@ export class BankingRulesController extends BaseController { * @param {NextFunction} next */ private async deleteBankRule( - req: Request, + req: Request<{ id: number }>, res: Response, next: NextFunction ) { const { id: ruleId } = req.params; + const { tenantId } = req; + try { await this.bankRulesApplication.deleteBankRule(tenantId, ruleId); return res .status(200) - .send({ message: 'The bank rule has been deleted.' }); + .send({ message: 'The bank rule has been deleted.', id: ruleId }); } catch (error) { next(error); } From 18899691917532d85e0b8e6d4348cd7e1011d06f Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Tue, 25 Jun 2024 23:45:31 +0200 Subject: [PATCH 17/42] feat: bank rules table --- .../containers/AlertsContainer/registered.tsx | 2 + .../Rules/RulesList/BankRulesAlerts.ts | 16 ++++ .../Banking/Rules/RulesList/RulesListBoot.tsx | 6 +- .../Banking/Rules/RulesList/RulesTable.tsx | 9 ++- .../Banking/Rules/RulesList/_components.tsx | 2 - .../RulesList/alerts/DeleteBankRuleAlert.tsx | 80 +++++++++++++++++++ .../Banking/Rules/RulesList/hooks.ts | 3 - .../Banking/Rules/RulesList/hooks.tsx | 36 +++++++++ packages/webapp/src/hooks/query/bank-rules.ts | 13 +-- 9 files changed, 153 insertions(+), 14 deletions(-) create mode 100644 packages/webapp/src/containers/Banking/Rules/RulesList/BankRulesAlerts.ts create mode 100644 packages/webapp/src/containers/Banking/Rules/RulesList/alerts/DeleteBankRuleAlert.tsx delete mode 100644 packages/webapp/src/containers/Banking/Rules/RulesList/hooks.ts create mode 100644 packages/webapp/src/containers/Banking/Rules/RulesList/hooks.tsx diff --git a/packages/webapp/src/containers/AlertsContainer/registered.tsx b/packages/webapp/src/containers/AlertsContainer/registered.tsx index 9bf51e222..d1257cc1b 100644 --- a/packages/webapp/src/containers/AlertsContainer/registered.tsx +++ b/packages/webapp/src/containers/AlertsContainer/registered.tsx @@ -26,6 +26,7 @@ import BranchesAlerts from '@/containers/Preferences/Branches/BranchesAlerts'; import ProjectAlerts from '@/containers/Projects/containers/ProjectAlerts'; import TaxRatesAlerts from '@/containers/TaxRates/alerts'; import { CashflowAlerts } from '../CashFlow/CashflowAlerts'; +import { BankRulesAlerts } from '../Banking/Rules/RulesList/BankRulesAlerts'; export default [ ...AccountsAlerts, @@ -55,4 +56,5 @@ export default [ ...ProjectAlerts, ...TaxRatesAlerts, ...CashflowAlerts, + ...BankRulesAlerts ]; diff --git a/packages/webapp/src/containers/Banking/Rules/RulesList/BankRulesAlerts.ts b/packages/webapp/src/containers/Banking/Rules/RulesList/BankRulesAlerts.ts new file mode 100644 index 000000000..5daec4359 --- /dev/null +++ b/packages/webapp/src/containers/Banking/Rules/RulesList/BankRulesAlerts.ts @@ -0,0 +1,16 @@ +// @ts-nocheck +import React from 'react'; + +const DeleteBankRuleAlert = React.lazy( + () => import('./alerts/DeleteBankRuleAlert'), +); + +/** + * Cashflow alerts. + */ +export const BankRulesAlerts = [ + { + name: 'bank-rule-delete', + component: DeleteBankRuleAlert, + }, +]; diff --git a/packages/webapp/src/containers/Banking/Rules/RulesList/RulesListBoot.tsx b/packages/webapp/src/containers/Banking/Rules/RulesList/RulesListBoot.tsx index f8c2cbd69..a022d150e 100644 --- a/packages/webapp/src/containers/Banking/Rules/RulesList/RulesListBoot.tsx +++ b/packages/webapp/src/containers/Banking/Rules/RulesList/RulesListBoot.tsx @@ -4,6 +4,7 @@ import { useBankRules } from '@/hooks/query/bank-rules'; interface RulesListBootValues { bankRules: any; + isBankRulesLoading: boolean; } const RulesListBootContext = createContext( @@ -18,10 +19,11 @@ function RulesListBoot({ ...props }: RulesListBootProps) { const { data: bankRules, isLoading: isBankRulesLoading } = useBankRules(); const provider = { bankRules, isBankRulesLoading } as RulesListBootValues; + const isLoading = isBankRulesLoading; return ( - - + + ); } diff --git a/packages/webapp/src/containers/Banking/Rules/RulesList/RulesTable.tsx b/packages/webapp/src/containers/Banking/Rules/RulesList/RulesTable.tsx index 5d5d9a88f..e81d5dbc2 100644 --- a/packages/webapp/src/containers/Banking/Rules/RulesList/RulesTable.tsx +++ b/packages/webapp/src/containers/Banking/Rules/RulesList/RulesTable.tsx @@ -15,6 +15,7 @@ import withDashboardActions from '@/containers/Dashboard/withDashboardActions'; import { useBankRulesTableColumns } from './hooks'; import { BankRulesTableActionsMenu } from './_components'; import { BankRulesLandingEmptyState } from './BankRulesLandingEmptyState'; +import { useRulesListBoot } from './RulesListBoot'; /** * Invoices datatable. @@ -32,8 +33,12 @@ function RulesTable({ // Invoices table columns. const columns = useBankRulesTableColumns(); + const { bankRules } = useRulesListBoot(); + // Handle edit bank rule. - const handleDeleteBankRule = ({ id }) => {}; + const handleDeleteBankRule = ({ id }) => { + openAlert('bank-rule-delete', { id }); + }; // Handle delete bank rule. const handleEditBankRule = () => {}; @@ -49,7 +54,7 @@ function RulesTable({ - } text={'Edit Rule'} onClick={safeCallback(onEdit, original)} /> - { + closeAlert(name); + }; + + // handleConfirm delete project + const handleConfirmBtnClick = () => { + deleteBankRule(id) + .then(() => { + AppToaster.show({ + message: 'The bank rule has deleted successfully.', + intent: Intent.SUCCESS, + }); + closeAlert(name); + }) + .catch( + ({ + response: { + data: { errors }, + }, + }) => { + AppToaster.show({ + message: 'Something went wrong.', + intent: Intent.DANGER, + }); + }, + ); + }; + + return ( + } + confirmButtonText={'Delete'} + intent={Intent.DANGER} + isOpen={isOpen} + onCancel={handleCancelDeleteAlert} + onConfirm={handleConfirmBtnClick} + loading={isLoading} + > +

Are you sure want to delete the bank rule?

+
+ ); +} + +export default compose( + withAlertStoreConnect(), + withAlertActions, + withDrawerActions, +)(BankRuleDeleteAlert); diff --git a/packages/webapp/src/containers/Banking/Rules/RulesList/hooks.ts b/packages/webapp/src/containers/Banking/Rules/RulesList/hooks.ts deleted file mode 100644 index 3ba4e2106..000000000 --- a/packages/webapp/src/containers/Banking/Rules/RulesList/hooks.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const useBankRulesTableColumns = () => { - return []; -}; diff --git a/packages/webapp/src/containers/Banking/Rules/RulesList/hooks.tsx b/packages/webapp/src/containers/Banking/Rules/RulesList/hooks.tsx new file mode 100644 index 000000000..1e3f74d97 --- /dev/null +++ b/packages/webapp/src/containers/Banking/Rules/RulesList/hooks.tsx @@ -0,0 +1,36 @@ +// @ts-nocheck +import { useMemo } from 'react'; +import { Intent, Tag } from '@blueprintjs/core'; + +export const useBankRulesTableColumns = () => { + return useMemo( + () => [ + { + Header: 'Apply to', + accessor: (rule) => + rule.apply_if_transaction_type === 'deposit' ? ( + Deposits + ) : ( + Withdrawals + ), + }, + { + Header: 'Rule Name', + accessor: 'name', + }, + { + Header: 'Categorize As', + accessor: () => 'Expense', + }, + { + Header: 'Apply To', + accessor: () => All Accounts, + }, + { + Header: 'Conditions', + accessor: () => '', + }, + ], + [], + ); +}; diff --git a/packages/webapp/src/hooks/query/bank-rules.ts b/packages/webapp/src/hooks/query/bank-rules.ts index d9fa54d2d..d01d6c47d 100644 --- a/packages/webapp/src/hooks/query/bank-rules.ts +++ b/packages/webapp/src/hooks/query/bank-rules.ts @@ -33,12 +33,15 @@ export function useDeleteBankRule(props) { const queryClient = useQueryClient(); const apiRequest = useApiRequest(); - return useMutation((id: number) => apiRequest.delete(`/bank-rules/${id}`), { - onSuccess: (res, id) => { - // Invalidate queries. + return useMutation( + (id: number) => apiRequest.delete(`/banking/rules/${id}`), + { + onSuccess: (res, id) => { + // Invalidate queries. + }, + ...props, }, - ...props, - }); + ); } /** From d2d37820f5c4e897b6a5c118853015475aa8f05f Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Wed, 26 Jun 2024 00:04:54 +0200 Subject: [PATCH 18/42] feat(webapp): edit bank rule --- .../Rules/RuleFormDialog/RuleFormBoot.tsx | 8 +++ .../RuleFormDialog/RuleFormContentForm.tsx | 59 ++++++++++++------- .../Rules/RuleFormDialog/RuleFormDialog.tsx | 4 +- .../Banking/Rules/RulesList/RulesTable.tsx | 5 +- packages/webapp/src/hooks/query/bank-rules.ts | 13 ++-- 5 files changed, 59 insertions(+), 30 deletions(-) diff --git a/packages/webapp/src/containers/Banking/Rules/RuleFormDialog/RuleFormBoot.tsx b/packages/webapp/src/containers/Banking/Rules/RuleFormDialog/RuleFormBoot.tsx index 8d3e72094..e9515e9d1 100644 --- a/packages/webapp/src/containers/Banking/Rules/RuleFormDialog/RuleFormBoot.tsx +++ b/packages/webapp/src/containers/Banking/Rules/RuleFormDialog/RuleFormBoot.tsx @@ -7,6 +7,8 @@ interface RuleFormBootValues { bankRule?: null; bankRuleId?: null; isBankRuleLoading: boolean; + isEditMode: boolean; + isNewMode: boolean; } const RuleFormBootContext = createContext( @@ -27,11 +29,17 @@ function RuleFormBoot({ bankRuleId, ...props }: RuleFormBootProps) { ); const { data: accounts, isLoading: isAccountsLoading } = useAccounts({}, {}); + const isNewMode = !bankRuleId; + const isEditMode = !isNewMode; + const provider = { + bankRuleId, bankRule, accounts, isBankRuleLoading, isAccountsLoading, + isEditMode, + isNewMode, } as RuleFormBootValues; const isLoading = isBankRuleLoading || isAccountsLoading; diff --git a/packages/webapp/src/containers/Banking/Rules/RuleFormDialog/RuleFormContentForm.tsx b/packages/webapp/src/containers/Banking/Rules/RuleFormDialog/RuleFormContentForm.tsx index a97ce4c19..71412afc4 100644 --- a/packages/webapp/src/containers/Banking/Rules/RuleFormDialog/RuleFormContentForm.tsx +++ b/packages/webapp/src/containers/Banking/Rules/RuleFormDialog/RuleFormContentForm.tsx @@ -14,7 +14,7 @@ import { Group, Stack, } from '@/components'; -import { useCreateBankRule } from '@/hooks/query/bank-rules'; +import { useCreateBankRule, useEditBankRule } from '@/hooks/query/bank-rules'; import { AssignTransactionTypeOptions, FieldCondition, @@ -24,7 +24,11 @@ import { initialValues, } from './_utils'; import { useRuleFormDialogBoot } from './RuleFormBoot'; -import { transfromToSnakeCase } from '@/utils'; +import { + transformToCamelCase, + transformToForm, + transfromToSnakeCase, +} from '@/utils'; import withDialogActions from '@/containers/Dialog/withDialogActions'; import { DialogsName } from '@/constants/dialogs'; @@ -33,42 +37,53 @@ function RuleFormContentFormRoot({ openDialog, closeDialog, }) { - const { accounts } = useRuleFormDialogBoot(); + const { accounts, bankRule, isEditMode, bankRuleId } = + useRuleFormDialogBoot(); const { mutateAsync: createBankRule } = useCreateBankRule(); + const { mutateAsync: editBankRule } = useEditBankRule(); const validationSchema = CreateRuleFormSchema; + const _initialValues = { + ...initialValues, + ...transformToForm(transformToCamelCase(bankRule), initialValues), + }; + // Handles the form submitting. const handleSubmit = ( values: RuleFormValues, { setSubmitting }: FormikHelpers, ) => { - const _value = transfromToSnakeCase(values); - + const _values = transfromToSnakeCase(values); setSubmitting(true); - createBankRule(_value) - .then(() => { - setSubmitting(false); - closeDialog(DialogsName.BankRuleForm); - AppToaster.show({ - intent: Intent.SUCCESS, - message: 'The bank rule has been created successfully.', - }); - }) - .catch((error) => { - setSubmitting(false); - - AppToaster.show({ - intent: Intent.DANGER, - message: 'Something went wrong!', - }); + const handleSuccess = () => { + setSubmitting(false); + closeDialog(DialogsName.BankRuleForm); + AppToaster.show({ + intent: Intent.SUCCESS, + message: 'The bank rule has been created successfully.', }); + }; + const handleError = (error) => { + setSubmitting(false); + AppToaster.show({ + intent: Intent.DANGER, + message: 'Something went wrong!', + }); + }; + if (isEditMode) { + editBankRule([bankRuleId, _values]) + .then(handleSuccess) + .catch(handleError); + } else { + createBankRule(_values).then(handleSuccess).catch(handleError); + } }; return ( - initialValues={initialValues} + initialValues={_initialValues} validationSchema={validationSchema} onSubmit={handleSubmit} > diff --git a/packages/webapp/src/containers/Banking/Rules/RuleFormDialog/RuleFormDialog.tsx b/packages/webapp/src/containers/Banking/Rules/RuleFormDialog/RuleFormDialog.tsx index 262f1576a..e61afa7dc 100644 --- a/packages/webapp/src/containers/Banking/Rules/RuleFormDialog/RuleFormDialog.tsx +++ b/packages/webapp/src/containers/Banking/Rules/RuleFormDialog/RuleFormDialog.tsx @@ -16,8 +16,8 @@ function RuleFormDialogRoot({ }) { return ( {}; + const handleEditBankRule = ({ id }) => { + openDialog(DialogsName.BankRuleForm, { bankRuleId: id }); + }; const isEmptyState = false; diff --git a/packages/webapp/src/hooks/query/bank-rules.ts b/packages/webapp/src/hooks/query/bank-rules.ts index d01d6c47d..02e19a8b1 100644 --- a/packages/webapp/src/hooks/query/bank-rules.ts +++ b/packages/webapp/src/hooks/query/bank-rules.ts @@ -21,12 +21,15 @@ export function useEditBankRule(props) { const queryClient = useQueryClient(); const apiRequest = useApiRequest(); - return useMutation((id: number) => apiRequest.post(`/bank-rules/${id}`), { - onSuccess: (res, id) => { - // Invalidate queries. + return useMutation( + ([id, values]) => apiRequest.post(`/banking/rules/${id}`, values), + { + onSuccess: (res, id) => { + // Invalidate queries. + }, + ...props, }, - ...props, - }); + ); } export function useDeleteBankRule(props) { From 7a9c7209bcaeadaf3fdc7bdb81acb4fd940af750 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Wed, 26 Jun 2024 17:39:12 +0200 Subject: [PATCH 19/42] feat: hook up the matching form to the server --- .../src/components/Aside/Aside.module.scss | 10 + .../webapp/src/components/Aside/Aside.tsx | 40 ++++ .../Banking/Rules/RulesList/RulesTable.tsx | 1 - .../AccountTransactionsUncategorizedTable.tsx | 25 +++ .../AccountTransactions/components.tsx | 8 +- .../CategorizeTransactionAside.module.scss | 44 +--- .../CategorizeTransactionAside.tsx | 141 +----------- .../CategorizeTransactionTabs.module.scss | 13 ++ .../CategorizeTransactionTabs.tsx | 23 ++ .../MatchTransaction.module.scss | 22 ++ .../MatchTransaction.tsx | 58 +++++ .../MatchingTransaction.tsx | 204 ++++++++++++++++++ .../MatchingTransactionBoot.tsx | 40 ++++ .../CategorizeTransactionAside/types.ts | 3 + .../CategorizeTransactionAside/utils.ts | 13 ++ packages/webapp/src/hooks/query/bank-rules.ts | 70 ++++++ 16 files changed, 538 insertions(+), 177 deletions(-) create mode 100644 packages/webapp/src/components/Aside/Aside.module.scss create mode 100644 packages/webapp/src/components/Aside/Aside.tsx create mode 100644 packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionTabs.module.scss create mode 100644 packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionTabs.tsx create mode 100644 packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchTransaction.module.scss create mode 100644 packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchTransaction.tsx create mode 100644 packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingTransaction.tsx create mode 100644 packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingTransactionBoot.tsx create mode 100644 packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/types.ts create mode 100644 packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/utils.ts diff --git a/packages/webapp/src/components/Aside/Aside.module.scss b/packages/webapp/src/components/Aside/Aside.module.scss new file mode 100644 index 000000000..a4a7c3a75 --- /dev/null +++ b/packages/webapp/src/components/Aside/Aside.module.scss @@ -0,0 +1,10 @@ +.title{ + align-items: center; + background: #fff; + border-bottom: 1px solid #E1E2E9; + display: flex; + flex: 0 0 auto; + min-height: 40px; + padding: 5px 5px 5px 15px; + z-index: 0; +} diff --git a/packages/webapp/src/components/Aside/Aside.tsx b/packages/webapp/src/components/Aside/Aside.tsx new file mode 100644 index 000000000..ade9d8c58 --- /dev/null +++ b/packages/webapp/src/components/Aside/Aside.tsx @@ -0,0 +1,40 @@ +import { Button, Classes } from '@blueprintjs/core'; +import { Box, Group } from '../Layout'; +import { Icon } from '../Icon'; +import styles from './Aside.module.scss'; + +interface AsideProps { + title?: string; + onClose?: () => void; + children?: React.ReactNode; + hideCloseButton?: boolean; +} + +export function Aside({ + title, + onClose, + children, + hideCloseButton, +}: AsideProps) { + const handleClose = () => { + onClose && onClose(); + }; + return ( + + + {title} + + {hideCloseButton !== true && ( + - - - - - ); -} diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionTabs.module.scss b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionTabs.module.scss new file mode 100644 index 000000000..b901d9160 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionTabs.module.scss @@ -0,0 +1,13 @@ + +.tabs :global .bp4-tab-panel{ + margin-top: 0; +} +.tabs :global .bp4-tab-list{ + background: #fff; + border-bottom: 1px solid #c7d5db; + padding: 0 22px; +} + +.tabs :global .bp4-large > .bp4-tab{ + font-size: 14px; +} diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionTabs.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionTabs.tsx new file mode 100644 index 000000000..8dd806111 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionTabs.tsx @@ -0,0 +1,23 @@ +import { Tab, Tabs } from '@blueprintjs/core'; +import { + CategorizeBankTransactionContent, + MatchingBankTransaction, +} from './MatchingTransaction'; +import styles from './CategorizeTransactionTabs.module.scss'; + +export function CategorizeTransactionTabs() { + return ( + + } + /> + } + /> + + ); +} diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchTransaction.module.scss b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchTransaction.module.scss new file mode 100644 index 000000000..f85e5d8fa --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchTransaction.module.scss @@ -0,0 +1,22 @@ + +.root{ + background: #fff; + border-radius: 5px; + border: 1px solid #D6DBE3; + padding: 12px 16px; + cursor: pointer; + + &.active{ + border-color: #88ABDB; + } + +} + + +.checkbox:global(.bp4-control.bp4-checkbox){ + margin: 0; +} +.checkbox:global(.bp4-control.bp4-checkbox) :global .bp4-control-indicator{ + border-color: #CBCBCB; +} + diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchTransaction.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchTransaction.tsx new file mode 100644 index 000000000..4458a26a5 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchTransaction.tsx @@ -0,0 +1,58 @@ +// @ts-nocheck +import clsx from 'classnames'; +import { Group, Stack } from '@/components'; +import { Checkbox, Text } from '@blueprintjs/core'; +import styles from './MatchTransaction.module.scss'; +import { useUncontrolled } from '@/hooks/useUncontrolled'; + +export interface MatchTransactionProps { + active?: boolean; + initialActive?: boolean; + onChange?: (state: boolean) => void; + label: string; + date: string; +} + +export function MatchTransaction({ + active, + initialActive, + onChange, + label, + date, +}: MatchTransactionProps) { + const [_active, handleChange] = useUncontrolled({ + value: active, + initialValue: initialActive, + finalValue: false, + onChange, + }); + + const handleClick = () => { + handleChange(!_active); + }; + + const handleCheckboxChange = (event) => { + handleChange(!event.target.checked); + }; + + return ( + + + {label} + Date: {date} + + + + + ); +} diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingTransaction.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingTransaction.tsx new file mode 100644 index 000000000..0f19bbcfb --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingTransaction.tsx @@ -0,0 +1,204 @@ +// @ts-nocheck +import { isEmpty } from 'lodash'; +import { AnchorButton, Button, Intent, Tag, Text } from '@blueprintjs/core'; +import { AppToaster, Box, Group, Stack } from '@/components'; +import { + MatchingTransactionBoot, + useMatchingTransactionBoot, +} from './MatchingTransactionBoot'; +import { MatchTransaction, MatchTransactionProps } from './MatchTransaction'; +import styles from './CategorizeTransactionAside.module.scss'; +import { FastField, FastFieldProps, Form, Formik } from 'formik'; +import { useMatchTransaction } from '@/hooks/query/bank-rules'; +import { MatchingTransactionFormValues } from './types'; +import { transformToReq } from './utils'; + +const initialValues = { + matched: {}, +}; + +export function MatchingBankTransaction() { + const uncategorizedTransactionId = 1; + const { mutateAsync: matchTransaction } = useMatchTransaction(); + + // Handles the form submitting. + const handleSubmit = (values: MatchingTransactionFormValues) => { + const _values = transformToReq(values); + + if (_values.matchedTransactions?.length === 0) { + AppToaster.show({ + message: 'You should select at least one transaction for matching.', + intent: Intent.DANGER, + }); + return; + } + matchTransaction([uncategorizedTransactionId, _values]) + .then(() => { + AppToaster.show({ + intent: Intent.SUCCESS, + message: 'The bank transaction has been matched successfully.', + }); + }) + .catch((err) => { + AppToaster.show({ + intent: Intent.DANGER, + message: 'Something went wrong.', + }); + }); + }; + + return ( + + + + + + + + ); +} + +function MatchingBankTransactionContent() { + return ( + + + + + + ); +} + +/** + * Renders the perfect match transactions. + * @returns {React.ReactNode} + */ +function PerfectMatchingTransactions() { + const { matchingTransactions } = useMatchingTransactionBoot(); + + // Can't continue if the perfect matches is empty. + if (isEmpty(matchingTransactions)) { + return null; + } + return ( + <> + + +

Perfect Matchines

+ + 2 + +
+
+ + + {matchingTransactions.map((match, index) => ( + + ))} + + + ); +} + +/** + * Renders the possible match transactions. + * @returns {React.ReactNode} + */ +function GoodMatchingTransactions() { + const { matchingTransactions } = useMatchingTransactionBoot(); + + // Can't continue if the possible matches is emoty. + if (isEmpty(matchingTransactions)) { + return null; + } + return ( + <> + + +

Possible Matches

+ + Transactions up to 20 Aug 2019 + +
+
+ + + {matchingTransactions.map((match, index) => ( + + ))} + + + ); +} +interface MatchTransactionFieldProps + extends Omit { + transactionId: number; + transactionType: string; +} + +function MatchTransactionField({ + transactionId, + transactionType, + ...props +}: MatchTransactionFieldProps) { + const name = `matched.${transactionType}-${transactionId}`; + + return ( + + {({ form, field: { value } }: FastFieldProps) => ( + { + form.setFieldValue(name, state); + }} + /> + )} + + ); +} + +export function CategorizeBankTransactionContent() { + return

Categorizing

; +} + +/** + * Renders the match transactions footer. + * @returns {React.ReactNode} + */ +function MatchTransactionFooter() { + return ( + + + + + Add Reconcile Transaction + + + Pending $10,000 + + + + + + + + + + + ); +} diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingTransactionBoot.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingTransactionBoot.tsx new file mode 100644 index 000000000..2c6b67ac6 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingTransactionBoot.tsx @@ -0,0 +1,40 @@ +import { useMatchingTransactions } from '@/hooks/query/bank-rules'; +import React, { createContext } from 'react'; + +interface MatchingTransactionBootValues { + isMatchingTransactionsLoading: boolean; + matchingTransactions: Array; + perfectMatchesCount: number; + perfectMatches: Array; + matches: Array; +} + +const RuleFormBootContext = createContext( + {} as MatchingTransactionBootValues, +); + +interface RuleFormBootProps { + children: React.ReactNode; +} + +function MatchingTransactionBoot({ ...props }: RuleFormBootProps) { + const { + data: matchingTransactions, + isLoading: isMatchingTransactionsLoading, + } = useMatchingTransactions(); + + const provider = { + isMatchingTransactionsLoading, + matchingTransactions, + perfectMatchesCount: 2, + perfectMatches: [], + matches: [], + } as MatchingTransactionBootValues; + + return ; +} + +const useMatchingTransactionBoot = () => + React.useContext(RuleFormBootContext); + +export { MatchingTransactionBoot, useMatchingTransactionBoot }; diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/types.ts b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/types.ts new file mode 100644 index 000000000..3a88e7edc --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/types.ts @@ -0,0 +1,3 @@ +export interface MatchingTransactionFormValues { + matched: Record; +} diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/utils.ts b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/utils.ts new file mode 100644 index 000000000..e3d30bbe0 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/utils.ts @@ -0,0 +1,13 @@ +import { MatchingTransactionFormValues } from './types'; + +export const transformToReq = (values: MatchingTransactionFormValues) => { + const matchedTransactions = Object.entries(values.matched) + .filter(([key, value]) => value) + .map(([key]) => { + const [reference_type, reference_id] = key.split('-'); + + return { reference_type, reference_id: parseInt(reference_id, 10) }; + }); + + return { matchedTransactions }; +}; diff --git a/packages/webapp/src/hooks/query/bank-rules.ts b/packages/webapp/src/hooks/query/bank-rules.ts index 02e19a8b1..15f47e87e 100644 --- a/packages/webapp/src/hooks/query/bank-rules.ts +++ b/packages/webapp/src/hooks/query/bank-rules.ts @@ -1,6 +1,7 @@ // @ts-nocheck import { useMutation, useQuery, useQueryClient } from 'react-query'; import useApiRequest from '../useRequest'; +import { transformToCamelCase } from '@/utils'; /** * @@ -75,3 +76,72 @@ export function useBankRule(bankRuleId: number, props) { props, ); } + +/** + * + * @returns + */ +export function useMatchingTransactions(props?: any) { + const apiRequest = useApiRequest(); + + return useQuery( + ['MATCHING_TRANSACTION'], + () => + apiRequest + .get(`/banking/matches`) + .then((res) => transformToCamelCase(res.data.data)), + props, + ); +} + +export function useExcludeUncategorizedTransaction(props) { + const queryClient = useQueryClient(); + const apiRequest = useApiRequest(); + + return useMutation( + (uncategorizedTransactionId: number) => + apiRequest.put( + `/cashflow/transactions/${uncategorizedTransactionId}/exclude`, + ), + { + onSuccess: (res, id) => { + // Invalidate queries. + }, + ...props, + }, + ); +} + +export function useUnexcludeUncategorizedTransaction(props) { + const queryClient = useQueryClient(); + const apiRequest = useApiRequest(); + + return useMutation( + (uncategorizedTransactionId: number) => + apiRequest.post( + `/cashflow/transactions/${uncategorizedTransactionId}/unexclude`, + ), + { + onSuccess: (res, id) => { + // Invalidate queries. + }, + ...props, + }, + ); +} + +export function useMatchTransaction(props?: any) { + const queryClient = useQueryClient(); + const apiRequest = useApiRequest(); + + return useMutation( + ([uncategorizedTransactionId, values]) => + apiRequest.post(`/banking/matches/${uncategorizedTransactionId}`, values), + { + onSuccess: (res, id) => { + // Invalidate queries. + }, + ...props, + }, + ); +} From d305c7ad32757ecb1c8691dcc971d1d630dbacd2 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Wed, 26 Jun 2024 19:33:01 +0200 Subject: [PATCH 20/42] feat: toggle banking matching aside --- .../src/components/AppShell/AppShell.tsx | 26 ++++++++++++++-- .../components/AppShell/AppShellProvider.tsx | 31 ++++++++++++++----- .../AccountTransactionsList.tsx | 18 +++++++++-- .../AccountTransactionsUncategorizedTable.tsx | 12 ++++--- .../AllTransactionsUncategorized.tsx | 21 ++++++++++++- .../CategorizeTransactionBoot.tsx | 7 +---- .../CategorizeTransactionContent.tsx | 8 ++--- .../CategorizeTransactionFormFooter.tsx | 10 +++--- .../CategorizeTransactionAside.tsx | 22 +++++++++++-- .../CategorizeTransactionTabs.tsx | 8 ++--- .../src/containers/CashFlow/withBanking.ts | 15 +++++++++ .../containers/CashFlow/withBankingActions.ts | 30 ++++++++++++++++++ .../src/store/banking/banking.actions.ts | 21 +++++++++++++ .../src/store/banking/banking.reducer.ts | 30 ++++++++++++++++-- 14 files changed, 215 insertions(+), 44 deletions(-) create mode 100644 packages/webapp/src/containers/CashFlow/withBanking.ts create mode 100644 packages/webapp/src/containers/CashFlow/withBankingActions.ts create mode 100644 packages/webapp/src/store/banking/banking.actions.ts diff --git a/packages/webapp/src/components/AppShell/AppShell.tsx b/packages/webapp/src/components/AppShell/AppShell.tsx index 8539117c2..57a84133a 100644 --- a/packages/webapp/src/components/AppShell/AppShell.tsx +++ b/packages/webapp/src/components/AppShell/AppShell.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { AppShellProvider } from './AppShellProvider'; +import { AppShellProvider, useAppShellContext } from './AppShellProvider'; import { Box } from '../Layout'; import styles from './AppShell.module.scss'; @@ -8,16 +8,26 @@ interface AppShellProps { mainProps: any; asideProps: any; children: React.ReactNode; + hideAside?: boolean; + hideMain?: boolean; } export function AppShell({ asideProps, mainProps, topbarOffset = 0, + hideAside = false, + hideMain = false, ...restProps }: AppShellProps) { return ( - + ); @@ -27,6 +37,12 @@ AppShell.Main = AppShellMain; AppShell.Aside = AppShellAside; function AppShellMain({ ...props }) { + const { hideMain } = useAppShellContext(); + + if (hideMain === true) { + return null; + } + return ; } @@ -35,5 +51,11 @@ interface AppShellAsideProps { } function AppShellAside({ ...props }: AppShellAsideProps) { + const { hideAside } = useAppShellContext(); + + console.log(hideAside, 'hideAsidehideAsidehideAsidehideAside'); + if (hideAside === true) { + return null; + } return ; } diff --git a/packages/webapp/src/components/AppShell/AppShellProvider.tsx b/packages/webapp/src/components/AppShell/AppShellProvider.tsx index 299f015f4..8ac2a1ff0 100644 --- a/packages/webapp/src/components/AppShell/AppShellProvider.tsx +++ b/packages/webapp/src/components/AppShell/AppShellProvider.tsx @@ -1,22 +1,37 @@ +// @ts-nocheck import React, { createContext } from 'react'; -interface AppShellContextValue { - topbarOffset: number +interface ContentShellCommonValue { + mainProps: any; + asideProps: any; + topbarOffset: number; + hideAside: boolean; + hideMain: boolean; +} + +interface AppShellContextValue extends ContentShellCommonValue { + topbarOffset: number; } const AppShellContext = createContext( {} as AppShellContextValue, ); -interface AppShellProviderProps { +interface AppShellProviderProps extends ContentShellCommonValue { children: React.ReactNode; - mainProps: any; - asideProps: any; - topbarOffset: number; } -export function AppShellProvider({ topbarOffset, ...props }: AppShellProviderProps) { - const provider = { topbarOffset } as AppShellContextValue; +export function AppShellProvider({ + topbarOffset, + hideAside, + hideMain, + ...props +}: AppShellProviderProps) { + const provider = { + topbarOffset, + hideAside, + hideMain, + } as AppShellContextValue; return ; } diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsList.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsList.tsx index a6db2db7e..5f0d743ab 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsList.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsList.tsx @@ -1,5 +1,6 @@ // @ts-nocheck import React, { Suspense } from 'react'; +import * as R from 'ramda'; import { Spinner } from '@blueprintjs/core'; import '@/style/pages/CashFlow/AccountTransactions/List.scss'; @@ -16,14 +17,18 @@ import { AccountTransactionsProgressBar } from './components'; import { AccountTransactionsFilterTabs } from './AccountTransactionsFilterTabs'; import { AppShell } from '@/components/AppShell/AppShell'; import { CategorizeTransactionAside } from '../CategorizeTransactionAside/CategorizeTransactionAside'; +import { withBanking } from '../withBanking'; /** * Account transactions list. */ -function AccountTransactionsList() { +function AccountTransactionsListRoot({ + // #withBanking + openMatchingTransactionAside, +}) { return ( - + @@ -46,7 +51,14 @@ function AccountTransactionsList() { ); } -export default AccountTransactionsList; +export default R.compose( + withBanking( + ({ selectedUncategorizedTransactionId, openMatchingTransactionAside }) => ({ + selectedUncategorizedTransactionId, + openMatchingTransactionAside, + }), + ), +)(AccountTransactionsListRoot); const AccountsTransactionsAll = React.lazy( () => import('./AccountsTransactionsAll'), diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsUncategorizedTable.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsUncategorizedTable.tsx index 8bd895198..a8abfa503 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsUncategorizedTable.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsUncategorizedTable.tsx @@ -15,6 +15,7 @@ import { TABLES } from '@/constants/tables'; import withSettings from '@/containers/Settings/withSettings'; import withDrawerActions from '@/containers/Drawer/withDrawerActions'; +import { withBankingActions } from '../withBankingActions'; import { useMemorizedColumnsWidths } from '@/hooks'; import { @@ -24,7 +25,6 @@ import { import { useAccountUncategorizedTransactionsContext } from './AllTransactionsUncategorizedBoot'; import { compose } from '@/utils'; -import { DRAWERS } from '@/constants/drawers'; import { useExcludeUncategorizedTransaction } from '@/hooks/query/bank-rules'; import { Intent } from '@blueprintjs/core'; @@ -35,6 +35,9 @@ function AccountTransactionsDataTable({ // #withSettings cashflowTansactionsTableSize, + // #withBankingActions + setUncategorizedTransactionIdForMatching, + // #withDrawerActions openDrawer, }) { @@ -53,10 +56,8 @@ function AccountTransactionsDataTable({ useMemorizedColumnsWidths(TABLES.UNCATEGORIZED_CASHFLOW_TRANSACTION); // Handle cell click. - const handleCellClick = (cell, event) => { - openDrawer(DRAWERS.CATEGORIZE_TRANSACTION, { - uncategorizedTransactionId: cell.row.original.id, - }); + const handleCellClick = (cell) => { + setUncategorizedTransactionIdForMatching(cell.row.original.id); }; // Handle exclude transaction. const handleExcludeTransaction = (transaction) => { @@ -111,6 +112,7 @@ export default compose( cashflowTansactionsTableSize: cashflowTransactionsSettings?.tableSize, })), withDrawerActions, + withBankingActions, )(AccountTransactionsDataTable); const DashboardConstrantTable = styled(DataTable)` diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AllTransactionsUncategorized.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AllTransactionsUncategorized.tsx index 716712a0d..182dd7541 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/AllTransactionsUncategorized.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AllTransactionsUncategorized.tsx @@ -1,10 +1,16 @@ // @ts-nocheck import styled from 'styled-components'; +import * as R from 'ramda'; import '@/style/pages/CashFlow/AccountTransactions/List.scss'; import AccountTransactionsUncategorizedTable from './AccountTransactionsUncategorizedTable'; import { AccountUncategorizedTransactionsBoot } from './AllTransactionsUncategorizedBoot'; +import { + WithBankingActionsProps, + withBankingActions, +} from '../withBankingActions'; +import { useEffect } from 'react'; const Box = styled.div` margin: 30px 15px; @@ -18,7 +24,18 @@ const CashflowTransactionsTableCard = styled.div` flex: 0 1; `; -export default function AllTransactionsUncategorized() { +interface AllTransactionsUncategorizedProps extends WithBankingActionsProps {} + +function AllTransactionsUncategorizedRoot({ + // #withBankingActions + closeMatchingTransactionAside, +}: AllTransactionsUncategorizedProps) { + useEffect( + () => () => { + closeMatchingTransactionAside(); + }, + [closeMatchingTransactionAside], + ); return ( @@ -29,3 +46,5 @@ export default function AllTransactionsUncategorized() { ); } + +export default R.compose(withBankingActions)(AllTransactionsUncategorizedRoot); diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionBoot.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionBoot.tsx index dbd9e00fc..1f7515066 100644 --- a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionBoot.tsx +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionBoot.tsx @@ -1,8 +1,7 @@ // @ts-nocheck import React, { useMemo } from 'react'; import { first } from 'lodash'; -import { DrawerHeaderContent, DrawerLoading } from '@/components'; -import { DRAWERS } from '@/constants/drawers'; +import { DrawerLoading } from '@/components'; import { useAccounts, useBranches, @@ -56,10 +55,6 @@ function CategorizeTransactionBoot({ uncategorizedTransactionId, ...props }) { return ( - ); diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionContent.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionContent.tsx index 7716e8beb..c3b3413b9 100644 --- a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionContent.tsx +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionContent.tsx @@ -4,9 +4,9 @@ import { DrawerBody } from '@/components'; import { CategorizeTransactionBoot } from './CategorizeTransactionBoot'; import { CategorizeTransactionForm } from './CategorizeTransactionForm'; -export default function CategorizeTransactionContent({ - uncategorizedTransactionId, -}) { +export function CategorizeTransactionContent({}) { + const uncategorizedTransactionId = 4; + return ( { + closeMatchingTransactionAside(); + }; -export function CategorizeTransactionAside() { return ( -