From ea8c5458ff90f6493a29e3a5e1a1edd88e3c936b Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Thu, 29 Feb 2024 23:53:26 +0200 Subject: [PATCH 01/12] feat: Categorize the bank synced transactions --- .../Cashflow/CashflowController.ts | 2 +- .../Cashflow/DeleteCashflowTransaction.ts | 6 +- .../Cashflow/GetCashflowAccounts.ts | 4 +- .../Cashflow/GetCashflowTransaction.ts | 2 +- .../Cashflow/NewCashflowTransaction.ts | 175 +++++++++++++++++- ...cateogrized_cashflow_transactions_table.js | 25 +++ ...6_add_categorized_transaction_id_column.js | 11 ++ packages/server/src/interfaces/CashFlow.ts | 24 +++ .../server/src/interfaces/CashflowService.ts | 27 +++ packages/server/src/loaders/eventEmitter.ts | 6 +- packages/server/src/loaders/tenantModels.ts | 4 +- .../server/src/models/CashflowTransaction.ts | 1 + packages/server/src/models/Expense.ts | 17 ++ .../UncategorizedCashflowTransaction.ts | 64 +++++++ .../src/services/Banking/Plaid/PlaidSyncDB.ts | 4 +- .../services/Cashflow/CashflowApplication.ts | 104 +++++++++++ .../CashflowTransactionJournalEntries.ts | 2 - .../Cashflow/CategorizeCashflowTransaction.ts | 93 ++++++++++ .../CategorizeTransactionAsExpense.ts | 80 ++++++++ .../Cashflow/CommandCasflowValidator.ts | 25 +++ .../DeleteCashflowTransactionService.ts | 8 +- .../GetCashflowTransactionsService.ts | 4 - .../Cashflow/GetUncategorizedTransactions.ts | 37 ++++ .../Cashflow/NewCashflowTransactionService.ts | 7 +- .../UncategorizeCashflowTransaction.ts | 72 +++++++ .../Cashflow/UncategorizeTransactionByRef.ts | 9 + .../UncategorizedTransactionTransformer.ts | 34 ++++ .../server/src/services/Cashflow/constants.ts | 4 +- ...DeleteCashflowTransactionOnUncategorize.ts | 40 ++++ .../server/src/services/Cashflow/utils.ts | 36 +++- packages/server/src/subscribers/events.ts | 9 + 31 files changed, 901 insertions(+), 35 deletions(-) create mode 100644 packages/server/src/database/migrations/20240228183404_create_uncateogrized_cashflow_transactions_table.js create mode 100644 packages/server/src/database/migrations/20240228224316_add_categorized_transaction_id_column.js create mode 100644 packages/server/src/models/UncategorizedCashflowTransaction.ts create mode 100644 packages/server/src/services/Cashflow/CashflowApplication.ts create mode 100644 packages/server/src/services/Cashflow/CategorizeCashflowTransaction.ts create mode 100644 packages/server/src/services/Cashflow/CategorizeTransactionAsExpense.ts create mode 100644 packages/server/src/services/Cashflow/GetUncategorizedTransactions.ts create mode 100644 packages/server/src/services/Cashflow/UncategorizeCashflowTransaction.ts create mode 100644 packages/server/src/services/Cashflow/UncategorizeTransactionByRef.ts create mode 100644 packages/server/src/services/Cashflow/UncategorizedTransactionTransformer.ts create mode 100644 packages/server/src/services/Cashflow/subscribers/DeleteCashflowTransactionOnUncategorize.ts diff --git a/packages/server/src/api/controllers/Cashflow/CashflowController.ts b/packages/server/src/api/controllers/Cashflow/CashflowController.ts index c6dfe5c29..42efa4c48 100644 --- a/packages/server/src/api/controllers/Cashflow/CashflowController.ts +++ b/packages/server/src/api/controllers/Cashflow/CashflowController.ts @@ -13,9 +13,9 @@ export default class CashflowController { router() { const router = Router(); + router.use(Container.get(CommandCashflowTransaction).router()); router.use(Container.get(GetCashflowTransaction).router()); router.use(Container.get(GetCashflowAccounts).router()); - router.use(Container.get(CommandCashflowTransaction).router()); router.use(Container.get(DeleteCashflowTransaction).router()); return router; diff --git a/packages/server/src/api/controllers/Cashflow/DeleteCashflowTransaction.ts b/packages/server/src/api/controllers/Cashflow/DeleteCashflowTransaction.ts index 0ddb6d74d..4d94022da 100644 --- a/packages/server/src/api/controllers/Cashflow/DeleteCashflowTransaction.ts +++ b/packages/server/src/api/controllers/Cashflow/DeleteCashflowTransaction.ts @@ -3,14 +3,14 @@ import { Router, Request, Response, NextFunction } from 'express'; import { param } from 'express-validator'; import BaseController from '../BaseController'; import { ServiceError } from '@/exceptions'; -import DeleteCashflowTransactionService from '../../../services/Cashflow/DeleteCashflowTransactionService'; +import { DeleteCashflowTransaction } from '../../../services/Cashflow/DeleteCashflowTransactionService'; import CheckPolicies from '@/api/middleware/CheckPolicies'; import { AbilitySubject, CashflowAction } from '@/interfaces'; @Service() -export default class DeleteCashflowTransaction extends BaseController { +export default class DeleteCashflowTransactionController extends BaseController { @Inject() - deleteCashflowService: DeleteCashflowTransactionService; + private deleteCashflowService: DeleteCashflowTransaction; /** * Controller router. diff --git a/packages/server/src/api/controllers/Cashflow/GetCashflowAccounts.ts b/packages/server/src/api/controllers/Cashflow/GetCashflowAccounts.ts index 59fdb91ba..b84dad4eb 100644 --- a/packages/server/src/api/controllers/Cashflow/GetCashflowAccounts.ts +++ b/packages/server/src/api/controllers/Cashflow/GetCashflowAccounts.ts @@ -11,10 +11,10 @@ import { AbilitySubject, CashflowAction } from '@/interfaces'; @Service() export default class GetCashflowAccounts extends BaseController { @Inject() - getCashflowAccountsService: GetCashflowAccountsService; + private getCashflowAccountsService: GetCashflowAccountsService; @Inject() - getCashflowTransactionsService: GetCashflowTransactionsService; + private getCashflowTransactionsService: GetCashflowTransactionsService; /** * Controller router. diff --git a/packages/server/src/api/controllers/Cashflow/GetCashflowTransaction.ts b/packages/server/src/api/controllers/Cashflow/GetCashflowTransaction.ts index 7cf8d2d8e..80d610f48 100644 --- a/packages/server/src/api/controllers/Cashflow/GetCashflowTransaction.ts +++ b/packages/server/src/api/controllers/Cashflow/GetCashflowTransaction.ts @@ -10,7 +10,7 @@ import { AbilitySubject, CashflowAction } from '@/interfaces'; @Service() export default class GetCashflowAccounts extends BaseController { @Inject() - getCashflowTransactionsService: GetCashflowTransactionsService; + private getCashflowTransactionsService: GetCashflowTransactionsService; /** * Controller router. diff --git a/packages/server/src/api/controllers/Cashflow/NewCashflowTransaction.ts b/packages/server/src/api/controllers/Cashflow/NewCashflowTransaction.ts index 91abfcf92..9c48934ea 100644 --- a/packages/server/src/api/controllers/Cashflow/NewCashflowTransaction.ts +++ b/packages/server/src/api/controllers/Cashflow/NewCashflowTransaction.ts @@ -6,18 +6,27 @@ import { ServiceError } from '@/exceptions'; import NewCashflowTransactionService from '@/services/Cashflow/NewCashflowTransactionService'; import CheckPolicies from '@/api/middleware/CheckPolicies'; import { AbilitySubject, CashflowAction } from '@/interfaces'; +import { CashflowApplication } from '@/services/Cashflow/CashflowApplication'; @Service() export default class NewCashflowTransactionController extends BaseController { @Inject() private newCashflowTranscationService: NewCashflowTransactionService; + @Inject() + private cashflowApplication: CashflowApplication; + /** * Router constructor. */ public router() { const router = Router(); + router.get( + '/transactions/uncategorized', + this.asyncMiddleware(this.getUncategorizedCashflowTransactions), + this.catchServiceErrors + ); router.post( '/transactions', CheckPolicies(CashflowAction.Create, AbilitySubject.Cashflow), @@ -26,13 +35,61 @@ export default class NewCashflowTransactionController extends BaseController { this.asyncMiddleware(this.newCashflowTransaction), this.catchServiceErrors ); + router.post( + '/transactions/:id/uncategorize', + this.revertCategorizedCashflowTransaction, + this.catchServiceErrors + ); + router.post( + '/transactions/:id/categorize', + this.categorizeCashflowTransactionValidationSchema, + this.validationResult, + this.categorizeCashflowTransaction, + this.catchServiceErrors + ); + router.post( + '/transaction/:id/categorize/expense', + this.categorizeAsExpenseValidationSchema, + this.validationResult, + this.categorizesCashflowTransactionAsExpense, + this.catchServiceErrors + ); return router; } + /** + * Categorize as expense validation schema. + */ + public get categorizeAsExpenseValidationSchema() { + return [ + check('expense_account_id').exists(), + check('date').isISO8601().exists(), + check('reference_no').optional(), + check('exchange_rate').optional().isFloat({ gt: 0 }).toFloat(), + ]; + } + + /** + * Categorize cashflow tranasction validation schema. + */ + public get categorizeCashflowTransactionValidationSchema() { + return [ + check('date').exists().isISO8601().toDate(), + + check('to_account_id').exists().isInt().toInt(), + check('from_account_id').exists().isInt().toInt(), + + check('transaction_type').exists(), + check('reference_no').optional(), + check('exchange_rate').optional().isFloat({ gt: 0 }).toFloat(), + check('description').optional(), + ]; + } + /** * New cashflow transaction validation schema. */ - get newTransactionValidationSchema() { + public get newTransactionValidationSchema() { return [ check('date').exists().isISO8601().toDate(), check('reference_no').optional({ nullable: true }).trim().escape(), @@ -48,12 +105,10 @@ export default class NewCashflowTransactionController extends BaseController { check('credit_account_id').exists().isInt().toInt(), check('exchange_rate').optional().isFloat({ gt: 0 }).toFloat(), - check('branch_id').optional({ nullable: true }).isNumeric().toInt(), - check('publish').default(false).isBoolean().toBoolean(), ]; - } + }√ /** * Creates a new cashflow transaction. @@ -76,7 +131,6 @@ export default class NewCashflowTransactionController extends BaseController { ownerContributionDTO, userId ); - return res.status(200).send({ id: cashflowTransaction.id, message: 'New cashflow transaction has been created successfully.', @@ -86,11 +140,118 @@ export default class NewCashflowTransactionController extends BaseController { } }; + /** + * Revert the categorized cashflow transaction. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + private revertCategorizedCashflowTransaction = async ( + req: Request, + res: Response, + next: NextFunction + ) => { + const { tenantId } = req; + const { id: cashflowTransactionId } = req.params; + + try { + const data= await this.cashflowApplication.uncategorizeTransaction( + tenantId, + cashflowTransactionId + ); + return res.status(200).send({ data }); + } catch (error) { + next(error); + } + }; + + /** + * Categorize the cashflow transaction. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + private categorizeCashflowTransaction = async ( + req: Request, + res: Response, + next: NextFunction + ) => { + const { tenantId } = req; + const { id: cashflowTransactionId } = req.params; + const cashflowTransaction = this.matchedBodyData(req); + + try { + await this.cashflowApplication.categorizeTransaction( + tenantId, + cashflowTransactionId, + cashflowTransaction + ); + return res.status(200).send({ + message: 'The cashflow transaction has been created successfully.', + }); + } catch (error) { + next(error); + } + }; + + /** + * Categorize the transaction as expense transaction. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + private categorizesCashflowTransactionAsExpense = async ( + req: Request, + res: Response, + next: NextFunction + ) => { + const { tenantId } = req; + const { id: cashflowTransactionId } = req.params; + const cashflowTransaction = this.matchedBodyData(req); + + try { + await this.cashflowApplication.categorizeAsExpense( + tenantId, + cashflowTransactionId, + cashflowTransaction + ); + return res.status(200).send({ + message: 'The cashflow transaction has been created successfully.', + }); + } catch (error) { + next(error); + } + }; + + /** + * Retrieves the uncategorized cashflow transactions. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + public getUncategorizedCashflowTransactions = async ( + req: Request, + res: Response, + next: NextFunction + ) => { + const { tenantId } = req; + + try { + const data = await this.cashflowApplication.getUncategorizedTransactions( + tenantId + ); + + return res.status(200).send(data); + } catch (error) { + next(error); + } + }; + /** * Handle the service errors. * @param error - * @param req - * @param res + * @param {Request} req + * @param {res * @param next * @returns */ diff --git a/packages/server/src/database/migrations/20240228183404_create_uncateogrized_cashflow_transactions_table.js b/packages/server/src/database/migrations/20240228183404_create_uncateogrized_cashflow_transactions_table.js new file mode 100644 index 000000000..2d72041f3 --- /dev/null +++ b/packages/server/src/database/migrations/20240228183404_create_uncateogrized_cashflow_transactions_table.js @@ -0,0 +1,25 @@ +exports.up = function (knex) { + return knex.schema.createTable( + 'uncategorized_cashflow_transactions', + (table) => { + table.increments('id'); + table.date('date').index(); + table.decimal('amount'); + table.string('reference_no').index(); + table + .integer('account_id') + .unsigned() + .references('id') + .inTable('accounts'); + table.string('description'); + table.string('categorize_ref_type'); + table.integer('categorize_ref_id').unsigned(); + table.boolean('categorized').defaultTo(false); + table.timestamps(); + } + ); +}; + +exports.down = function (knex) { + return knex.schema.dropTableIfExists('uncategorized_cashflow_transactions'); +}; diff --git a/packages/server/src/database/migrations/20240228224316_add_categorized_transaction_id_column.js b/packages/server/src/database/migrations/20240228224316_add_categorized_transaction_id_column.js new file mode 100644 index 000000000..749cc53b6 --- /dev/null +++ b/packages/server/src/database/migrations/20240228224316_add_categorized_transaction_id_column.js @@ -0,0 +1,11 @@ +exports.up = function (knex) { + return knex.schema.table('expenses_transactions', (table) => { + table + .integer('categorized_transaction_id') + .unsigned() + .references('id') + .inTable('uncategorized_cashflow_transactions'); + }); +}; + +exports.down = function (knex) {}; diff --git a/packages/server/src/interfaces/CashFlow.ts b/packages/server/src/interfaces/CashFlow.ts index 40cd50896..8bcc1eafc 100644 --- a/packages/server/src/interfaces/CashFlow.ts +++ b/packages/server/src/interfaces/CashFlow.ts @@ -233,3 +233,27 @@ export interface ICashflowTransactionSchema { } export interface ICashflowTransactionInput extends ICashflowTransactionSchema {} + +export interface ICategorizeCashflowTransactioDTO { + fromAccountId: number; + toAccountId: number; + referenceNo: string; + transactionNumber: string; + transactionType: string; + exchangeRate: number; + description: string; + branchId: number; +} + +export interface IUncategorizedCashflowTransaction { + id?: number; + amount: number; + date: Date; + currencyCode: string; + accountId: number; + description: string; + referenceNo: string; + categorizeRefType: string; + categorizeRefId: number; + categorized: boolean; +} diff --git a/packages/server/src/interfaces/CashflowService.ts b/packages/server/src/interfaces/CashflowService.ts index e279aec05..5b446571d 100644 --- a/packages/server/src/interfaces/CashflowService.ts +++ b/packages/server/src/interfaces/CashflowService.ts @@ -1,5 +1,6 @@ import { Knex } from 'knex'; import { IAccount } from './Account'; +import { IUncategorizedCashflowTransaction } from './CashFlow'; export interface ICashflowAccountTransactionsFilter { page: number; @@ -124,8 +125,34 @@ export interface ICommandCashflowDeletedPayload { trx: Knex.Transaction; } +export interface ICashflowTransactionCategorizedPayload { + tenantId: number; + cashflowTransactionId: number; + cashflowTransaction: ICashflowTransaction; + trx: Knex.Transaction; +} +export interface ICashflowTransactionUncategorizingPayload { + tenantId: number; + uncategorizedTransaction: IUncategorizedCashflowTransaction; + trx: Knex.Transaction; +} +export interface ICashflowTransactionUncategorizedPayload { + tenantId: number; + uncategorizedTransaction: IUncategorizedCashflowTransaction; + oldUncategorizedTransaction: IUncategorizedCashflowTransaction; + trx: Knex.Transaction; +} + export enum CashflowAction { Create = 'Create', Delete = 'Delete', View = 'View', } + +export interface CategorizeTransactionAsExpenseDTO { + expenseAccountId: number; + exchangeRate: number; + referenceNo: string; + description: string; + branchId?: number; +} diff --git a/packages/server/src/loaders/eventEmitter.ts b/packages/server/src/loaders/eventEmitter.ts index 5e874a2ad..ccc28df3d 100644 --- a/packages/server/src/loaders/eventEmitter.ts +++ b/packages/server/src/loaders/eventEmitter.ts @@ -88,6 +88,7 @@ import { PlaidUpdateTransactionsOnItemCreatedSubscriber } from '@/services/Banki import { InvoiceChangeStatusOnMailSentSubscriber } from '@/services/Sales/Invoices/subscribers/InvoiceChangeStatusOnMailSentSubscriber'; import { SaleReceiptMarkClosedOnMailSentSubcriber } from '@/services/Sales/Receipts/subscribers/SaleReceiptMarkClosedOnMailSentSubcriber'; import { SaleEstimateMarkApprovedOnMailSent } from '@/services/Sales/Estimates/subscribers/SaleEstimateMarkApprovedOnMailSent'; +import { DeleteCashflowTransactionOnUncategorize } from '@/services/Cashflow/subscribers/DeleteCashflowTransactionOnUncategorize'; export default () => { return new EventPublisher(); @@ -212,6 +213,9 @@ export const susbcribers = () => { SyncItemTaxRateOnEditTaxSubscriber, // Plaid - PlaidUpdateTransactionsOnItemCreatedSubscriber + PlaidUpdateTransactionsOnItemCreatedSubscriber, + + // Cashflow + DeleteCashflowTransactionOnUncategorize, ]; }; diff --git a/packages/server/src/loaders/tenantModels.ts b/packages/server/src/loaders/tenantModels.ts index 2a3a3664f..c3d08ab6f 100644 --- a/packages/server/src/loaders/tenantModels.ts +++ b/packages/server/src/loaders/tenantModels.ts @@ -62,6 +62,7 @@ import TaxRate from 'models/TaxRate'; import TaxRateTransaction from 'models/TaxRateTransaction'; import Attachment from 'models/Attachment'; import PlaidItem from 'models/PlaidItem'; +import UncategorizedCashflowTransaction from 'models/UncategorizedCashflowTransaction'; export default (knex) => { const models = { @@ -126,7 +127,8 @@ export default (knex) => { TaxRate, TaxRateTransaction, Attachment, - PlaidItem + PlaidItem, + UncategorizedCashflowTransaction }; return mapValues(models, (model) => model.bindKnex(knex)); }; diff --git a/packages/server/src/models/CashflowTransaction.ts b/packages/server/src/models/CashflowTransaction.ts index d4743d4ce..4e47d0e2d 100644 --- a/packages/server/src/models/CashflowTransaction.ts +++ b/packages/server/src/models/CashflowTransaction.ts @@ -12,6 +12,7 @@ export default class CashflowTransaction extends TenantModel { transactionType: string; amount: number; exchangeRate: number; + uncategorize: boolean; /** * Table name. diff --git a/packages/server/src/models/Expense.ts b/packages/server/src/models/Expense.ts index ed756e2bb..967c9c734 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 Media = require('models/Media'); const Branch = require('models/Branch'); + const UncategorizedCashflowTransaction = require('models/UncategorizedCashflowTransaction'); return { paymentAccount: { @@ -215,6 +216,10 @@ export default class Expense extends mixin(TenantModel, [ to: 'branches.id', }, }, + + /** + * + */ media: { relation: Model.ManyToManyRelation, modelClass: Media.default, @@ -230,6 +235,18 @@ export default class Expense extends mixin(TenantModel, [ query.where('model_name', 'Expense'); }, }, + + /** + * Retrieves the related uncategorized cashflow transaction. + */ + categorized: { + relation: Model.BelongsToOneRelation, + modelClass: UncategorizedCashflowTransaction.default, + join: { + from: 'expenses_transactions.categorizedTransactionId', + to: 'uncategorized_cashflow_transactions.id', + }, + } }; } diff --git a/packages/server/src/models/UncategorizedCashflowTransaction.ts b/packages/server/src/models/UncategorizedCashflowTransaction.ts new file mode 100644 index 000000000..ed00be47e --- /dev/null +++ b/packages/server/src/models/UncategorizedCashflowTransaction.ts @@ -0,0 +1,64 @@ +/* eslint-disable global-require */ +import TenantModel from 'models/TenantModel'; +import { Model } from 'objection'; + +export default class UncategorizedCashflowTransaction extends TenantModel { + amount: number; + /** + * Table name. + */ + static get tableName() { + return 'uncategorized_cashflow_transactions'; + } + + /** + * Timestamps columns. + */ + static get timestamps() { + return ['createdAt', 'updatedAt']; + } + + /** + * Retrieves the withdrawal amount. + * @returns {number} + */ + public withdrawal() { + return this.amount > 0 ? Math.abs(this.amount) : 0; + } + + /** + * Retrieves the deposit amount. + * @returns {number} + */ + public deposit() { + return this.amount < 0 ? Math.abs(this.amount) : 0; + } + + /** + * Virtual attributes. + */ + static get virtualAttributes() { + return ['withdrawal', 'deposit']; + } + + /** + * Relationship mapping. + */ + static get relationMappings() { + const Account = require('models/Account'); + + return { + /** + * Transaction may has associated to account. + */ + account: { + relation: Model.BelongsToOneRelation, + modelClass: Account.default, + join: { + from: 'uncategorized_cashflow_transactions.accountId', + to: 'accounts.id', + }, + }, + }; + } +} diff --git a/packages/server/src/services/Banking/Plaid/PlaidSyncDB.ts b/packages/server/src/services/Banking/Plaid/PlaidSyncDB.ts index 6f0260a2b..a9f9d14fd 100644 --- a/packages/server/src/services/Banking/Plaid/PlaidSyncDB.ts +++ b/packages/server/src/services/Banking/Plaid/PlaidSyncDB.ts @@ -9,7 +9,7 @@ import { transformPlaidTrxsToCashflowCreate, } from './utils'; import NewCashflowTransactionService from '@/services/Cashflow/NewCashflowTransactionService'; -import DeleteCashflowTransactionService from '@/services/Cashflow/DeleteCashflowTransactionService'; +import { DeleteCashflowTransaction } from '@/services/Cashflow/DeleteCashflowTransactionService'; import HasTenancyService from '@/services/Tenancy/TenancyService'; const CONCURRENCY_ASYNC = 10; @@ -26,7 +26,7 @@ export class PlaidSyncDb { private createCashflowTransactionService: NewCashflowTransactionService; @Inject() - private deleteCashflowTransactionService: DeleteCashflowTransactionService; + private deleteCashflowTransactionService: DeleteCashflowTransaction; /** * Syncs the plaid accounts to the system accounts. diff --git a/packages/server/src/services/Cashflow/CashflowApplication.ts b/packages/server/src/services/Cashflow/CashflowApplication.ts new file mode 100644 index 000000000..ee7548fee --- /dev/null +++ b/packages/server/src/services/Cashflow/CashflowApplication.ts @@ -0,0 +1,104 @@ +import { Inject, Service } from 'typedi'; +import { DeleteCashflowTransaction } from './DeleteCashflowTransactionService'; +import { UncategorizeCashflowTransaction } from './UncategorizeCashflowTransaction'; +import { CategorizeCashflowTransaction } from './CategorizeCashflowTransaction'; +import { + CategorizeTransactionAsExpenseDTO, + ICategorizeCashflowTransactioDTO, +} from '@/interfaces'; +import { CategorizeTransactionAsExpense } from './CategorizeTransactionAsExpense'; +import { GetUncategorizedTransactions } from './GetUncategorizedTransactions'; + +@Service() +export class CashflowApplication { + @Inject() + private deleteTransactionService: DeleteCashflowTransaction; + + @Inject() + private uncategorizeTransactionService: UncategorizeCashflowTransaction; + + @Inject() + private categorizeTransactionService: CategorizeCashflowTransaction; + + @Inject() + private categorizeAsExpenseService: CategorizeTransactionAsExpense; + + @Inject() + private getUncategorizedTransactionsService: GetUncategorizedTransactions; + + /** + * Deletes the given cashflow transaction. + * @param {number} tenantId + * @param {number} cashflowTransactionId + * @returns + */ + public deleteTransaction(tenantId: number, cashflowTransactionId: number) { + return this.deleteTransactionService.deleteCashflowTransaction( + tenantId, + cashflowTransactionId + ); + } + + /** + * Uncategorize the given cashflow transaction. + * @param {number} tenantId + * @param {number} cashflowTransactionId + * @returns + */ + public uncategorizeTransaction( + tenantId: number, + cashflowTransactionId: number + ) { + return this.uncategorizeTransactionService.uncategorize( + tenantId, + cashflowTransactionId + ); + } + + /** + * Categorize the given cashflow transaction. + * @param {number} tenantId + * @param {number} cashflowTransactionId + * @param {ICategorizeCashflowTransactioDTO} categorizeDTO + * @returns + */ + public categorizeTransaction( + tenantId: number, + cashflowTransactionId: number, + categorizeDTO: ICategorizeCashflowTransactioDTO + ) { + return this.categorizeTransactionService.categorize( + tenantId, + cashflowTransactionId, + categorizeDTO + ); + } + + /** + * Categorizes the given cashflow transaction as expense transaction. + * @param {number} tenantId + * @param {number} cashflowTransactionId + * @param {CategorizeTransactionAsExpenseDTO} transactionDTO + * @returns + */ + public categorizeAsExpense( + tenantId: number, + cashflowTransactionId: number, + transactionDTO: CategorizeTransactionAsExpenseDTO + ) { + return this.categorizeAsExpenseService.categorize( + tenantId, + cashflowTransactionId, + transactionDTO + ); + } + + /** + * Retrieves the uncategorized cashflow transactions. + * @param {number} tenantId + * @returns {} + */ + public getUncategorizedTransactions(tenantId: number) { + return this.getUncategorizedTransactionsService.getTransactions(tenantId); + } +} diff --git a/packages/server/src/services/Cashflow/CashflowTransactionJournalEntries.ts b/packages/server/src/services/Cashflow/CashflowTransactionJournalEntries.ts index 21df84f34..c1c590d55 100644 --- a/packages/server/src/services/Cashflow/CashflowTransactionJournalEntries.ts +++ b/packages/server/src/services/Cashflow/CashflowTransactionJournalEntries.ts @@ -1,11 +1,9 @@ import { Inject, Service } from 'typedi'; import { Knex } from 'knex'; -import * as R from 'ramda'; import { ILedgerEntry, ICashflowTransaction, AccountNormal, - ICashflowTransactionLine, } from '../../interfaces'; import { transformCashflowTransactionType, diff --git a/packages/server/src/services/Cashflow/CategorizeCashflowTransaction.ts b/packages/server/src/services/Cashflow/CategorizeCashflowTransaction.ts new file mode 100644 index 000000000..e965afede --- /dev/null +++ b/packages/server/src/services/Cashflow/CategorizeCashflowTransaction.ts @@ -0,0 +1,93 @@ +import { Inject, Service } from 'typedi'; +import HasTenancyService from '../Tenancy/TenancyService'; +import events from '@/subscribers/events'; +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import UnitOfWork from '../UnitOfWork'; +import { + ICashflowTransactionCategorizedPayload, + ICashflowTransactionUncategorizingPayload, + ICategorizeCashflowTransactioDTO, +} from '@/interfaces'; +import { Knex } from 'knex'; +import { transformCategorizeTransToCashflow } from './utils'; +import { CommandCashflowValidator } from './CommandCasflowValidator'; +import NewCashflowTransactionService from './NewCashflowTransactionService'; + +@Service() +export class CategorizeCashflowTransaction { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private eventPublisher: EventPublisher; + + @Inject() + private uow: UnitOfWork; + + @Inject() + private commandValidators: CommandCashflowValidator; + + @Inject() + private createCashflow: NewCashflowTransactionService; + + /** + * Categorize the given cashflow transaction. + * @param {number} tenantId + * @param {ICategorizeCashflowTransactioDTO} categorizeDTO + */ + public async categorize( + tenantId: number, + uncategorizedTransactionId: number, + categorizeDTO: ICategorizeCashflowTransactioDTO + ) { + const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId); + + // Retrieves the uncategorized transaction or throw an error. + const transaction = await UncategorizedCashflowTransaction.query() + .findById(uncategorizedTransactionId) + .throwIfNotFound(); + + // Validates the transaction shouldn't be categorized before. + this.commandValidators.validateTransactionShouldNotCategorized(transaction); + + // Edits the cashflow transaction under UOW env. + return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => { + // Triggers `onTransactionCategorizing` event. + await this.eventPublisher.emitAsync( + events.cashflow.onTransactionCategorizing, + { + tenantId, + trx, + } as ICashflowTransactionUncategorizingPayload + ); + // Transformes the categorize DTO to the cashflow transaction. + const cashflowTransactionDTO = transformCategorizeTransToCashflow( + transaction, + categorizeDTO + ); + // Creates a new cashflow transaction. + const cashflowTransaction = + await this.createCashflow.newCashflowTransaction( + tenantId, + cashflowTransactionDTO + ); + // Updates the uncategorized transaction as categorized. + await UncategorizedCashflowTransaction.query(trx) + .findById(uncategorizedTransactionId) + .patch({ + categorized: true, + categorizeRefType: 'CashflowTransaction', + categorizeRefId: cashflowTransaction.id, + }); + // Triggers `onCashflowTransactionCategorized` event. + await this.eventPublisher.emitAsync( + events.cashflow.onTransactionCategorized, + { + tenantId, + // cashflowTransaction, + trx, + } as ICashflowTransactionCategorizedPayload + ); + }); + } +} diff --git a/packages/server/src/services/Cashflow/CategorizeTransactionAsExpense.ts b/packages/server/src/services/Cashflow/CategorizeTransactionAsExpense.ts new file mode 100644 index 000000000..119f0cc7b --- /dev/null +++ b/packages/server/src/services/Cashflow/CategorizeTransactionAsExpense.ts @@ -0,0 +1,80 @@ +import { + CategorizeTransactionAsExpenseDTO, + ICashflowTransactionCategorizedPayload, +} from '@/interfaces'; +import { Inject, Service } from 'typedi'; +import UnitOfWork from '../UnitOfWork'; +import events from '@/subscribers/events'; +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import HasTenancyService from '../Tenancy/TenancyService'; +import { Knex } from 'knex'; +import { CreateExpense } from '../Expenses/CRUD/CreateExpense'; + +@Service() +export class CategorizeTransactionAsExpense { + @Inject() + private uow: UnitOfWork; + + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private eventPublisher: EventPublisher; + + @Inject() + private createExpenseService: CreateExpense; + + /** + * Categorize the transaction as expense transaction. + * @param {number} tenantId + * @param {number} cashflowTransactionId + * @param {CategorizeTransactionAsExpenseDTO} transactionDTO + */ + public async categorize( + tenantId: number, + cashflowTransactionId: number, + transactionDTO: CategorizeTransactionAsExpenseDTO + ) { + const { CashflowTransaction } = this.tenancy.models(tenantId); + + const transaction = await CashflowTransaction.query() + .findById(cashflowTransactionId) + .throwIfNotFound(); + + return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => { + // Triggers `onTransactionUncategorizing` event. + await this.eventPublisher.emitAsync( + events.cashflow.onTransactionCategorizingAsExpense, + { + tenantId, + trx, + } as ICashflowTransactionCategorizedPayload + ); + // Creates a new expense transaction. + const expenseTransaction = await this.createExpenseService.newExpense( + tenantId, + { + + }, + 1 + ); + // Updates the item on the storage and fetches the updated once. + const cashflowTransaction = await CashflowTransaction.query( + trx + ).patchAndFetchById(cashflowTransactionId, { + categorizeRefType: 'Expense', + categorizeRefId: expenseTransaction.id, + uncategorized: true, + }); + // Triggers `onTransactionUncategorized` event. + await this.eventPublisher.emitAsync( + events.cashflow.onTransactionCategorizedAsExpense, + { + tenantId, + cashflowTransaction, + trx, + } as ICashflowTransactionUncategorizedPayload + ); + }); + } +} diff --git a/packages/server/src/services/Cashflow/CommandCasflowValidator.ts b/packages/server/src/services/Cashflow/CommandCasflowValidator.ts index 07722e50e..aabdc6301 100644 --- a/packages/server/src/services/Cashflow/CommandCasflowValidator.ts +++ b/packages/server/src/services/Cashflow/CommandCasflowValidator.ts @@ -4,6 +4,7 @@ import { IAccount } from '@/interfaces'; import { getCashflowTransactionType } from './utils'; import { ServiceError } from '@/exceptions'; import { CASHFLOW_TRANSACTION_TYPE, ERRORS } from './constants'; +import CashflowTransaction from '@/models/CashflowTransaction'; @Service() export class CommandCashflowValidator { @@ -46,4 +47,28 @@ export class CommandCashflowValidator { } return transformedType; }; + + /** + * Validate the given transaction should be categorized. + * @param {CashflowTransaction} cashflowTransaction + */ + public validateTransactionShouldCategorized( + cashflowTransaction: CashflowTransaction + ) { + if (!cashflowTransaction.uncategorize) { + throw new ServiceError(ERRORS.TRANSACTION_ALREADY_CATEGORIZED); + } + } + + /** + * Validate the given transcation shouldn't be categorized. + * @param {CashflowTransaction} cashflowTransaction + */ + public validateTransactionShouldNotCategorized( + cashflowTransaction: CashflowTransaction + ) { + if (cashflowTransaction.uncategorize) { + throw new ServiceError(ERRORS.TRANSACTION_ALREADY_CATEGORIZED); + } + } } diff --git a/packages/server/src/services/Cashflow/DeleteCashflowTransactionService.ts b/packages/server/src/services/Cashflow/DeleteCashflowTransactionService.ts index e07073c3d..dd6c3002a 100644 --- a/packages/server/src/services/Cashflow/DeleteCashflowTransactionService.ts +++ b/packages/server/src/services/Cashflow/DeleteCashflowTransactionService.ts @@ -13,15 +13,15 @@ import UnitOfWork from '@/services/UnitOfWork'; import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; @Service() -export default class CommandCashflowTransactionService { +export class DeleteCashflowTransaction { @Inject() - tenancy: HasTenancyService; + private tenancy: HasTenancyService; @Inject() - eventPublisher: EventPublisher; + private eventPublisher: EventPublisher; @Inject() - uow: UnitOfWork; + private uow: UnitOfWork; /** * Deletes the cashflow transaction with associated journal entries. diff --git a/packages/server/src/services/Cashflow/GetCashflowTransactionsService.ts b/packages/server/src/services/Cashflow/GetCashflowTransactionsService.ts index eb22dd305..13852dd05 100644 --- a/packages/server/src/services/Cashflow/GetCashflowTransactionsService.ts +++ b/packages/server/src/services/Cashflow/GetCashflowTransactionsService.ts @@ -4,7 +4,6 @@ import { CashflowTransactionTransformer } from './CashflowTransactionTransformer import { ERRORS } from './constants'; import { ICashflowTransaction } from '@/interfaces'; import { ServiceError } from '@/exceptions'; -import I18nService from '@/services/I18n/I18nService'; import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; @Service() @@ -12,9 +11,6 @@ export default class GetCashflowTransactionsService { @Inject() private tenancy: HasTenancyService; - @Inject() - private i18nService: I18nService; - @Inject() private transfromer: TransformerInjectable; diff --git a/packages/server/src/services/Cashflow/GetUncategorizedTransactions.ts b/packages/server/src/services/Cashflow/GetUncategorizedTransactions.ts new file mode 100644 index 000000000..955a5538a --- /dev/null +++ b/packages/server/src/services/Cashflow/GetUncategorizedTransactions.ts @@ -0,0 +1,37 @@ +import { Inject, Service } from 'typedi'; +import HasTenancyService from '../Tenancy/TenancyService'; +import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; +import { UncategorizedTransactionTransformer } from './UncategorizedTransactionTransformer'; + +@Service() +export class GetUncategorizedTransactions { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private transformer: TransformerInjectable; + + /** + * Retrieves the uncategorized cashflow transactions. + * @param {number} tenantId + */ + public async getTransactions(tenantId: number) { + const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId); + + const { results, pagination } = + await UncategorizedCashflowTransaction.query() + .where('categorized', false) + .withGraphFetched('account') + .pagination(0, 10); + + const data = await this.transformer.transform( + tenantId, + results, + new UncategorizedTransactionTransformer() + ); + return { + data, + pagination, + }; + } +} diff --git a/packages/server/src/services/Cashflow/NewCashflowTransactionService.ts b/packages/server/src/services/Cashflow/NewCashflowTransactionService.ts index e8c53f5fc..5222a2664 100644 --- a/packages/server/src/services/Cashflow/NewCashflowTransactionService.ts +++ b/packages/server/src/services/Cashflow/NewCashflowTransactionService.ts @@ -1,11 +1,10 @@ import { Service, Inject } from 'typedi'; -import { isEmpty, pick } from 'lodash'; +import { pick } from 'lodash'; import { Knex } from 'knex'; import * as R from 'ramda'; import { ICashflowNewCommandDTO, ICashflowTransaction, - ICashflowTransactionLine, ICommandCashflowCreatedPayload, ICommandCashflowCreatingPayload, ICashflowTransactionInput, @@ -126,7 +125,7 @@ export default class NewCashflowTransactionService { tenantId: number, newTransactionDTO: ICashflowNewCommandDTO, userId?: number - ): Promise<{ cashflowTransaction: ICashflowTransaction }> => { + ): Promise => { const { CashflowTransaction, Account } = this.tenancy.models(tenantId); // Retrieves the cashflow account or throw not found error. @@ -175,7 +174,7 @@ export default class NewCashflowTransactionService { trx, } as ICommandCashflowCreatedPayload ); - return { cashflowTransaction }; + return cashflowTransaction; }); }; } diff --git a/packages/server/src/services/Cashflow/UncategorizeCashflowTransaction.ts b/packages/server/src/services/Cashflow/UncategorizeCashflowTransaction.ts new file mode 100644 index 000000000..ba6740685 --- /dev/null +++ b/packages/server/src/services/Cashflow/UncategorizeCashflowTransaction.ts @@ -0,0 +1,72 @@ +import { Knex } from 'knex'; +import { Inject, Service } from 'typedi'; +import HasTenancyService from '../Tenancy/TenancyService'; +import UnitOfWork from '../UnitOfWork'; +import events from '@/subscribers/events'; +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import { + ICashflowTransactionUncategorizedPayload, + ICashflowTransactionUncategorizingPayload, +} from '@/interfaces'; + +@Service() +export class UncategorizeCashflowTransaction { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private eventPublisher: EventPublisher; + + @Inject() + private uow: UnitOfWork; + + /** + * Uncategorizes the given cashflow transaction. + * @param {number} tenantId + * @param {number} cashflowTransactionId + */ + public async uncategorize( + tenantId: number, + uncategorizedTransactionId: number + ) { + const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId); + + const oldUncategorizedTransaction = + await UncategorizedCashflowTransaction.query() + .findById(uncategorizedTransactionId) + .throwIfNotFound(); + + // Updates the transaction under UOW. + return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => { + // Triggers `onTransactionUncategorizing` event. + await this.eventPublisher.emitAsync( + events.cashflow.onTransactionUncategorizing, + { + tenantId, + trx, + } as ICashflowTransactionUncategorizingPayload + ); + // Removes the ref relation with the related transaction. + const uncategorizedTransaction = + await UncategorizedCashflowTransaction.query(trx).updateAndFetchById( + uncategorizedTransactionId, + { + categorized: false, + categorizeRefId: null, + categorizeRefType: null, + } + ); + // Triggers `onTransactionUncategorized` event. + await this.eventPublisher.emitAsync( + events.cashflow.onTransactionUncategorized, + { + tenantId, + uncategorizedTransaction, + oldUncategorizedTransaction, + trx, + } as ICashflowTransactionUncategorizedPayload + ); + return uncategorizedTransaction; + }); + } +} diff --git a/packages/server/src/services/Cashflow/UncategorizeTransactionByRef.ts b/packages/server/src/services/Cashflow/UncategorizeTransactionByRef.ts new file mode 100644 index 000000000..f5590fb83 --- /dev/null +++ b/packages/server/src/services/Cashflow/UncategorizeTransactionByRef.ts @@ -0,0 +1,9 @@ +import { Service } from 'typedi'; +import { UncategorizeCashflowTransaction } from './UncategorizeCashflowTransaction'; + +@Service() +export class UncategorizeTransactionByRef { + private uncategorizeTransactionService: UncategorizeCashflowTransaction; + + public uncategorize(tenantId: number, refId: number, refType: string) {} +} diff --git a/packages/server/src/services/Cashflow/UncategorizedTransactionTransformer.ts b/packages/server/src/services/Cashflow/UncategorizedTransactionTransformer.ts new file mode 100644 index 000000000..2a4ceb4a4 --- /dev/null +++ b/packages/server/src/services/Cashflow/UncategorizedTransactionTransformer.ts @@ -0,0 +1,34 @@ +import { Transformer } from '@/lib/Transformer/Transformer'; +import { formatNumber } from '@/utils'; + +export class UncategorizedTransactionTransformer extends Transformer { + /** + * Include these attributes to sale invoice object. + * @returns {string[]} + */ + public includeAttributes = (): string[] => { + return ['formattetDepositAmount', 'formattedWithdrawalAmount']; + }; + + /** + * Formatted deposit amount. + * @param transaction + * @returns {string} + */ + protected formattetDepositAmount(transaction) { + return formatNumber(transaction.deposit, { + currencyCode: transaction.currencyCode, + }); + } + + /** + * Formatted withdrawal amount. + * @param transaction + * @returns {string} + */ + protected formattedWithdrawalAmount(transaction) { + return formatNumber(transaction.withdrawal, { + currencyCode: transaction.currencyCode, + }); + } +} diff --git a/packages/server/src/services/Cashflow/constants.ts b/packages/server/src/services/Cashflow/constants.ts index 2e664a519..4ec2d7004 100644 --- a/packages/server/src/services/Cashflow/constants.ts +++ b/packages/server/src/services/Cashflow/constants.ts @@ -8,7 +8,9 @@ export const ERRORS = { CREDIT_ACCOUNTS_IDS_NOT_FOUND: 'CREDIT_ACCOUNTS_IDS_NOT_FOUND', CREDIT_ACCOUNTS_HAS_INVALID_TYPE: 'CREDIT_ACCOUNTS_HAS_INVALID_TYPE', ACCOUNT_ID_HAS_INVALID_TYPE: 'ACCOUNT_ID_HAS_INVALID_TYPE', - ACCOUNT_HAS_ASSOCIATED_TRANSACTIONS: 'account_has_associated_transactions' + ACCOUNT_HAS_ASSOCIATED_TRANSACTIONS: 'account_has_associated_transactions', + TRANSACTION_ALREADY_CATEGORIZED: 'TRANSACTION_ALREADY_CATEGORIZED', + TRANSACTION_ALREADY_UNCATEGORIZED: 'TRANSACTION_ALREADY_UNCATEGORIZED' }; export enum CASHFLOW_DIRECTION { diff --git a/packages/server/src/services/Cashflow/subscribers/DeleteCashflowTransactionOnUncategorize.ts b/packages/server/src/services/Cashflow/subscribers/DeleteCashflowTransactionOnUncategorize.ts new file mode 100644 index 000000000..3715b769c --- /dev/null +++ b/packages/server/src/services/Cashflow/subscribers/DeleteCashflowTransactionOnUncategorize.ts @@ -0,0 +1,40 @@ +import { Inject, Service } from 'typedi'; +import events from '@/subscribers/events'; +import { ICashflowTransactionUncategorizedPayload } from '@/interfaces'; +import { DeleteCashflowTransaction } from '../DeleteCashflowTransactionService'; + +@Service() +export class DeleteCashflowTransactionOnUncategorize { + @Inject() + private deleteCashflowTransactionService: DeleteCashflowTransaction; + + /** + * Attaches events with handlers. + */ + public attach = (bus) => { + bus.subscribe( + events.cashflow.onTransactionUncategorized, + this.deleteCashflowTransactionOnUncategorize.bind(this) + ); + }; + + /** + * Deletes the cashflow transaction on uncategorize transaction. + * @param {ICashflowTransactionUncategorizedPayload} payload + */ + public async deleteCashflowTransactionOnUncategorize({ + tenantId, + oldUncategorizedTransaction, + trx, + }: ICashflowTransactionUncategorizedPayload) { + // Deletes the cashflow transaction. + if ( + oldUncategorizedTransaction.categorizeRefType === 'CashflowTransaction' + ) { + await this.deleteCashflowTransactionService.deleteCashflowTransaction( + tenantId, + oldUncategorizedTransaction.categorizeRefId + ); + } + } +} diff --git a/packages/server/src/services/Cashflow/utils.ts b/packages/server/src/services/Cashflow/utils.ts index 31af4b1bc..9d76f1862 100644 --- a/packages/server/src/services/Cashflow/utils.ts +++ b/packages/server/src/services/Cashflow/utils.ts @@ -1,13 +1,19 @@ -import { upperFirst, camelCase } from 'lodash'; +import { upperFirst, camelCase, omit } from 'lodash'; import { CASHFLOW_TRANSACTION_TYPE, CASHFLOW_TRANSACTION_TYPE_META, ICashflowTransactionTypeMeta, } from './constants'; +import { + ICashflowNewCommandDTO, + ICashflowTransaction, + ICategorizeCashflowTransactioDTO, + IUncategorizedCashflowTransaction, +} from '@/interfaces'; /** * Ensures the given transaction type to transformed to appropriate format. - * @param {string} type + * @param {string} type * @returns {string} */ export const transformCashflowTransactionType = (type) => { @@ -32,3 +38,29 @@ export function getCashflowTransactionType( export const getCashflowAccountTransactionsTypes = () => { return Object.values(CASHFLOW_TRANSACTION_TYPE_META).map((meta) => meta.type); }; + +/** + * Tranasformes the given uncategorized transaction and categorized DTO + * to cashflow create DTO. + * @param {IUncategorizedCashflowTransaction} uncategorizeModel + * @param {ICategorizeCashflowTransactioDTO} categorizeDTO + * @returns {ICashflowNewCommandDTO} + */ +export const transformCategorizeTransToCashflow = ( + uncategorizeModel: IUncategorizedCashflowTransaction, + categorizeDTO: ICategorizeCashflowTransactioDTO +): ICashflowNewCommandDTO => { + return { + date: uncategorizeModel.date, + referenceNo: categorizeDTO.referenceNo || uncategorizeModel.referenceNo, + description: categorizeDTO.description || uncategorizeModel.description, + cashflowAccountId: uncategorizeModel.accountId, + creditAccountId: categorizeDTO.fromAccountId || categorizeDTO.toAccountId, + exchangeRate: categorizeDTO.exchangeRate || 1, + currencyCode: uncategorizeModel.currencyCode, + amount: uncategorizeModel.amount, + transactionNumber: categorizeDTO.transactionNumber, + transactionType: categorizeDTO.transactionType, + publish: true, + }; +}; diff --git a/packages/server/src/subscribers/events.ts b/packages/server/src/subscribers/events.ts index bae27d1a6..0243cd172 100644 --- a/packages/server/src/subscribers/events.ts +++ b/packages/server/src/subscribers/events.ts @@ -392,6 +392,15 @@ export default { onTransactionDeleting: 'onCashflowTransactionDeleting', onTransactionDeleted: 'onCashflowTransactionDeleted', + + onTransactionCategorizing: 'onTransactionCategorizing', + onTransactionCategorized: 'onCashflowTransactionCategorized', + + onTransactionUncategorizing: 'onTransactionUncategorizing', + onTransactionUncategorized: 'onTransactionUncategorized', + + onTransactionCategorizingAsExpense: 'onTransactionCategorizingAsExpense', + onTransactionCategorizedAsExpense: 'onTransactionCategorizedAsExpense', }, /** From 5b4ddadcf6c6826e0ce883da12fac1f5c8588de1 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Fri, 1 Mar 2024 17:12:56 +0200 Subject: [PATCH 02/12] feat(server): categorize the synced bank transactions --- .../Cashflow/NewCashflowTransaction.ts | 24 ++++++++++---- ...cateogrized_cashflow_transactions_table.js | 1 + .../UncategorizedCashflowTransaction.ts | 29 ++++++++++++++--- .../Cashflow/CategorizeCashflowTransaction.ts | 7 ++++ .../Cashflow/CommandCasflowValidator.ts | 32 +++++++++++++++++-- .../server/src/services/Cashflow/constants.ts | 3 +- 6 files changed, 82 insertions(+), 14 deletions(-) diff --git a/packages/server/src/api/controllers/Cashflow/NewCashflowTransaction.ts b/packages/server/src/api/controllers/Cashflow/NewCashflowTransaction.ts index 9c48934ea..a3bd70b4f 100644 --- a/packages/server/src/api/controllers/Cashflow/NewCashflowTransaction.ts +++ b/packages/server/src/api/controllers/Cashflow/NewCashflowTransaction.ts @@ -1,5 +1,5 @@ import { Service, Inject } from 'typedi'; -import { check } from 'express-validator'; +import { check, oneOf } from 'express-validator'; import { Router, Request, Response, NextFunction } from 'express'; import BaseController from '../BaseController'; import { ServiceError } from '@/exceptions'; @@ -75,14 +75,16 @@ export default class NewCashflowTransactionController extends BaseController { public get categorizeCashflowTransactionValidationSchema() { return [ check('date').exists().isISO8601().toDate(), - - check('to_account_id').exists().isInt().toInt(), - check('from_account_id').exists().isInt().toInt(), - + oneOf([ + check('to_account_id').exists().isInt().toInt(), + check('from_account_id').exists().isInt().toInt(), + ]), + check('transaction_number').optional(), check('transaction_type').exists(), check('reference_no').optional(), check('exchange_rate').optional().isFloat({ gt: 0 }).toFloat(), check('description').optional(), + check('branch_id').optional({ nullable: true }).isNumeric().toInt(), ]; } @@ -125,7 +127,7 @@ export default class NewCashflowTransactionController extends BaseController { const ownerContributionDTO = this.matchedBodyData(req); try { - const { cashflowTransaction } = + const cashflowTransaction = await this.newCashflowTranscationService.newCashflowTransaction( tenantId, ownerContributionDTO, @@ -301,6 +303,16 @@ export default class NewCashflowTransactionController extends BaseController { ], }); } + if (error.errorType === 'UNCATEGORIZED_TRANSACTION_TYPE_INVALID') { + return res.boom.badRequest(null, { + errors: [ + { + type: 'UNCATEGORIZED_TRANSACTION_TYPE_INVALID', + code: 4100, + } + ] + }); + } } next(error); } diff --git a/packages/server/src/database/migrations/20240228183404_create_uncateogrized_cashflow_transactions_table.js b/packages/server/src/database/migrations/20240228183404_create_uncateogrized_cashflow_transactions_table.js index 2d72041f3..aab649b63 100644 --- a/packages/server/src/database/migrations/20240228183404_create_uncateogrized_cashflow_transactions_table.js +++ b/packages/server/src/database/migrations/20240228183404_create_uncateogrized_cashflow_transactions_table.js @@ -5,6 +5,7 @@ exports.up = function (knex) { table.increments('id'); table.date('date').index(); table.decimal('amount'); + table.string('currency_code'); table.string('reference_no').index(); table .integer('account_id') diff --git a/packages/server/src/models/UncategorizedCashflowTransaction.ts b/packages/server/src/models/UncategorizedCashflowTransaction.ts index ed00be47e..bb6798504 100644 --- a/packages/server/src/models/UncategorizedCashflowTransaction.ts +++ b/packages/server/src/models/UncategorizedCashflowTransaction.ts @@ -22,23 +22,42 @@ export default class UncategorizedCashflowTransaction extends TenantModel { * Retrieves the withdrawal amount. * @returns {number} */ - public withdrawal() { - return this.amount > 0 ? Math.abs(this.amount) : 0; + public get withdrawal() { + return this.amount < 0 ? Math.abs(this.amount) : 0; } /** * Retrieves the deposit amount. * @returns {number} */ - public deposit() { - return this.amount < 0 ? Math.abs(this.amount) : 0; + public get deposit(): number { + return this.amount > 0 ? Math.abs(this.amount) : 0; + } + + /** + * Detarmines whether the transaction is deposit transaction. + */ + public get isDepositTransaction(): boolean { + return 0 < this.deposit; + } + + /** + * Detarmines whether the transaction is withdrawal transaction. + */ + public get isWithdrawalTransaction(): boolean { + return 0 < this.withdrawal; } /** * Virtual attributes. */ static get virtualAttributes() { - return ['withdrawal', 'deposit']; + return [ + 'withdrawal', + 'deposit', + 'isDepositTransaction', + 'isWithdrawalTransaction', + ]; } /** diff --git a/packages/server/src/services/Cashflow/CategorizeCashflowTransaction.ts b/packages/server/src/services/Cashflow/CategorizeCashflowTransaction.ts index e965afede..da5b81419 100644 --- a/packages/server/src/services/Cashflow/CategorizeCashflowTransaction.ts +++ b/packages/server/src/services/Cashflow/CategorizeCashflowTransaction.ts @@ -12,6 +12,7 @@ import { Knex } from 'knex'; import { transformCategorizeTransToCashflow } from './utils'; import { CommandCashflowValidator } from './CommandCasflowValidator'; import NewCashflowTransactionService from './NewCashflowTransactionService'; +import { TransferAuthorizationGuaranteeDecision } from 'plaid'; @Service() export class CategorizeCashflowTransaction { @@ -50,6 +51,12 @@ export class CategorizeCashflowTransaction { // Validates the transaction shouldn't be categorized before. this.commandValidators.validateTransactionShouldNotCategorized(transaction); + // Validate the uncateogirzed transaction if it's deposit the transaction direction + // should `IN` and the same thing if it's withdrawal the direction should be OUT. + this.commandValidators.validateUncategorizeTransactionType( + transaction, + categorizeDTO.transactionType + ); // Edits the cashflow transaction under UOW env. return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => { // Triggers `onTransactionCategorizing` event. diff --git a/packages/server/src/services/Cashflow/CommandCasflowValidator.ts b/packages/server/src/services/Cashflow/CommandCasflowValidator.ts index aabdc6301..7a6c5c973 100644 --- a/packages/server/src/services/Cashflow/CommandCasflowValidator.ts +++ b/packages/server/src/services/Cashflow/CommandCasflowValidator.ts @@ -1,9 +1,13 @@ import { Service } from 'typedi'; import { includes, camelCase, upperFirst } from 'lodash'; -import { IAccount } from '@/interfaces'; +import { IAccount, IUncategorizedCashflowTransaction } from '@/interfaces'; import { getCashflowTransactionType } from './utils'; import { ServiceError } from '@/exceptions'; -import { CASHFLOW_TRANSACTION_TYPE, ERRORS } from './constants'; +import { + CASHFLOW_DIRECTION, + CASHFLOW_TRANSACTION_TYPE, + ERRORS, +} from './constants'; import CashflowTransaction from '@/models/CashflowTransaction'; @Service() @@ -71,4 +75,28 @@ export class CommandCashflowValidator { throw new ServiceError(ERRORS.TRANSACTION_ALREADY_CATEGORIZED); } } + + /** + * + * @param {uncategorizeTransaction} + * @param {string} transactionType + * @throws {ServiceError(ERRORS.UNCATEGORIZED_TRANSACTION_TYPE_INVALID)} + */ + public validateUncategorizeTransactionType( + uncategorizeTransaction: IUncategorizedCashflowTransaction, + transactionType: string + ) { + const type = getCashflowTransactionType( + upperFirst(camelCase(transactionType)) as CASHFLOW_TRANSACTION_TYPE + ); + if ( + (type.direction === CASHFLOW_DIRECTION.IN && + uncategorizeTransaction.isDepositTransaction) || + (type.direction === CASHFLOW_DIRECTION.OUT && + uncategorizeTransaction.isWithdrawalTransaction) + ) { + return; + } + throw new ServiceError(ERRORS.UNCATEGORIZED_TRANSACTION_TYPE_INVALID); + } } diff --git a/packages/server/src/services/Cashflow/constants.ts b/packages/server/src/services/Cashflow/constants.ts index 4ec2d7004..293275855 100644 --- a/packages/server/src/services/Cashflow/constants.ts +++ b/packages/server/src/services/Cashflow/constants.ts @@ -10,7 +10,8 @@ export const ERRORS = { ACCOUNT_ID_HAS_INVALID_TYPE: 'ACCOUNT_ID_HAS_INVALID_TYPE', ACCOUNT_HAS_ASSOCIATED_TRANSACTIONS: 'account_has_associated_transactions', TRANSACTION_ALREADY_CATEGORIZED: 'TRANSACTION_ALREADY_CATEGORIZED', - TRANSACTION_ALREADY_UNCATEGORIZED: 'TRANSACTION_ALREADY_UNCATEGORIZED' + TRANSACTION_ALREADY_UNCATEGORIZED: 'TRANSACTION_ALREADY_UNCATEGORIZED', + UNCATEGORIZED_TRANSACTION_TYPE_INVALID: 'UNCATEGORIZED_TRANSACTION_TYPE_INVALID' }; export enum CASHFLOW_DIRECTION { From 0273714a07feb01952526ef8ad7ad39112962cf6 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sat, 2 Mar 2024 17:01:58 +0200 Subject: [PATCH 03/12] feat(webapp): Filter account transactions by categorized/uncategorized transactions --- .../components/ContentTabs/ContentTabs.tsx | 111 ++++++++++++++++++ .../src/components/ContentTabs/index.ts | 1 + .../AccountTransactionsFilterTabs.tsx | 33 ++++++ .../AccountTransactionsList.tsx | 19 ++- .../AccountTransactionsUncategorizeFilter.tsx | 27 +++++ 5 files changed, 187 insertions(+), 4 deletions(-) create mode 100644 packages/webapp/src/components/ContentTabs/ContentTabs.tsx create mode 100644 packages/webapp/src/components/ContentTabs/index.ts create mode 100644 packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsFilterTabs.tsx create mode 100644 packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsUncategorizeFilter.tsx diff --git a/packages/webapp/src/components/ContentTabs/ContentTabs.tsx b/packages/webapp/src/components/ContentTabs/ContentTabs.tsx new file mode 100644 index 000000000..f872430b3 --- /dev/null +++ b/packages/webapp/src/components/ContentTabs/ContentTabs.tsx @@ -0,0 +1,111 @@ +// @ts-nocheck +import React from 'react'; +import styled from 'styled-components'; +import { useUncontrolled } from '@/hooks/useUncontrolled'; + +const ContentTabsRoot = styled('div')` + display: flex; + gap: 10px; + +`; +interface ContentTabItemRootProps { + active?: boolean; +} +const ContentTabItemRoot = styled.button` + flex: 1 0; + background: #fff; + border: 1px solid #e1e2e8; + border-radius: 5px; + padding: 11px; + text-align: left; + cursor: pointer; + + ${(props) => + props.active && + ` + border-color: #1552c8; + box-shadow: 0 0 0 0.25px #1552c8; + + ${ContentTabTitle} { + color: #1552c8; + font-weight: 500; + } + ${ContentTabDesc} { + color: #1552c8; + } + `} + &:hover, + &:active { + border-color: #1552c8; + } +`; +const ContentTabTitle = styled('h3')` + font-size: 14px; + font-weight: 400; + color: #2f343c; +`; +const ContentTabDesc = styled('p')` + margin: 0; + color: #5f6b7c; + margin-top: 4px; + font-size: 12px; +`; + +interface ContentTabsItemProps { + id: string; + title?: React.ReactNode; + description?: React.ReactNode; + active?: boolean; +} + +const ContentTabsItem = ({ + title, + description, + active, +}: ContentTabsItemProps) => { + return ( + + {title} + {description} + + ); +}; + +interface ContentTabsProps { + initialValue?: string; + value?: string; + onChange?: (value: string) => void; + children?: React.ReactNode; + className?: string; +} + +export function ContentTabs({ + initialValue, + value, + onChange, + children, + className +}: ContentTabsProps) { + const [localValue, handleItemChange] = useUncontrolled({ + initialValue, + value, + onChange, + finalValue: '', + }); + const tabs = React.Children.toArray(children); + + return ( + + {tabs.map((tab) => ( + handleItemChange(tab.props?.id)} + /> + ))} + + ); +} + +ContentTabs.Tab = ContentTabsItem; diff --git a/packages/webapp/src/components/ContentTabs/index.ts b/packages/webapp/src/components/ContentTabs/index.ts new file mode 100644 index 000000000..332e23bfb --- /dev/null +++ b/packages/webapp/src/components/ContentTabs/index.ts @@ -0,0 +1 @@ +export * from './ContentTabs'; diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsFilterTabs.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsFilterTabs.tsx new file mode 100644 index 000000000..d8a4b8056 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsFilterTabs.tsx @@ -0,0 +1,33 @@ +import styled from 'styled-components'; +import { ContentTabs } from '@/components/ContentTabs/ContentTabs'; + +const AccountContentTabs = styled(ContentTabs)` + margin: 15px 15px 0 15px; +`; + +export function AccountTransactionsFilterTabs() { + return ( + + + + 20 Uncategorized + Transactions + + } + description={'For Bank Statement'} + /> + + + ); +} diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsList.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsList.tsx index 27fd17af2..e85d48a1c 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsList.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsList.tsx @@ -11,6 +11,8 @@ import AccountTransactionsDataTable from './AccountTransactionsDataTable'; import { AccountTransactionsProvider } from './AccountTransactionsProvider'; import { AccountTransactionsDetailsBar } from './AccountTransactionsDetailsBar'; import { AccountTransactionsProgressBar } from './components'; +import { AccountTransactionsFilterTabs } from './AccountTransactionsFilterTabs'; +import { AccountTransactionsUncategorizeFilter } from './AccountTransactionsUncategorizeFilter'; /** * Account transactions list. @@ -23,9 +25,15 @@ function AccountTransactionsList() { - - - + + + + + + + + + ); @@ -37,7 +45,10 @@ const CashflowTransactionsTableCard = styled.div` border: 2px solid #f0f0f0; border-radius: 10px; padding: 30px 18px; - margin: 30px 15px; background: #fff; flex: 0 1; `; + +const Box = styled.div` + margin: 30px 15px; +`; diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsUncategorizeFilter.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsUncategorizeFilter.tsx new file mode 100644 index 000000000..862a55855 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsUncategorizeFilter.tsx @@ -0,0 +1,27 @@ +// @ts-nocheck +import styled from 'styled-components'; +import { Tag } from '@blueprintjs/core'; + +const Root = styled.div` + display: flex; + flex-direction: row; + gap: 10px; + margin-bottom: 18px; +`; + +const FilterTag = styled(Tag)` + min-height: 26px; +`; + +export function AccountTransactionsUncategorizeFilter() { + return ( + + + All (2) + + + Recognized (0) + + + ); +} From 9db03350e08fcd99968edfc49bf431f597f6a44f Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Mon, 4 Mar 2024 13:41:15 +0200 Subject: [PATCH 04/12] feat(webapp): categorize the cashflow uncategorized transactions --- .../components/ContentTabs/ContentTabs.tsx | 6 +- .../AccountTransactionsFilterTabs.tsx | 10 +- .../AccountTransactionsList.tsx | 46 +++--- .../AccountTransactionsProvider.tsx | 58 +++++++- .../AccountTransactionsUncategorizedTable.tsx | 139 ++++++++++++++++++ .../AccountsTransactionsAll.tsx | 31 ++++ .../AllTransactionsUncategorized.tsx | 29 ++++ .../AccountTransactions/components.tsx | 72 ++++++++- .../CashFlowAccountsActionsBar.tsx | 4 +- .../drawers/CategorizeTransactionDrawer.tsx | 33 +++++ .../src/hooks/query/cashflowAccounts.tsx | 41 +++++- packages/webapp/src/hooks/query/types.tsx | 6 +- 12 files changed, 439 insertions(+), 36 deletions(-) create mode 100644 packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsUncategorizedTable.tsx create mode 100644 packages/webapp/src/containers/CashFlow/AccountTransactions/AccountsTransactionsAll.tsx create mode 100644 packages/webapp/src/containers/CashFlow/AccountTransactions/AllTransactionsUncategorized.tsx create mode 100644 packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer.tsx diff --git a/packages/webapp/src/components/ContentTabs/ContentTabs.tsx b/packages/webapp/src/components/ContentTabs/ContentTabs.tsx index f872430b3..58f844782 100644 --- a/packages/webapp/src/components/ContentTabs/ContentTabs.tsx +++ b/packages/webapp/src/components/ContentTabs/ContentTabs.tsx @@ -6,7 +6,6 @@ import { useUncontrolled } from '@/hooks/useUncontrolled'; const ContentTabsRoot = styled('div')` display: flex; gap: 10px; - `; interface ContentTabItemRootProps { active?: boolean; @@ -62,9 +61,10 @@ const ContentTabsItem = ({ title, description, active, + onClick, }: ContentTabsItemProps) => { return ( - + {title} {description} @@ -84,7 +84,7 @@ export function ContentTabs({ value, onChange, children, - className + className, }: ContentTabsProps) { const [localValue, handleItemChange] = useUncontrolled({ initialValue, diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsFilterTabs.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsFilterTabs.tsx index d8a4b8056..aede21571 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsFilterTabs.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsFilterTabs.tsx @@ -1,13 +1,21 @@ +// @ts-nocheck import styled from 'styled-components'; import { ContentTabs } from '@/components/ContentTabs/ContentTabs'; +import { useAccountTransactionsContext } from './AccountTransactionsProvider'; const AccountContentTabs = styled(ContentTabs)` margin: 15px 15px 0 15px; `; export function AccountTransactionsFilterTabs() { + const { filterTab, setFilterTab } = useAccountTransactionsContext(); + + const handleChange = (value) => { + setFilterTab(value); + }; + return ( - + - - - - - - - + }> + + ); @@ -41,14 +39,20 @@ function AccountTransactionsList() { export default AccountTransactionsList; -const CashflowTransactionsTableCard = styled.div` - border: 2px solid #f0f0f0; - border-radius: 10px; - padding: 30px 18px; - background: #fff; - flex: 0 1; -`; +const AccountsTransactionsAll = React.lazy( + () => import('./AccountsTransactionsAll'), +); -const Box = styled.div` - margin: 30px 15px; -`; +const AccountsTransactionsUncategorized = React.lazy( + () => import('./AllTransactionsUncategorized'), +); + +function AccountTransactionsContent() { + const { filterTab } = useAccountTransactionsContext(); + + return filterTab === 'uncategorized' ? ( + + ) : ( + + ); +} diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsProvider.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsProvider.tsx index 744863b87..f0aa661e3 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsProvider.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsProvider.tsx @@ -7,7 +7,9 @@ import { useAccountTransactionsInfinity, useCashflowAccounts, useAccount, + useAccountUncategorizedTransactionsInfinity, } from '@/hooks/query'; +import { useAppQueryString } from '@/hooks'; const AccountTransactionsContext = React.createContext(); @@ -15,6 +17,10 @@ function flattenInfinityPages(data) { return flatten(map(data.pages, (page) => page.transactions)); } +function flattenInfinityPagesData(data) { + return flatten(map(data.pages, (page) => page.data)); +} + /** * Account transctions provider. */ @@ -22,6 +28,13 @@ function AccountTransactionsProvider({ query, ...props }) { const { id } = useParams(); const accountId = parseInt(id, 10); + const [locationQuery, setLocationQuery] = useAppQueryString(); + + const filterTab = locationQuery?.filter || 'all'; + const setFilterTab = (value: stirng) => { + setLocationQuery({ filter: value }); + }; + // Fetch cashflow account transactions list const { data: cashflowTransactionsPages, @@ -31,10 +44,32 @@ function AccountTransactionsProvider({ query, ...props }) { fetchNextPage: fetchNextTransactionsPage, isFetchingNextPage, hasNextPage, - } = useAccountTransactionsInfinity(accountId, { - page_size: 50, - account_id: accountId, - }); + } = useAccountTransactionsInfinity( + accountId, + { + page_size: 50, + account_id: accountId, + }, + { + enabled: filterTab === 'all' || filterTab === 'dashboard', + }, + ); + + const { + data: uncategorizedTransactionsPage, + isFetching: isUncategorizedTransactionFetching, + isLoading: isUncategorizedTransactionsLoading, + isSuccess: isUncategorizedTransactionsSuccess, + fetchNextPage: fetchNextUncategorizedTransactionsPage, + } = useAccountUncategorizedTransactionsInfinity( + accountId, + { + page_size: 50, + }, + { + enabled: filterTab === 'uncategorized', + }, + ); // Memorized the cashflow account transactions. const cashflowTransactions = React.useMemo( @@ -45,6 +80,15 @@ function AccountTransactionsProvider({ query, ...props }) { [cashflowTransactionsPages, isCashflowTransactionsSuccess], ); + // Memorized the cashflow account transactions. + const uncategorizedTransactions = React.useMemo( + () => + isUncategorizedTransactionsSuccess + ? flattenInfinityPagesData(uncategorizedTransactionsPage) + : [], + [uncategorizedTransactionsPage, isUncategorizedTransactionsSuccess], + ); + // Fetch cashflow accounts. const { data: cashflowAccounts, @@ -78,6 +122,12 @@ function AccountTransactionsProvider({ query, ...props }) { isCashFlowAccountsLoading, isCurrentAccountFetching, isCurrentAccountLoading, + + filterTab, + setFilterTab, + + uncategorizedTransactions, + isUncategorizedTransactionFetching }; return ( diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsUncategorizedTable.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsUncategorizedTable.tsx new file mode 100644 index 000000000..b6a671bb6 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsUncategorizedTable.tsx @@ -0,0 +1,139 @@ +// @ts-nocheck +import React from 'react'; +import styled from 'styled-components'; + +import { + DataTable, + TableFastCell, + TableSkeletonRows, + TableSkeletonHeader, + TableVirtualizedListRows, + FormattedMessage as T, +} from '@/components'; +import { TABLES } from '@/constants/tables'; + +import withSettings from '@/containers/Settings/withSettings'; +import withAlertsActions from '@/containers/Alert/withAlertActions'; +import withDrawerActions from '@/containers/Drawer/withDrawerActions'; + +import { useMemorizedColumnsWidths } from '@/hooks'; +import { + ActionsMenu, + useAccountUncategorizedTransactionsColumns, +} from './components'; +import { useAccountTransactionsContext } from './AccountTransactionsProvider'; +import { handleCashFlowTransactionType } from './utils'; + +import { compose } from '@/utils'; + +/** + * Account transactions data table. + */ +function AccountTransactionsDataTable({ + // #withSettings + cashflowTansactionsTableSize, + + // #withAlertsActions + openAlert, + + // #withDrawerActions + openDrawer, +}) { + // Retrieve table columns. + const columns = useAccountUncategorizedTransactionsColumns(); + + // Retrieve list context. + const { uncategorizedTransactions, isCashFlowTransactionsLoading } = + useAccountTransactionsContext(); + + // Local storage memorizing columns widths. + const [initialColumnsWidths, , handleColumnResizing] = + useMemorizedColumnsWidths(TABLES.CASHFLOW_Transactions); + + // handle delete transaction + const handleDeleteTransaction = ({ reference_id }) => {}; + + const handleViewDetailCashflowTransaction = (referenceType) => {}; + + // Handle cell click. + const handleCellClick = (cell, event) => {}; + + return ( + } + className="table-constrant" + payload={{ + onViewDetails: handleViewDetailCashflowTransaction, + onDelete: handleDeleteTransaction, + }} + /> + ); +} + +export default compose( + withSettings(({ cashflowTransactionsSettings }) => ({ + cashflowTansactionsTableSize: cashflowTransactionsSettings?.tableSize, + })), + withAlertsActions, + withDrawerActions, +)(AccountTransactionsDataTable); + +const DashboardConstrantTable = styled(DataTable)` + .table { + .thead { + .th { + background: #fff; + } + } + + .tbody { + .tr:last-child .td { + border-bottom: 0; + } + } + } +`; + +const CashflowTransactionsTable = styled(DashboardConstrantTable)` + .table .tbody { + .tbody-inner .tr.no-results { + .td { + padding: 2rem 0; + font-size: 14px; + color: #888; + font-weight: 400; + border-bottom: 0; + } + } + + .tbody-inner { + .tr .td:not(:first-child) { + border-left: 1px solid #e6e6e6; + } + + .td-description { + color: #5F6B7C; + } + } + } +`; diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountsTransactionsAll.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountsTransactionsAll.tsx new file mode 100644 index 000000000..f181983af --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountsTransactionsAll.tsx @@ -0,0 +1,31 @@ +// @ts-nocheck +import styled from 'styled-components'; + +import '@/style/pages/CashFlow/AccountTransactions/List.scss'; + +import AccountTransactionsDataTable from './AccountTransactionsDataTable'; +import { AccountTransactionsUncategorizeFilter } from './AccountTransactionsUncategorizeFilter'; + +const Box = styled.div` + margin: 30px 15px; +`; + +const CashflowTransactionsTableCard = styled.div` + border: 2px solid #f0f0f0; + border-radius: 10px; + padding: 30px 18px; + background: #fff; + flex: 0 1; +`; + +export default function AccountTransactionsAll() { + return ( + + + + + + + + ); +} diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AllTransactionsUncategorized.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AllTransactionsUncategorized.tsx new file mode 100644 index 000000000..1595b746d --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AllTransactionsUncategorized.tsx @@ -0,0 +1,29 @@ +// @ts-nocheck +import styled from 'styled-components'; + +import '@/style/pages/CashFlow/AccountTransactions/List.scss'; + +import AccountTransactionsUncategorizedTable from './AccountTransactionsUncategorizedTable'; + +const Box = styled.div` + margin: 30px 15px; +`; + +const CashflowTransactionsTableCard = styled.div` + border: 2px solid #f0f0f0; + border-radius: 10px; + padding: 30px 18px; + background: #fff; + flex: 0 1; +` + + +export default function AllTransactionsUncategorized() { + return ( + + + + + + ) +} \ No newline at end of file diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/components.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/components.tsx index 18e75161e..7d5b17f3a 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/components.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/components.tsx @@ -131,7 +131,75 @@ export function useAccountTransactionsColumns() { * Account transactions progress bar. */ export function AccountTransactionsProgressBar() { - const { isCashFlowTransactionsFetching } = useAccountTransactionsContext(); + const { isCashFlowTransactionsFetching, isUncategorizedTransactionFetching } = + useAccountTransactionsContext(); - return isCashFlowTransactionsFetching ? : null; + return isCashFlowTransactionsFetching || + isUncategorizedTransactionFetching ? ( + + ) : null; +} + +/** + * Retrieve account uncategorized transctions table columns. + */ +export function useAccountUncategorizedTransactionsColumns() { + return React.useMemo( + () => [ + { + id: 'date', + Header: intl.get('date'), + accessor: 'formatted_date', + width: 40, + clickable: true, + textOverview: true, + }, + { + id: 'description', + Header: 'Description', + accessor: 'description', + width: 160, + textOverview: true, + clickable: true, + }, + { + id: 'payee', + Header: 'Payee', + accessor: 'payee', + width: 60, + clickable: true, + textOverview: true, + }, + { + id: 'reference_number', + Header: intl.get('reference_no'), + accessor: 'reference_number', + width: 50, + className: 'reference_number', + clickable: true, + textOverview: true, + }, + { + id: 'deposit', + Header: intl.get('cash_flow.label.deposit'), + accessor: 'formattet_deposit_amount', + width: 40, + className: 'deposit', + textOverview: true, + align: 'right', + clickable: true, + }, + { + id: 'withdrawal', + Header: intl.get('cash_flow.label.withdrawal'), + accessor: 'formatted_withdrawal_amount', + className: 'withdrawal', + width: 40, + textOverview: true, + align: 'right', + clickable: true, + }, + ], + [], + ); } diff --git a/packages/webapp/src/containers/CashFlow/CashFlowAccounts/CashFlowAccountsActionsBar.tsx b/packages/webapp/src/containers/CashFlow/CashFlowAccounts/CashFlowAccountsActionsBar.tsx index a0028f231..66298dc8b 100644 --- a/packages/webapp/src/containers/CashFlow/CashFlowAccounts/CashFlowAccountsActionsBar.tsx +++ b/packages/webapp/src/containers/CashFlow/CashFlowAccounts/CashFlowAccountsActionsBar.tsx @@ -110,12 +110,12 @@ function CashFlowAccountsActionsBar({ - {/* + + + + + ); +} diff --git a/packages/webapp/src/hooks/query/cashflowAccounts.tsx b/packages/webapp/src/hooks/query/cashflowAccounts.tsx index a2be16b13..179317f2f 100644 --- a/packages/webapp/src/hooks/query/cashflowAccounts.tsx +++ b/packages/webapp/src/hooks/query/cashflowAccounts.tsx @@ -211,3 +211,23 @@ export function useRefreshCashflowTransactions() { }, }; } + +/** + * + */ +export function useUncategorizedTransaction( + uncategorizedTranasctionId: nunber, + props, +) { + return useRequestQuery( + [t.CASHFLOW_UNCAATEGORIZED_TRANSACTION, uncategorizedTranasctionId], + { + method: 'get', + url: `cashflow/transactions/uncategorized/${uncategorizedTranasctionId}`, + }, + { + select: (res) => res.data?.data, + ...props, + }, + ); +} diff --git a/packages/webapp/src/hooks/query/types.tsx b/packages/webapp/src/hooks/query/types.tsx index 81e7cd647..1d8d2d7a8 100644 --- a/packages/webapp/src/hooks/query/types.tsx +++ b/packages/webapp/src/hooks/query/types.tsx @@ -202,6 +202,8 @@ const CASH_FLOW_ACCOUNTS = { 'CASHFLOW_ACCOUNT_TRANSACTIONS_INFINITY', CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY: 'CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY', + + CASHFLOW_UNCAATEGORIZED_TRANSACTION: 'CASHFLOW_UNCAATEGORIZED_TRANSACTION', }; const TARNSACTIONS_LOCKING = { From d87d674abaec8db2d3752ea0838195db1bf2ff82 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Wed, 6 Mar 2024 22:15:31 +0200 Subject: [PATCH 09/12] feat: wip categorize the cashflow transactions --- .../Cashflow/NewCashflowTransaction.ts | 5 +- packages/server/src/interfaces/CashFlow.ts | 3 +- packages/server/src/models/Account.ts | 2 +- .../UncategorizedCashflowTransaction.ts | 17 ++- .../Cashflow/CategorizeCashflowTransaction.ts | 9 +- .../GetCashflowTransactionsService.ts | 1 + .../Cashflow/GetUncategorizedTransactions.ts | 3 +- .../UncategorizedTransactionTransformer.ts | 12 ++ .../server/src/services/Cashflow/utils.ts | 2 +- .../src/components/Drawer/DrawerBody.tsx | 5 +- .../webapp/src/components/Forms/Select.tsx | 4 +- .../CategorizeTransactionBoot.tsx | 33 +++- .../CategorizeTransactionContent.tsx | 10 +- .../CategorizeTransactionForm.schema.tsx | 11 +- .../CategorizeTransactionForm.tsx | 95 ++++++++---- .../CategorizeTransactionFormContent.tsx | 144 +++++++++--------- .../CategorizeTransactionFormFooter.tsx | 66 +++++--- .../CategorizeTransactionOtherIncome.tsx | 73 +++++++++ ...CategorizeTransactionOwnerContribution.tsx | 68 +++++++++ .../CategorizeTransactionTransferFrom.tsx | 73 +++++++++ .../CategorizeTransactionOtherExpense.tsx | 73 +++++++++ .../CategorizeTransactionOwnerDrawings.tsx | 73 +++++++++ .../CategorizeTransactionToAccount.tsx | 73 +++++++++ .../CategorizeTransactionDrawer/_utils.ts | 31 ++++ .../CategorizeTransactionDrawer/index.ts | 1 + .../src/hooks/query/cashflowAccounts.tsx | 24 +++ packages/webapp/src/hooks/query/types.tsx | 1 - 27 files changed, 768 insertions(+), 144 deletions(-) create mode 100644 packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyIn/CategorizeTransactionOtherIncome.tsx create mode 100644 packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyIn/CategorizeTransactionOwnerContribution.tsx create mode 100644 packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyIn/CategorizeTransactionTransferFrom.tsx create mode 100644 packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyOut/CategorizeTransactionOtherExpense.tsx create mode 100644 packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyOut/CategorizeTransactionOwnerDrawings.tsx create mode 100644 packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyOut/CategorizeTransactionToAccount.tsx create mode 100644 packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/_utils.ts create mode 100644 packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/index.ts diff --git a/packages/server/src/api/controllers/Cashflow/NewCashflowTransaction.ts b/packages/server/src/api/controllers/Cashflow/NewCashflowTransaction.ts index 30b2a2326..d0953d8d8 100644 --- a/packages/server/src/api/controllers/Cashflow/NewCashflowTransaction.ts +++ b/packages/server/src/api/controllers/Cashflow/NewCashflowTransaction.ts @@ -80,10 +80,7 @@ export default class NewCashflowTransactionController extends BaseController { public get categorizeCashflowTransactionValidationSchema() { return [ check('date').exists().isISO8601().toDate(), - oneOf([ - check('to_account_id').exists().isInt().toInt(), - check('from_account_id').exists().isInt().toInt(), - ]), + check('credit_account_id').exists().isInt().toInt(), check('transaction_number').optional(), check('transaction_type').exists(), check('reference_no').optional(), diff --git a/packages/server/src/interfaces/CashFlow.ts b/packages/server/src/interfaces/CashFlow.ts index aab3bb766..499c526b0 100644 --- a/packages/server/src/interfaces/CashFlow.ts +++ b/packages/server/src/interfaces/CashFlow.ts @@ -235,8 +235,7 @@ export interface ICashflowTransactionSchema { export interface ICashflowTransactionInput extends ICashflowTransactionSchema {} export interface ICategorizeCashflowTransactioDTO { - fromAccountId: number; - toAccountId: number; + creditAccountId: number; referenceNo: string; transactionNumber: string; transactionType: string; diff --git a/packages/server/src/models/Account.ts b/packages/server/src/models/Account.ts index c46f9e77b..7e0d8d6e4 100644 --- a/packages/server/src/models/Account.ts +++ b/packages/server/src/models/Account.ts @@ -318,7 +318,7 @@ export default class Account extends mixin(TenantModel, [ to: 'uncategorized_cashflow_transactions.accountId', }, filter: (query) => { - query.filter('categorized', false); + query.where('categorized', false); }, }, }; diff --git a/packages/server/src/models/UncategorizedCashflowTransaction.ts b/packages/server/src/models/UncategorizedCashflowTransaction.ts index d8f3db543..cb5ebfeef 100644 --- a/packages/server/src/models/UncategorizedCashflowTransaction.ts +++ b/packages/server/src/models/UncategorizedCashflowTransaction.ts @@ -1,6 +1,6 @@ /* eslint-disable global-require */ import TenantModel from 'models/TenantModel'; -import { Model } from 'objection'; +import { Model, ModelOptions, QueryContext } from 'objection'; import Account from './Account'; export default class UncategorizedCashflowTransaction extends TenantModel { @@ -95,6 +95,19 @@ export default class UncategorizedCashflowTransaction extends TenantModel { .increment('uncategorized_transactions', 1); } + public async $afterUpdate( + opt: ModelOptions, + queryContext: QueryContext + ): void | Promise { + await super.$afterUpdate(opt, queryContext); + + if (this.id && this.categorized) { + await Account.query(queryContext.transaction) + .findById(this.accountId) + .decrement('uncategorized_transactions', 1); + } + } + /** * * @param queryContext @@ -102,7 +115,7 @@ export default class UncategorizedCashflowTransaction extends TenantModel { public async $afterDelete(queryContext) { await super.$afterDelete(queryContext); - await Account.query() + await Account.query(queryContext.transaction) .findById(this.accountId) .decrement('uncategorized_transactions', 1); } diff --git a/packages/server/src/services/Cashflow/CategorizeCashflowTransaction.ts b/packages/server/src/services/Cashflow/CategorizeCashflowTransaction.ts index da5b81419..3d19e1547 100644 --- a/packages/server/src/services/Cashflow/CategorizeCashflowTransaction.ts +++ b/packages/server/src/services/Cashflow/CategorizeCashflowTransaction.ts @@ -79,13 +79,14 @@ export class CategorizeCashflowTransaction { cashflowTransactionDTO ); // Updates the uncategorized transaction as categorized. - await UncategorizedCashflowTransaction.query(trx) - .findById(uncategorizedTransactionId) - .patch({ + await UncategorizedCashflowTransaction.query(trx).patchAndFetchById( + uncategorizedTransactionId, + { categorized: true, categorizeRefType: 'CashflowTransaction', categorizeRefId: cashflowTransaction.id, - }); + } + ); // Triggers `onCashflowTransactionCategorized` event. await this.eventPublisher.emitAsync( events.cashflow.onTransactionCategorized, diff --git a/packages/server/src/services/Cashflow/GetCashflowTransactionsService.ts b/packages/server/src/services/Cashflow/GetCashflowTransactionsService.ts index 13852dd05..42bf7ca9e 100644 --- a/packages/server/src/services/Cashflow/GetCashflowTransactionsService.ts +++ b/packages/server/src/services/Cashflow/GetCashflowTransactionsService.ts @@ -31,6 +31,7 @@ export default class GetCashflowTransactionsService { .withGraphFetched('entries.cashflowAccount') .withGraphFetched('entries.creditAccount') .withGraphFetched('transactions.account') + .orderBy('date', 'DESC') .throwIfNotFound(); this.throwErrorCashflowTranscationNotFound(cashflowTransaction); diff --git a/packages/server/src/services/Cashflow/GetUncategorizedTransactions.ts b/packages/server/src/services/Cashflow/GetUncategorizedTransactions.ts index 9b273920f..41cfa2e85 100644 --- a/packages/server/src/services/Cashflow/GetUncategorizedTransactions.ts +++ b/packages/server/src/services/Cashflow/GetUncategorizedTransactions.ts @@ -24,7 +24,8 @@ export class GetUncategorizedTransactions { .where('accountId', accountId) .where('categorized', false) .withGraphFetched('account') - .pagination(0, 10); + .orderBy('date', 'DESC') + .pagination(0, 1000); const data = await this.transformer.transform( tenantId, diff --git a/packages/server/src/services/Cashflow/UncategorizedTransactionTransformer.ts b/packages/server/src/services/Cashflow/UncategorizedTransactionTransformer.ts index bf0a6a1cf..85d1a1fbb 100644 --- a/packages/server/src/services/Cashflow/UncategorizedTransactionTransformer.ts +++ b/packages/server/src/services/Cashflow/UncategorizedTransactionTransformer.ts @@ -8,6 +8,7 @@ export class UncategorizedTransactionTransformer extends Transformer { */ public includeAttributes = (): string[] => { return [ + 'formattedAmount', 'formattedDate', 'formattetDepositAmount', 'formattedWithdrawalAmount', @@ -23,6 +24,17 @@ export class UncategorizedTransactionTransformer extends Transformer { return this.formatDate(transaction.date); } + /** + * Formatted amount. + * @param transaction + * @returns {string} + */ + public formattedAmount(transaction) { + return formatNumber(transaction.amount, { + currencyCode: transaction.currencyCode, + }); + } + /** * Formatted deposit amount. * @param transaction diff --git a/packages/server/src/services/Cashflow/utils.ts b/packages/server/src/services/Cashflow/utils.ts index 9d76f1862..7957b73a9 100644 --- a/packages/server/src/services/Cashflow/utils.ts +++ b/packages/server/src/services/Cashflow/utils.ts @@ -55,7 +55,7 @@ export const transformCategorizeTransToCashflow = ( referenceNo: categorizeDTO.referenceNo || uncategorizeModel.referenceNo, description: categorizeDTO.description || uncategorizeModel.description, cashflowAccountId: uncategorizeModel.accountId, - creditAccountId: categorizeDTO.fromAccountId || categorizeDTO.toAccountId, + creditAccountId: categorizeDTO.creditAccountId, exchangeRate: categorizeDTO.exchangeRate || 1, currencyCode: uncategorizeModel.currencyCode, amount: uncategorizeModel.amount, diff --git a/packages/webapp/src/components/Drawer/DrawerBody.tsx b/packages/webapp/src/components/Drawer/DrawerBody.tsx index be7f34ac4..e6bab2b05 100644 --- a/packages/webapp/src/components/Drawer/DrawerBody.tsx +++ b/packages/webapp/src/components/Drawer/DrawerBody.tsx @@ -1,5 +1,6 @@ // @ts-nocheck import React from 'react'; +import clsx from 'classnames'; import { Classes } from '@blueprintjs/core'; import { LoadingIndicator } from '../Indicator'; @@ -11,8 +12,8 @@ export function DrawerLoading({ loading, mount = false, children }) { ); } -export function DrawerBody({ children }) { - return
{children}
; +export function DrawerBody({ children, className }) { + return
{children}
; } export * from './DrawerActionsBar'; diff --git a/packages/webapp/src/components/Forms/Select.tsx b/packages/webapp/src/components/Forms/Select.tsx index cae52b1b8..c77c51ce2 100644 --- a/packages/webapp/src/components/Forms/Select.tsx +++ b/packages/webapp/src/components/Forms/Select.tsx @@ -26,12 +26,12 @@ const SelectButton = styled(Button)` position: relative; padding-right: 30px; - &.bp4-small{ + &.bp4-small { padding-right: 24px; } &:not(.is-selected):not([class*='bp4-intent-']):not(.bp4-minimal) { - color: #5c7080; + color: #8f99a8; } &:after { content: ''; diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionBoot.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionBoot.tsx index 24af8cd26..6a7b8ea2b 100644 --- a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionBoot.tsx +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionBoot.tsx @@ -2,30 +2,55 @@ import React from 'react'; import { DrawerHeaderContent, DrawerLoading } from '@/components'; import { DRAWERS } from '@/constants/drawers'; -import { useUncategorizedTransaction } from '@/hooks/query'; +import { + useAccounts, + useBranches, + useUncategorizedTransaction, +} from '@/hooks/query'; +import { useFeatureCan } from '@/hooks/state'; +import { Features } from '@/constants'; const CategorizeTransactionBootContext = React.createContext(); /** - * Estimate detail provider. + * Categorize transcation boot. */ function CategorizeTransactionBoot({ uncategorizedTransactionId, ...props }) { + // Detarmines whether the feature is enabled. + const { featureCan } = useFeatureCan(); + const isBranchFeatureCan = featureCan(Features.Branches); + + // Fetches accounts list. + const { isLoading: isAccountsLoading, data: accounts } = useAccounts(); + + // Fetches the branches list. + const { data: branches, isLoading: isBranchesLoading } = useBranches( + {}, + { enabled: isBranchFeatureCan }, + ); + // Retrieves the uncategorized transaction. const { data: uncategorizedTransaction, isLoading: isUncategorizedTransactionLoading, } = useUncategorizedTransaction(uncategorizedTransactionId); const provider = { + uncategorizedTransactionId, uncategorizedTransaction, isUncategorizedTransactionLoading, + branches, + accounts, + isBranchesLoading, + isAccountsLoading, }; + const isLoading = + isBranchesLoading || isUncategorizedTransactionLoading || isAccountsLoading; return ( - + diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionContent.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionContent.tsx index 9bda3cdbc..7716e8beb 100644 --- a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionContent.tsx +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionContent.tsx @@ -1,4 +1,5 @@ // @ts-nocheck +import styled from 'styled-components'; import { DrawerBody } from '@/components'; import { CategorizeTransactionBoot } from './CategorizeTransactionBoot'; import { CategorizeTransactionForm } from './CategorizeTransactionForm'; @@ -10,9 +11,14 @@ export default function CategorizeTransactionContent({ - + - + ); } + +export const CategorizeTransactionDrawerBody = styled(DrawerBody)` + padding: 20px; + background-color: #fff; +`; diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionForm.schema.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionForm.schema.tsx index f25c18a64..8c6d0eb72 100644 --- a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionForm.schema.tsx +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionForm.schema.tsx @@ -1,7 +1,14 @@ // @ts-nocheck import * as Yup from 'yup'; -const Schema = Yup.object().shape({}); +const Schema = Yup.object().shape({ + amount: Yup.string().required().label('Amount'), + exchangeRate: Yup.string().required().label('Exchange rate'), + transactionType: Yup.string().required().label('Transaction type'), + date: Yup.string().required().label('Date'), + creditAccountId: Yup.string().required().label('Credit account'), + referenceNo: Yup.string().optional().label('Reference No.'), + description: Yup.string().optional().label('Description'), +}); export const CreateCategorizeTransactionSchema = Schema; -export const EditCategorizeTransactionSchema = Schema; diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionForm.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionForm.tsx index 479be60bf..364c4111c 100644 --- a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionForm.tsx +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionForm.tsx @@ -1,34 +1,57 @@ // @ts-nocheck import { Formik, Form } from 'formik'; +import styled from 'styled-components'; import withDialogActions from '@/containers/Dialog/withDialogActions'; -import { - EditCategorizeTransactionSchema, - CreateCategorizeTransactionSchema, -} from './CategorizeTransactionForm.schema'; -import { compose, transformToForm } from '@/utils'; +import { CreateCategorizeTransactionSchema } from './CategorizeTransactionForm.schema'; import { CategorizeTransactionFormContent } from './CategorizeTransactionFormContent'; import { CategorizeTransactionFormFooter } from './CategorizeTransactionFormFooter'; - -// Default initial form values. -const defaultInitialValues = {}; +import { useCategorizeTransaction } from '@/hooks/query'; +import { useCategorizeTransactionBoot } from './CategorizeTransactionBoot'; +import { DRAWERS } from '@/constants/drawers'; +import { + transformToCategorizeForm, + defaultInitialValues, + tranformToRequest, +} from './_utils'; +import { compose } from '@/utils'; +import withDrawerActions from '@/containers/Drawer/withDrawerActions'; +import { AppToaster } from '@/components'; +import { Intent } from '@blueprintjs/core'; /** * Categorize cashflow transaction form dialog content. */ function CategorizeTransactionFormRoot({ - // #withDialogActions - closeDialog, + // #withDrawerActions + closeDrawer, }) { - const isNewMode = true; - - // Form validation schema in create and edit mode. - const validationSchema = isNewMode - ? CreateCategorizeTransactionSchema - : EditCategorizeTransactionSchema; + const { uncategorizedTransactionId, uncategorizedTransaction } = + useCategorizeTransactionBoot(); + const { mutateAsync: categorizeTransaction } = useCategorizeTransaction(); // Callbacks handles form submit. - const handleFormSubmit = (values, { setSubmitting, setErrors }) => {}; + const handleFormSubmit = (values, { setSubmitting, setErrors }) => { + const transformedValues = tranformToRequest(values); + setSubmitting(true); + categorizeTransaction([uncategorizedTransactionId, transformedValues]) + .then(() => { + setSubmitting(false); + closeDrawer(DRAWERS.CATEGORIZE_TRANSACTION); + + AppToaster.show({ + message: 'The uncategorized transaction has been categorized.', + intent: Intent.SUCCESS, + }); + }) + .catch(() => { + setSubmitting(false); + AppToaster.show({ + message: 'Something went wrong!', + intent: Intent.DANGER, + }); + }); + }; // Form initial values in create and edit mode. const initialValues = { ...defaultInitialValues, @@ -37,23 +60,37 @@ function CategorizeTransactionFormRoot({ * values such as `notes` come back from the API as null, so remove those * as well. */ - ...transformToForm({}, defaultInitialValues), + ...transformToCategorizeForm(uncategorizedTransaction), }; return ( - -
- - - -
+ + +
+ + + +
+
); } -export const CategorizeTransactionForm = compose(withDialogActions)( +export const CategorizeTransactionForm = compose(withDrawerActions)( CategorizeTransactionFormRoot, ); + +const DivRoot = styled.div` + .bp4-form-group .bp4-form-content { + flex: 1 0; + } + .bp4-form-group .bp4-label { + width: 140px; + } + .bp4-form-group { + margin-bottom: 18px; + } +`; diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionFormContent.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionFormContent.tsx index 9e5362f02..dfe1db6b0 100644 --- a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionFormContent.tsx +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionFormContent.tsx @@ -1,31 +1,39 @@ // @ts-nocheck -import { Position } from '@blueprintjs/core'; +import React from 'react'; import styled from 'styled-components'; -import { - AccountsSelect, - FDateInput, - FFormGroup, - FInputGroup, - FSelect, - FSuggest, - FTextArea, -} from '@/components'; -import { getAddMoneyInOptions } from '@/constants'; +import { FormGroup } from '@blueprintjs/core'; +import { FFormGroup, FSelect, FSuggest } from '@/components'; +import { getAddMoneyInOptions, getAddMoneyOutOptions } from '@/constants'; +import { useFormikContext } from 'formik'; +import { useCategorizeTransactionBoot } from './CategorizeTransactionBoot'; // Retrieves the add money in button options. -const AddMoneyInOptions = getAddMoneyInOptions(); +const MoneyInOptions = getAddMoneyInOptions(); +const MoneyOutOptions = getAddMoneyOutOptions(); -const Title = styled('h3')``; +const Title = styled('h3')` + font-size: 20px; + font-weight: 400; + color: #cd4246; +`; export function CategorizeTransactionFormContent() { + const { uncategorizedTransaction } = useCategorizeTransactionBoot(); + + const transactionTypes = uncategorizedTransaction?.is_deposit_transaction + ? MoneyInOptions + : MoneyOutOptions; + return ( <> - $22,583.00 + + {uncategorizedTransaction.formatted_amount} + - - - date.toLocaleDateString()} - parseDate={(str) => new Date(str)} - inputProps={{ fill: true }} - /> - - - - - - - - - - - - - - - - - + ); } + +const CategorizeTransactionOtherIncome = React.lazy( + () => import('./MoneyIn/CategorizeTransactionOtherIncome'), +); + +const CategorizeTransactionOwnerContribution = React.lazy( + () => import('./MoneyIn/CategorizeTransactionOwnerContribution'), +); + +const CategorizeTransactionTransferFrom = React.lazy( + () => import('./MoneyIn/CategorizeTransactionTransferFrom'), +); + +const CategorizeTransactionOtherExpense = React.lazy( + () => import('./MoneyOut/CategorizeTransactionOtherExpense'), +); + +const CategorizeTransactionToAccount = React.lazy( + () => import('./MoneyOut/CategorizeTransactionToAccount'), +); + +const CategorizeTransactionOwnerDrawings = React.lazy( + () => import('./MoneyOut/CategorizeTransactionOwnerDrawings'), +); + +function CategorizeTransactionFormSubContent() { + const { values } = useFormikContext(); + + // Other expense. + if (values.transactionType === 'other_expense') { + return ; + // Owner contribution. + } else if (values.transactionType === 'owner_contribution') { + return ; + // Other Income. + } else if (values.transactionType === 'other_income') { + return ; + // Transfer from account. + } else if (values.transactionType === 'transfer_from_account') { + return ; + // Transfer to account. + } else if (values.transactionType === 'transfer_to_account') { + return ; + // Owner drawings. + } else if (values.transactionType === 'OwnerDrawing') { + return ; + } + return null; +} diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionFormFooter.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionFormFooter.tsx index acfaeec2c..3f8d7009b 100644 --- a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionFormFooter.tsx +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionFormFooter.tsx @@ -1,26 +1,56 @@ +// @ts-nocheck +import * as R from 'ramda'; import { Button, Classes, Intent } from '@blueprintjs/core'; +import { useFormikContext } from 'formik'; +import styled from 'styled-components'; +import withDrawerActions from '@/containers/Drawer/withDrawerActions'; +import { DRAWERS } from '@/constants/drawers'; +import { Group } from '@/components'; + +function CategorizeTransactionFormFooterRoot({ + // #withDrawerActions + closeDrawer, +}) { + const { isSubmitting } = useFormikContext(); + + const handleClose = () => { + closeDrawer(DRAWERS.CATEGORIZE_TRANSACTION); + }; -export function CategorizeTransactionFormFooter() { return ( -
+
- + + - + +
-
+ ); } + +export const CategorizeTransactionFormFooter = R.compose(withDrawerActions)( + CategorizeTransactionFormFooterRoot, +); + +const Root = styled.div` + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: #fff; +`; 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 new file mode 100644 index 000000000..2afc65f87 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyIn/CategorizeTransactionOtherIncome.tsx @@ -0,0 +1,73 @@ +// @ts-nocheck +import { Position } from '@blueprintjs/core'; +import { + AccountsSelect, + FDateInput, + FFormGroup, + FInputGroup, + FTextArea, +} from '@/components'; +import { useCategorizeTransactionBoot } from '../CategorizeTransactionBoot'; + +export default function CategorizeTransactionOtherIncome() { + const { accounts } = useCategorizeTransactionBoot(); + + return ( + <> + + date.toLocaleDateString()} + parseDate={(str) => new Date(str)} + inputProps={{ fill: true }} + /> + + + + + + + + + + + + + + + + + + + ); +} diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyIn/CategorizeTransactionOwnerContribution.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyIn/CategorizeTransactionOwnerContribution.tsx new file mode 100644 index 000000000..83b485c51 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyIn/CategorizeTransactionOwnerContribution.tsx @@ -0,0 +1,68 @@ +// @ts-nocheck +import { Position } from '@blueprintjs/core'; +import { + AccountsSelect, + FDateInput, + FFormGroup, + FInputGroup, + FTextArea, +} from '@/components'; +import { useCategorizeTransactionBoot } from '../CategorizeTransactionBoot'; + +export default function CategorizeTransactionOwnerContribution() { + const { accounts } = useCategorizeTransactionBoot(); + + return ( + <> + + date.toLocaleDateString()} + parseDate={(str) => new Date(str)} + inputProps={{ fill: true }} + /> + + + + + + + + + + + + + + + + + + + ); +} diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyIn/CategorizeTransactionTransferFrom.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyIn/CategorizeTransactionTransferFrom.tsx new file mode 100644 index 000000000..57f2a1911 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyIn/CategorizeTransactionTransferFrom.tsx @@ -0,0 +1,73 @@ +// @ts-nocheck +import { Position } from '@blueprintjs/core'; +import { + AccountsSelect, + FDateInput, + FFormGroup, + FInputGroup, + FTextArea, +} from '@/components'; +import { useCategorizeTransactionBoot } from '../CategorizeTransactionBoot'; + +export default function CategorizeTransactionTransferFrom() { + const { accounts } = useCategorizeTransactionBoot(); + + return ( + <> + + date.toLocaleDateString()} + parseDate={(str) => new Date(str)} + inputProps={{ fill: true }} + /> + + + + + + + + + + + + + + + + + + + ); +} diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyOut/CategorizeTransactionOtherExpense.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyOut/CategorizeTransactionOtherExpense.tsx new file mode 100644 index 000000000..b85436e17 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyOut/CategorizeTransactionOtherExpense.tsx @@ -0,0 +1,73 @@ +// @ts-nocheck +import { Position } from '@blueprintjs/core'; +import { + AccountsSelect, + FDateInput, + FFormGroup, + FInputGroup, + FTextArea, +} from '@/components'; +import { useCategorizeTransactionBoot } from '../CategorizeTransactionBoot'; + +export default function CategorizeTransactionOtherExpense() { + const { accounts } = useCategorizeTransactionBoot(); + + return ( + <> + + date.toLocaleDateString()} + parseDate={(str) => new Date(str)} + inputProps={{ fill: true }} + /> + + + + + + + + + + + + + + + + + + + ); +} diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyOut/CategorizeTransactionOwnerDrawings.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyOut/CategorizeTransactionOwnerDrawings.tsx new file mode 100644 index 000000000..e39235fd9 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyOut/CategorizeTransactionOwnerDrawings.tsx @@ -0,0 +1,73 @@ +// @ts-nocheck +import { Position } from '@blueprintjs/core'; +import { + AccountsSelect, + FDateInput, + FFormGroup, + FInputGroup, + FTextArea, +} from '@/components'; +import { useCategorizeTransactionBoot } from '../CategorizeTransactionBoot'; + +export default function CategorizeTransactionOwnerDrawings() { + const { accounts } = useCategorizeTransactionBoot(); + + return ( + <> + + date.toLocaleDateString()} + parseDate={(str) => new Date(str)} + inputProps={{ fill: true }} + /> + + + + + + + + + + + + + + + + + + + ); +} diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyOut/CategorizeTransactionToAccount.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyOut/CategorizeTransactionToAccount.tsx new file mode 100644 index 000000000..4e4545e52 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/MoneyOut/CategorizeTransactionToAccount.tsx @@ -0,0 +1,73 @@ +// @ts-nocheck +import { Position } from '@blueprintjs/core'; +import { + AccountsSelect, + FDateInput, + FFormGroup, + FInputGroup, + FTextArea, +} from '@/components'; +import { useCategorizeTransactionBoot } from '../CategorizeTransactionBoot'; + +export default function CategorizeTransactionToAccount() { + const { accounts } = useCategorizeTransactionBoot(); + + return ( + <> + + date.toLocaleDateString()} + parseDate={(str) => new Date(str)} + inputProps={{ fill: true }} + /> + + + + + + + + + + + + + + + + + + + ); +} diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/_utils.ts b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/_utils.ts new file mode 100644 index 000000000..9fedc3678 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/_utils.ts @@ -0,0 +1,31 @@ +// @ts-nocheck +import { transformToForm, transfromToSnakeCase } from '@/utils'; + +// Default initial form values. +export const defaultInitialValues = { + amount: '', + date: '', + creditAccountId: '', + debitAccountId: '', + exchangeRate: '1', + transactionType: '', + referenceNo: '', + description: '', +}; + +export const transformToCategorizeForm = (uncategorizedTransaction) => { + const defaultValues = { + debitAccountId: uncategorizedTransaction.account_id, + transactionType: uncategorizedTransaction.is_deposit_transaction + ? 'other_income' + : 'other_expense', + amount: uncategorizedTransaction.amount, + date: uncategorizedTransaction.date, + }; + return transformToForm(defaultValues, defaultInitialValues); +}; + + +export const tranformToRequest = (formValues) => { + return transfromToSnakeCase(formValues); +}; \ No newline at end of file diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/index.ts b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/index.ts new file mode 100644 index 000000000..bff919dc5 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/index.ts @@ -0,0 +1 @@ +export * from './CategorizeTransactionDrawer'; \ No newline at end of file diff --git a/packages/webapp/src/hooks/query/cashflowAccounts.tsx b/packages/webapp/src/hooks/query/cashflowAccounts.tsx index 179317f2f..3a225db58 100644 --- a/packages/webapp/src/hooks/query/cashflowAccounts.tsx +++ b/packages/webapp/src/hooks/query/cashflowAccounts.tsx @@ -231,3 +231,27 @@ export function useUncategorizedTransaction( }, ); } + +/** + * Categorize the cashflow transaction. + */ +export function useCategorizeTransaction(props) { + const queryClient = useQueryClient(); + const apiRequest = useApiRequest(); + + return useMutation( + ([id, values]) => + apiRequest.post(`cashflow/transactions/${id}/categorize`, values), + { + onSuccess: (res, id) => { + // Invalidate queries. + commonInvalidateQueries(queryClient); + queryClient.invalidateQueries(t.CASHFLOW_UNCAATEGORIZED_TRANSACTION); + queryClient.invalidateQueries( + t.CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY, + ); + }, + ...props, + }, + ); +} diff --git a/packages/webapp/src/hooks/query/types.tsx b/packages/webapp/src/hooks/query/types.tsx index 1d8d2d7a8..5446282e2 100644 --- a/packages/webapp/src/hooks/query/types.tsx +++ b/packages/webapp/src/hooks/query/types.tsx @@ -202,7 +202,6 @@ const CASH_FLOW_ACCOUNTS = { 'CASHFLOW_ACCOUNT_TRANSACTIONS_INFINITY', CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY: 'CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY', - CASHFLOW_UNCAATEGORIZED_TRANSACTION: 'CASHFLOW_UNCAATEGORIZED_TRANSACTION', }; From 62d3e386dd80cd35a68468e07d192ca94f0564e4 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Thu, 7 Mar 2024 14:19:11 +0200 Subject: [PATCH 10/12] feat(server): move all cashflow under application service --- .../Cashflow/DeleteCashflowTransaction.ts | 7 +- .../Cashflow/GetCashflowAccounts.ts | 11 +-- .../Cashflow/GetCashflowTransaction.ts | 13 +-- .../Cashflow/NewCashflowTransaction.ts | 52 +++++++----- .../server/src/interfaces/CashflowService.ts | 5 ++ .../services/Cashflow/CashflowApplication.ts | 82 +++++++++++++++++-- .../GetCashflowTransactionsService.ts | 2 +- .../Cashflow/GetUncategorizedTransactions.ts | 15 +++- 8 files changed, 138 insertions(+), 49 deletions(-) diff --git a/packages/server/src/api/controllers/Cashflow/DeleteCashflowTransaction.ts b/packages/server/src/api/controllers/Cashflow/DeleteCashflowTransaction.ts index 4d94022da..1d0edece0 100644 --- a/packages/server/src/api/controllers/Cashflow/DeleteCashflowTransaction.ts +++ b/packages/server/src/api/controllers/Cashflow/DeleteCashflowTransaction.ts @@ -3,14 +3,15 @@ import { Router, Request, Response, NextFunction } from 'express'; import { param } from 'express-validator'; import BaseController from '../BaseController'; import { ServiceError } from '@/exceptions'; -import { DeleteCashflowTransaction } from '../../../services/Cashflow/DeleteCashflowTransactionService'; import CheckPolicies from '@/api/middleware/CheckPolicies'; + import { AbilitySubject, CashflowAction } from '@/interfaces'; +import { CashflowApplication } from '@/services/Cashflow/CashflowApplication'; @Service() export default class DeleteCashflowTransactionController extends BaseController { @Inject() - private deleteCashflowService: DeleteCashflowTransaction; + private cashflowApplication: CashflowApplication; /** * Controller router. @@ -44,7 +45,7 @@ export default class DeleteCashflowTransactionController extends BaseController try { const { oldCashflowTransaction } = - await this.deleteCashflowService.deleteCashflowTransaction( + await this.cashflowApplication.deleteTransaction( tenantId, transactionId ); diff --git a/packages/server/src/api/controllers/Cashflow/GetCashflowAccounts.ts b/packages/server/src/api/controllers/Cashflow/GetCashflowAccounts.ts index b84dad4eb..d1bc97e0a 100644 --- a/packages/server/src/api/controllers/Cashflow/GetCashflowAccounts.ts +++ b/packages/server/src/api/controllers/Cashflow/GetCashflowAccounts.ts @@ -7,14 +7,12 @@ import GetCashflowTransactionsService from '@/services/Cashflow/GetCashflowTrans import { ServiceError } from '@/exceptions'; import CheckPolicies from '@/api/middleware/CheckPolicies'; import { AbilitySubject, CashflowAction } from '@/interfaces'; +import { CashflowApplication } from '@/services/Cashflow/CashflowApplication'; @Service() export default class GetCashflowAccounts extends BaseController { @Inject() - private getCashflowAccountsService: GetCashflowAccountsService; - - @Inject() - private getCashflowTransactionsService: GetCashflowTransactionsService; + private cashflowApplication: CashflowApplication; /** * Controller router. @@ -62,10 +60,7 @@ export default class GetCashflowAccounts extends BaseController { try { const cashflowAccounts = - await this.getCashflowAccountsService.getCashflowAccounts( - tenantId, - filter - ); + await this.cashflowApplication.getCashflowAccounts(tenantId, filter); return res.status(200).send({ cashflow_accounts: this.transfromToResponse(cashflowAccounts), diff --git a/packages/server/src/api/controllers/Cashflow/GetCashflowTransaction.ts b/packages/server/src/api/controllers/Cashflow/GetCashflowTransaction.ts index 80d610f48..9e7169859 100644 --- a/packages/server/src/api/controllers/Cashflow/GetCashflowTransaction.ts +++ b/packages/server/src/api/controllers/Cashflow/GetCashflowTransaction.ts @@ -6,11 +6,12 @@ import GetCashflowTransactionsService from '@/services/Cashflow/GetCashflowTrans import { ServiceError } from '@/exceptions'; import CheckPolicies from '@/api/middleware/CheckPolicies'; import { AbilitySubject, CashflowAction } from '@/interfaces'; +import { CashflowApplication } from '@/services/Cashflow/CashflowApplication'; @Service() export default class GetCashflowAccounts extends BaseController { @Inject() - private getCashflowTransactionsService: GetCashflowTransactionsService; + private cashflowApplication: CashflowApplication; /** * Controller router. @@ -43,11 +44,11 @@ export default class GetCashflowAccounts extends BaseController { const { transactionId } = req.params; try { - const cashflowTransaction = - await this.getCashflowTransactionsService.getCashflowTransaction( - tenantId, - transactionId - ); + const cashflowTransaction = await this.cashflowApplication.getTransaction( + tenantId, + transactionId + + ); return res.status(200).send({ cashflow_transaction: this.transfromToResponse(cashflowTransaction), diff --git a/packages/server/src/api/controllers/Cashflow/NewCashflowTransaction.ts b/packages/server/src/api/controllers/Cashflow/NewCashflowTransaction.ts index d0953d8d8..a1af70c15 100644 --- a/packages/server/src/api/controllers/Cashflow/NewCashflowTransaction.ts +++ b/packages/server/src/api/controllers/Cashflow/NewCashflowTransaction.ts @@ -1,18 +1,14 @@ import { Service, Inject } from 'typedi'; -import { check, oneOf } from 'express-validator'; +import { ValidationChain, check, param, query } from 'express-validator'; import { Router, Request, Response, NextFunction } from 'express'; import BaseController from '../BaseController'; import { ServiceError } from '@/exceptions'; -import NewCashflowTransactionService from '@/services/Cashflow/NewCashflowTransactionService'; import CheckPolicies from '@/api/middleware/CheckPolicies'; import { AbilitySubject, CashflowAction } from '@/interfaces'; import { CashflowApplication } from '@/services/Cashflow/CashflowApplication'; @Service() export default class NewCashflowTransactionController extends BaseController { - @Inject() - private newCashflowTranscationService: NewCashflowTransactionService; - @Inject() private cashflowApplication: CashflowApplication; @@ -29,6 +25,8 @@ export default class NewCashflowTransactionController extends BaseController { ); router.get( '/transactions/:id/uncategorized', + this.getUncategorizedTransactionsValidationSchema, + this.validationResult, this.asyncMiddleware(this.getUncategorizedCashflowTransactions), this.catchServiceErrors ); @@ -62,6 +60,18 @@ export default class NewCashflowTransactionController extends BaseController { return router; } + /** + * Getting uncategorized transactions validation schema. + * @returns {ValidationChain} + */ + public get getUncategorizedTransactionsValidationSchema() { + return [ + param('id').exists().isNumeric().toInt(), + query('page').optional().isNumeric().toInt(), + query('page_size').optional().isNumeric().toInt(), + ]; + } + /** * Categorize as expense validation schema. */ @@ -112,7 +122,7 @@ export default class NewCashflowTransactionController extends BaseController { check('branch_id').optional({ nullable: true }).isNumeric().toInt(), check('publish').default(false).isBoolean().toBoolean(), ]; - }√ + } /** * Creates a new cashflow transaction. @@ -130,7 +140,7 @@ export default class NewCashflowTransactionController extends BaseController { try { const cashflowTransaction = - await this.newCashflowTranscationService.newCashflowTransaction( + await this.cashflowApplication.createTransaction( tenantId, ownerContributionDTO, userId @@ -159,7 +169,7 @@ export default class NewCashflowTransactionController extends BaseController { const { id: cashflowTransactionId } = req.params; try { - const data= await this.cashflowApplication.uncategorizeTransaction( + const data = await this.cashflowApplication.uncategorizeTransaction( tenantId, cashflowTransactionId ); @@ -229,9 +239,9 @@ export default class NewCashflowTransactionController extends BaseController { /** * Retrieves the uncategorized cashflow transactions. - * @param {Request} req - * @param {Response} res - * @param {NextFunction} next + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next */ public getUncategorizedCashflowTransaction = async ( req: Request, @@ -240,7 +250,7 @@ export default class NewCashflowTransactionController extends BaseController { ) => { const { tenantId } = req; const { id: transactionId } = req.params; - + try { const data = await this.cashflowApplication.getUncategorizedTransaction( tenantId, @@ -254,9 +264,9 @@ export default class NewCashflowTransactionController extends BaseController { /** * Retrieves the uncategorized cashflow transactions. - * @param {Request} req - * @param {Response} res - * @param {NextFunction} next + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next */ public getUncategorizedCashflowTransactions = async ( req: Request, @@ -265,11 +275,13 @@ export default class NewCashflowTransactionController extends BaseController { ) => { const { tenantId } = req; const { id: accountId } = req.params; - + const query = this.matchedQueryData(req); + try { const data = await this.cashflowApplication.getUncategorizedTransactions( tenantId, - accountId + accountId, + query ); return res.status(200).send(data); @@ -337,9 +349,9 @@ export default class NewCashflowTransactionController extends BaseController { errors: [ { type: 'UNCATEGORIZED_TRANSACTION_TYPE_INVALID', - code: 4100, - } - ] + code: 4100, + }, + ], }); } } diff --git a/packages/server/src/interfaces/CashflowService.ts b/packages/server/src/interfaces/CashflowService.ts index 5b446571d..acce307db 100644 --- a/packages/server/src/interfaces/CashflowService.ts +++ b/packages/server/src/interfaces/CashflowService.ts @@ -156,3 +156,8 @@ export interface CategorizeTransactionAsExpenseDTO { description: string; branchId?: number; } + +export interface IGetUncategorizedTransactionsQuery { + page?: number; + pageSize?: number; +} diff --git a/packages/server/src/services/Cashflow/CashflowApplication.ts b/packages/server/src/services/Cashflow/CashflowApplication.ts index 2fb1ff7bb..6688c9016 100644 --- a/packages/server/src/services/Cashflow/CashflowApplication.ts +++ b/packages/server/src/services/Cashflow/CashflowApplication.ts @@ -5,19 +5,33 @@ import { CategorizeCashflowTransaction } from './CategorizeCashflowTransaction'; import { CategorizeTransactionAsExpenseDTO, CreateUncategorizedTransactionDTO, + ICashflowAccountsFilter, + ICashflowNewCommandDTO, ICategorizeCashflowTransactioDTO, - IUncategorizedCashflowTransaction, + IGetUncategorizedTransactionsQuery, } from '@/interfaces'; import { CategorizeTransactionAsExpense } from './CategorizeTransactionAsExpense'; import { GetUncategorizedTransactions } from './GetUncategorizedTransactions'; import { CreateUncategorizedTransaction } from './CreateUncategorizedTransaction'; import { GetUncategorizedTransaction } from './GetUncategorizedTransaction'; +import NewCashflowTransactionService from './NewCashflowTransactionService'; +import GetCashflowAccountsService from './GetCashflowAccountsService'; +import { GetCashflowTransactionService } from './GetCashflowTransactionsService'; @Service() export class CashflowApplication { + @Inject() + private createTransactionService: NewCashflowTransactionService; + @Inject() private deleteTransactionService: DeleteCashflowTransaction; + @Inject() + private getCashflowAccountsService: GetCashflowAccountsService; + + @Inject() + private getCashflowTransactionService: GetCashflowTransactionService; + @Inject() private uncategorizeTransactionService: UncategorizeCashflowTransaction; @@ -36,6 +50,25 @@ export class CashflowApplication { @Inject() private createUncategorizedTransactionService: CreateUncategorizedTransaction; + /** + * Creates a new cashflow transaction. + * @param {number} tenantId + * @param {ICashflowNewCommandDTO} transactionDTO + * @param {number} userId + * @returns + */ + public createTransaction( + tenantId: number, + transactionDTO: ICashflowNewCommandDTO, + userId?: number + ) { + return this.createTransactionService.newCashflowTransaction( + tenantId, + transactionDTO, + userId + ); + } + /** * Deletes the given cashflow transaction. * @param {number} tenantId @@ -49,6 +82,35 @@ export class CashflowApplication { ); } + /** + * Retrieves specific cashflow transaction. + * @param {number} tenantId + * @param {number} cashflowTransactionId + * @returns + */ + public getTransaction(tenantId: number, cashflowTransactionId: number) { + return this.getCashflowTransactionService.getCashflowTransaction( + tenantId, + cashflowTransactionId + ); + } + + /** + * Retrieves the cashflow accounts. + * @param {number} tenantId + * @param {ICashflowAccountsFilter} filterDTO + * @returns + */ + public getCashflowAccounts( + tenantId: number, + filterDTO: ICashflowAccountsFilter + ) { + return this.getCashflowAccountsService.getCashflowAccounts( + tenantId, + filterDTO + ); + } + /** * Creates a new uncategorized cash transaction. * @param {number} tenantId @@ -105,7 +167,6 @@ export class CashflowApplication { * @param {number} tenantId * @param {number} cashflowTransactionId * @param {CategorizeTransactionAsExpenseDTO} transactionDTO - * @returns */ public categorizeAsExpense( tenantId: number, @@ -122,20 +183,23 @@ export class CashflowApplication { /** * Retrieves the uncategorized cashflow transactions. * @param {number} tenantId - * @returns {} */ - public getUncategorizedTransactions(tenantId: number, accountId: number) { + public getUncategorizedTransactions( + tenantId: number, + accountId: number, + query: IGetUncategorizedTransactionsQuery + ) { return this.getUncategorizedTransactionsService.getTransactions( tenantId, - accountId + accountId, + query ); } /** - * - * @param {number} tenantId - * @param {number} uncategorizedTransactionId - * @returns + * Retrieves specific uncategorized transaction. + * @param {number} tenantId + * @param {number} uncategorizedTransactionId */ public getUncategorizedTransaction( tenantId: number, diff --git a/packages/server/src/services/Cashflow/GetCashflowTransactionsService.ts b/packages/server/src/services/Cashflow/GetCashflowTransactionsService.ts index 42bf7ca9e..64afd2194 100644 --- a/packages/server/src/services/Cashflow/GetCashflowTransactionsService.ts +++ b/packages/server/src/services/Cashflow/GetCashflowTransactionsService.ts @@ -7,7 +7,7 @@ import { ServiceError } from '@/exceptions'; import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; @Service() -export default class GetCashflowTransactionsService { +export class GetCashflowTransactionService { @Inject() private tenancy: HasTenancyService; diff --git a/packages/server/src/services/Cashflow/GetUncategorizedTransactions.ts b/packages/server/src/services/Cashflow/GetUncategorizedTransactions.ts index 41cfa2e85..36606f582 100644 --- a/packages/server/src/services/Cashflow/GetUncategorizedTransactions.ts +++ b/packages/server/src/services/Cashflow/GetUncategorizedTransactions.ts @@ -2,6 +2,7 @@ import { Inject, Service } from 'typedi'; import HasTenancyService from '../Tenancy/TenancyService'; import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; import { UncategorizedTransactionTransformer } from './UncategorizedTransactionTransformer'; +import { IGetUncategorizedTransactionsQuery } from '@/interfaces'; @Service() export class GetUncategorizedTransactions { @@ -16,16 +17,26 @@ export class GetUncategorizedTransactions { * @param {number} tenantId - Tenant id. * @param {number} accountId - Account Id. */ - public async getTransactions(tenantId: number, accountId: number) { + public async getTransactions( + tenantId: number, + accountId: number, + query: IGetUncategorizedTransactionsQuery + ) { const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId); + // Parsed query with default values. + const _query = { + page: 1, + pageSize: 20, + ...query, + }; const { results, pagination } = await UncategorizedCashflowTransaction.query() .where('accountId', accountId) .where('categorized', false) .withGraphFetched('account') .orderBy('date', 'DESC') - .pagination(0, 1000); + .pagination(_query.page - 1, _query.pageSize); const data = await this.transformer.transform( tenantId, From b9a00418fa084f8e83069db73712e0ce9a78648c Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Thu, 7 Mar 2024 14:31:59 +0200 Subject: [PATCH 11/12] feat: abstract the uncategorized and all transactions boot wrappers --- packages/webapp/src/constants/tables.tsx | 1 + .../AccountTransactionsAllBoot.tsx | 78 +++++++++++++++ .../AccountTransactionsDataTable.tsx | 7 +- .../AccountTransactionsProvider.tsx | 94 +------------------ .../AccountTransactionsUncategorizedTable.tsx | 29 ++---- .../AccountsTransactionsAll.tsx | 15 +-- .../AllTransactionsUncategorized.tsx | 20 ++-- .../AllTransactionsUncategorizedBoot.tsx | 78 +++++++++++++++ .../AccountTransactions/components.tsx | 1 + 9 files changed, 193 insertions(+), 130 deletions(-) create mode 100644 packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsAllBoot.tsx create mode 100644 packages/webapp/src/containers/CashFlow/AccountTransactions/AllTransactionsUncategorizedBoot.tsx diff --git a/packages/webapp/src/constants/tables.tsx b/packages/webapp/src/constants/tables.tsx index 2770d9cf8..33ad65b3e 100644 --- a/packages/webapp/src/constants/tables.tsx +++ b/packages/webapp/src/constants/tables.tsx @@ -15,6 +15,7 @@ export const TABLES = { EXPENSES: 'expenses', CASHFLOW_ACCOUNTS: 'cashflow_accounts', CASHFLOW_Transactions: 'cashflow_transactions', + UNCATEGORIZED_CASHFLOW_TRANSACTION: 'UNCATEGORIZED_CASHFLOW_TRANSACTION', CREDIT_NOTES: 'credit_notes', VENDOR_CREDITS: 'vendor_credits', WAREHOUSE_TRANSFERS: 'warehouse_transfers', diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsAllBoot.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsAllBoot.tsx new file mode 100644 index 000000000..618a542fb --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsAllBoot.tsx @@ -0,0 +1,78 @@ +// @ts-nocheck +import React from 'react'; +import { flatten, map } from 'lodash'; +import { IntersectionObserver } from '@/components'; +import { useAccountTransactionsInfinity } from '@/hooks/query'; +import { useAccountTransactionsContext } from './AccountTransactionsProvider'; + +const AccountTransactionsAllBootContext = React.createContext(); + +function flattenInfinityPages(data) { + return flatten(map(data.pages, (page) => page.transactions)); +} + +interface AccountTransactionsAllPoviderProps { + children: React.ReactNode; +} + +/** + * Account transctions all provider. + */ +function AccountTransactionsAllProvider({ + children, +}: AccountTransactionsAllPoviderProps) { + const { accountId } = useAccountTransactionsContext(); + + // Fetch cashflow account transactions list + const { + data: cashflowTransactionsPages, + isFetching: isCashFlowTransactionsFetching, + isLoading: isCashFlowTransactionsLoading, + isSuccess: isCashflowTransactionsSuccess, + fetchNextPage: fetchNextTransactionsPage, + isFetchingNextPage: isCashflowTransactionsFetchingNextPage, + hasNextPage: hasCashflowTransactionsNextPgae, + } = useAccountTransactionsInfinity(accountId, { + page_size: 50, + account_id: accountId, + }); + // Memorized the cashflow account transactions. + const cashflowTransactions = React.useMemo( + () => + isCashflowTransactionsSuccess + ? flattenInfinityPages(cashflowTransactionsPages) + : [], + [cashflowTransactionsPages, isCashflowTransactionsSuccess], + ); + // Handle the observer ineraction. + const handleObserverInteract = React.useCallback(() => { + if (!isCashFlowTransactionsFetching && hasCashflowTransactionsNextPgae) { + fetchNextTransactionsPage(); + } + }, [ + isCashFlowTransactionsFetching, + hasCashflowTransactionsNextPgae, + fetchNextTransactionsPage, + ]); + // Provider payload. + const provider = { + cashflowTransactions, + isCashFlowTransactionsFetching, + isCashFlowTransactionsLoading, + }; + + return ( + + {children} + + + ); +} + +const useAccountTransactionsAllContext = () => + React.useContext(AccountTransactionsAllBootContext); + +export { AccountTransactionsAllProvider, useAccountTransactionsAllContext }; diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsDataTable.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsDataTable.tsx index cd189a933..2b7c9e79a 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsDataTable.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsDataTable.tsx @@ -18,10 +18,10 @@ import withDrawerActions from '@/containers/Drawer/withDrawerActions'; import { useMemorizedColumnsWidths } from '@/hooks'; import { useAccountTransactionsColumns, ActionsMenu } from './components'; -import { useAccountTransactionsContext } from './AccountTransactionsProvider'; import { handleCashFlowTransactionType } from './utils'; import { compose } from '@/utils'; +import { useAccountTransactionsAllContext } from './AccountTransactionsAllBoot'; /** * Account transactions data table. @@ -41,7 +41,7 @@ function AccountTransactionsDataTable({ // Retrieve list context. const { cashflowTransactions, isCashFlowTransactionsLoading } = - useAccountTransactionsContext(); + useAccountTransactionsAllContext(); // Local storage memorizing columns widths. const [initialColumnsWidths, , handleColumnResizing] = @@ -51,11 +51,10 @@ function AccountTransactionsDataTable({ const handleDeleteTransaction = ({ reference_id }) => { openAlert('account-transaction-delete', { referenceId: reference_id }); }; - + // Handle view details action. const handleViewDetailCashflowTransaction = (referenceType) => { handleCashFlowTransactionType(referenceType, openDrawer); }; - // Handle cell click. const handleCellClick = (cell, event) => { const referenceType = cell.row.original; diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsProvider.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsProvider.tsx index f0aa661e3..1b3c98a29 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsProvider.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsProvider.tsx @@ -1,26 +1,12 @@ // @ts-nocheck import React from 'react'; import { useParams } from 'react-router-dom'; -import { flatten, map } from 'lodash'; -import { IntersectionObserver, DashboardInsider } from '@/components'; -import { - useAccountTransactionsInfinity, - useCashflowAccounts, - useAccount, - useAccountUncategorizedTransactionsInfinity, -} from '@/hooks/query'; +import { DashboardInsider } from '@/components'; +import { useCashflowAccounts, useAccount } from '@/hooks/query'; import { useAppQueryString } from '@/hooks'; const AccountTransactionsContext = React.createContext(); -function flattenInfinityPages(data) { - return flatten(map(data.pages, (page) => page.transactions)); -} - -function flattenInfinityPagesData(data) { - return flatten(map(data.pages, (page) => page.data)); -} - /** * Account transctions provider. */ @@ -31,64 +17,9 @@ function AccountTransactionsProvider({ query, ...props }) { const [locationQuery, setLocationQuery] = useAppQueryString(); const filterTab = locationQuery?.filter || 'all'; - const setFilterTab = (value: stirng) => { + const setFilterTab = (value: string) => { setLocationQuery({ filter: value }); }; - - // Fetch cashflow account transactions list - const { - data: cashflowTransactionsPages, - isFetching: isCashFlowTransactionsFetching, - isLoading: isCashFlowTransactionsLoading, - isSuccess: isCashflowTransactionsSuccess, - fetchNextPage: fetchNextTransactionsPage, - isFetchingNextPage, - hasNextPage, - } = useAccountTransactionsInfinity( - accountId, - { - page_size: 50, - account_id: accountId, - }, - { - enabled: filterTab === 'all' || filterTab === 'dashboard', - }, - ); - - const { - data: uncategorizedTransactionsPage, - isFetching: isUncategorizedTransactionFetching, - isLoading: isUncategorizedTransactionsLoading, - isSuccess: isUncategorizedTransactionsSuccess, - fetchNextPage: fetchNextUncategorizedTransactionsPage, - } = useAccountUncategorizedTransactionsInfinity( - accountId, - { - page_size: 50, - }, - { - enabled: filterTab === 'uncategorized', - }, - ); - - // Memorized the cashflow account transactions. - const cashflowTransactions = React.useMemo( - () => - isCashflowTransactionsSuccess - ? flattenInfinityPages(cashflowTransactionsPages) - : [], - [cashflowTransactionsPages, isCashflowTransactionsSuccess], - ); - - // Memorized the cashflow account transactions. - const uncategorizedTransactions = React.useMemo( - () => - isUncategorizedTransactionsSuccess - ? flattenInfinityPagesData(uncategorizedTransactionsPage) - : [], - [uncategorizedTransactionsPage, isUncategorizedTransactionsSuccess], - ); - // Fetch cashflow accounts. const { data: cashflowAccounts, @@ -97,27 +28,19 @@ function AccountTransactionsProvider({ query, ...props }) { } = useCashflowAccounts(query, { keepPreviousData: true }); // Retrieve specific account details. + const { data: currentAccount, isFetching: isCurrentAccountFetching, isLoading: isCurrentAccountLoading, } = useAccount(accountId, { keepPreviousData: true }); - // Handle the observer ineraction. - const handleObserverInteract = React.useCallback(() => { - if (!isFetchingNextPage && hasNextPage) { - fetchNextTransactionsPage(); - } - }, [isFetchingNextPage, hasNextPage, fetchNextTransactionsPage]); - // Provider payload. const provider = { accountId, - cashflowTransactions, cashflowAccounts, currentAccount, - isCashFlowTransactionsFetching, - isCashFlowTransactionsLoading, + isCashFlowAccountsFetching, isCashFlowAccountsLoading, isCurrentAccountFetching, @@ -125,18 +48,11 @@ function AccountTransactionsProvider({ query, ...props }) { filterTab, setFilterTab, - - uncategorizedTransactions, - isUncategorizedTransactionFetching }; return ( - ); } diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsUncategorizedTable.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsUncategorizedTable.tsx index 488b416e5..781f9b9b1 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsUncategorizedTable.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsUncategorizedTable.tsx @@ -13,7 +13,6 @@ import { import { TABLES } from '@/constants/tables'; import withSettings from '@/containers/Settings/withSettings'; -import withAlertsActions from '@/containers/Alert/withAlertActions'; import withDrawerActions from '@/containers/Drawer/withDrawerActions'; import { useMemorizedColumnsWidths } from '@/hooks'; @@ -21,8 +20,7 @@ import { ActionsMenu, useAccountUncategorizedTransactionsColumns, } from './components'; -import { useAccountTransactionsContext } from './AccountTransactionsProvider'; -import { handleCashFlowTransactionType } from './utils'; +import { useAccountUncategorizedTransactionsContext } from './AllTransactionsUncategorizedBoot'; import { compose } from '@/utils'; import { DRAWERS } from '@/constants/drawers'; @@ -34,9 +32,6 @@ function AccountTransactionsDataTable({ // #withSettings cashflowTansactionsTableSize, - // #withAlertsActions - openAlert, - // #withDrawerActions openDrawer, }) { @@ -44,17 +39,12 @@ function AccountTransactionsDataTable({ const columns = useAccountUncategorizedTransactionsColumns(); // Retrieve list context. - const { uncategorizedTransactions, isCashFlowTransactionsLoading } = - useAccountTransactionsContext(); + const { uncategorizedTransactions, isUncategorizedTransactionsLoading } = + useAccountUncategorizedTransactionsContext(); // Local storage memorizing columns widths. const [initialColumnsWidths, , handleColumnResizing] = - useMemorizedColumnsWidths(TABLES.CASHFLOW_Transactions); - - // handle delete transaction - const handleDeleteTransaction = ({ reference_id }) => {}; - - const handleViewDetailCashflowTransaction = (referenceType) => {}; + useMemorizedColumnsWidths(TABLES.UNCATEGORIZED_CASHFLOW_TRANSACTION); // Handle cell click. const handleCellClick = (cell, event) => { @@ -69,8 +59,8 @@ function AccountTransactionsDataTable({ columns={columns} data={uncategorizedTransactions || []} sticky={true} - loading={isCashFlowTransactionsLoading} - headerLoading={isCashFlowTransactionsLoading} + loading={isUncategorizedTransactionsLoading} + headerLoading={isUncategorizedTransactionsLoading} expandColumnSpace={1} expandToggleColumn={2} selectionColumnWidth={45} @@ -81,16 +71,12 @@ function AccountTransactionsDataTable({ ContextMenu={ActionsMenu} onCellClick={handleCellClick} // #TableVirtualizedListRows props. - vListrowHeight={cashflowTansactionsTableSize == 'small' ? 32 : 40} + vListrowHeight={cashflowTansactionsTableSize === 'small' ? 32 : 40} vListOverscanRowCount={0} initialColumnsWidths={initialColumnsWidths} onColumnResizing={handleColumnResizing} noResults={} className="table-constrant" - payload={{ - onViewDetails: handleViewDetailCashflowTransaction, - onDelete: handleDeleteTransaction, - }} /> ); } @@ -99,7 +85,6 @@ export default compose( withSettings(({ cashflowTransactionsSettings }) => ({ cashflowTansactionsTableSize: cashflowTransactionsSettings?.tableSize, })), - withAlertsActions, withDrawerActions, )(AccountTransactionsDataTable); diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountsTransactionsAll.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountsTransactionsAll.tsx index f181983af..c598e4bdc 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountsTransactionsAll.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountsTransactionsAll.tsx @@ -5,6 +5,7 @@ import '@/style/pages/CashFlow/AccountTransactions/List.scss'; import AccountTransactionsDataTable from './AccountTransactionsDataTable'; import { AccountTransactionsUncategorizeFilter } from './AccountTransactionsUncategorizeFilter'; +import { AccountTransactionsAllProvider } from './AccountTransactionsAllBoot'; const Box = styled.div` margin: 30px 15px; @@ -20,12 +21,14 @@ const CashflowTransactionsTableCard = styled.div` export default function AccountTransactionsAll() { return ( - - + + + - - - - + + + + + ); } diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AllTransactionsUncategorized.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AllTransactionsUncategorized.tsx index 1595b746d..716712a0d 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/AllTransactionsUncategorized.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AllTransactionsUncategorized.tsx @@ -4,6 +4,7 @@ import styled from 'styled-components'; import '@/style/pages/CashFlow/AccountTransactions/List.scss'; import AccountTransactionsUncategorizedTable from './AccountTransactionsUncategorizedTable'; +import { AccountUncategorizedTransactionsBoot } from './AllTransactionsUncategorizedBoot'; const Box = styled.div` margin: 30px 15px; @@ -15,15 +16,16 @@ const CashflowTransactionsTableCard = styled.div` padding: 30px 18px; background: #fff; flex: 0 1; -` - +`; export default function AllTransactionsUncategorized() { return ( - - - - - - ) -} \ No newline at end of file + + + + + + + + ); +} diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AllTransactionsUncategorizedBoot.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AllTransactionsUncategorizedBoot.tsx new file mode 100644 index 000000000..ce57832b3 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AllTransactionsUncategorizedBoot.tsx @@ -0,0 +1,78 @@ +// @ts-nocheck + +import React from 'react'; +import { flatten, map } from 'lodash'; +import { IntersectionObserver } from '@/components'; +import { useAccountUncategorizedTransactionsInfinity } from '@/hooks/query'; +import { useAccountTransactionsContext } from './AccountTransactionsProvider'; + +const AccountUncategorizedTransactionsContext = React.createContext(); + +function flattenInfinityPagesData(data) { + return flatten(map(data.pages, (page) => page.data)); +} + +/** + * Account uncategorized transctions provider. + */ +function AccountUncategorizedTransactionsBoot({ children }) { + const { accountId } = useAccountTransactionsContext(); + + // Fetches the uncategorized transactions. + const { + data: uncategorizedTransactionsPage, + isFetching: isUncategorizedTransactionFetching, + isLoading: isUncategorizedTransactionsLoading, + isSuccess: isUncategorizedTransactionsSuccess, + isFetchingNextPage: isUncategorizedTransactionFetchNextPage, + fetchNextPage: fetchNextUncategorizedTransactionsPage, + hasNextPage: hasUncategorizedTransactionsNextPage, + } = useAccountUncategorizedTransactionsInfinity(accountId, { + page_size: 50, + }); + // Memorized the cashflow account transactions. + const uncategorizedTransactions = React.useMemo( + () => + isUncategorizedTransactionsSuccess + ? flattenInfinityPagesData(uncategorizedTransactionsPage) + : [], + [uncategorizedTransactionsPage, isUncategorizedTransactionsSuccess], + ); + // Handle the observer ineraction. + const handleObserverInteract = React.useCallback(() => { + if ( + !isUncategorizedTransactionFetching && + hasUncategorizedTransactionsNextPage + ) { + fetchNextUncategorizedTransactionsPage(); + } + }, [ + isUncategorizedTransactionFetching, + hasUncategorizedTransactionsNextPage, + fetchNextUncategorizedTransactionsPage, + ]); + // Provider payload. + const provider = { + uncategorizedTransactions, + isUncategorizedTransactionFetching, + isUncategorizedTransactionsLoading, + }; + + return ( + + {children} + + + ); +} + +const useAccountUncategorizedTransactionsContext = () => + React.useContext(AccountUncategorizedTransactionsContext); + +export { + AccountUncategorizedTransactionsBoot, + useAccountUncategorizedTransactionsContext, +}; diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/components.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/components.tsx index 7d5b17f3a..6dc334aa6 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/components.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/components.tsx @@ -39,6 +39,7 @@ export function ActionsMenu({ ); } + /** * Retrieve account transctions table columns. */ From 83fbb7225d2d95e2526541b10463530e6ce1af01 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Thu, 7 Mar 2024 20:58:44 +0200 Subject: [PATCH 12/12] feat: remove uncategorized transaction from expenses --- .../Cashflow/GetCashflowAccounts.ts | 4 +- .../Cashflow/GetCashflowTransaction.ts | 2 - ...6_add_categorized_transaction_id_column.js | 11 --- packages/server/src/models/Expense.ts | 13 --- .../UncategorizedCashflowTransaction.ts | 80 ++++++++++++------- .../src/services/Banking/Plaid/PlaidSyncDB.ts | 4 - .../Cashflow/UncategorizeTransactionByRef.ts | 9 --- .../AccountTransactionsList.tsx | 1 - .../drawers/CategorizeTransactionDrawer.tsx | 33 -------- .../CategorizeTransactionForm.tsx | 1 - .../CategorizeTransactionFormContent.tsx | 2 +- .../src/hooks/query/cashflowAccounts.tsx | 3 +- 12 files changed, 53 insertions(+), 110 deletions(-) delete mode 100644 packages/server/src/database/migrations/20240228224316_add_categorized_transaction_id_column.js delete mode 100644 packages/server/src/services/Cashflow/UncategorizeTransactionByRef.ts delete mode 100644 packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer.tsx diff --git a/packages/server/src/api/controllers/Cashflow/GetCashflowAccounts.ts b/packages/server/src/api/controllers/Cashflow/GetCashflowAccounts.ts index d1bc97e0a..559a5f4f2 100644 --- a/packages/server/src/api/controllers/Cashflow/GetCashflowAccounts.ts +++ b/packages/server/src/api/controllers/Cashflow/GetCashflowAccounts.ts @@ -1,9 +1,7 @@ import { Service, Inject } from 'typedi'; import { Router, Request, Response, NextFunction } from 'express'; -import { param, query } from 'express-validator'; -import GetCashflowAccountsService from '@/services/Cashflow/GetCashflowAccountsService'; +import { query } from 'express-validator'; import BaseController from '../BaseController'; -import GetCashflowTransactionsService from '@/services/Cashflow/GetCashflowTransactionsService'; import { ServiceError } from '@/exceptions'; import CheckPolicies from '@/api/middleware/CheckPolicies'; import { AbilitySubject, CashflowAction } from '@/interfaces'; diff --git a/packages/server/src/api/controllers/Cashflow/GetCashflowTransaction.ts b/packages/server/src/api/controllers/Cashflow/GetCashflowTransaction.ts index 9e7169859..2625a1cb9 100644 --- a/packages/server/src/api/controllers/Cashflow/GetCashflowTransaction.ts +++ b/packages/server/src/api/controllers/Cashflow/GetCashflowTransaction.ts @@ -2,7 +2,6 @@ import { Service, Inject } from 'typedi'; import { Router, Request, Response, NextFunction } from 'express'; import { param } from 'express-validator'; import BaseController from '../BaseController'; -import GetCashflowTransactionsService from '@/services/Cashflow/GetCashflowTransactionsService'; import { ServiceError } from '@/exceptions'; import CheckPolicies from '@/api/middleware/CheckPolicies'; import { AbilitySubject, CashflowAction } from '@/interfaces'; @@ -47,7 +46,6 @@ export default class GetCashflowAccounts extends BaseController { const cashflowTransaction = await this.cashflowApplication.getTransaction( tenantId, transactionId - ); return res.status(200).send({ diff --git a/packages/server/src/database/migrations/20240228224316_add_categorized_transaction_id_column.js b/packages/server/src/database/migrations/20240228224316_add_categorized_transaction_id_column.js deleted file mode 100644 index 749cc53b6..000000000 --- a/packages/server/src/database/migrations/20240228224316_add_categorized_transaction_id_column.js +++ /dev/null @@ -1,11 +0,0 @@ -exports.up = function (knex) { - return knex.schema.table('expenses_transactions', (table) => { - table - .integer('categorized_transaction_id') - .unsigned() - .references('id') - .inTable('uncategorized_cashflow_transactions'); - }); -}; - -exports.down = function (knex) {}; diff --git a/packages/server/src/models/Expense.ts b/packages/server/src/models/Expense.ts index 967c9c734..b2fce9a65 100644 --- a/packages/server/src/models/Expense.ts +++ b/packages/server/src/models/Expense.ts @@ -182,7 +182,6 @@ export default class Expense extends mixin(TenantModel, [ const ExpenseCategory = require('models/ExpenseCategory'); const Media = require('models/Media'); const Branch = require('models/Branch'); - const UncategorizedCashflowTransaction = require('models/UncategorizedCashflowTransaction'); return { paymentAccount: { @@ -235,18 +234,6 @@ export default class Expense extends mixin(TenantModel, [ query.where('model_name', 'Expense'); }, }, - - /** - * Retrieves the related uncategorized cashflow transaction. - */ - categorized: { - relation: Model.BelongsToOneRelation, - modelClass: UncategorizedCashflowTransaction.default, - join: { - from: 'expenses_transactions.categorizedTransactionId', - to: 'uncategorized_cashflow_transactions.id', - }, - } }; } diff --git a/packages/server/src/models/UncategorizedCashflowTransaction.ts b/packages/server/src/models/UncategorizedCashflowTransaction.ts index cb5ebfeef..928db9a4d 100644 --- a/packages/server/src/models/UncategorizedCashflowTransaction.ts +++ b/packages/server/src/models/UncategorizedCashflowTransaction.ts @@ -4,7 +4,11 @@ import { Model, ModelOptions, QueryContext } from 'objection'; import Account from './Account'; export default class UncategorizedCashflowTransaction extends TenantModel { - amount: number; + id!: number; + amount!: number; + categorized!: boolean; + accountId!: number; + /** * Table name. */ @@ -19,6 +23,18 @@ export default class UncategorizedCashflowTransaction extends TenantModel { return ['createdAt', 'updatedAt']; } + /** + * Virtual attributes. + */ + static get virtualAttributes() { + return [ + 'withdrawal', + 'deposit', + 'isDepositTransaction', + 'isWithdrawalTransaction', + ]; + } + /** * Retrieves the withdrawal amount. * @returns {number} @@ -49,18 +65,6 @@ export default class UncategorizedCashflowTransaction extends TenantModel { return 0 < this.withdrawal; } - /** - * Virtual attributes. - */ - static get virtualAttributes() { - return [ - 'withdrawal', - 'deposit', - 'isDepositTransaction', - 'isWithdrawalTransaction', - ]; - } - /** * Relationship mapping. */ @@ -83,40 +87,54 @@ export default class UncategorizedCashflowTransaction extends TenantModel { } /** - * - * @param queryContext + * Updates the count of uncategorized transactions for the associated account + * based on the specified operation. + * @param {QueryContext} queryContext - The query context for the transaction. + * @param {boolean} increment - Indicates whether to increment or decrement the count. + */ + private async updateUncategorizedTransactionCount( + queryContext: QueryContext, + increment: boolean + ) { + const operation = increment ? 'increment' : 'decrement'; + const amount = increment ? 1 : -1; + + await Account.query(queryContext.transaction) + .findById(this.accountId) + [operation]('uncategorized_transactions', amount); + } + + /** + * Runs after insert. + * @param {QueryContext} queryContext */ public async $afterInsert(queryContext) { await super.$afterInsert(queryContext); - - // Increments the uncategorized transactions count of the associated account. - await Account.query(queryContext.transaction) - .findById(this.accountId) - .increment('uncategorized_transactions', 1); + await this.updateUncategorizedTransactionCount(queryContext, true); } + /** + * Runs after update. + * @param {ModelOptions} opt + * @param {QueryContext} queryContext + */ public async $afterUpdate( opt: ModelOptions, queryContext: QueryContext - ): void | Promise { + ): Promise { await super.$afterUpdate(opt, queryContext); if (this.id && this.categorized) { - await Account.query(queryContext.transaction) - .findById(this.accountId) - .decrement('uncategorized_transactions', 1); + await this.updateUncategorizedTransactionCount(queryContext, false); } } /** - * - * @param queryContext + * Runs after delete. + * @param {QueryContext} queryContext */ - public async $afterDelete(queryContext) { + public async $afterDelete(queryContext: QueryContext) { await super.$afterDelete(queryContext); - - await Account.query(queryContext.transaction) - .findById(this.accountId) - .decrement('uncategorized_transactions', 1); + await this.updateUncategorizedTransactionCount(queryContext, false); } } diff --git a/packages/server/src/services/Banking/Plaid/PlaidSyncDB.ts b/packages/server/src/services/Banking/Plaid/PlaidSyncDB.ts index b313d7f3e..b3bf85ddc 100644 --- a/packages/server/src/services/Banking/Plaid/PlaidSyncDB.ts +++ b/packages/server/src/services/Banking/Plaid/PlaidSyncDB.ts @@ -8,7 +8,6 @@ import { transformPlaidAccountToCreateAccount, transformPlaidTrxsToCashflowCreate, } from './utils'; -import NewCashflowTransactionService from '@/services/Cashflow/NewCashflowTransactionService'; import { DeleteCashflowTransaction } from '@/services/Cashflow/DeleteCashflowTransactionService'; import HasTenancyService from '@/services/Tenancy/TenancyService'; import { CashflowApplication } from '@/services/Cashflow/CashflowApplication'; @@ -26,9 +25,6 @@ export class PlaidSyncDb { @Inject() private cashflowApp: CashflowApplication; - @Inject() - private createCashflowTransactionService: NewCashflowTransactionService; - @Inject() private deleteCashflowTransactionService: DeleteCashflowTransaction; diff --git a/packages/server/src/services/Cashflow/UncategorizeTransactionByRef.ts b/packages/server/src/services/Cashflow/UncategorizeTransactionByRef.ts deleted file mode 100644 index f5590fb83..000000000 --- a/packages/server/src/services/Cashflow/UncategorizeTransactionByRef.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Service } from 'typedi'; -import { UncategorizeCashflowTransaction } from './UncategorizeCashflowTransaction'; - -@Service() -export class UncategorizeTransactionByRef { - private uncategorizeTransactionService: UncategorizeCashflowTransaction; - - public uncategorize(tenantId: number, refId: number, refType: string) {} -} diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsList.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsList.tsx index 5a5b33d3f..43b4b706d 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsList.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsList.tsx @@ -1,6 +1,5 @@ // @ts-nocheck import React, { Suspense } from 'react'; -import styled from 'styled-components'; import { Spinner } from '@blueprintjs/core'; import '@/style/pages/CashFlow/AccountTransactions/List.scss'; diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer.tsx deleted file mode 100644 index 1a54468c0..000000000 --- a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer.tsx +++ /dev/null @@ -1,33 +0,0 @@ -// @ts-nocheck -import React, { lazy } from 'react'; -import { Drawer, DrawerSuspense } from '@/components'; -import withDrawers from '@/containers/Drawer/withDrawers'; - -import { compose } from '@/utils'; - -const AccountDrawerContent = lazy(() => import('./AccountDrawerContent')); - -/** - * Categorize the uncategorized transaction drawer. - */ -function CategorizeTransactionDrawer({ - name, - // #withDrawer - isOpen, - payload: { uncategorizedTranasctionId }, -}) { - return ( - - - - - - ); -} - -export default compose(withDrawers())(AccountDrawer); diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionForm.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionForm.tsx index 364c4111c..a10387af6 100644 --- a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionForm.tsx +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionForm.tsx @@ -1,7 +1,6 @@ // @ts-nocheck import { Formik, Form } from 'formik'; import styled from 'styled-components'; -import withDialogActions from '@/containers/Dialog/withDialogActions'; import { CreateCategorizeTransactionSchema } from './CategorizeTransactionForm.schema'; import { CategorizeTransactionFormContent } from './CategorizeTransactionFormContent'; import { CategorizeTransactionFormFooter } from './CategorizeTransactionFormFooter'; diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionFormContent.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionFormContent.tsx index dfe1db6b0..95d2bc974 100644 --- a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionFormContent.tsx +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionFormContent.tsx @@ -2,7 +2,7 @@ import React from 'react'; import styled from 'styled-components'; import { FormGroup } from '@blueprintjs/core'; -import { FFormGroup, FSelect, FSuggest } from '@/components'; +import { FFormGroup, FSelect, } from '@/components'; import { getAddMoneyInOptions, getAddMoneyOutOptions } from '@/constants'; import { useFormikContext } from 'formik'; import { useCategorizeTransactionBoot } from './CategorizeTransactionBoot'; diff --git a/packages/webapp/src/hooks/query/cashflowAccounts.tsx b/packages/webapp/src/hooks/query/cashflowAccounts.tsx index 3a225db58..0bafef8bb 100644 --- a/packages/webapp/src/hooks/query/cashflowAccounts.tsx +++ b/packages/webapp/src/hooks/query/cashflowAccounts.tsx @@ -213,7 +213,8 @@ export function useRefreshCashflowTransactions() { } /** - * + * Retrieves specific uncategorized transaction. + * @param {number} uncategorizedTranasctionId - */ export function useUncategorizedTransaction( uncategorizedTranasctionId: nunber,