diff --git a/packages/server/src/api/controllers/Banking/BankAccountsController.ts b/packages/server/src/api/controllers/Banking/BankAccountsController.ts new file mode 100644 index 000000000..4b062768f --- /dev/null +++ b/packages/server/src/api/controllers/Banking/BankAccountsController.ts @@ -0,0 +1,49 @@ +import { Inject, Service } from 'typedi'; +import { NextFunction, Request, Response, Router } from 'express'; +import BaseController from '@/api/controllers/BaseController'; +import { CashflowApplication } from '@/services/Cashflow/CashflowApplication'; +import { GetBankAccountSummary } from '@/services/Banking/BankAccounts/GetBankAccountSummary'; + +@Service() +export class BankAccountsController extends BaseController { + @Inject() + private getBankAccountSummaryService: GetBankAccountSummary; + + /** + * Router constructor. + */ + router() { + const router = Router(); + + router.get('/:bankAccountId/meta', this.getBankAccountSummary.bind(this)); + + return router; + } + + /** + * Retrieves the bank account meta summary. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + * @returns {Promise} + */ + async getBankAccountSummary( + req: Request<{ bankAccountId: number }>, + res: Response, + next: NextFunction + ) { + const { bankAccountId } = req.params; + const { tenantId } = req; + + try { + const data = + await this.getBankAccountSummaryService.getBankAccountSummary( + tenantId, + bankAccountId + ); + return res.status(200).send({ data }); + } catch (error) { + next(error); + } + } +} 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..23c392e46 --- /dev/null +++ b/packages/server/src/api/controllers/Banking/BankTransactionsMatchingController.ts @@ -0,0 +1,103 @@ +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 { body, param } from 'express-validator'; +import { + GetMatchedTransactionsFilter, + IMatchTransactionsDTO, +} 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(), + body('matchedTransactions').isArray({ min: 1 }), + body('matchedTransactions.*.reference_type').exists(), + body('matchedTransactions.*.reference_id').isNumeric().toInt(), + ], + this.validationResult, + this.matchBankTransaction.bind(this) + ); + router.post( + '/unmatch/:transactionId', + [param('transactionId').exists()], + this.validationResult, + this.unmatchMatchedBankTransaction.bind(this) + ); + return router; + } + + /** + * Matches the given bank transaction. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + * @returns {Promise} + */ + private async matchBankTransaction( + req: Request<{ transactionId: number }>, + res: Response, + next: NextFunction + ) { + const { tenantId } = req; + const { transactionId } = req.params; + const matchTransactionDTO = this.matchedBodyData( + req + ) as IMatchTransactionsDTO; + + try { + await this.bankTransactionsMatchingApp.matchTransaction( + tenantId, + transactionId, + matchTransactionDTO + ); + return res.status(200).send({ + id: transactionId, + message: 'The bank transaction has been matched.', + }); + } catch (error) { + next(error); + } + } + + /** + * Unmatches the matched bank transaction. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + * @returns {Promise} + */ + private async unmatchMatchedBankTransaction( + req: Request<{ transactionId: number }>, + res: Response, + next: NextFunction + ) { + const { tenantId } = req; + const { transactionId } = req.params; + + try { + await this.bankTransactionsMatchingApp.unmatchMatchedTransaction( + tenantId, + transactionId + ); + return res.status(200).send({ + id: transactionId, + message: 'The bank matched transaction has been unmatched.', + }); + } 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 27838a285..30695b19d 100644 --- a/packages/server/src/api/controllers/Banking/BankingController.ts +++ b/packages/server/src/api/controllers/Banking/BankingController.ts @@ -2,17 +2,33 @@ import Container, { Inject, Service } from 'typedi'; import { Router } from 'express'; import BaseController from '@/api/controllers/BaseController'; import { PlaidBankingController } from './PlaidBankingController'; +import { BankingRulesController } from './BankingRulesController'; +import { BankTransactionsMatchingController } from './BankTransactionsMatchingController'; +import { RecognizedTransactionsController } from './RecognizedTransactionsController'; +import { BankAccountsController } from './BankAccountsController'; @Service() export class BankingController extends BaseController { /** * Router constructor. */ - router() { + public router() { const router = Router(); router.use('/plaid', Container.get(PlaidBankingController).router()); - + router.use('/rules', Container.get(BankingRulesController).router()); + router.use( + '/matches', + Container.get(BankTransactionsMatchingController).router() + ); + router.use( + '/recognized', + Container.get(RecognizedTransactionsController).router() + ); + router.use( + '/bank_accounts', + Container.get(BankAccountsController).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..f005a0da0 --- /dev/null +++ b/packages/server/src/api/controllers/Banking/BankingRulesController.ts @@ -0,0 +1,214 @@ +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 }), + + body('recognition').isBoolean().toBoolean().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 {NextFunction} next + */ + private 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 {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + private 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 {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + private async deleteBankRule( + 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.', id: ruleId }); + } catch (error) { + next(error); + } + } + + /** + * Retrieve the given bank rule. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + private 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 {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + private 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/api/controllers/Banking/ExcludeBankTransactionsController.ts b/packages/server/src/api/controllers/Banking/ExcludeBankTransactionsController.ts new file mode 100644 index 000000000..79a6c7336 --- /dev/null +++ b/packages/server/src/api/controllers/Banking/ExcludeBankTransactionsController.ts @@ -0,0 +1,124 @@ +import { Inject, Service } from 'typedi'; +import { param } from 'express-validator'; +import { NextFunction, Request, Response, Router, query } from 'express'; +import BaseController from '../BaseController'; +import { ExcludeBankTransactionsApplication } from '@/services/Banking/Exclude/ExcludeBankTransactionsApplication'; + +@Service() +export class ExcludeBankTransactionsController extends BaseController { + @Inject() + private 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) + ); + router.get( + '/excluded', + [], + this.validationResult, + this.getExcludedBankTransactions.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); + } + } + + /** + * Retrieves the excluded uncategorized bank transactions. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + * @returns {Promise} + */ + private async getExcludedBankTransactions( + req: Request, + res: Response, + next: NextFunction + ): Promise { + const { tenantId } = req; + const filter = this.matchedBodyData(req); + + console.log('123'); + try { + const data = + await this.excludeBankTransactionApp.getExcludedBankTransactions( + tenantId, + filter + ); + return res.status(200).send(data); + } catch (error) { + next(error); + } + } +} diff --git a/packages/server/src/api/controllers/Banking/RecognizedTransactionsController.ts b/packages/server/src/api/controllers/Banking/RecognizedTransactionsController.ts new file mode 100644 index 000000000..b87662f14 --- /dev/null +++ b/packages/server/src/api/controllers/Banking/RecognizedTransactionsController.ts @@ -0,0 +1,77 @@ +import { Inject, Service } from 'typedi'; +import { NextFunction, Request, Response, Router } from 'express'; +import BaseController from '@/api/controllers/BaseController'; +import { CashflowApplication } from '@/services/Cashflow/CashflowApplication'; + +@Service() +export class RecognizedTransactionsController extends BaseController { + @Inject() + private cashflowApplication: CashflowApplication; + + /** + * Router constructor. + */ + router() { + const router = Router(); + + router.get('/', this.getRecognizedTransactions.bind(this)); + router.get( + '/transactions/:uncategorizedTransactionId', + this.getRecognizedTransaction.bind(this) + ); + + return router; + } + + /** + * Retrieves the recognized bank transactions. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + * @returns {Promise} + */ + async getRecognizedTransactions( + req: Request<{ accountId: number }>, + res: Response, + next: NextFunction + ) { + const filter = this.matchedQueryData(req); + const { tenantId } = req; + + try { + const data = await this.cashflowApplication.getRecognizedTransactions( + tenantId, + filter + ); + return res.status(200).send(data); + } catch (error) { + next(error); + } + } + + /** + * Retrieves the recognized transaction of the ginen uncategorized transaction. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + * @returns {Promise} + */ + async getRecognizedTransaction( + req: Request<{ uncategorizedTransactionId: number }>, + res: Response, + next: NextFunction + ) { + const { tenantId } = req; + const { uncategorizedTransactionId } = req.params; + + try { + const data = await this.cashflowApplication.getRecognizedTransaction( + tenantId, + uncategorizedTransactionId + ); + return res.status(200).send({ data }); + } 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/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/api/controllers/Cashflow/GetCashflowTransaction.ts b/packages/server/src/api/controllers/Cashflow/GetCashflowTransaction.ts index 2625a1cb9..6b0be76e2 100644 --- a/packages/server/src/api/controllers/Cashflow/GetCashflowTransaction.ts +++ b/packages/server/src/api/controllers/Cashflow/GetCashflowTransaction.ts @@ -6,18 +6,27 @@ import { ServiceError } from '@/exceptions'; import CheckPolicies from '@/api/middleware/CheckPolicies'; import { AbilitySubject, CashflowAction } from '@/interfaces'; import { CashflowApplication } from '@/services/Cashflow/CashflowApplication'; +import { GetMatchedTransactionsFilter } from '@/services/Banking/Matching/types'; +import { MatchBankTransactionsApplication } from '@/services/Banking/Matching/MatchBankTransactionsApplication'; @Service() export default class GetCashflowAccounts extends BaseController { @Inject() private cashflowApplication: CashflowApplication; + @Inject() + private bankTransactionsMatchingApp: MatchBankTransactionsApplication; + /** * Controller router. */ public router() { const router = Router(); + router.get( + '/transactions/:transactionId/matches', + this.getMatchedTransactions.bind(this) + ); router.get( '/transactions/:transactionId', CheckPolicies(CashflowAction.View, AbilitySubject.Cashflow), @@ -47,7 +56,6 @@ export default class GetCashflowAccounts extends BaseController { tenantId, transactionId ); - return res.status(200).send({ cashflow_transaction: this.transfromToResponse(cashflowTransaction), }); @@ -56,6 +64,34 @@ export default class GetCashflowAccounts extends BaseController { } }; + /** + * Retrieves the matched transactions. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + private async getMatchedTransactions( + req: Request<{ transactionId: number }>, + res: Response, + next: NextFunction + ) { + const { tenantId } = req; + const { transactionId } = req.params; + const filter = this.matchedQueryData(req) as GetMatchedTransactionsFilter; + + try { + const data = + await this.bankTransactionsMatchingApp.getMatchedTransactions( + tenantId, + transactionId, + filter + ); + return res.status(200).send(data); + } catch (error) { + next(error); + } + } + /** * Catches the service errors. * @param {Error} error - 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..c11ce89f2 --- /dev/null +++ b/packages/server/src/database/migrations/20240618100137_create_bank_rules_table.js @@ -0,0 +1,45 @@ +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() + .references('id') + .inTable('accounts'); + table.string('apply_if_transaction_type'); + + table.string('assign_category'); + table + .integer('assign_account_id') + .unsigned() + .references('id') + .inTable('accounts'); + 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() + .references('id') + .inTable('bank_rules'); + 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/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..aed658e65 --- /dev/null +++ b/packages/server/src/database/migrations/20240618171553_create_recognized_bank_transactions_table.js @@ -0,0 +1,30 @@ +exports.up = function (knex) { + return knex.schema.createTable('recognized_bank_transactions', (table) => { + table.increments('id'); + table + .integer('uncategorized_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() + .references('id') + .inTable('accounts'); + 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/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..2ca0a0966 --- /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').unsigned(); + table.decimal('amount'); + table.timestamps(); + }); +}; + +exports.down = function (knex) { + return knex.schema.dropTableIfExists('matched_bank_transactions'); +}; 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..f27351438 --- /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.datetime('excluded_at'); + }); +}; + +exports.down = function (knex) { + return knex.schema.table('uncategorized_cashflow_transactions', (table) => { + table.dropColumn('excluded_at'); + }); +}; 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/CashflowService.ts b/packages/server/src/interfaces/CashflowService.ts index 7d427b998..cabdea423 100644 --- a/packages/server/src/interfaces/CashflowService.ts +++ b/packages/server/src/interfaces/CashflowService.ts @@ -164,3 +164,10 @@ export interface IGetUncategorizedTransactionsQuery { page?: number; pageSize?: number; } + + +export interface IGetRecognizedTransactionsQuery { + page?: number; + pageSize?: number; + accountId?: number; +} \ No newline at end of file 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/lib/Transformer/Transformer.ts b/packages/server/src/lib/Transformer/Transformer.ts index 185e09c16..20efb4415 100644 --- a/packages/server/src/lib/Transformer/Transformer.ts +++ b/packages/server/src/lib/Transformer/Transformer.ts @@ -169,8 +169,8 @@ export class Transformer { * @param number * @returns */ - protected formatNumber(number) { - return formatNumber(number, { money: false }); + protected formatNumber(number, props?) { + return formatNumber(number, { money: false, ...props }); } /** diff --git a/packages/server/src/loaders/eventEmitter.ts b/packages/server/src/loaders/eventEmitter.ts index ad83f6dec..6a282a8f7 100644 --- a/packages/server/src/loaders/eventEmitter.ts +++ b/packages/server/src/loaders/eventEmitter.ts @@ -102,6 +102,14 @@ 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'; +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'; +import { RecognizeSyncedBankTranasctions } from '@/services/Banking/Plaid/subscribers/RecognizeSyncedBankTransactions'; +import { UnlinkBankRuleOnDeleteBankRule } from '@/services/Banking/Rules/events/UnlinkBankRuleOnDeleteBankRule'; export default () => { return new EventPublisher(); @@ -246,5 +254,19 @@ export const susbcribers = () => { AttachmentsOnBillPayments, AttachmentsOnManualJournals, AttachmentsOnExpenses, + + // Bank Rules + TriggerRecognizedTransactions, + UnlinkBankRuleOnDeleteBankRule, + + // Validate matching + ValidateMatchingOnCashflowDelete, + ValidateMatchingOnExpenseDelete, + ValidateMatchingOnManualJournalDelete, + ValidateMatchingOnPaymentReceivedDelete, + ValidateMatchingOnPaymentMadeDelete, + + // Plaid + RecognizeSyncedBankTranasctions, ]; }; 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/loaders/tenantModels.ts b/packages/server/src/loaders/tenantModels.ts index 5183e85ae..02877491a 100644 --- a/packages/server/src/loaders/tenantModels.ts +++ b/packages/server/src/loaders/tenantModels.ts @@ -64,6 +64,10 @@ 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'; +import { RecognizedBankTransaction } from '@/models/RecognizedBankTransaction'; +import { MatchedBankTransaction } from '@/models/MatchedBankTransaction'; export default (knex) => { const models = { @@ -131,6 +135,10 @@ export default (knex) => { DocumentLink, PlaidItem, UncategorizedCashflowTransaction, + BankRule, + BankRuleCondition, + RecognizedBankTransaction, + MatchedBankTransaction, }; 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..051a0f226 --- /dev/null +++ b/packages/server/src/models/BankRule.ts @@ -0,0 +1,70 @@ +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 + */ + 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'); + const Account = require('models/Account'); + + return { + /** + * Sale invoice associated entries. + */ + conditions: { + relation: Model.HasManyRelation, + modelClass: BankRuleCondition, + join: { + from: 'bank_rules.id', + to: 'bank_rule_conditions.ruleId', + }, + }, + + /** + * Bank rule may associated to the assign account. + */ + assignAccount: { + relation: Model.BelongsToOneRelation, + modelClass: Account.default, + join: { + from: 'bank_rules.assignAccountId', + to: 'accounts.id', + }, + }, + }; + } +} 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/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/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/models/MatchedBankTransaction.ts b/packages/server/src/models/MatchedBankTransaction.ts new file mode 100644 index 000000000..4d5df6315 --- /dev/null +++ b/packages/server/src/models/MatchedBankTransaction.ts @@ -0,0 +1,32 @@ +import TenantModel from 'models/TenantModel'; +import { Model } from 'objection'; + +export class MatchedBankTransaction extends TenantModel { + /** + * Table name. + */ + static get tableName() { + return 'matched_bank_transactions'; + } + + /** + * Timestamps columns. + */ + get timestamps() { + return ['createdAt', 'updatedAt']; + } + + /** + * Virtual attributes. + */ + static get virtualAttributes() { + return []; + } + + /** + * Relationship mapping. + */ + static get relationMappings() { + return {}; + } +} diff --git a/packages/server/src/models/RecognizedBankTransaction.ts b/packages/server/src/models/RecognizedBankTransaction.ts new file mode 100644 index 000000000..32798445e --- /dev/null +++ b/packages/server/src/models/RecognizedBankTransaction.ts @@ -0,0 +1,72 @@ +import TenantModel from 'models/TenantModel'; +import { Model } from 'objection'; + +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 []; + } + + /** + * Relationship mapping. + */ + static get relationMappings() { + const UncategorizedCashflowTransaction = require('./UncategorizedCashflowTransaction'); + const Account = require('./Account'); + const { BankRule } = require('./BankRule'); + + return { + /** + * Recognized bank transaction may belongs to uncategorized transactions. + */ + uncategorizedTransactions: { + relation: Model.HasManyRelation, + modelClass: UncategorizedCashflowTransaction.default, + join: { + from: 'recognized_bank_transactions.uncategorizedTransactionId', + to: 'uncategorized_cashflow_transactions.id', + }, + }, + + /** + * Recognized bank transaction may belongs to assign account. + */ + assignAccount: { + relation: Model.BelongsToOneRelation, + modelClass: Account.default, + join: { + from: 'recognized_bank_transactions.assignedAccountId', + to: 'accounts.id', + }, + }, + + /** + * Recognized bank transaction may belongs to bank rule. + */ + bankRule: { + relation: Model.BelongsToOneRelation, + modelClass: BankRule, + join: { + from: 'recognized_bank_transactions.bankRuleId', + to: 'bank_rules.id', + }, + }, + }; + } +} 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 bcd4a2770..3b6b8723b 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. @@ -38,6 +44,7 @@ export default class UncategorizedCashflowTransaction extends mixin( 'deposit', 'isDepositTransaction', 'isWithdrawalTransaction', + 'isRecognized', ]; } @@ -75,11 +82,43 @@ export default class UncategorizedCashflowTransaction extends mixin( return 0 < this.withdrawal; } + /** + * Detarmines whether the transaction is recognized. + */ + public get isRecognized(): boolean { + return !!this.recognizedTransactionId; + } + + /** + * Model modifiers. + */ + static get modifiers() { + return { + /** + * Filters the not excluded transactions. + */ + notExcluded(query) { + query.whereNull('excluded_at'); + }, + + /** + * Filters the excluded transactions. + */ + excluded(query) { + query.whereNotNull('excluded_at') + } + }; + }, + /** * Relationship mapping. */ static get relationMappings() { const Account = require('models/Account'); + const { + RecognizedBankTransaction, + } = require('models/RecognizedBankTransaction'); + const { MatchedBankTransaction } = require('models/MatchedBankTransaction'); return { /** @@ -93,6 +132,30 @@ 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', + }, + }, + + /** + * Uncategorized transaction may has association to matched transaction. + */ + matchedBankTransactions: { + relation: Model.HasManyRelation, + modelClass: MatchedBankTransaction, + join: { + from: 'uncategorized_cashflow_transactions.id', + to: 'matched_bank_transactions.uncategorizedTransactionId', + }, + }, }; } diff --git a/packages/server/src/services/Banking/BankAccounts/GetBankAccountSummary.ts b/packages/server/src/services/Banking/BankAccounts/GetBankAccountSummary.ts new file mode 100644 index 000000000..6a07e614a --- /dev/null +++ b/packages/server/src/services/Banking/BankAccounts/GetBankAccountSummary.ts @@ -0,0 +1,55 @@ +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { Server } from 'socket.io'; +import { Inject, Service } from 'typedi'; + +@Service() +export class GetBankAccountSummary { + @Inject() + private tenancy: HasTenancyService; + + /** + * Retrieves the bank account meta summary + * @param {number} tenantId + * @param {number} bankAccountId + * @returns + */ + public async getBankAccountSummary(tenantId: number, bankAccountId: number) { + const { + Account, + UncategorizedCashflowTransaction, + RecognizedBankTransaction, + } = this.tenancy.models(tenantId); + + const bankAccount = await Account.query() + .findById(bankAccountId) + .throwIfNotFound(); + + // Retrieves the uncategorized transactions count of the given bank account. + const uncategorizedTranasctionsCount = + await UncategorizedCashflowTransaction.query() + .where('accountId', bankAccountId) + .count('id as total') + .first(); + + // Retrieves the recognized transactions count of the given bank account. + const recognizedTransactionsCount = await RecognizedBankTransaction.query() + .whereExists( + UncategorizedCashflowTransaction.query().where( + 'accountId', + bankAccountId + ) + ) + .count('id as total') + .first(); + + const totalUncategorizedTransactions = + uncategorizedTranasctionsCount?.total; + const totalRecognizedTransactions = recognizedTransactionsCount?.total; + + return { + name: bankAccount.name, + totalUncategorizedTransactions, + totalRecognizedTransactions, + }; + } +} 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..7a1938199 --- /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, + uncategorizedTransactionId: number + ) { + const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId); + + const oldUncategorizedTransaction = + await UncategorizedCashflowTransaction.query() + .findById(uncategorizedTransactionId) + .throwIfNotFound(); + + validateTransactionNotCategorized(oldUncategorizedTransaction); + + return this.uow.withTransaction(tenantId, async (trx) => { + await UncategorizedCashflowTransaction.query(trx) + .findById(uncategorizedTransactionId) + .patch({ + excludedAt: new Date(), + }); + }); + } +} 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..a87b63815 --- /dev/null +++ b/packages/server/src/services/Banking/Exclude/ExcludeBankTransactionsApplication.ts @@ -0,0 +1,59 @@ +import { Inject, Service } from 'typedi'; +import { ExcludeBankTransaction } from './ExcludeBankTransaction'; +import { UnexcludeBankTransaction } from './UnexcludeBankTransaction'; +import { GetExcludedBankTransactionsService } from './GetExcludedBankTransactions'; +import { ExcludedBankTransactionsQuery } from './_types'; + +@Service() +export class ExcludeBankTransactionsApplication { + @Inject() + private excludeBankTransactionService: ExcludeBankTransaction; + + @Inject() + private unexcludeBankTransactionService: UnexcludeBankTransaction; + + @Inject() + private getExcludedBankTransactionsService: GetExcludedBankTransactionsService; + + /** + * Marks a bank transaction as excluded. + * @param {number} tenantId - The ID of the tenant. + * @param {number} bankTransactionId - The ID of the bank transaction to exclude. + * @returns {Promise} + */ + 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 + ); + } + + /** + * Retrieves the excluded bank transactions. + * @param {number} tenantId + * @param {ExcludedBankTransactionsQuery} filter + * @returns {} + */ + public getExcludedBankTransactions( + tenantId: number, + filter: ExcludedBankTransactionsQuery + ) { + return this.getExcludedBankTransactionsService.getExcludedBankTransactions( + tenantId, + filter + ); + } +} diff --git a/packages/server/src/services/Banking/Exclude/GetExcludedBankTransactions.ts b/packages/server/src/services/Banking/Exclude/GetExcludedBankTransactions.ts new file mode 100644 index 000000000..41435f1b8 --- /dev/null +++ b/packages/server/src/services/Banking/Exclude/GetExcludedBankTransactions.ts @@ -0,0 +1,52 @@ +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { Inject, Service } from 'typedi'; +import { ExcludedBankTransactionsQuery } from './_types'; +import { UncategorizedTransactionTransformer } from '@/services/Cashflow/UncategorizedTransactionTransformer'; +import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; + +@Service() +export class GetExcludedBankTransactionsService { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private transformer: TransformerInjectable; + + /** + * Retrieves the excluded uncategorized bank transactions. + * @param {number} tenantId + * @param {ExcludedBankTransactionsQuery} filter + * @returns + */ + public async getExcludedBankTransactions( + tenantId: number, + filter: ExcludedBankTransactionsQuery + ) { + const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId); + + // Parsed query with default values. + const _query = { + page: 1, + pageSize: 20, + ...filter, + }; + const { results, pagination } = + await UncategorizedCashflowTransaction.query() + .onBuild((q) => { + q.modify('excluded'); + q.orderBy('date', 'DESC'); + + if (_query.accountId) { + q.where('account_id', _query.accountId); + } + }) + .pagination(_query.page - 1, _query.pageSize); + + const data = await this.transformer.transform( + tenantId, + results, + new UncategorizedTransactionTransformer() + ); + return { data, pagination }; + } +} 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..46148b81b --- /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, + uncategorizedTransactionId: number + ) { + const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId); + + const oldUncategorizedTransaction = + await UncategorizedCashflowTransaction.query() + .findById(uncategorizedTransactionId) + .throwIfNotFound(); + + validateTransactionNotCategorized(oldUncategorizedTransaction); + + return this.uow.withTransaction(tenantId, async (trx) => { + await UncategorizedCashflowTransaction.query(trx) + .findById(uncategorizedTransactionId) + .patch({ + excludedAt: null, + }); + }); + } +} diff --git a/packages/server/src/services/Banking/Exclude/_types.ts b/packages/server/src/services/Banking/Exclude/_types.ts new file mode 100644 index 000000000..d8a5188a7 --- /dev/null +++ b/packages/server/src/services/Banking/Exclude/_types.ts @@ -0,0 +1,6 @@ + +export interface ExcludedBankTransactionsQuery { + page?: number; + pageSize?: number; + accountId?: number; +} \ No newline at end of file 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); + } +}; 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..f5dd9eaa0 --- /dev/null +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionBillsTransformer.ts @@ -0,0 +1,103 @@ +import { Transformer } from '@/lib/Transformer/Transformer'; + +export class GetMatchedTransactionBillsTransformer extends Transformer { + /** + * Include these attributes to sale credit note object. + * @returns {Array} + */ + public includeAttributes = (): string[] => { + return [ + 'referenceNo', + 'amount', + 'amountFormatted', + 'transactionNo', + 'date', + 'dateFormatted', + 'transactionId', + 'transactionNo', + 'transactionType', + 'transsactionTypeFormatted', + ]; + }; + + /** + * Exclude all attributes. + * @returns {Array} + */ + public excludeAttributes = (): string[] => { + return ['*']; + }; + + /** + * Retrieve the reference number of the bill. + * @param {Object} bill - The bill object. + * @returns {string} + */ + protected referenceNo(bill) { + return bill.referenceNo; + } + + /** + * Retrieve the amount of the bill. + * @param {Object} bill - The bill object. + * @returns {number} + */ + protected amount(bill) { + return bill.amount; + } + + /** + * Retrieve the formatted amount of the bill. + * @param {Object} bill - The bill object. + * @returns {string} + */ + protected amountFormatted(bill) { + return this.formatNumber(bill.amount, { + currencyCode: bill.currencyCode, + money: true, + }); + } + + /** + * Retrieve the date of the bill. + * @param {Object} bill - The bill object. + * @returns {string} + */ + protected date(bill) { + return bill.billDate; + } + + /** + * Retrieve the formatted date of the bill. + * @param {Object} bill - The bill object. + * @returns {string} + */ + protected dateFormatted(bill) { + return this.formatDate(bill.billDate); + } + + /** + * Retrieve the transcation id of the bill. + * @param {Object} bill - The bill object. + * @returns {number} + */ + protected transactionId(bill) { + return bill.id; + } + + /** + * Retrieve the manual journal transaction type. + * @returns {string} + */ + protected transactionType() { + return 'Bill'; + } + + /** + * Retrieves the manual journal formatted transaction type. + * @returns {string} + */ + protected transsactionTypeFormatted() { + return 'Bill'; + } +} 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..d6f71e705 --- /dev/null +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionExpensesTransformer.ts @@ -0,0 +1,114 @@ +import { Transformer } from '@/lib/Transformer/Transformer'; + +export class GetMatchedTransactionExpensesTransformer extends Transformer { + /** + * Include these attributes to sale credit note object. + * @returns {Array} + */ + public includeAttributes = (): string[] => { + return [ + 'referenceNo', + 'amount', + 'amountFormatted', + 'transactionNo', + 'date', + 'dateFormatted', + 'transactionId', + 'transactionNo', + 'transactionType', + 'transsactionTypeFormatted', + ]; + }; + + /** + * Exclude all attributes. + * @returns {Array} + */ + public excludeAttributes = (): string[] => { + return ['*']; + }; + + /** + * Retrieves the expense reference number. + * @param expense + * @returns {string} + */ + protected referenceNo(expense) { + return expense.referenceNo; + } + + /** + * Retrieves the expense amount. + * @param expense + * @returns {number} + */ + protected amount(expense) { + return expense.totalAmount; + } + + /** + * Formats the amount of the expense. + * @param expense + * @returns {string} + */ + protected amountFormatted(expense) { + return this.formatNumber(expense.totalAmount, { + currencyCode: expense.currencyCode, + money: true, + }); + } + + /** + * Retrieves the date of the expense. + * @param expense + * @returns {Date} + */ + protected date(expense) { + return expense.paymentDate; + } + + /** + * Formats the date of the expense. + * @param expense + * @returns {string} + */ + protected dateFormatted(expense) { + return this.formatDate(expense.paymentDate); + } + + /** + * Retrieves the transaction ID of the expense. + * @param expense + * @returns {number} + */ + protected transactionId(expense) { + return expense.id; + } + + /** + * Retrieves the expense transaction number. + * @param expense + * @returns {string} + */ + protected transactionNo(expense) { + return expense.expenseNo; + } + + /** + * Retrieves the expense transaction type. + * @param expense + * @returns {String} + */ + protected transactionType() { + return 'Expense'; + } + + /** + * Retrieves the formatted transaction type of the expense. + * @param expense + * @returns {string} + */ + protected transsactionTypeFormatted() { + return 'Expense'; + } +} 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..ed2dcfaa2 --- /dev/null +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionInvoicesTransformer.ts @@ -0,0 +1,111 @@ +import { Transformer } from '@/lib/Transformer/Transformer'; + +export class GetMatchedTransactionInvoicesTransformer extends Transformer { + /** + * Include these attributes to sale credit note object. + * @returns {Array} + */ + public includeAttributes = (): string[] => { + return [ + 'referenceNo', + 'amount', + 'amountFormatted', + 'transactionNo', + 'date', + 'dateFormatted', + 'transactionId', + 'transactionNo', + 'transactionType', + 'transsactionTypeFormatted', + ]; + }; + + /** + * Exclude all attributes. + * @returns {Array} + */ + public excludeAttributes = (): string[] => { + return ['*']; + }; + + /** + * Retrieve the invoice reference number. + * @returns {string} + */ + protected referenceNo(invoice) { + return invoice.referenceNo; + } + + /** + * Retrieve the invoice amount. + * @param invoice + * @returns {number} + */ + protected amount(invoice) { + return invoice.dueAmount; + } + /** + * Format the amount of the invoice. + * @param invoice + * @returns {string} + */ + protected formatAmount(invoice) { + return this.formatNumber(invoice.dueAmount, { + currencyCode: invoice.currencyCode, + money: true, + }); + } + + /** + * Retrieve the date of the invoice. + * @param invoice + * @returns {Date} + */ + protected date(invoice) { + return invoice.invoiceDate; + } + + /** + * Format the date of the invoice. + * @param invoice + * @returns {string} + */ + protected dateFormatted(invoice) { + return this.formatDate(invoice.invoiceDate); + } + + /** + * Retrieve the transaction ID of the invoice. + * @param invoice + * @returns {number} + */ + protected getTransactionId(invoice) { + return invoice.id; + } + /** + * Retrieve the invoice transaction number. + * @param invoice + * @returns {string} + */ + protected transactionNo(invoice) { + return invoice.invoiceNo; + } + + /** + * Retrieve the invoice transaction type. + * @param invoice + * @returns {String} + */ + protected transactionType(invoice) { + return 'SaleInvoice'; + } + + /** + * Retrieve the invoice formatted transaction type. + * @param invoice + * @returns {string} + */ + protected transsactionTypeFormatted(invoice) { + return 'Sale invoice'; + } +} 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..9b19b01a0 --- /dev/null +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionManualJournalsTransformer.ts @@ -0,0 +1,111 @@ +import { Transformer } from '@/lib/Transformer/Transformer'; + +export class GetMatchedTransactionManualJournalsTransformer extends Transformer { + /** + * Include these attributes to sale credit note object. + * @returns {Array} + */ + public includeAttributes = (): string[] => { + return [ + 'referenceNo', + 'amount', + 'amountFormatted', + 'transactionNo', + 'date', + 'dateFormatted', + 'transactionId', + 'transactionNo', + 'transactionType', + 'transsactionTypeFormatted', + ]; + }; + + /** + * Exclude all attributes. + * @returns {Array} + */ + public excludeAttributes = (): string[] => { + return ['*']; + }; + + /** + * Retrieves the manual journal reference no. + * @param manualJournal + * @returns {string} + */ + protected referenceNo(manualJournal) { + return manualJournal.referenceNo; + } + + /** + * Retrieves the manual journal amount. + * @param manualJournal + * @returns {number} + */ + protected amount(manualJournal) { + return manualJournal.amount; + } + + /** + * Retrieves the manual journal formatted amount. + * @param manualJournal + * @returns {string} + */ + protected amountFormatted(manualJournal) { + return this.formatNumber(manualJournal.amount, { + currencyCode: manualJournal.currencyCode, + money: true, + }); + } + + /** + * Retreives the manual journal date. + * @param manualJournal + * @returns {Date} + */ + protected date(manualJournal) { + return manualJournal.date; + } + + /** + * Retrieves the manual journal formatted date. + * @param manualJournal + * @returns {string} + */ + protected dateFormatted(manualJournal) { + return this.formatDate(manualJournal.date); + } + + /** + * Retrieve the manual journal transaction id. + * @returns {number} + */ + protected transactionId(manualJournal) { + return manualJournal.id; + } + + /** + * Retrieve the manual journal transaction number. + * @param manualJournal + */ + protected transactionNo(manualJournal) { + return manualJournal.journalNumber; + } + + /** + * Retrieve the manual journal transaction type. + * @returns {string} + */ + protected transactionType() { + return 'ManualJournal'; + } + + /** + * Retrieves the manual journal formatted transaction type. + * @returns {string} + */ + protected transsactionTypeFormatted() { + return 'Manual Journal'; + } +} + 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..43f5ba532 --- /dev/null +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactions.ts @@ -0,0 +1,107 @@ +import { Inject, Service } from 'typedi'; +import * as R from 'ramda'; +import moment from 'moment'; +import { PromisePool } from '@supercharge/promise-pool'; +import { GetMatchedTransactionsFilter, MatchedTransactionsPOJO } from './types'; +import { GetMatchedTransactionsByExpenses } from './GetMatchedTransactionsByExpenses'; +import { GetMatchedTransactionsByBills } from './GetMatchedTransactionsByBills'; +import { GetMatchedTransactionsByManualJournals } from './GetMatchedTransactionsByManualJournals'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { sortClosestMatchTransactions } from './_utils'; + +@Service() +export class GetMatchedTransactions { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private getMatchedInvoicesService: GetMatchedTransactionsByExpenses; + + @Inject() + private getMatchedBillsService: GetMatchedTransactionsByBills; + + @Inject() + private getMatchedManualJournalService: GetMatchedTransactionsByManualJournals; + + @Inject() + private getMatchedExpensesService: GetMatchedTransactionsByExpenses; + + /** + * Registered matched transactions types. + */ + get registered() { + return [ + { type: 'SaleInvoice', service: this.getMatchedInvoicesService }, + { type: 'Bill', service: this.getMatchedBillsService }, + { type: 'Expense', service: this.getMatchedExpensesService }, + { type: 'ManualJournal', service: this.getMatchedManualJournalService }, + ]; + } + + /** + * Retrieves the matched transactions. + * @param {number} tenantId - + * @param {GetMatchedTransactionsFilter} filter - + * @returns {Promise} + */ + public async getMatchedTransactions( + tenantId: number, + uncategorizedTransactionId: number, + filter: GetMatchedTransactionsFilter + ): Promise { + const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId); + + const uncategorizedTransaction = + await UncategorizedCashflowTransaction.query() + .findById(uncategorizedTransactionId) + .throwIfNotFound(); + + const filtered = filter.transactionType + ? this.registered.filter((item) => item.type === filter.transactionType) + : this.registered; + + const matchedTransactions = await PromisePool.withConcurrency(2) + .for(filtered) + .process(async ({ type, service }) => { + return service.getMatchedTransactions(tenantId, filter); + }); + + const { perfectMatches, possibleMatches } = this.groupMatchedResults( + uncategorizedTransaction, + matchedTransactions + ); + return { + perfectMatches, + possibleMatches, + }; + } + + /** + * Groups the given results for getting perfect and possible matches + * based on the given uncategorized transaction. + * @param uncategorizedTransaction + * @param matchedTransactions + * @returns {MatchedTransactionsPOJO} + */ + private groupMatchedResults( + uncategorizedTransaction, + matchedTransactions + ): MatchedTransactionsPOJO { + const results = R.compose(R.flatten)(matchedTransactions?.results); + + // Sort the results based on amount, date, and transaction type + const closestResullts = sortClosestMatchTransactions( + uncategorizedTransaction, + results + ); + const perfectMatches = R.filter( + (match) => + match.amount === uncategorizedTransaction.amount && + moment(match.date).isSame(uncategorizedTransaction.date, 'day'), + closestResullts + ); + const possibleMatches = R.difference(closestResullts, perfectMatches); + + return { perfectMatches, possibleMatches }; + } +} 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..4796f7598 --- /dev/null +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByBills.ts @@ -0,0 +1,58 @@ +import { Inject, Service } from 'typedi'; +import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; +import { GetMatchedTransactionBillsTransformer } from './GetMatchedTransactionBillsTransformer'; +import { GetMatchedTransactionsFilter, MatchedTransactionPOJO } from './types'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { GetMatchedTransactionsByType } from './GetMatchedTransactionsByType'; + +@Service() +export class GetMatchedTransactionsByBills extends GetMatchedTransactionsByType { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private transformer: TransformerInjectable; + + /** + * Retrieves the matched transactions. + * @param {number} tenantId - + * @param {GetMatchedTransactionsFilter} filter - + */ + public async getMatchedTransactions( + tenantId: number, + filter: GetMatchedTransactionsFilter + ) { + const { Bill } = this.tenancy.models(tenantId); + + const bills = await Bill.query().onBuild((q) => { + q.whereNotExists(Bill.relatedQuery('matchedBankTransaction')); + }); + + return this.transformer.transform( + tenantId, + bills, + new GetMatchedTransactionBillsTransformer() + ); + } + + /** + * Retrieves the given bill matched transaction. + * @param {number} tenantId + * @param {number} transactionId + * @returns {Promise} + */ + public async getMatchedTransaction( + tenantId: number, + transactionId: number + ): Promise { + const { Bill } = this.tenancy.models(tenantId); + + const bill = await Bill.query().findById(transactionId).throwIfNotFound(); + + return this.transformer.transform( + tenantId, + bill, + 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..8996c4b91 --- /dev/null +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByExpenses.ts @@ -0,0 +1,72 @@ +import { Inject, Service } from 'typedi'; +import { GetMatchedTransactionsFilter, MatchedTransactionPOJO } from './types'; +import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { GetMatchedTransactionsByType } from './GetMatchedTransactionsByType'; +import { GetMatchedTransactionExpensesTransformer } from './GetMatchedTransactionExpensesTransformer'; + +@Service() +export class GetMatchedTransactionsByExpenses extends GetMatchedTransactionsByType { + @Inject() + protected tenancy: HasTenancyService; + + @Inject() + protected transformer: TransformerInjectable; + + /** + * Retrieves the matched transactions of expenses. + * @param {number} tenantId + * @param {GetMatchedTransactionsFilter} filter + * @returns + */ + async getMatchedTransactions( + tenantId: number, + filter: GetMatchedTransactionsFilter + ) { + const { Expense } = this.tenancy.models(tenantId); + + const expenses = await Expense.query().onBuild((query) => { + query.whereNotExists(Expense.relatedQuery('matchedBankTransaction')); + if (filter.fromDate) { + query.where('payment_date', '>=', filter.fromDate); + } + if (filter.toDate) { + query.where('payment_date', '<=', filter.toDate); + } + if (filter.minAmount) { + query.where('total_amount', '>=', filter.minAmount); + } + if (filter.maxAmount) { + query.where('total_amount', '<=', filter.maxAmount); + } + }); + return this.transformer.transform( + tenantId, + expenses, + new GetMatchedTransactionExpensesTransformer() + ); + } + + /** + * Retrieves the given matched expense transaction. + * @param {number} tenantId + * @param {number} transactionId + * @returns {GetMatchedTransactionExpensesTransformer-} + */ + public async getMatchedTransaction( + tenantId: number, + transactionId: number + ): Promise { + const { Expense } = this.tenancy.models(tenantId); + + const expense = await Expense.query() + .findById(transactionId) + .throwIfNotFound(); + + return this.transformer.transform( + tenantId, + expense, + new GetMatchedTransactionExpensesTransformer() + ); + } +} 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..141d22fe1 --- /dev/null +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByInvoices.ts @@ -0,0 +1,63 @@ +import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; +import { GetMatchedTransactionInvoicesTransformer } from './GetMatchedTransactionInvoicesTransformer'; +import { + GetMatchedTransactionsFilter, + MatchedTransactionPOJO, + MatchedTransactionsPOJO, +} from './types'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { Inject, Service } from 'typedi'; +import { GetMatchedTransactionsByType } from './GetMatchedTransactionsByType'; + +@Service() +export class GetMatchedTransactionsByInvoices extends GetMatchedTransactionsByType { + @Inject() + protected tenancy: HasTenancyService; + + @Inject() + protected transformer: TransformerInjectable; + + /** + * Retrieves the matched transactions. + * @param {number} tenantId - + * @param {GetMatchedTransactionsFilter} filter - + * @returns {Promise} + */ + public async getMatchedTransactions( + tenantId: number, + filter: GetMatchedTransactionsFilter + ): Promise { + const { SaleInvoice } = this.tenancy.models(tenantId); + + const invoices = await SaleInvoice.query().onBuild((q) => { + q.whereNotExists(SaleInvoice.relatedQuery('matchedBankTransaction')); + }); + + return this.transformer.transform( + tenantId, + invoices, + new GetMatchedTransactionInvoicesTransformer() + ); + } + + /** + * Retrieves the matched transaction. + * @param {number} tenantId + * @param {number} transactionId + * @returns {Promise} + */ + public async getMatchedTransaction( + tenantId: number, + transactionId: number + ): Promise { + const { SaleInvoice } = this.tenancy.models(tenantId); + + 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 new file mode 100644 index 000000000..2aa6341af --- /dev/null +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByManualJournals.ts @@ -0,0 +1,68 @@ +import { Inject, Service } from 'typedi'; +import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; +import { GetMatchedTransactionManualJournalsTransformer } from './GetMatchedTransactionManualJournalsTransformer'; +import { GetMatchedTransactionsByType } from './GetMatchedTransactionsByType'; +import { GetMatchedTransactionsFilter } from './types'; + +@Service() +export class GetMatchedTransactionsByManualJournals extends GetMatchedTransactionsByType { + @Inject() + private transformer: TransformerInjectable; + + /** + * Retrieve the matched transactions of manual journals. + * @param {number} tenantId + * @param {GetMatchedTransactionsFilter} filter + * @returns + */ + async getMatchedTransactions( + tenantId: number, + filter: Omit + ) { + const { ManualJournal } = this.tenancy.models(tenantId); + + const manualJournals = await ManualJournal.query().onBuild((query) => { + query.whereNotExists( + ManualJournal.relatedQuery('matchedBankTransaction') + ); + if (filter.fromDate) { + query.where('date', '>=', filter.fromDate); + } + if (filter.toDate) { + query.where('date', '<=', filter.toDate); + } + if (filter.minAmount) { + query.where('amount', '>=', filter.minAmount); + } + if (filter.maxAmount) { + query.where('amount', '<=', filter.maxAmount); + } + }); + return this.transformer.transform( + tenantId, + manualJournals, + new GetMatchedTransactionManualJournalsTransformer() + ); + } + + /** + * Retrieves the matched transaction of manual journals. + * @param {number} tenantId + * @param {number} transactionId + * @returns + */ + async getMatchedTransaction(tenantId: number, transactionId: number) { + const { ManualJournal } = this.tenancy.models(tenantId); + + const manualJournal = await ManualJournal.query() + .findById(transactionId) + .whereNotExists(ManualJournal.relatedQuery('matchedBankTransaction')) + .throwIfNotFound(); + + return this.transformer.transform( + tenantId, + manualJournal, + new GetMatchedTransactionManualJournalsTransformer() + ); + } +} 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..ad036fa98 --- /dev/null +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByType.ts @@ -0,0 +1,66 @@ +import { Knex } from 'knex'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { + GetMatchedTransactionsFilter, + IMatchTransactionDTO, + MatchedTransactionPOJO, + MatchedTransactionsPOJO, +} from './types'; +import { Inject, Service } from 'typedi'; + +export abstract class GetMatchedTransactionsByType { + @Inject() + protected tenancy: HasTenancyService; + + /** + * Retrieves the matched transactions. + * @param {number} tenantId - + * @param {GetMatchedTransactionsFilter} filter - + * @returns {Promise} + */ + 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 - + * @returns {Promise} + */ + 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/MatchBankTransactionsApplication.ts b/packages/server/src/services/Banking/Matching/MatchBankTransactionsApplication.ts new file mode 100644 index 000000000..61884190f --- /dev/null +++ b/packages/server/src/services/Banking/Matching/MatchBankTransactionsApplication.ts @@ -0,0 +1,70 @@ +import { Inject, Service } from 'typedi'; +import { GetMatchedTransactions } from './GetMatchedTransactions'; +import { MatchBankTransactions } from './MatchTransactions'; +import { UnmatchMatchedBankTransaction } from './UnmatchMatchedTransaction'; +import { GetMatchedTransactionsFilter, IMatchTransactionsDTO } from './types'; + +@Service() +export class MatchBankTransactionsApplication { + @Inject() + private getMatchedTransactionsService: GetMatchedTransactions; + + @Inject() + private matchTransactionService: MatchBankTransactions; + + @Inject() + private unmatchMatchedTransactionService: UnmatchMatchedBankTransaction; + + /** + * Retrieves the matched transactions. + * @param {number} tenantId - + * @param {GetMatchedTransactionsFilter} filter - + * @returns + */ + public getMatchedTransactions( + tenantId: number, + uncategorizedTransactionId: number, + filter: GetMatchedTransactionsFilter + ) { + return this.getMatchedTransactionsService.getMatchedTransactions( + tenantId, + uncategorizedTransactionId, + filter + ); + } + + /** + * Matches the given uncategorized transaction with the given system transaction. + * @param {number} tenantId + * @param {number} uncategorizedTransactionId + * @param {IMatchTransactionDTO} matchTransactionsDTO + * @returns {Promise} + */ + public matchTransaction( + tenantId: number, + uncategorizedTransactionId: number, + matchTransactionsDTO: IMatchTransactionsDTO + ): 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..c91cb152d --- /dev/null +++ b/packages/server/src/services/Banking/Matching/MatchTransactions.ts @@ -0,0 +1,157 @@ +import { isEmpty, sumBy } from 'lodash'; +import { Knex } from 'knex'; +import { Inject, Service } from 'typedi'; +import { PromisePool } from '@supercharge/promise-pool'; +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import UnitOfWork from '@/services/UnitOfWork'; +import events from '@/subscribers/events'; +import { + ERRORS, + IBankTransactionMatchedEventPayload, + IBankTransactionMatchingEventPayload, + IMatchTransactionsDTO, +} from './types'; +import { MatchTransactionsTypes } from './MatchTransactionsTypes'; +import { ServiceError } from '@/exceptions'; + +@Service() +export class MatchBankTransactions { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private uow: UnitOfWork; + + @Inject() + private eventPublisher: EventPublisher; + + @Inject() + private matchedBankTransactions: MatchTransactionsTypes; + + /** + * Validates the match bank transactions DTO. + * @param {number} tenantId + * @param {number} uncategorizedTransactionId + * @param {IMatchTransactionsDTO} matchTransactionsDTO + * @returns {Promise} + */ + async validate( + tenantId: number, + uncategorizedTransactionId: number, + matchTransactionsDTO: IMatchTransactionsDTO + ) { + const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId); + const { matchedTransactions } = matchTransactionsDTO; + + // Validates the uncategorized transaction existance. + const uncategorizedTransaction = + await UncategorizedCashflowTransaction.query() + .findById(uncategorizedTransactionId) + .withGraphFetched('matchedBankTransactions') + .throwIfNotFound(); + + // Validates the uncategorized transaction is not already matched. + if (!isEmpty(uncategorizedTransaction.matchedBankTransactions)) { + throw new ServiceError(ERRORS.TRANSACTION_ALREADY_MATCHED); + } + // Validate the uncategorized transaction is not excluded. + if (uncategorizedTransaction.excluded) { + throw new ServiceError(ERRORS.CANNOT_MATCH_EXCLUDED_TRANSACTION); + } + // Validates the given matched transaction. + const validateMatchedTransaction = async (matchedTransaction) => { + const getMatchedTransactionsService = + this.matchedBankTransactions.registry.get( + matchedTransaction.referenceType + ); + if (!getMatchedTransactionsService) { + throw new ServiceError( + ERRORS.RESOURCE_TYPE_MATCHING_TRANSACTION_INVALID + ); + } + const foundMatchedTransaction = + await getMatchedTransactionsService.getMatchedTransaction( + tenantId, + matchedTransaction.referenceId + ); + if (!foundMatchedTransaction) { + throw new ServiceError(ERRORS.RESOURCE_ID_MATCHING_TRANSACTION_INVALID); + } + return foundMatchedTransaction; + }; + // Matches the given transactions under promise pool concurrency controlling. + const validatationResult = await PromisePool.withConcurrency(10) + .for(matchedTransactions) + .process(validateMatchedTransaction); + + if (validatationResult.errors?.length > 0) { + const error = validatationResult.errors.map((er) => er.raw)[0]; + throw new ServiceError(error); + } + // Calculate the total given matching transactions. + const totalMatchedTranasctions = sumBy( + validatationResult.results, + 'amount' + ); + // Validates the total given matching transcations whether is not equal + // uncategorized transaction amount. + if (totalMatchedTranasctions !== uncategorizedTransaction.amount) { + throw new ServiceError(ERRORS.TOTAL_MATCHING_TRANSACTIONS_INVALID); + } + } + + /** + * Matches the given uncategorized transaction to the given references. + * @param {number} tenantId + * @param {number} uncategorizedTransactionId + * @returns {Promise} + */ + public async matchTransaction( + tenantId: number, + uncategorizedTransactionId: number, + matchTransactionsDTO: IMatchTransactionsDTO + ): Promise { + const { matchedTransactions } = matchTransactionsDTO; + + // Validates the given matching transactions DTO. + await this.validate( + tenantId, + uncategorizedTransactionId, + matchTransactionsDTO + ); + return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => { + // Triggers the event `onBankTransactionMatching`. + await this.eventPublisher.emitAsync(events.bankMatch.onMatching, { + tenantId, + uncategorizedTransactionId, + matchTransactionsDTO, + trx, + } as IBankTransactionMatchingEventPayload); + + // Matches the given transactions under promise pool concurrency controlling. + await PromisePool.withConcurrency(10) + .for(matchedTransactions) + .process(async (matchedTransaction) => { + const getMatchedTransactionsService = + this.matchedBankTransactions.registry.get( + matchedTransaction.referenceType + ); + await getMatchedTransactionsService.createMatchedTransaction( + tenantId, + uncategorizedTransactionId, + matchedTransaction, + trx + ); + }); + + // Triggers the event `onBankTransactionMatched`. + await this.eventPublisher.emitAsync(events.bankMatch.onMatched, { + tenantId, + uncategorizedTransactionId, + matchTransactionsDTO, + trx, + } as IBankTransactionMatchedEventPayload); + }); + } +} 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/UnmatchMatchedTransaction.ts b/packages/server/src/services/Banking/Matching/UnmatchMatchedTransaction.ts new file mode 100644 index 000000000..0f4a1def7 --- /dev/null +++ b/packages/server/src/services/Banking/Matching/UnmatchMatchedTransaction.ts @@ -0,0 +1,47 @@ +import { Inject, Service } from 'typedi'; +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import UnitOfWork from '@/services/UnitOfWork'; +import events from '@/subscribers/events'; +import { IBankTransactionUnmatchingEventPayload } from './types'; + +@Service() +export class UnmatchMatchedBankTransaction { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private uow: UnitOfWork; + + @Inject() + private eventPublisher: EventPublisher; + + /** + * Unmatch the matched the given uncategorized bank transaction. + * @param {number} tenantId + * @param {number} uncategorizedTransactionId + * @returns {Promise} + */ + public unmatchMatchedTransaction( + tenantId: number, + uncategorizedTransactionId: number + ): Promise { + 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/ValidateTransactionsMatched.ts b/packages/server/src/services/Banking/Matching/ValidateTransactionsMatched.ts new file mode 100644 index 000000000..e6e5a2cd7 --- /dev/null +++ b/packages/server/src/services/Banking/Matching/ValidateTransactionsMatched.ts @@ -0,0 +1,34 @@ +import { ServiceError } from '@/exceptions'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { Inject, Service } from 'typedi'; +import { ERRORS } from './types'; + +@Service() +export class ValidateTransactionMatched { + @Inject() + private tenancy: HasTenancyService; + + /** + * Validate the given transaction whether is matched with bank transactions. + * @param {number} tenantId + * @param {string} referenceType - Transaction reference type. + * @param {number} referenceId - Transaction reference id. + * @returns {Promise} + */ + 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/_utils.ts b/packages/server/src/services/Banking/Matching/_utils.ts new file mode 100644 index 000000000..89a316a4b --- /dev/null +++ b/packages/server/src/services/Banking/Matching/_utils.ts @@ -0,0 +1,22 @@ +import moment from 'moment'; +import * as R from 'ramda'; +import UncategorizedCashflowTransaction from '@/models/UncategorizedCashflowTransaction'; +import { MatchedTransactionPOJO } from './types'; + +export const sortClosestMatchTransactions = ( + uncategorizedTransaction: UncategorizedCashflowTransaction, + matches: MatchedTransactionPOJO[] +) => { + return R.sortWith([ + // Sort by amount difference (closest to uncategorized transaction amount first) + R.ascend((match: MatchedTransactionPOJO) => + Math.abs(match.amount - uncategorizedTransaction.amount) + ), + // Sort by date difference (closest to uncategorized transaction date first) + R.ascend((match: MatchedTransactionPOJO) => + Math.abs( + moment(match.date).diff(moment(uncategorizedTransaction.date), 'days') + ) + ), + ])(matches); +}; 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..c6087b9e8 --- /dev/null +++ b/packages/server/src/services/Banking/Matching/events/ValidateMatchingOnCashflowDelete.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 ValidateMatchingOnCashflowDelete { + @Inject() + private validateNoMatchingLinkedService: ValidateTransactionMatched; + + /** + * Constructor method. + */ + public attach(bus) { + bus.subscribe( + events.cashflow.onTransactionDeleting, + this.validateMatchingOnCashflowDeleting.bind(this) + ); + } + + /** + * Validates the cashflow transaction whether matched with bank transaction on deleting. + * @param {IManualJournalDeletingPayload} + */ + public async validateMatchingOnCashflowDeleting({ + tenantId, + oldManualJournal, + trx, + }: IManualJournalDeletingPayload) { + await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking( + tenantId, + 'ManualJournal', + oldManualJournal.id + ); + } +} 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..38c2dcba8 --- /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.validateMatchingOnExpenseDeleting.bind(this) + ); + } + + /** + * Validates the expense transaction whether matched with bank transaction on deleting. + * @param {IExpenseEventDeletePayload} + */ + public async validateMatchingOnExpenseDeleting({ + tenantId, + oldExpense, + trx, + }: IExpenseEventDeletePayload) { + await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking( + tenantId, + 'Expense', + oldExpense.id + ); + } +} 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..90078bfdc --- /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.validateMatchingOnManualJournalDeleting.bind(this) + ); + } + + /** + * Validates the manual journal transaction whether matched with bank transaction on deleting. + * @param {IManualJournalDeletingPayload} + */ + public async validateMatchingOnManualJournalDeleting({ + tenantId, + oldManualJournal, + trx, + }: IManualJournalDeletingPayload) { + await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking( + tenantId, + 'ManualJournal', + oldManualJournal.id + ); + } +} 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..0ce97cdb9 --- /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.validateMatchingOnPaymentMadeDeleting.bind(this) + ); + } + + /** + * Validates the payment made transaction whether matched with bank transaction on deleting. + * @param {IPaymentReceiveDeletedPayload} + */ + public async validateMatchingOnPaymentMadeDeleting({ + tenantId, + oldBillPayment, + trx, + }: IBillPaymentEventDeletedPayload) { + await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking( + tenantId, + 'PaymentMade', + oldBillPayment.id + ); + } +} 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..20c3018ac --- /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.validateMatchingOnPaymentReceivedDeleting.bind(this) + ); + } + + /** + * Validates the payment received transaction whether matched with bank transaction on deleting. + * @param {IPaymentReceiveDeletedPayload} + */ + public async validateMatchingOnPaymentReceivedDeleting({ + tenantId, + oldPaymentReceive, + trx, + }: IPaymentReceiveDeletedPayload) { + await this.validateNoMatchingLinkedService.validateTransactionNoMatchLinking( + tenantId, + 'PaymentReceive', + oldPaymentReceive.id + ); + } +} 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..5e60f88f9 --- /dev/null +++ b/packages/server/src/services/Banking/Matching/types.ts @@ -0,0 +1,67 @@ +import { Knex } from 'knex'; + +export interface IBankTransactionMatchingEventPayload { + tenantId: number; + uncategorizedTransactionId: number; + matchTransactionsDTO: IMatchTransactionsDTO; + trx?: Knex.Transaction; +} + +export interface IBankTransactionMatchedEventPayload { + tenantId: number; + uncategorizedTransactionId: number; + matchTransactionsDTO: IMatchTransactionsDTO; + trx?: Knex.Transaction; +} + +export interface IBankTransactionUnmatchingEventPayload { + tenantId: number; +} + +export interface IBankTransactionUnmatchedEventPayload { + tenantId: number; +} + +export interface IMatchTransactionDTO { + referenceType: string; + referenceId: number; +} + +export interface IMatchTransactionsDTO { + matchedTransactions: Array; +} + +export interface GetMatchedTransactionsFilter { + fromDate: string; + toDate: string; + minAmount: number; + maxAmount: number; + transactionType: string; +} + +export interface MatchedTransactionPOJO { + amount: number; + amountFormatted: string; + date: string; + dateFormatted: string; + referenceNo: string; + transactionNo: string; + transactionId: number; + transactionType: string; +} + +export type MatchedTransactionsPOJO = { + perfectMatches: Array; + possibleMatches: 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', + 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/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 new file mode 100644 index 000000000..5582652d8 --- /dev/null +++ b/packages/server/src/services/Banking/RegonizeTranasctions/RecognizeTranasctionsService.ts @@ -0,0 +1,110 @@ +import { Knex } from 'knex'; +import UncategorizedCashflowTransaction from '@/models/UncategorizedCashflowTransaction'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { transformToMapBy } from '@/utils'; +import { Inject, Service } from 'typedi'; +import { PromisePool } from '@supercharge/promise-pool'; +import { BankRule } from '@/models/BankRule'; +import { bankRulesMatchTransaction } from './_utils'; + +@Service() +export class RecognizeTranasctionsService { + @Inject() + private tenancy: HasTenancyService; + + /** + * Marks the uncategorized transaction as recognized from the given bank rule. + * @param {number} tenantId - + * @param {BankRule} bankRule - + * @param {UncategorizedCashflowTransaction} transaction - + * @param {Knex.Transaction} trx - + */ + private markBankRuleAsRecognized = async ( + tenantId: number, + bankRule: BankRule, + transaction: UncategorizedCashflowTransaction, + trx?: Knex.Transaction + ) => { + const { RecognizedBankTransaction, UncategorizedCashflowTransaction } = + this.tenancy.models(tenantId); + + const recognizedTransaction = await RecognizedBankTransaction.query( + trx + ).insert({ + bankRuleId: bankRule.id, + uncategorizedTransactionId: transaction.id, + assignedCategory: bankRule.assignCategory, + assignedAccountId: bankRule.assignAccountId, + assignedPayee: bankRule.assignPayee, + assignedMemo: bankRule.assignMemo, + }); + await UncategorizedCashflowTransaction.query(trx) + .findById(transaction.id) + .patch({ + recognizedTransactionId: recognizedTransaction.id, + }); + }; + + /** + * Regonized the uncategorized transactions. + * @param {number} tenantId - + * @param {Knex.Transaction} trx - + */ + public async recognizeTransactions( + tenantId: number, + batch: string = '', + trx?: Knex.Transaction + ) { + const { UncategorizedCashflowTransaction, BankRule } = + this.tenancy.models(tenantId); + + const uncategorizedTranasctions = + await UncategorizedCashflowTransaction.query().onBuild((query) => { + query.where('recognized_transaction_id', null); + query.where('categorized', false); + + if (batch) query.where('batch', batch); + }); + const bankRules = await BankRule.query().withGraphFetched('conditions'); + const bankRulesByAccountId = transformToMapBy( + bankRules, + 'applyIfAccountId' + ); + // Try to recognize the transaction. + const regonizeTransaction = async ( + transaction: UncategorizedCashflowTransaction + ) => { + const allAccountsBankRules = bankRulesByAccountId.get(`null`); + const accountBankRules = bankRulesByAccountId.get( + `${transaction.accountId}` + ); + const recognizedBankRule = bankRulesMatchTransaction( + transaction, + accountBankRules + ); + if (recognizedBankRule) { + await this.markBankRuleAsRecognized( + tenantId, + recognizedBankRule, + transaction, + trx + ); + } + }; + const result = await PromisePool.withConcurrency(MIGRATION_CONCURRENCY) + .for(uncategorizedTranasctions) + .process((transaction: UncategorizedCashflowTransaction, index, pool) => { + return regonizeTransaction(transaction); + }); + } + + /** + * + * @param {number} uncategorizedTransaction + */ + public async regonizeTransaction( + uncategorizedTransaction: UncategorizedCashflowTransaction + ) {} +} + +const MIGRATION_CONCURRENCY = 10; 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..2eee22f53 --- /dev/null +++ b/packages/server/src/services/Banking/RegonizeTranasctions/RecognizeTransactionsJob.ts @@ -0,0 +1,32 @@ +import Container, { Service } from 'typedi'; +import { RecognizeTranasctionsService } from './RecognizeTranasctionsService'; + +@Service() +export class RegonizeTransactionsJob { + /** + * Constructor method. + */ + constructor(agenda) { + agenda.define( + 'recognize-uncategorized-transactions-job', + { priority: 'high', concurrency: 2 }, + this.handler + ); + } + + /** + * Triggers sending invoice mail. + */ + private handler = async (job, done: Function) => { + const { tenantId } = job.attrs.data; + const regonizeTransactions = Container.get(RecognizeTranasctionsService); + + try { + await regonizeTransactions.recognizeTransactions(tenantId); + done(); + } catch (error) { + console.log(error); + done(error); + } + }; +} 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/RegonizeTranasctions/events/TriggerRecognizedTransactions.ts b/packages/server/src/services/Banking/RegonizeTranasctions/events/TriggerRecognizedTransactions.ts new file mode 100644 index 000000000..bb8c87b43 --- /dev/null +++ b/packages/server/src/services/Banking/RegonizeTranasctions/events/TriggerRecognizedTransactions.ts @@ -0,0 +1,76 @@ +import { Inject, Service } from 'typedi'; +import events from '@/subscribers/events'; +import { + IBankRuleEventCreatedPayload, + IBankRuleEventDeletedPayload, + IBankRuleEventEditedPayload, +} from '../../Rules/types'; + +@Service() +export class TriggerRecognizedTransactions { + @Inject('agenda') + private agenda: any; + + /** + * Constructor method. + */ + public attach(bus) { + bus.subscribe( + events.bankRules.onCreated, + this.recognizedTransactionsOnRuleCreated.bind(this) + ); + bus.subscribe( + events.bankRules.onEdited, + this.recognizedTransactionsOnRuleEdited.bind(this) + ); + bus.subscribe( + events.bankRules.onDeleted, + this.recognizedTransactionsOnRuleDeleted.bind(this) + ); + } + + /** + * Triggers the recognize uncategorized transactions job on rule created. + * @param {IBankRuleEventCreatedPayload} payload - + */ + private async recognizedTransactionsOnRuleCreated({ + tenantId, + createRuleDTO, + }: IBankRuleEventCreatedPayload) { + const payload = { tenantId }; + + // Cannot run recognition if the option is not enabled. + if (createRuleDTO.recognition) { + return; + } + await this.agenda.now('recognize-uncategorized-transactions-job', payload); + } + + /** + * Triggers the recognize uncategorized transactions job on rule edited. + * @param {IBankRuleEventEditedPayload} payload - + */ + private async recognizedTransactionsOnRuleEdited({ + tenantId, + editRuleDTO, + }: IBankRuleEventEditedPayload) { + const payload = { tenantId }; + + // Cannot run recognition if the option is not enabled. + if (!editRuleDTO.recognition) { + return; + } + await this.agenda.now('recognize-uncategorized-transactions-job', payload); + } + + /** + * Triggers the recognize uncategorized transactions job on rule deleted. + * @param {IBankRuleEventDeletedPayload} payload - + */ + private async recognizedTransactionsOnRuleDeleted({ + tenantId, + }: IBankRuleEventDeletedPayload) { + const payload = { tenantId }; + await this.agenda.now('recognize-uncategorized-transactions-job', payload); + } +} 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..7beb5e1f4 --- /dev/null +++ b/packages/server/src/services/Banking/Rules/BankRulesApplication.ts @@ -0,0 +1,82 @@ +import { Inject, Service } from 'typedi'; +import { CreateBankRuleService } from './CreateBankRule'; +import { DeleteBankRuleSerivce } from './DeleteBankRule'; +import { EditBankRuleService } from './EditBankRule'; +import { GetBankRuleService } from './GetBankRule'; +import { GetBankRulesService } from './GetBankRules'; +import { ICreateBankRuleDTO, IEditBankRuleDTO } from './types'; + +@Service() +export class BankRulesApplication { + @Inject() + private createBankRuleService: CreateBankRuleService; + + @Inject() + private editBankRuleService: EditBankRuleService; + + @Inject() + private deleteBankRuleService: DeleteBankRuleSerivce; + + @Inject() + private getBankRuleService: GetBankRuleService; + + @Inject() + private getBankRulesService: GetBankRulesService; + + /** + * Creates new bank rule. + * @param {number} tenantId + * @param {ICreateBankRuleDTO} createRuleDTO + * @returns {Promise} + */ + public createBankRule( + tenantId: number, + createRuleDTO: ICreateBankRuleDTO + ): Promise { + return this.createBankRuleService.createBankRule(tenantId, createRuleDTO); + } + + /** + * Edits the given bank rule. + * @param {number} tenantId + * @param {IEditBankRuleDTO} editRuleDTO + * @returns {Promise} + */ + public editBankRule( + tenantId: number, + ruleId: number, + editRuleDTO: IEditBankRuleDTO + ): Promise { + return this.editBankRuleService.editBankRule(tenantId, ruleId, editRuleDTO); + } + + /** + * Deletes the given bank rule. + * @param {number} tenantId + * @param {number} ruleId + * @returns {Promise} + */ + public deleteBankRule(tenantId: number, ruleId: number): Promise { + return this.deleteBankRuleService.deleteBankRule(tenantId, ruleId); + } + + /** + * Retrieves the given bank rule. + * @param {number} tenantId + * @param {number} ruleId + * @returns {Promise} + */ + public getBankRule(tenantId: number, ruleId: number): Promise { + return this.getBankRuleService.getBankRule(tenantId, ruleId); + } + + /** + * Retrieves the bank rules of the given account. + * @param {number} tenantId + * @param {number} accountId + * @returns {Promise} + */ + 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 new file mode 100644 index 000000000..ccca113e3 --- /dev/null +++ b/packages/server/src/services/Banking/Rules/CreateBankRule.ts @@ -0,0 +1,71 @@ +import { Knex } from 'knex'; +import { Inject, Service } from 'typedi'; +import { + IBankRuleEventCreatedPayload, + IBankRuleEventCreatingPayload, + ICreateBankRuleDTO, +} from './types'; +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import UnitOfWork from '@/services/UnitOfWork'; +import events from '@/subscribers/events'; + +@Service() +export class CreateBankRuleService { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private uow: UnitOfWork; + + @Inject() + private eventPublisher: EventPublisher; + + /** + * Transformes the DTO to model. + * @param {ICreateBankRuleDTO} createDTO + * @returns + */ + private transformDTO(createDTO: ICreateBankRuleDTO) { + return { + ...createDTO, + }; + } + + /** + * Creates a new bank rule. + * @param {number} tenantId + * @param {ICreateBankRuleDTO} createRuleDTO + * @returns {Promise} + */ + public createBankRule( + tenantId: number, + createRuleDTO: ICreateBankRuleDTO + ): Promise { + const { BankRule } = this.tenancy.models(tenantId); + + const transformDTO = this.transformDTO(createRuleDTO); + + return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => { + // Triggers `onBankRuleCreating` event. + await this.eventPublisher.emitAsync(events.bankRules.onCreating, { + tenantId, + createRuleDTO, + trx, + } as IBankRuleEventCreatingPayload); + + const bankRule = await BankRule.query(trx).upsertGraph({ + ...transformDTO, + }); + + // Triggers `onBankRuleCreated` event. + await this.eventPublisher.emitAsync(events.bankRules.onCreated, { + tenantId, + createRuleDTO, + trx, + } as IBankRuleEventCreatedPayload); + + return bankRule; + }); + } +} 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..c02ab6686 --- /dev/null +++ b/packages/server/src/services/Banking/Rules/DeleteBankRule.ts @@ -0,0 +1,56 @@ +import { Knex } from 'knex'; +import { Inject, Service } from 'typedi'; +import UnitOfWork from '@/services/UnitOfWork'; +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import events from '@/subscribers/events'; +import { + IBankRuleEventDeletedPayload, + IBankRuleEventDeletingPayload, +} from './types'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; + +@Service() +export class DeleteBankRuleSerivce { + @Inject() + private uow: UnitOfWork; + + @Inject() + private eventPublisher: EventPublisher; + + @Inject() + private tenancy: HasTenancyService; + + /** + * Deletes the given bank rule. + * @param {number} tenantId + * @param {number} ruleId + * @returns {Promise} + */ + public async deleteBankRule(tenantId: number, ruleId: number): Promise { + const { BankRule, BankRuleCondition } = this.tenancy.models(tenantId); + + const oldBankRule = await BankRule.query() + .findById(ruleId) + .throwIfNotFound(); + + return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => { + // Triggers `onBankRuleDeleting` event. + await this.eventPublisher.emitAsync(events.bankRules.onDeleting, { + tenantId, + oldBankRule, + ruleId, + trx, + } as IBankRuleEventDeletingPayload); + + await BankRuleCondition.query(trx).where('ruleId', ruleId).delete(); + await BankRule.query(trx).findById(ruleId).delete(); + + // Triggers `onBankRuleDeleted` event. + await await this.eventPublisher.emitAsync(events.bankRules.onDeleted, { + tenantId, + ruleId, + trx, + } as IBankRuleEventDeletedPayload); + }); + } +} 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..5073e1c59 --- /dev/null +++ b/packages/server/src/services/Banking/Rules/EditBankRule.ts @@ -0,0 +1,82 @@ +import { Knex } from 'knex'; +import { Inject, Service } from 'typedi'; +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import UnitOfWork from '@/services/UnitOfWork'; +import events from '@/subscribers/events'; +import { + IBankRuleEventEditedPayload, + IBankRuleEventEditingPayload, + IEditBankRuleDTO, +} from './types'; + +@Service() +export class EditBankRuleService { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private uow: UnitOfWork; + + @Inject() + private eventPublisher: EventPublisher; + + /** + * + * @param createDTO + * @returns + */ + private transformDTO(createDTO: IEditBankRuleDTO) { + return { + ...createDTO, + }; + } + + /** + * Edits the given bank rule. + * @param {number} tenantId + * @param {number} ruleId - + * @param {IEditBankRuleDTO} editBankDTO + */ + public async editBankRule( + tenantId: number, + ruleId: number, + editRuleDTO: IEditBankRuleDTO + ) { + const { BankRule } = this.tenancy.models(tenantId); + + const oldBankRule = await BankRule.query() + .findById(ruleId) + .throwIfNotFound(); + + const tranformDTO = this.transformDTO(editRuleDTO); + + return this.uow.withTransaction( + tenantId, + async (trx?: Knex.Transaction) => { + // Triggers `onBankRuleEditing` event. + await this.eventPublisher.emitAsync(events.bankRules.onEditing, { + tenantId, + oldBankRule, + ruleId, + editRuleDTO, + trx, + } as IBankRuleEventEditingPayload); + + // Updates the given bank rule. + await BankRule.query(trx) + .findById(ruleId) + .patch({ ...tranformDTO }); + + // Triggers `onBankRuleEdited` event. + await this.eventPublisher.emitAsync(events.bankRules.onEdited, { + tenantId, + oldBankRule, + ruleId, + editRuleDTO, + trx, + } as IBankRuleEventEditedPayload); + } + ); + } +} 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..67a26e5a1 --- /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 {Promise} + */ + async getBankRule(tenantId: number, ruleId: number): Promise { + 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..fdf34fb2c --- /dev/null +++ b/packages/server/src/services/Banking/Rules/GetBankRules.ts @@ -0,0 +1,33 @@ +import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { Inject, Service } from 'typedi'; +import { GetBankRulesTransformer } from './GetBankRulesTransformer'; + +@Service() +export class GetBankRulesService { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private transformer: TransformerInjectable; + + /** + * Retrieves the bank rules of the given account. + * @param {number} tenantId + * @param {number} accountId + * @returns {Promise} + */ + public async getBankRules(tenantId: number): Promise { + const { BankRule } = this.tenancy.models(tenantId); + + const bankRule = await BankRule.query() + .withGraphFetched('conditions') + .withGraphFetched('assignAccount'); + + 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..431b4f42d --- /dev/null +++ b/packages/server/src/services/Banking/Rules/GetBankRulesTransformer.ts @@ -0,0 +1,51 @@ +import { upperFirst, camelCase } from 'lodash'; +import { Transformer } from '@/lib/Transformer/Transformer'; +import { getTransactionTypeLabel } from '@/utils/transactions-types'; + +export class GetBankRulesTransformer extends Transformer { + /** + * Include these attributes to sale invoice object. + * @returns {Array} + */ + public includeAttributes = (): string[] => { + return [ + 'assignAccountName', + 'assignCategoryFormatted', + 'conditionsFormatted', + ]; + }; + + /** + * Get the assign account name. + * @param bankRule + * @returns {string} + */ + protected assignAccountName(bankRule: any) { + return bankRule.assignAccount.name; + } + + /** + * Assigned category formatted. + * @returns {string} + */ + protected assignCategoryFormatted(bankRule: any) { + const assignCategory = upperFirst(camelCase(bankRule.assignCategory)); + return getTransactionTypeLabel(assignCategory); + } + + /** + * Get the bank rule formatted conditions. + * @param bankRule + * @returns {string} + */ + protected conditionsFormatted(bankRule: any) { + return bankRule.conditions + .map((condition) => { + const field = + condition.field.charAt(0).toUpperCase() + condition.field.slice(1); + + return `${field} ${condition.comparator} ${condition.value}`; + }) + .join(bankRule.conditionsType === 'and' ? ' and ' : ' or '); + } +} diff --git a/packages/server/src/services/Banking/Rules/UnlinkBankRuleRecognizedTransactions.ts b/packages/server/src/services/Banking/Rules/UnlinkBankRuleRecognizedTransactions.ts new file mode 100644 index 000000000..25bff99ef --- /dev/null +++ b/packages/server/src/services/Banking/Rules/UnlinkBankRuleRecognizedTransactions.ts @@ -0,0 +1,54 @@ +import { Inject, Service } from 'typedi'; +import { Knex } from 'knex'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import UnitOfWork from '@/services/UnitOfWork'; + +@Service() +export class UnlinkBankRuleRecognizedTransactions { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private uow: UnitOfWork; + + /** + * Unlinks the given bank rule out of recognized transactions. + * @param {number} tenantId - Tenant id. + * @param {number} bankRuleId - Bank rule id. + * @param {Knex.Transaction} trx - Knex transaction. + * @returns {Promise} + */ + public async unlinkBankRuleOutRecognizedTransactions( + tenantId: number, + bankRuleId: number, + trx?: Knex.Transaction + ): Promise { + const { UncategorizedCashflowTransaction, RecognizedBankTransaction } = + this.tenancy.models(tenantId); + + return this.uow.withTransaction( + tenantId, + async (trx: Knex.Transaction) => { + // Retrieves all the recognized transactions of the banbk rule. + const recognizedTransactions = await RecognizedBankTransaction.query( + trx + ).where('bankRuleId', bankRuleId); + + const uncategorizedTransactionIds = recognizedTransactions.map( + (r) => r.uncategorizedTransactionId + ); + // Unlink the recongized transactions out of uncategorized transactions. + await UncategorizedCashflowTransaction.query(trx) + .whereIn('id', uncategorizedTransactionIds) + .patch({ + recognizedTransactionId: null, + }); + // Delete the recognized bank transactions that assocaited to bank rule. + await RecognizedBankTransaction.query(trx) + .where({ bankRuleId }) + .delete(); + }, + trx + ); + } +} diff --git a/packages/server/src/services/Banking/Rules/events/UnlinkBankRuleOnDeleteBankRule.ts b/packages/server/src/services/Banking/Rules/events/UnlinkBankRuleOnDeleteBankRule.ts new file mode 100644 index 000000000..91ad36fe9 --- /dev/null +++ b/packages/server/src/services/Banking/Rules/events/UnlinkBankRuleOnDeleteBankRule.ts @@ -0,0 +1,34 @@ +import { Inject, Service } from 'typedi'; +import events from '@/subscribers/events'; +import { UnlinkBankRuleRecognizedTransactions } from '../UnlinkBankRuleRecognizedTransactions'; +import { IBankRuleEventDeletingPayload } from '../types'; + +@Service() +export class UnlinkBankRuleOnDeleteBankRule { + @Inject() + private unlinkBankRule: UnlinkBankRuleRecognizedTransactions; + + /** + * Constructor method. + */ + public attach(bus) { + bus.subscribe( + events.bankRules.onDeleting, + this.unlinkBankRuleOutRecognizedTransactionsOnRuleDeleting.bind(this) + ); + } + + /** + * Unlinks the bank rule out of recognized transactions. + * @param {IBankRuleEventDeletingPayload} payload - + */ + private async unlinkBankRuleOutRecognizedTransactionsOnRuleDeleting({ + tenantId, + ruleId, + }: IBankRuleEventDeletingPayload) { + await this.unlinkBankRule.unlinkBankRuleOutRecognizedTransactions( + tenantId, + ruleId + ); + } +} 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..007756f41 --- /dev/null +++ b/packages/server/src/services/Banking/Rules/types.ts @@ -0,0 +1,116 @@ +import { Knex } from 'knex'; + +export enum BankRuleConditionField { + Amount = 'Amount', + Description = 'Description', + Payee = 'Payee' +} + +export enum BankRuleConditionComparator { + Contains = 'contains', + Equals = 'equals', + NotContain = 'not_contain'; +} + +export interface IBankRuleCondition { + id?: number; + field: BankRuleConditionField; + comparator: BankRuleConditionComparator; + value: number; +} + +export enum BankRuleConditionType { + Or = 'or', + And = 'and' +} + +export enum BankRuleApplyIfTransactionType { + Deposit = 'deposit', + Withdrawal = 'withdrawal', +} + +export interface IBankRule { + name: string; + order?: number; + applyIfAccountId: number; + applyIfTransactionType: BankRuleApplyIfTransactionType; + + conditionsType: BankRuleConditionType; + conditions: IBankRuleCondition[]; + + assignCategory: BankRuleAssignCategory; + assignAccountId: number; + assignPayee?: string; + assignMemo?: string; +} + +export enum BankRuleAssignCategory { + InterestIncome = 'InterestIncome', + OtherIncome = 'OtherIncome', + Deposit = 'Deposit', + Expense = 'Expense', + OwnerDrawings = 'OwnerDrawings', +} + +export interface IBankRuleConditionDTO { + id?: number; + field: string; + comparator: string; + value: number; +} + +export interface IBankRuleCommonDTO { + name: string; + order?: number; + applyIfAccountId: number; + applyIfTransactionType: string; + + conditions: IBankRuleConditionDTO[]; + + assignCategory: BankRuleAssignCategory; + assignAccountId: number; + assignPayee?: string; + assignMemo?: string; + + recognition?: boolean; +} + +export interface ICreateBankRuleDTO extends IBankRuleCommonDTO {} +export interface IEditBankRuleDTO extends IBankRuleCommonDTO {} + +export interface IBankRuleEventCreatingPayload { + tenantId: number; + createRuleDTO: ICreateBankRuleDTO; + trx?: Knex.Transaction; +} +export interface IBankRuleEventCreatedPayload { + tenantId: number; + createRuleDTO: ICreateBankRuleDTO; + trx?: Knex.Transaction; +} + +export interface IBankRuleEventEditingPayload { + tenantId: number; + ruleId: number; + oldBankRule: any; + editRuleDTO: IEditBankRuleDTO; + trx?: Knex.Transaction; +} +export interface IBankRuleEventEditedPayload { + tenantId: number; + ruleId: number; + editRuleDTO: IEditBankRuleDTO; + trx?: Knex.Transaction; +} + +export interface IBankRuleEventDeletingPayload { + tenantId: number; + oldBankRule: any; + ruleId: number; + trx?: Knex.Transaction; +} +export interface IBankRuleEventDeletedPayload { + tenantId: number; + ruleId: number; + trx?: Knex.Transaction; +} diff --git a/packages/server/src/services/Cashflow/CashflowApplication.ts b/packages/server/src/services/Cashflow/CashflowApplication.ts index f7487badd..f534e706e 100644 --- a/packages/server/src/services/Cashflow/CashflowApplication.ts +++ b/packages/server/src/services/Cashflow/CashflowApplication.ts @@ -9,6 +9,7 @@ import { ICashflowAccountsFilter, ICashflowNewCommandDTO, ICategorizeCashflowTransactioDTO, + IGetRecognizedTransactionsQuery, IGetUncategorizedTransactionsQuery, } from '@/interfaces'; import { CategorizeTransactionAsExpense } from './CategorizeTransactionAsExpense'; @@ -18,6 +19,8 @@ import { GetUncategorizedTransaction } from './GetUncategorizedTransaction'; import NewCashflowTransactionService from './NewCashflowTransactionService'; import GetCashflowAccountsService from './GetCashflowAccountsService'; import { GetCashflowTransactionService } from './GetCashflowTransactionsService'; +import { GetRecognizedTransactionsService } from './GetRecongizedTransactions'; +import { GetRecognizedTransactionService } from './GetRecognizedTransaction'; @Service() export class CashflowApplication { @@ -51,6 +54,12 @@ export class CashflowApplication { @Inject() private createUncategorizedTransactionService: CreateUncategorizedTransaction; + @Inject() + private getRecognizedTranasctionsService: GetRecognizedTransactionsService; + + @Inject() + private getRecognizedTransactionService: GetRecognizedTransactionService; + /** * Creates a new cashflow transaction. * @param {number} tenantId @@ -213,4 +222,36 @@ export class CashflowApplication { uncategorizedTransactionId ); } + + /** + * Retrieves the recognized bank transactions. + * @param {number} tenantId + * @param {number} accountId + * @returns + */ + public getRecognizedTransactions( + tenantId: number, + filter?: IGetRecognizedTransactionsQuery + ) { + return this.getRecognizedTranasctionsService.getRecognizedTranactions( + tenantId, + filter + ); + } + + /** + * Retrieves the recognized transaction of the given uncategorized transaction. + * @param {number} tenantId + * @param {number} uncategorizedTransactionId + * @returns + */ + public getRecognizedTransaction( + tenantId: number, + uncategorizedTransactionId: number + ) { + return this.getRecognizedTransactionService.getRecognizedTransaction( + tenantId, + uncategorizedTransactionId + ); + } } 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/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 diff --git a/packages/server/src/services/Cashflow/GetRecognizedTransaction.ts b/packages/server/src/services/Cashflow/GetRecognizedTransaction.ts new file mode 100644 index 000000000..5ac725084 --- /dev/null +++ b/packages/server/src/services/Cashflow/GetRecognizedTransaction.ts @@ -0,0 +1,40 @@ +import { Inject, Service } from 'typedi'; +import HasTenancyService from '../Tenancy/TenancyService'; +import { GetRecognizedTransactionTransformer } from './GetRecognizedTransactionTransformer'; +import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; + +@Service() +export class GetRecognizedTransactionService { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private transformer: TransformerInjectable; + + /** + * Retrieves the recognized transaction of the given uncategorized transaction. + * @param {number} tenantId + * @param {number} uncategorizedTransactionId + */ + public async getRecognizedTransaction( + tenantId: number, + uncategorizedTransactionId: number + ) { + const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId); + + const uncategorizedTransaction = + await UncategorizedCashflowTransaction.query() + .findById(uncategorizedTransactionId) + .withGraphFetched('matchedBankTransactions') + .withGraphFetched('recognizedTransaction.assignAccount') + .withGraphFetched('recognizedTransaction.bankRule') + .withGraphFetched('account') + .throwIfNotFound(); + + return this.transformer.transform( + tenantId, + uncategorizedTransaction, + new GetRecognizedTransactionTransformer() + ); + } +} diff --git a/packages/server/src/services/Cashflow/GetRecognizedTransactionTransformer.ts b/packages/server/src/services/Cashflow/GetRecognizedTransactionTransformer.ts new file mode 100644 index 000000000..dc4ecaf00 --- /dev/null +++ b/packages/server/src/services/Cashflow/GetRecognizedTransactionTransformer.ts @@ -0,0 +1,262 @@ +import { Transformer } from '@/lib/Transformer/Transformer'; +import { formatNumber } from '@/utils'; + +export class GetRecognizedTransactionTransformer extends Transformer { + /** + * Include these attributes to sale credit note object. + * @returns {Array} + */ + public includeAttributes = (): string[] => { + return [ + 'uncategorizedTransactionId', + 'referenceNo', + 'description', + 'payee', + 'amount', + 'formattedAmount', + 'date', + 'formattedDate', + 'assignedAccountId', + 'assignedAccountName', + 'assignedAccountCode', + 'assignedPayee', + 'assignedMemo', + 'assignedCategory', + 'assignedCategoryFormatted', + 'withdrawal', + 'deposit', + 'isDepositTransaction', + 'isWithdrawalTransaction', + 'formattedDepositAmount', + 'formattedWithdrawalAmount', + 'bankRuleId', + 'bankRuleName', + ]; + }; + + /** + * Exclude all attributes. + * @returns {Array} + */ + public excludeAttributes = (): string[] => { + return ['*']; + }; + + /** + * Get the uncategorized transaction id. + * @param transaction + * @returns {number} + */ + public uncategorizedTransactionId = (transaction): number => { + return transaction.id; + } + + /** + * Get the reference number of the transaction. + * @param {object} transaction + * @returns {string} + */ + public referenceNo(transaction: any): string { + return transaction.referenceNo; + } + + /** + * Get the description of the transaction. + * @param {object} transaction + * @returns {string} + */ + public description(transaction: any): string { + return transaction.description; + } + + /** + * Get the payee of the transaction. + * @param {object} transaction + * @returns {string} + */ + public payee(transaction: any): string { + return transaction.payee; + } + + /** + * Get the amount of the transaction. + * @param {object} transaction + * @returns {number} + */ + public amount(transaction: any): number { + return transaction.amount; + } + + /** + * Get the formatted amount of the transaction. + * @param {object} transaction + * @returns {string} + */ + public formattedAmount(transaction: any): string { + return this.formatNumber(transaction.formattedAmount, { + money: true, + }); + } + + /** + * Get the date of the transaction. + * @param {object} transaction + * @returns {string} + */ + public date(transaction: any): string { + return transaction.date; + } + + /** + * Get the formatted date of the transaction. + * @param {object} transaction + * @returns {string} + */ + public formattedDate(transaction: any): string { + return this.formatDate(transaction.date); + } + + /** + * Get the assigned account ID of the transaction. + * @param {object} transaction + * @returns {number} + */ + public assignedAccountId(transaction: any): number { + return transaction.recognizedTransaction.assignedAccountId; + } + + /** + * Get the assigned account name of the transaction. + * @param {object} transaction + * @returns {string} + */ + public assignedAccountName(transaction: any): string { + return transaction.recognizedTransaction.assignAccount.name; + } + + /** + * Get the assigned account code of the transaction. + * @param {object} transaction + * @returns {string} + */ + public assignedAccountCode(transaction: any): string { + return transaction.recognizedTransaction.assignAccount.code; + } + + /** + * Get the assigned payee of the transaction. + * @param {object} transaction + * @returns {string} + */ + public getAssignedPayee(transaction: any): string { + return transaction.recognizedTransaction.assignedPayee; + } + + /** + * Get the assigned memo of the transaction. + * @param {object} transaction + * @returns {string} + */ + public assignedMemo(transaction: any): string { + return transaction.recognizedTransaction.assignedMemo; + } + + /** + * Get the assigned category of the transaction. + * @param {object} transaction + * @returns {string} + */ + public assignedCategory(transaction: any): string { + return transaction.recognizedTransaction.assignedCategory; + } + + /** + * + * @returns {string} + */ + public assignedCategoryFormatted() { + return 'Other Income' + } + + /** + * Check if the transaction is a withdrawal. + * @param {object} transaction + * @returns {boolean} + */ + public isWithdrawal(transaction: any): boolean { + return transaction.withdrawal; + } + + /** + * Check if the transaction is a deposit. + * @param {object} transaction + * @returns {boolean} + */ + public isDeposit(transaction: any): boolean { + return transaction.deposit; + } + + /** + * Check if the transaction is a deposit transaction. + * @param {object} transaction + * @returns {boolean} + */ + public isDepositTransaction(transaction: any): boolean { + return transaction.isDepositTransaction; + } + + /** + * Check if the transaction is a withdrawal transaction. + * @param {object} transaction + * @returns {boolean} + */ + public isWithdrawalTransaction(transaction: any): boolean { + return transaction.isWithdrawalTransaction; + } + + /** + * Get formatted deposit amount. + * @param {any} transaction + * @returns {string} + */ + protected formattedDepositAmount(transaction) { + if (transaction.isDepositTransaction) { + return formatNumber(transaction.deposit, { + currencyCode: transaction.currencyCode, + }); + } + return ''; + } + + /** + * Get formatted withdrawal amount. + * @param transaction + * @returns {string} + */ + protected formattedWithdrawalAmount(transaction) { + if (transaction.isWithdrawalTransaction) { + return formatNumber(transaction.withdrawal, { + currencyCode: transaction.currencyCode, + }); + } + return ''; + } + + /** + * Get the transaction bank rule id. + * @param transaction + * @returns {string} + */ + protected bankRuleId(transaction) { + return transaction.recognizedTransaction.bankRuleId; + } + + /** + * Get the transaction bank rule name. + * @param transaction + * @returns {string} + */ + protected bankRuleName(transaction) { + return transaction.recognizedTransaction.bankRule.name; + } +} diff --git a/packages/server/src/services/Cashflow/GetRecongizedTransactions.ts b/packages/server/src/services/Cashflow/GetRecongizedTransactions.ts new file mode 100644 index 000000000..3bc6ac4fc --- /dev/null +++ b/packages/server/src/services/Cashflow/GetRecongizedTransactions.ts @@ -0,0 +1,51 @@ +import { Inject, Service } from 'typedi'; +import HasTenancyService from '../Tenancy/TenancyService'; +import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; +import { GetRecognizedTransactionTransformer } from './GetRecognizedTransactionTransformer'; +import { IGetRecognizedTransactionsQuery } from '@/interfaces'; + +@Service() +export class GetRecognizedTransactionsService { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private transformer: TransformerInjectable; + + /** + * Retrieves the recognized transactions of the given account. + * @param {number} tenantId + * @param {IGetRecognizedTransactionsQuery} filter - + */ + async getRecognizedTranactions( + tenantId: number, + filter?: IGetRecognizedTransactionsQuery + ) { + const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId); + + const _filter = { + page: 1, + pageSize: 20, + ...filter, + }; + const { results, pagination } = + await UncategorizedCashflowTransaction.query() + .onBuild((q) => { + q.withGraphFetched('recognizedTransaction.assignAccount'); + q.withGraphFetched('recognizedTransaction.bankRule'); + q.whereNotNull('recognizedTransactionId'); + + if (_filter.accountId) { + q.where('accountId', _filter.accountId); + } + }) + .pagination(_filter.page - 1, _filter.pageSize); + + const data = await this.transformer.transform( + tenantId, + results, + new GetRecognizedTransactionTransformer() + ); + return { data, pagination }; + } +} diff --git a/packages/server/src/services/Cashflow/GetUncategorizedTransactions.ts b/packages/server/src/services/Cashflow/GetUncategorizedTransactions.ts index 36606f582..7b3c76774 100644 --- a/packages/server/src/services/Cashflow/GetUncategorizedTransactions.ts +++ b/packages/server/src/services/Cashflow/GetUncategorizedTransactions.ts @@ -1,4 +1,5 @@ import { Inject, Service } from 'typedi'; +import { initialize } from 'objection'; import HasTenancyService from '../Tenancy/TenancyService'; import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; import { UncategorizedTransactionTransformer } from './UncategorizedTransactionTransformer'; @@ -22,7 +23,13 @@ export class GetUncategorizedTransactions { accountId: number, query: IGetUncategorizedTransactionsQuery ) { - const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId); + const { + UncategorizedCashflowTransaction, + RecognizedBankTransaction, + MatchedBankTransaction, + Account, + } = this.tenancy.models(tenantId); + const knex = this.tenancy.knex(tenantId); // Parsed query with default values. const _query = { @@ -30,12 +37,30 @@ export class GetUncategorizedTransactions { pageSize: 20, ...query, }; + + // Initialize the ORM models metadata. + await initialize(knex, [ + UncategorizedCashflowTransaction, + MatchedBankTransaction, + RecognizedBankTransaction, + Account, + ]); + const { results, pagination } = await UncategorizedCashflowTransaction.query() - .where('accountId', accountId) - .where('categorized', false) - .withGraphFetched('account') - .orderBy('date', 'DESC') + .onBuild((q) => { + q.where('accountId', accountId); + q.where('categorized', false); + q.modify('notExcluded'); + + q.withGraphFetched('account'); + q.withGraphFetched('recognizedTransaction.assignAccount'); + + q.withGraphJoined('matchedBankTransactions'); + + q.whereNull('matchedBankTransactions.id'); + q.orderBy('date', 'DESC'); + }) .pagination(_query.page - 1, _query.pageSize); const data = await this.transformer.transform( diff --git a/packages/server/src/services/Cashflow/UncategorizedTransactionTransformer.ts b/packages/server/src/services/Cashflow/UncategorizedTransactionTransformer.ts index 85d1a1fbb..83fe13e5e 100644 --- a/packages/server/src/services/Cashflow/UncategorizedTransactionTransformer.ts +++ b/packages/server/src/services/Cashflow/UncategorizedTransactionTransformer.ts @@ -10,11 +10,27 @@ export class UncategorizedTransactionTransformer extends Transformer { return [ 'formattedAmount', 'formattedDate', - 'formattetDepositAmount', + 'formattedDepositAmount', 'formattedWithdrawalAmount', + + 'assignedAccountId', + 'assignedAccountName', + 'assignedAccountCode', + 'assignedPayee', + 'assignedMemo', + 'assignedCategory', + 'assignedCategoryFormatted', ]; }; + /** + * Exclude all attributes. + * @returns {Array} + */ + public excludeAttributes = (): string[] => { + return ['recognizedTransaction']; + }; + /** * Formattes the transaction date. * @param transaction @@ -26,7 +42,7 @@ export class UncategorizedTransactionTransformer extends Transformer { /** * Formatted amount. - * @param transaction + * @param transaction * @returns {string} */ public formattedAmount(transaction) { @@ -40,7 +56,7 @@ export class UncategorizedTransactionTransformer extends Transformer { * @param transaction * @returns {string} */ - protected formattetDepositAmount(transaction) { + protected formattedDepositAmount(transaction) { if (transaction.isDepositTransaction) { return formatNumber(transaction.deposit, { currencyCode: transaction.currencyCode, @@ -62,4 +78,69 @@ export class UncategorizedTransactionTransformer extends Transformer { } return ''; } + + // -------------------------------------------------------- + // # Recgonized transaction + // -------------------------------------------------------- + /** + * Get the assigned account ID of the transaction. + * @param {object} transaction + * @returns {number} + */ + public assignedAccountId(transaction: any): number { + return transaction.recognizedTransaction?.assignedAccountId; + } + + /** + * Get the assigned account name of the transaction. + * @param {object} transaction + * @returns {string} + */ + public assignedAccountName(transaction: any): string { + return transaction.recognizedTransaction?.assignAccount?.name; + } + + /** + * Get the assigned account code of the transaction. + * @param {object} transaction + * @returns {string} + */ + public assignedAccountCode(transaction: any): string { + return transaction.recognizedTransaction?.assignAccount?.code; + } + + /** + * Get the assigned payee of the transaction. + * @param {object} transaction + * @returns {string} + */ + public getAssignedPayee(transaction: any): string { + return transaction.recognizedTransaction?.assignedPayee; + } + + /** + * Get the assigned memo of the transaction. + * @param {object} transaction + * @returns {string} + */ + public assignedMemo(transaction: any): string { + return transaction.recognizedTransaction?.assignedMemo; + } + + /** + * Get the assigned category of the transaction. + * @param {object} transaction + * @returns {string} + */ + public assignedCategory(transaction: any): string { + return transaction.recognizedTransaction?.assignedCategory; + } + + /** + * Get the assigned formatted category. + * @returns {string} + */ + public assignedCategoryFormatted() { + return 'Other Income'; + } } 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 { diff --git a/packages/server/src/subscribers/events.ts b/packages/server/src/subscribers/events.ts index b96cadf95..507fe9e52 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', }, /** @@ -616,5 +616,27 @@ export default { plaid: { onItemCreated: 'onPlaidItemCreated', + onTransactionsSynced: 'onPlaidTransactionsSynced', + }, + + // Bank rules. + bankRules: { + onCreating: 'onBankRuleCreating', + onCreated: 'onBankRuleCreated', + + onEditing: 'onBankRuleEditing', + onEdited: 'onBankRuleEdited', + + onDeleting: 'onBankRuleDeleting', + onDeleted: 'onBankRuleDeleted', + }, + + // Bank matching. + bankMatch: { + onMatching: 'onBankTransactionMatching', + onMatched: 'onBankTransactionMatched', + + onUnmatching: 'onBankTransactionUnmathcing', + onUnmatched: 'onBankTransactionUnmathced', }, }; diff --git a/packages/webapp/src/components/AppShell/AppContentShell/AppContentShell.module.scss b/packages/webapp/src/components/AppShell/AppContentShell/AppContentShell.module.scss new file mode 100644 index 000000000..7d3364303 --- /dev/null +++ b/packages/webapp/src/components/AppShell/AppContentShell/AppContentShell.module.scss @@ -0,0 +1,27 @@ +:root { + --aside-topbar-offset: 60px; + --aside-width: 34%; + --aside-min-width: 400px; +} + +.main{ + flex: 1 0; + height: inherit; + overflow: auto; +} + +.aside{ + width: var(--aside-width); + min-width: var(--aside-min-width); + height: 100dvh; + border-left: 1px solid rgba(17, 20, 24, 0.15); + height: inherit; + overflow: auto; + display: flex; + flex-direction: column; +} + +.root { + display: flex; + height: calc(100dvh - var(--aside-topbar-offset)); +} \ No newline at end of file diff --git a/packages/webapp/src/components/AppShell/AppContentShell/AppContentShell.tsx b/packages/webapp/src/components/AppShell/AppContentShell/AppContentShell.tsx new file mode 100644 index 000000000..da442eb6e --- /dev/null +++ b/packages/webapp/src/components/AppShell/AppContentShell/AppContentShell.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { AppShellProvider, useAppShellContext } from './AppContentShellProvider'; +import { Box, BoxProps } from '../../Layout'; +import styles from './AppContentShell.module.scss'; + +interface AppContentShellProps { + topbarOffset?: number; + mainProps?: BoxProps; + asideProps?: BoxProps; + children: React.ReactNode; + hideAside?: boolean; + hideMain?: boolean; +} + +export function AppContentShell({ + asideProps, + mainProps, + topbarOffset = 0, + hideAside = false, + hideMain = false, + ...restProps +}: AppContentShellProps) { + return ( + + + + ); +} + +interface AppContentShellMainProps extends BoxProps {} + +function AppContentShellMain({ ...props }: AppContentShellMainProps) { + const { hideMain } = useAppShellContext(); + + if (hideMain === true) { + return null; + } + return ; +} + +interface AppContentShellAsideProps extends BoxProps { + children: React.ReactNode; +} + +function AppContentShellAside({ ...props }: AppContentShellAsideProps) { + const { hideAside } = useAppShellContext(); + + if (hideAside === true) { + return null; + } + return ; +} + +AppContentShell.Main = AppContentShellMain; +AppContentShell.Aside = AppContentShellAside; diff --git a/packages/webapp/src/components/AppShell/AppContentShell/AppContentShellProvider.tsx b/packages/webapp/src/components/AppShell/AppContentShell/AppContentShellProvider.tsx new file mode 100644 index 000000000..8ac2a1ff0 --- /dev/null +++ b/packages/webapp/src/components/AppShell/AppContentShell/AppContentShellProvider.tsx @@ -0,0 +1,40 @@ +// @ts-nocheck +import React, { createContext } from 'react'; + +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 extends ContentShellCommonValue { + children: React.ReactNode; +} + +export function AppShellProvider({ + topbarOffset, + hideAside, + hideMain, + ...props +}: AppShellProviderProps) { + const provider = { + topbarOffset, + hideAside, + hideMain, + } as AppShellContextValue; + + return ; +} + +export const useAppShellContext = () => + React.useContext(AppShellContext); diff --git a/packages/webapp/src/components/AppShell/AppContentShell/index.ts b/packages/webapp/src/components/AppShell/AppContentShell/index.ts new file mode 100644 index 000000000..1979e45a6 --- /dev/null +++ b/packages/webapp/src/components/AppShell/AppContentShell/index.ts @@ -0,0 +1 @@ +export * from './AppContentShell'; diff --git a/packages/webapp/src/components/AppShell/index.ts b/packages/webapp/src/components/AppShell/index.ts new file mode 100644 index 000000000..1979e45a6 --- /dev/null +++ b/packages/webapp/src/components/AppShell/index.ts @@ -0,0 +1 @@ +export * from './AppContentShell'; 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..742d0c4c5 --- /dev/null +++ b/packages/webapp/src/components/Aside/Aside.module.scss @@ -0,0 +1,24 @@ +.root{ + display: flex; + flex-direction: column; + flex: 1 1 auto; +} + +.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; + font-weight: 600; +} + +.content{ + display: flex; + flex-direction: column; + flex: 1 1 auto; + background-color: #fff; +} \ No newline at end of file diff --git a/packages/webapp/src/components/Aside/Aside.tsx b/packages/webapp/src/components/Aside/Aside.tsx new file mode 100644 index 000000000..7967cd2b3 --- /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 && ( + + + ); +} + +/** + * Rule form actions buttons. + * @returns {React.ReactNode} + */ +function RuleFormActionsRoot({ + // #withDialogActions + closeDialog, +}) { + const { isSubmitting, submitForm } = useFormikContext(); + + const handleSaveBtnClick = () => { + submitForm(); + }; + const handleCancelBtnClick = () => { + closeDialog(DialogsName.BankRuleForm); + }; + + return ( + + + + + + + ); +} + +const RuleFormActions = R.compose(withDialogActions)(RuleFormActionsRoot); 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..e61afa7dc --- /dev/null +++ b/packages/webapp/src/containers/Banking/Rules/RuleFormDialog/RuleFormDialog.tsx @@ -0,0 +1,35 @@ +// @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 RuleFormDialogRoot({ + dialogName, + payload: { bankRuleId = null }, + isOpen, +}) { + return ( + + + + + + ); +} + +export const RuleFormDialog = compose(withDialogRedux())(RuleFormDialogRoot); + +RuleFormDialog.displayName = 'RuleFormDialog'; diff --git a/packages/webapp/src/containers/Banking/Rules/RuleFormDialog/_utils.ts b/packages/webapp/src/containers/Banking/Rules/RuleFormDialog/_utils.ts new file mode 100644 index 000000000..696464b87 --- /dev/null +++ b/packages/webapp/src/containers/Banking/Rules/RuleFormDialog/_utils.ts @@ -0,0 +1,49 @@ +export const initialValues = { + name: '', + order: 0, + applyIfAccountId: '', + applyIfTransactionType: '', + conditionsType: 'and', + conditions: [ + { + field: 'description', + comparator: 'contains', + value: '', + }, + ], + assignCategory: '', + assignAccountId: '', +}; + +export 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 const TransactionTypeOptions = [ + { value: 'deposit', text: 'Deposit' }, + { value: 'withdrawal', text: 'Withdrawal' }, +]; +export const Fields = [ + { value: 'description', text: 'Description' }, + { value: 'amount', text: 'Amount' }, + { value: 'payee', text: 'Payee' }, +]; +export const FieldCondition = [ + { value: 'contains', text: 'Contains' }, + { value: 'equals', text: 'Equals' }, + { value: 'not_contains', text: 'Not Contains' }, +]; +export const AssignTransactionTypeOptions = [ + { value: 'expense', text: 'Expense' }, +]; 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/BankRulesLandingEmptyState.module.scss b/packages/webapp/src/containers/Banking/Rules/RulesList/BankRulesLandingEmptyState.module.scss new file mode 100644 index 000000000..9bea9513f --- /dev/null +++ b/packages/webapp/src/containers/Banking/Rules/RulesList/BankRulesLandingEmptyState.module.scss @@ -0,0 +1,3 @@ +.root{ + max-width: 600px; +} \ No newline at end of file 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..737fdca6d --- /dev/null +++ b/packages/webapp/src/containers/Banking/Rules/RulesList/BankRulesLandingEmptyState.tsx @@ -0,0 +1,51 @@ +// @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'; +import { DialogsName } from '@/constants/dialogs'; +import styles from './BankRulesLandingEmptyState.module.scss'; + +function BankRulesLandingEmptyStateRoot({ + // #withDialogAction + openDialog, +}) { + const handleNewBtnClick = () => { + openDialog(DialogsName.BankRuleForm); + }; + + return ( + + Bank rules will run automatically to categorize the incoming bank + transactions under the conditions you set up. +

+ } + action={ + <> + + + + + + + } + classNames={{ root: styles.root }} + /> + ); +} + +export const BankRulesLandingEmptyState = R.compose(withDialogActions)( + BankRulesLandingEmptyStateRoot, +); diff --git a/packages/webapp/src/containers/Banking/Rules/RulesList/RulesLandingPage.ts b/packages/webapp/src/containers/Banking/Rules/RulesList/RulesLandingPage.ts new file mode 100644 index 000000000..7d5d953f5 --- /dev/null +++ b/packages/webapp/src/containers/Banking/Rules/RulesList/RulesLandingPage.ts @@ -0,0 +1,3 @@ +import { RulesList } from './RulesList'; + +export default RulesList; \ No newline at end of file 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..338178182 --- /dev/null +++ b/packages/webapp/src/containers/Banking/Rules/RulesList/RulesList.tsx @@ -0,0 +1,24 @@ +// @ts-nocheck +import { DashboardPageContent } from '@/components'; +import { RulesListBoot } from './RulesListBoot'; +import { RulesListActionsBar } from './RulesListActionsBar'; +import { BankRulesTable } from './RulesTable'; +import React from 'react'; + +/** + * Renders the rules landing page. + * @returns {React.ReactNode} + */ +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..2516c1563 --- /dev/null +++ b/packages/webapp/src/containers/Banking/Rules/RulesList/RulesListActionsBar.tsx @@ -0,0 +1,35 @@ +// @ts-nocheck +import { Button, Classes, NavbarGroup } from '@blueprintjs/core'; +import * as R from 'ramda'; +import { Can, DashboardActionsBar, Icon } from '@/components'; +import { AbilitySubject, BankRuleAction } from '@/constants/abilityOption'; +import withDialogActions from '@/containers/Dialog/withDialogActions'; +import { DialogsName } from '@/constants/dialogs'; + +function RulesListActionsBarRoot({ + // #withDialogActions + openDialog, +}) { + const handleCreateBtnClick = () => { + openDialog(DialogsName.BankRuleForm); + }; + + return ( + + + + + + - - - + + ); } -export const CategorizeTransactionFormFooter = R.compose(withDrawerActions)( +export const CategorizeTransactionFormFooter = R.compose(withBankingActions)( CategorizeTransactionFormFooterRoot, ); const Root = styled.div` - position: absolute; - bottom: 0; - left: 0; - right: 0; - background: #fff; + border-top: 1px solid #c7d5db; + padding: 14px 20px; `; diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyIn/CategorizeTransactionOtherIncome.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyIn/CategorizeTransactionOtherIncome.tsx index 17e701096..b45f00bb9 100644 --- a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyIn/CategorizeTransactionOtherIncome.tsx +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyIn/CategorizeTransactionOtherIncome.tsx @@ -58,7 +58,7 @@ export default function CategorizeTransactionOtherIncome() { - + diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/_utils.ts b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/_utils.ts index cf13dc829..340ed16fd 100644 --- a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/_utils.ts +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/_utils.ts @@ -1,5 +1,8 @@ // @ts-nocheck +import * as R from 'ramda'; import { transformToForm, transfromToSnakeCase } from '@/utils'; +import { useCategorizeTransactionTabsBoot } from '@/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionTabsBoot'; +import { useCategorizeTransactionBoot } from './CategorizeTransactionBoot'; // Default initial form values. export const defaultInitialValues = { @@ -14,8 +17,11 @@ export const defaultInitialValues = { branchId: '', }; -export const transformToCategorizeForm = (uncategorizedTransaction) => { - const defaultValues = { +export const transformToCategorizeForm = ( + uncategorizedTransaction: any, + recognizedTransaction?: any, +) => { + let defaultValues = { debitAccountId: uncategorizedTransaction.account_id, transactionType: uncategorizedTransaction.is_deposit_transaction ? 'other_income' @@ -23,10 +29,51 @@ export const transformToCategorizeForm = (uncategorizedTransaction) => { amount: uncategorizedTransaction.amount, date: uncategorizedTransaction.date, }; + if (recognizedTransaction) { + const recognizedDefaults = getRecognizedTransactionDefaultValues( + recognizedTransaction, + ); + defaultValues = R.merge(defaultValues, recognizedDefaults); + } return transformToForm(defaultValues, defaultInitialValues); }; +export const getRecognizedTransactionDefaultValues = ( + recognizedTransaction: any, +) => { + return { + creditAccountId: recognizedTransaction.assignedAccountId || '', + // transactionType: recognizedTransaction.assignCategory, + referenceNo: recognizedTransaction.referenceNo || '', + }; +}; -export const tranformToRequest = (formValues) => { +export const tranformToRequest = (formValues: Record) => { return transfromToSnakeCase(formValues); -}; \ No newline at end of file +}; + +/** + * Categorize transaction form initial values. + * @returns + */ +export const useCategorizeTransactionFormInitialValues = () => { + const { primaryBranch, recognizedTranasction } = + useCategorizeTransactionBoot(); + const { uncategorizedTransaction } = useCategorizeTransactionTabsBoot(); + + return { + ...defaultInitialValues, + /** + * We only care about the fields in the form. Previously unfilled optional + * values such as `notes` come back from the API as null, so remove those + * as well. + */ + ...transformToCategorizeForm( + uncategorizedTransaction, + recognizedTranasction, + ), + + /** Assign the primary branch id as default value. */ + branchId: primaryBranch?.id || null, + }; +}; 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..bc6646aa5 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionAside.module.scss @@ -0,0 +1,40 @@ + +.root { + flex: 1 1 auto; + overflow: auto; + padding-bottom: 60px; +} + +.transaction { + +} + +.matchBar{ + padding: 12px 14px; + background: #fff; + border-bottom: 1px solid #E1E2E9; + + &:not(:first-of-type) { + border-top: 1px solid #E1E2E9; + } +} + +.matchBarTitle { + font-size: 14px; + font-weight: 500; +} + +.footer { + background-color: #fff; +} + +.footerActions { + padding: 14px 16px; + border-top: 1px solid #d8d9d9; +} + +.footerTotal { + padding: 8px 16px; + border-top: 1px solid #d8d9d9; + line-height: 24px; +} 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..463cfe461 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionAside.tsx @@ -0,0 +1,45 @@ +// @ts-nocheck +import * as R from 'ramda'; +import { Aside } from '@/components/Aside/Aside'; +import { CategorizeTransactionTabs } from './CategorizeTransactionTabs'; +import { + WithBankingActionsProps, + withBankingActions, +} from '../withBankingActions'; +import { CategorizeTransactionTabsBoot } from './CategorizeTransactionTabsBoot'; +import { withBanking } from '../withBanking'; + +interface CategorizeTransactionAsideProps extends WithBankingActionsProps {} + +function CategorizeTransactionAsideRoot({ + // #withBankingActions + closeMatchingTransactionAside, + + // #withBanking + selectedUncategorizedTransactionId, +}: CategorizeTransactionAsideProps) { + const handleClose = () => { + closeMatchingTransactionAside(); + }; + const uncategorizedTransactionId = selectedUncategorizedTransactionId; + + if (!selectedUncategorizedTransactionId) { + return null; + } + return ( + + ); +} + +export const CategorizeTransactionAside = R.compose( + withBankingActions, + withBanking(({ selectedUncategorizedTransactionId }) => ({ + selectedUncategorizedTransactionId, + })), +)(CategorizeTransactionAsideRoot); 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..279b58051 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionTabs.module.scss @@ -0,0 +1,23 @@ + +.tabs :global .bp4-tab-panel{ + margin-top: 0; + display: flex; + flex-direction: column; + flex: 1 1 auto; + height: calc(100dvh - 144px); +} +.tabs :global .bp4-tab-list{ + background: #fff; + border-bottom: 1px solid #c7d5db; + padding: 0 22px; +} + +.tabs :global .bp4-large > .bp4-tab{ + font-size: 14px; +} + +.tabs { + flex: 1 1 auto; + display: flex; + flex-direction: column; +} \ No newline at end of file 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..3e81d8ae5 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionTabs.tsx @@ -0,0 +1,33 @@ +// @ts-nocheck +import { Tab, Tabs } from '@blueprintjs/core'; +import { MatchingBankTransaction } from './MatchingTransaction'; +import { CategorizeTransactionContent } from '../CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionContent'; +import { useCategorizeTransactionTabsBoot } from './CategorizeTransactionTabsBoot'; +import styles from './CategorizeTransactionTabs.module.scss'; + +export function CategorizeTransactionTabs() { + const { uncategorizedTransaction } = useCategorizeTransactionTabsBoot(); + const defaultSelectedTabId = uncategorizedTransaction?.is_recognized + ? 'categorize' + : 'matching'; + + return ( + + } + /> + } + /> + + ); +} diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionTabsBoot.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionTabsBoot.tsx new file mode 100644 index 000000000..0454c5b7f --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionTabsBoot.tsx @@ -0,0 +1,61 @@ +// @ts-nocheck +import React from 'react'; +import { Spinner } from '@blueprintjs/core'; +import { useUncategorizedTransaction } from '@/hooks/query'; + +interface CategorizeTransactionTabsValue { + uncategorizedTransactionId: number; + isUncategorizedTransactionLoading: boolean; + uncategorizedTransaction: any; +} + +interface CategorizeTransactionTabsBootProps { + uncategorizedTransactionId: number; + children: React.ReactNode; +} + +const CategorizeTransactionTabsBootContext = + React.createContext( + {} as CategorizeTransactionTabsValue, + ); + +/** + * Categorize transcation tabs boot. + */ +export function CategorizeTransactionTabsBoot({ + uncategorizedTransactionId, + children, +}: CategorizeTransactionTabsBootProps) { + const { + data: uncategorizedTransaction, + isLoading: isUncategorizedTransactionLoading, + } = useUncategorizedTransaction(uncategorizedTransactionId); + + const provider = { + uncategorizedTransactionId, + uncategorizedTransaction, + isUncategorizedTransactionLoading, + }; + const isLoading = isUncategorizedTransactionLoading; + + // Use a key prop to force re-render of children when uncategorizedTransactionId changes + const childrenPerKey = React.useMemo(() => { + return React.Children.map(children, (child) => + React.cloneElement(child, { key: uncategorizedTransactionId }), + ); + }, [children, uncategorizedTransactionId]); + + if (isLoading) { + return ; + } + return ( + + {childrenPerKey} + + ); +} + +export const useCategorizeTransactionTabsBoot = () => + React.useContext( + CategorizeTransactionTabsBootContext, + ); diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchTransactionCheckbox.module.scss b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchTransactionCheckbox.module.scss new file mode 100644 index 000000000..94b4613f5 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchTransactionCheckbox.module.scss @@ -0,0 +1,45 @@ + +.root{ + background: #fff; + border-radius: 5px; + border: 1px solid #D6DBE3; + padding: 10px 16px; + cursor: pointer; + + &.active{ + border-color: #88ABDB; + box-shadow: 0 0 0 2px rgba(136, 171, 219, 0.2); + + .label, + .date { + color: rgb(21, 82, 200), + } + } + + &:hover:not(.active){ + border-color: #c0c0c0; + } +} + +.checkbox:global(.bp4-control.bp4-checkbox){ + margin: 0; +} +.checkbox:global(.bp4-control.bp4-checkbox) :global .bp4-control-indicator{ + border-color: #CBCBCB; +} +.checkbox:global(.bp4-control.bp4-checkbox) :global .bp4-control-indicator{ + margin-right: 4px; + margin-left: 0; + height: 16px; + width: 16px; +} + +.label { + color: #10161A; + font-size: 15px; +} + +.date { + font-size: 12px; + color: #5C7080; +} \ No newline at end of file diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchTransactionCheckbox.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchTransactionCheckbox.tsx new file mode 100644 index 000000000..29fcd294c --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchTransactionCheckbox.tsx @@ -0,0 +1,58 @@ +// @ts-nocheck +import clsx from 'classnames'; +import { Checkbox, Text } from '@blueprintjs/core'; +import { useUncontrolled } from '@/hooks/useUncontrolled'; +import { Group, Stack } from '@/components'; +import styles from './MatchTransactionCheckbox.module.scss'; + +export interface MatchTransactionCheckboxProps { + active?: boolean; + initialActive?: boolean; + onChange?: (state: boolean) => void; + label: string; + date: string; +} + +export function MatchTransactionCheckbox({ + active, + initialActive, + onChange, + label, + date, +}: MatchTransactionCheckboxProps) { + 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..8aef55e8e --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingTransaction.tsx @@ -0,0 +1,266 @@ +// @ts-nocheck +import { isEmpty } from 'lodash'; +import * as R from 'ramda'; +import { AnchorButton, Button, Intent, Tag, Text } from '@blueprintjs/core'; +import { FastField, FastFieldProps, Formik, useFormikContext } from 'formik'; +import { AppToaster, Box, FormatNumber, Group, Stack } from '@/components'; +import { + MatchingTransactionBoot, + useMatchingTransactionBoot, +} from './MatchingTransactionBoot'; +import { + MatchTransactionCheckbox, + MatchTransactionCheckboxProps, +} from './MatchTransactionCheckbox'; +import { useMatchUncategorizedTransaction } from '@/hooks/query/bank-rules'; +import { MatchingTransactionFormValues } from './types'; +import { + transformToReq, + useGetPendingAmountMatched, + useIsShowReconcileTransactionLink, +} from './utils'; +import { useCategorizeTransactionTabsBoot } from './CategorizeTransactionTabsBoot'; +import { + WithBankingActionsProps, + withBankingActions, +} from '../withBankingActions'; +import styles from './CategorizeTransactionAside.module.scss'; + +const initialValues = { + matched: {}, +}; + +/** + * Renders the bank transaction matching form. + * @returns {React.ReactNode} + */ +function MatchingBankTransactionRoot({ + // #withBankingActions + closeMatchingTransactionAside, +}) { + const { uncategorizedTransactionId } = useCategorizeTransactionTabsBoot(); + const { mutateAsync: matchTransaction } = useMatchUncategorizedTransaction(); + + // Handles the form submitting. + const handleSubmit = ( + values: MatchingTransactionFormValues, + { setSubmitting }: FormikHelpers, + ) => { + 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; + } + setSubmitting(true); + matchTransaction({ id: uncategorizedTransactionId, value: _values }) + .then(() => { + AppToaster.show({ + intent: Intent.SUCCESS, + message: 'The bank transaction has been matched successfully.', + }); + setSubmitting(false); + closeMatchingTransactionAside(); + }) + .catch((err) => { + AppToaster.show({ + intent: Intent.DANGER, + message: 'Something went wrong.', + }); + setSubmitting(false); + }); + }; + + return ( + + + <> + + + + + + ); +} + +export const MatchingBankTransaction = R.compose(withBankingActions)( + MatchingBankTransactionRoot, +); + +function MatchingBankTransactionContent() { + return ( + + + + + ); +} + +/** + * Renders the perfect match transactions. + * @returns {React.ReactNode} + */ +function PerfectMatchingTransactions() { + const { perfectMatches, perfectMatchesCount } = useMatchingTransactionBoot(); + + // Can't continue if the perfect matches is empty. + if (isEmpty(perfectMatches)) { + return null; + } + return ( + <> + + +

Perfect Matchines

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

Possible Matches

+ + Transactions up to 20 Aug 2019 + +
+
+ + + {possibleMatches.map((match, index) => ( + + ))} + + + ); +} +interface MatchTransactionFieldProps + extends Omit< + MatchTransactionCheckboxProps, + 'onChange' | 'active' | 'initialActive' + > { + transactionId: number; + transactionType: string; +} + +function MatchTransactionField({ + transactionId, + transactionType, + ...props +}: MatchTransactionFieldProps) { + const name = `matched.${transactionType}-${transactionId}`; + + return ( + + {({ form, field: { value } }: FastFieldProps) => ( + { + form.setFieldValue(name, state); + }} + /> + )} + + ); +} + +interface MatchTransctionFooterProps extends WithBankingActionsProps {} + +/** + * Renders the match transactions footer. + * @returns {React.ReactNode} + */ +const MatchTransactionFooter = R.compose(withBankingActions)( + ({ closeMatchingTransactionAside }: MatchTransctionFooterProps) => { + const { submitForm, isSubmitting } = useFormikContext(); + const totalPending = useGetPendingAmountMatched(); + const showReconcileLink = useIsShowReconcileTransactionLink(); + const submitDisabled = totalPending !== 0; + + const handleCancelBtnClick = () => { + closeMatchingTransactionAside(); + }; + const handleSubmitBtnClick = () => { + submitForm(); + }; + + return ( + + + + {showReconcileLink && ( + + Add Reconcile Transaction + + + )} + + Pending + + + + + + + + + + + + + ); + }, +); + +MatchTransactionFooter.displayName = 'MatchTransactionFooter'; 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..ff14cafe1 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingTransactionBoot.tsx @@ -0,0 +1,44 @@ +import { defaultTo } from 'lodash'; +import React, { createContext } from 'react'; +import { useGetBankTransactionsMatches } from '@/hooks/query/bank-rules'; + +interface MatchingTransactionBootValues { + isMatchingTransactionsLoading: boolean; + possibleMatches: Array; + perfectMatchesCount: number; + perfectMatches: Array; + matches: Array; +} + +const RuleFormBootContext = createContext( + {} as MatchingTransactionBootValues, +); + +interface RuleFormBootProps { + uncategorizedTransactionId: number; + children: React.ReactNode; +} + +function MatchingTransactionBoot({ + uncategorizedTransactionId, + ...props +}: RuleFormBootProps) { + const { + data: matchingTransactions, + isLoading: isMatchingTransactionsLoading, + } = useGetBankTransactionsMatches(uncategorizedTransactionId); + + const provider = { + isMatchingTransactionsLoading, + possibleMatches: defaultTo(matchingTransactions?.possibleMatches, []), + perfectMatchesCount: matchingTransactions?.perfectMatches?.length || 0, + perfectMatches: defaultTo(matchingTransactions?.perfectMatches, []), + } 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..6cb13f0b0 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/utils.ts @@ -0,0 +1,56 @@ +import { useFormikContext } from 'formik'; +import { MatchingTransactionFormValues } from './types'; +import { useMatchingTransactionBoot } from './MatchingTransactionBoot'; +import { useCategorizeTransactionTabsBoot } from './CategorizeTransactionTabsBoot'; +import { useMemo } from 'react'; + +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 }; +}; + +export const useGetPendingAmountMatched = () => { + const { values } = useFormikContext(); + const { perfectMatches, possibleMatches } = useMatchingTransactionBoot(); + const { uncategorizedTransaction } = useCategorizeTransactionTabsBoot(); + + return useMemo(() => { + const matchedItems = [...perfectMatches, ...possibleMatches].filter( + (match) => { + const key = `${match.transactionType}-${match.transactionId}`; + return values.matched[key]; + }, + ); + const totalMatchedAmount = matchedItems.reduce( + (total, item) => total + parseFloat(item.amount), + 0, + ); + const amount = uncategorizedTransaction.amount; + const pendingAmount = amount - totalMatchedAmount; + + return pendingAmount; + }, [uncategorizedTransaction, perfectMatches, possibleMatches, values]); +}; + +export const useAtleastOneMatchedSelected = () => { + const { values } = useFormikContext(); + + return useMemo(() => { + const matchedCount = Object.values(values.matched).filter(Boolean).length; + return matchedCount > 0; + }, [values]); +}; + +export const useIsShowReconcileTransactionLink = () => { + const pendingAmount = useGetPendingAmountMatched(); + const atleastOneSelected = useAtleastOneMatchedSelected(); + + return atleastOneSelected && pendingAmount !== 0; +}; diff --git a/packages/webapp/src/containers/CashFlow/withBanking.ts b/packages/webapp/src/containers/CashFlow/withBanking.ts new file mode 100644 index 000000000..93e056a29 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/withBanking.ts @@ -0,0 +1,15 @@ +// @ts-nocheck + +import { connect } from 'react-redux'; + +export const withBanking = (mapState) => { + const mapStateToProps = (state, props) => { + const mapped = { + openMatchingTransactionAside: state.plaid.openMatchingTransactionAside, + selectedUncategorizedTransactionId: + state.plaid.uncategorizedTransactionIdForMatching, + }; + return mapState ? mapState(mapped, state, props) : mapped; + }; + return connect(mapStateToProps); +}; diff --git a/packages/webapp/src/containers/CashFlow/withBankingActions.ts b/packages/webapp/src/containers/CashFlow/withBankingActions.ts new file mode 100644 index 000000000..5a7f86ea5 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/withBankingActions.ts @@ -0,0 +1,30 @@ +import { connect } from 'react-redux'; +import { + closeMatchingTransactionAside, + setUncategorizedTransactionIdForMatching, +} from '@/store/banking/banking.reducer'; + +export interface WithBankingActionsProps { + closeMatchingTransactionAside: () => void; + setUncategorizedTransactionIdForMatching: ( + uncategorizedTransactionId: number, + ) => void; +} + +const mapDipatchToProps = (dispatch: any): WithBankingActionsProps => ({ + closeMatchingTransactionAside: () => + dispatch(closeMatchingTransactionAside()), + setUncategorizedTransactionIdForMatching: ( + uncategorizedTransactionId: number, + ) => + dispatch( + setUncategorizedTransactionIdForMatching(uncategorizedTransactionId), + ), +}); + +export const withBankingActions = connect< + null, + WithBankingActionsProps, + {}, + any +>(null, mapDipatchToProps); diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoicesLanding/components.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoicesLanding/components.tsx index ca458f00f..5bdb11d26 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoicesLanding/components.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoicesLanding/components.tsx @@ -117,6 +117,12 @@ export const handleDeleteErrors = (errors) => { intent: Intent.DANGER, }); } + if (errors.find((e) => e.type === 'CANNOT_DELETE_TRANSACTION_MATCHED')) { + AppToaster.show({ + intent: Intent.DANGER, + message: 'Cannot delete a transaction matched with a bank transaction.', + }); + } }; export function ActionsMenu({ diff --git a/packages/webapp/src/hooks/query/bank-rules.ts b/packages/webapp/src/hooks/query/bank-rules.ts new file mode 100644 index 000000000..9608f7496 --- /dev/null +++ b/packages/webapp/src/hooks/query/bank-rules.ts @@ -0,0 +1,447 @@ +// @ts-nocheck +import { + QueryClient, + UseMutationOptions, + UseMutationResult, + UseQueryOptions, + UseQueryResult, + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from 'react-query'; +import useApiRequest from '../useRequest'; +import { transformToCamelCase } from '@/utils'; +import t from './types'; + +const QUERY_KEY = { + BANK_RULES: 'BANK_RULE', + BANK_TRANSACTION_MATCHES: 'BANK_TRANSACTION_MATCHES', + RECOGNIZED_BANK_TRANSACTION: 'RECOGNIZED_BANK_TRANSACTION', + EXCLUDED_BANK_TRANSACTIONS_INFINITY: 'EXCLUDED_BANK_TRANSACTIONS_INFINITY', + RECOGNIZED_BANK_TRANSACTIONS_INFINITY: + 'RECOGNIZED_BANK_TRANSACTIONS_INFINITY', + BANK_ACCOUNT_SUMMARY_META: 'BANK_ACCOUNT_SUMMARY_META', +}; + +const commonInvalidateQueries = (query: QueryClient) => { + query.invalidateQueries(QUERY_KEY.BANK_RULES); + query.invalidateQueries(QUERY_KEY.RECOGNIZED_BANK_TRANSACTIONS_INFINITY); +}; + +interface CreateBankRuleValues { + value: any; +} +interface CreateBankRuleResponse {} + +/** + * Creates a new bank rule. + * @param {UseMutationOptions} options - + * @returns {UseMutationResult} + */ +export function useCreateBankRule( + options?: UseMutationOptions< + CreateBankRuleValues, + Error, + CreateBankRuleValues + >, +): UseMutationResult { + const queryClient = useQueryClient(); + const apiRequest = useApiRequest(); + + return useMutation( + (values) => + apiRequest.post(`/banking/rules`, values).then((res) => res.data), + { + ...options, + onSuccess: () => { + commonInvalidateQueries(queryClient); + }, + }, + ); +} + +interface EditBankRuleValues { + id: number; + value: any; +} +interface EditBankRuleResponse {} + +/** + * Edits the given bank rule. + * @param {UseMutationOptions} options - + * @returns + */ +export function useEditBankRule( + options?: UseMutationOptions, +): UseMutationResult { + const queryClient = useQueryClient(); + const apiRequest = useApiRequest(); + + return useMutation( + ({ id, value }) => apiRequest.post(`/banking/rules/${id}`, value), + { + ...options, + onSuccess: () => { + commonInvalidateQueries(queryClient); + }, + }, + ); +} + +interface DeleteBankRuleResponse {} +type DeleteBankRuleValue = number; + +/** + * Deletes the given bank rule. + * @param {UseMutationOptions} options + * @returns {UseMutationResult, +): UseMutationResult { + const queryClient = useQueryClient(); + const apiRequest = useApiRequest(); + + return useMutation( + (id: number) => apiRequest.delete(`/banking/rules/${id}`), + { + onSuccess: (res, id) => { + commonInvalidateQueries(queryClient); + + queryClient.invalidateQueries( + QUERY_KEY.RECOGNIZED_BANK_TRANSACTIONS_INFINITY, + ); + queryClient.invalidateQueries([ + t.CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY, + ]); + }, + ...options, + }, + ); +} + +interface BankRulesResponse {} + +/** + * Retrieves all bank rules. + * @param {UseQueryOptions} params - + * @returns {UseQueryResult} + */ +export function useBankRules( + options?: UseQueryOptions, +): UseQueryResult { + const apiRequest = useApiRequest(); + + return useQuery( + [QUERY_KEY.BANK_RULES], + () => apiRequest.get('/banking/rules').then((res) => res.data.bank_rules), + { ...options }, + ); +} + +interface GetBankRuleRes {} + +/** + * Retrieve the given bank rule. + * @param {number} bankRuleId - + * @param {UseQueryOptions} options - + * @returns {UseQueryResult} + */ +export function useBankRule( + bankRuleId: number, + options?: UseQueryOptions, +): UseQueryResult { + const apiRequest = useApiRequest(); + + return useQuery( + [QUERY_KEY.BANK_RULES, bankRuleId], + () => + apiRequest + .get(`/banking/rules/${bankRuleId}`) + .then((res) => res.data.bank_rule), + { ...options }, + ); +} + +type GetBankTransactionsMatchesValue = number; +interface GetBankTransactionsMatchesResponse { + perfectMatches: Array; + possibleMatches: Array; +} + +/** + * Retrieves the bank transactions matches. + * @param {UseQueryOptions} params - + * @returns {UseQueryResult} + */ +export function useGetBankTransactionsMatches( + uncategorizedTransactionId: number, + options?: UseQueryOptions, +): UseQueryResult { + const apiRequest = useApiRequest(); + + return useQuery( + [QUERY_KEY.BANK_TRANSACTION_MATCHES, uncategorizedTransactionId], + () => + apiRequest + .get(`/cashflow/transactions/${uncategorizedTransactionId}/matches`) + .then((res) => transformToCamelCase(res.data)), + options, + ); +} + +type ExcludeUncategorizedTransactionValue = number; + +interface ExcludeUncategorizedTransactionRes {} +/** + * Excludes the given uncategorized transaction. + * @param {UseMutationOptions} + * @returns {UseMutationResult } + */ +export function useExcludeUncategorizedTransaction( + options?: UseMutationOptions< + ExcludeUncategorizedTransactionRes, + Error, + ExcludeUncategorizedTransactionValue + >, +): UseMutationResult< + ExcludeUncategorizedTransactionRes, + Error, + ExcludeUncategorizedTransactionValue +> { + const queryClient = useQueryClient(); + const apiRequest = useApiRequest(); + + return useMutation< + ExcludeUncategorizedTransactionRes, + Error, + ExcludeUncategorizedTransactionValue + >( + (uncategorizedTransactionId: number) => + apiRequest.put( + `/cashflow/transactions/${uncategorizedTransactionId}/exclude`, + ), + { + onSuccess: (res, id) => { + // Invalidate queries. + queryClient.invalidateQueries( + QUERY_KEY.EXCLUDED_BANK_TRANSACTIONS_INFINITY, + ); + queryClient.invalidateQueries( + t.CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY, + ); + }, + ...options, + }, + ); +} + +type ExcludeBankTransactionValue = number; + +interface ExcludeBankTransactionResponse {} + +/** + * Excludes the uncategorized bank transaction. + * @param {UseMutationResult} options + * @returns {UseMutationResult} + */ +export function useUnexcludeUncategorizedTransaction( + options?: UseMutationOptions< + ExcludeBankTransactionResponse, + Error, + ExcludeBankTransactionValue + >, +): UseMutationResult< + ExcludeBankTransactionResponse, + Error, + ExcludeBankTransactionValue +> { + const queryClient = useQueryClient(); + const apiRequest = useApiRequest(); + + return useMutation< + ExcludeBankTransactionResponse, + Error, + ExcludeBankTransactionValue + >( + (uncategorizedTransactionId: number) => + apiRequest.put( + `/cashflow/transactions/${uncategorizedTransactionId}/unexclude`, + ), + { + onSuccess: (res, id) => { + // Invalidate queries. + queryClient.invalidateQueries( + QUERY_KEY.EXCLUDED_BANK_TRANSACTIONS_INFINITY, + ); + queryClient.invalidateQueries( + t.CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY, + ); + }, + ...options, + }, + ); +} + +interface MatchUncategorizedTransactionValues { + id: number; + value: any; +} +interface MatchUncategorizedTransactionRes {} + +/** + * Matchess the given uncateogrized transaction. + * @param props + * @returns + */ +export function useMatchUncategorizedTransaction( + props?: UseMutationOptions< + MatchUncategorizedTransactionRes, + Error, + MatchUncategorizedTransactionValues + >, +): UseMutationResult< + MatchUncategorizedTransactionRes, + Error, + MatchUncategorizedTransactionValues +> { + const queryClient = useQueryClient(); + const apiRequest = useApiRequest(); + + return useMutation< + MatchUncategorizedTransactionRes, + Error, + MatchUncategorizedTransactionValues + >(({ id, value }) => apiRequest.post(`/banking/matches/${id}`, value), { + onSuccess: (res, id) => { + queryClient.invalidateQueries( + t.CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY, + ); + }, + ...props, + }); +} + +interface GetRecognizedBankTransactionRes {} + +/** + * REtrieves the given recognized bank transaction. + * @param {number} uncategorizedTransactionId + * @param {UseQueryOptions} options + * @returns {UseQueryResult} + */ +export function useGetRecognizedBankTransaction( + uncategorizedTransactionId: number, + options?: UseQueryOptions, +): UseQueryResult { + const apiRequest = useApiRequest(); + + return useQuery( + [QUERY_KEY.RECOGNIZED_BANK_TRANSACTION, uncategorizedTransactionId], + () => + apiRequest + .get(`/banking/recognized/transactions/${uncategorizedTransactionId}`) + .then((res) => transformToCamelCase(res.data?.data)), + options, + ); +} + +interface GetBankAccountSummaryMetaRes { + name: string; + totalUncategorizedTransactions: number; + totalRecognizedTransactions: number; +} + +/** + * Get the given bank account meta summary. + * @param {number} bankAccountId + * @param {UseQueryOptions} options + * @returns {UseQueryResult} + */ +export function useGetBankAccountSummaryMeta( + bankAccountId: number, + options?: UseQueryOptions, +): UseQueryResult { + const apiRequest = useApiRequest(); + + return useQuery( + [QUERY_KEY.BANK_ACCOUNT_SUMMARY_META, bankAccountId], + () => + apiRequest + .get(`/banking/bank_accounts/${bankAccountId}/meta`) + .then((res) => transformToCamelCase(res.data?.data)), + { ...options }, + ); +} + +/** + * @returns + */ +export function useRecognizedBankTransactionsInfinity( + query, + infinityProps, + axios, +) { + const apiRequest = useApiRequest(); + + return useInfiniteQuery( + [QUERY_KEY.RECOGNIZED_BANK_TRANSACTIONS_INFINITY, query], + async ({ pageParam = 1 }) => { + const response = await apiRequest.http({ + ...axios, + method: 'get', + url: `/api/banking/recognized`, + params: { page: pageParam, ...query }, + }); + return response.data; + }, + { + getPreviousPageParam: (firstPage) => firstPage.pagination.page - 1, + getNextPageParam: (lastPage) => { + const { pagination } = lastPage; + + return pagination.total > pagination.page_size * pagination.page + ? lastPage.pagination.page + 1 + : undefined; + }, + ...infinityProps, + }, + ); +} + +export function useExcludedBankTransactionsInfinity( + query, + infinityProps, + axios, +) { + const apiRequest = useApiRequest(); + + return useInfiniteQuery( + [QUERY_KEY.EXCLUDED_BANK_TRANSACTIONS_INFINITY, query], + async ({ pageParam = 1 }) => { + const response = await apiRequest.http({ + ...axios, + method: 'get', + url: `/api/cashflow/excluded`, + params: { page: pageParam, ...query }, + }); + return response.data; + }, + { + getPreviousPageParam: (firstPage) => firstPage.pagination.page - 1, + getNextPageParam: (lastPage) => { + const { pagination } = lastPage; + + return pagination.total > pagination.page_size * pagination.page + ? lastPage.pagination.page + 1 + : undefined; + }, + ...infinityProps, + }, + ); +} diff --git a/packages/webapp/src/hooks/useUncontrolled.ts b/packages/webapp/src/hooks/useUncontrolled.ts index 6d441fb8b..74659b236 100644 --- a/packages/webapp/src/hooks/useUncontrolled.ts +++ b/packages/webapp/src/hooks/useUncontrolled.ts @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import { useState } from 'react'; interface UseUncontrolledInput { /** Value for controlled state */ @@ -19,7 +19,7 @@ export function useUncontrolled({ initialValue, finalValue, onChange = () => {}, -}: UseUncontrolledInput) { +}: UseUncontrolledInput): [T, (value: T) => void, boolean] { const [uncontrolledValue, setUncontrolledValue] = useState( initialValue !== undefined ? initialValue : finalValue, ); diff --git a/packages/webapp/src/routes/dashboard.tsx b/packages/webapp/src/routes/dashboard.tsx index 8f1122bae..b1b4cb1d4 100644 --- a/packages/webapp/src/routes/dashboard.tsx +++ b/packages/webapp/src/routes/dashboard.tsx @@ -1221,6 +1221,16 @@ export const getDashboardRoutes = () => [ pageTitle: 'Tax Rates', subscriptionActive: [SUBSCRIPTION_TYPE.MAIN], }, + // Bank Rules + { + path: '/bank-rules', + component: lazy( + () => import('@/containers/Banking/Rules/RulesList/RulesLandingPage'), + ), + pageTitle: 'Bank Rules', + breadcrumb: 'Bank Rules', + subscriptionActive: [SUBSCRIPTION_TYPE.MAIN], + }, // Homepage { path: `/`, diff --git a/packages/webapp/src/static/json/icons.tsx b/packages/webapp/src/static/json/icons.tsx index 0e6d27c62..c39e72f30 100644 --- a/packages/webapp/src/static/json/icons.tsx +++ b/packages/webapp/src/static/json/icons.tsx @@ -605,4 +605,28 @@ 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', + }, + arrowRight: { + path: [ + 'M14.7,7.29l-5-5C9.52,2.1,9.27,1.99,8.99,1.99c-0.55,0-1,0.45-1,1c0,0.28,0.11,0.53,0.29,0.71l3.29,3.29H1.99c-0.55,0-1,0.45-1,1s0.45,1,1,1h9.59l-3.29,3.29c-0.18,0.18-0.29,0.43-0.29,0.71c0,0.55,0.45,1,1,1c0.28,0,0.53-0.11,0.71-0.29l5-5c0.18-0.18,0.29-0.43,0.29-0.71S14.88,7.47,14.7,7.29z', + ], + viewBox: '0 0 16 16', + }, + disable: { + path: [ + 'M7.99-0.01c-4.42,0-8,3.58-8,8s3.58,8,8,8s8-3.58,8-8S12.41-0.01,7.99-0.01zM1.99,7.99c0-3.31,2.69-6,6-6c1.3,0,2.49,0.42,3.47,1.12l-8.35,8.35C2.41,10.48,1.99,9.29,1.99,7.99z M7.99,13.99c-1.3,0-2.49-0.42-3.47-1.12l8.35-8.35c0.7,0.98,1.12,2.17,1.12,3.47C13.99,11.31,11.31,13.99,7.99,13.99z', + ], + viewBox: '0 0 16 16', + }, + redo: { + path: [ + 'M4,11c-1.1,0-2,0.9-2,2s0.9,2,2,2s2-0.9,2-2S5.1,11,4,11z M11,4H3.41l1.29-1.29C4.89,2.53,5,2.28,5,2c0-0.55-0.45-1-1-1C3.72,1,3.47,1.11,3.29,1.29l-3,3C0.11,4.47,0,4.72,0,5c0,0.28,0.11,0.53,0.29,0.71l3,3C3.47,8.89,3.72,9,4,9c0.55,0,1-0.45,1-1c0-0.28-0.11-0.53-0.29-0.71L3.41,6H11c1.66,0,3,1.34,3,3s-1.34,3-3,3H7v2h4c2.76,0,5-2.24,5-5S13.76,4,11,4z', + ], + viewBox: '0 0 16 16', + }, }; diff --git a/packages/webapp/src/store/banking/banking.reducer.ts b/packages/webapp/src/store/banking/banking.reducer.ts index d6a842d32..d77a146aa 100644 --- a/packages/webapp/src/store/banking/banking.reducer.ts +++ b/packages/webapp/src/store/banking/banking.reducer.ts @@ -2,22 +2,46 @@ import { PayloadAction, createSlice } from '@reduxjs/toolkit'; interface StorePlaidState { plaidToken: string; + openMatchingTransactionAside: boolean; + uncategorizedTransactionIdForMatching: number | null; } export const PlaidSlice = createSlice({ name: 'plaid', initialState: { plaidToken: '', + openMatchingTransactionAside: false, + uncategorizedTransactionIdForMatching: null, } as StorePlaidState, reducers: { setPlaidId: (state: StorePlaidState, action: PayloadAction) => { state.plaidToken = action.payload; }, + resetPlaidId: (state: StorePlaidState) => { state.plaidToken = ''; - } + }, + + setUncategorizedTransactionIdForMatching: ( + state: StorePlaidState, + action: PayloadAction, + ) => { + state.openMatchingTransactionAside = true; + state.uncategorizedTransactionIdForMatching = action.payload; + }, + + closeMatchingTransactionAside: (state: StorePlaidState) => { + state.openMatchingTransactionAside = false; + state.uncategorizedTransactionIdForMatching = null; + }, }, }); -export const { setPlaidId, resetPlaidId } = PlaidSlice.actions; -export const getPlaidToken = (state: any) => state.plaid.plaidToken; \ No newline at end of file +export const { + setPlaidId, + resetPlaidId, + setUncategorizedTransactionIdForMatching, + closeMatchingTransactionAside, +} = PlaidSlice.actions; + +export const getPlaidToken = (state: any) => state.plaid.plaidToken;