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..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 DeleteCashflowTransactionService 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 DeleteCashflowTransaction extends BaseController { +export default class DeleteCashflowTransactionController extends BaseController { @Inject() - deleteCashflowService: DeleteCashflowTransactionService; + private cashflowApplication: CashflowApplication; /** * Controller router. @@ -44,7 +45,7 @@ export default class DeleteCashflowTransaction 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 59fdb91ba..559a5f4f2 100644 --- a/packages/server/src/api/controllers/Cashflow/GetCashflowAccounts.ts +++ b/packages/server/src/api/controllers/Cashflow/GetCashflowAccounts.ts @@ -1,20 +1,16 @@ 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'; +import { CashflowApplication } from '@/services/Cashflow/CashflowApplication'; @Service() export default class GetCashflowAccounts extends BaseController { @Inject() - getCashflowAccountsService: GetCashflowAccountsService; - - @Inject() - getCashflowTransactionsService: GetCashflowTransactionsService; + private cashflowApplication: CashflowApplication; /** * Controller router. @@ -62,10 +58,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 7cf8d2d8e..2625a1cb9 100644 --- a/packages/server/src/api/controllers/Cashflow/GetCashflowTransaction.ts +++ b/packages/server/src/api/controllers/Cashflow/GetCashflowTransaction.ts @@ -2,15 +2,15 @@ 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'; +import { CashflowApplication } from '@/services/Cashflow/CashflowApplication'; @Service() export default class GetCashflowAccounts extends BaseController { @Inject() - getCashflowTransactionsService: GetCashflowTransactionsService; + private cashflowApplication: CashflowApplication; /** * Controller router. @@ -43,11 +43,10 @@ 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 91abfcf92..a1af70c15 100644 --- a/packages/server/src/api/controllers/Cashflow/NewCashflowTransaction.ts +++ b/packages/server/src/api/controllers/Cashflow/NewCashflowTransaction.ts @@ -1,16 +1,16 @@ import { Service, Inject } from 'typedi'; -import { check } 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; + private cashflowApplication: CashflowApplication; /** * Router constructor. @@ -18,6 +18,18 @@ export default class NewCashflowTransactionController extends BaseController { public router() { const router = Router(); + router.get( + '/transactions/uncategorized/:id', + this.asyncMiddleware(this.getUncategorizedCashflowTransaction), + this.catchServiceErrors + ); + router.get( + '/transactions/:id/uncategorized', + this.getUncategorizedTransactionsValidationSchema, + this.validationResult, + this.asyncMiddleware(this.getUncategorizedCashflowTransactions), + this.catchServiceErrors + ); router.post( '/transactions', CheckPolicies(CashflowAction.Create, AbilitySubject.Cashflow), @@ -26,13 +38,72 @@ 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; } + /** + * 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. + */ + 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('credit_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(), + ]; + } + /** * 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,9 +119,7 @@ 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(), ]; } @@ -70,13 +139,12 @@ export default class NewCashflowTransactionController extends BaseController { const ownerContributionDTO = this.matchedBodyData(req); try { - const { cashflowTransaction } = - await this.newCashflowTranscationService.newCashflowTransaction( + const cashflowTransaction = + await this.cashflowApplication.createTransaction( tenantId, ownerContributionDTO, userId ); - return res.status(200).send({ id: cashflowTransaction.id, message: 'New cashflow transaction has been created successfully.', @@ -86,11 +154,147 @@ 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 getUncategorizedCashflowTransaction = async ( + req: Request, + res: Response, + next: NextFunction + ) => { + const { tenantId } = req; + const { id: transactionId } = req.params; + + try { + const data = await this.cashflowApplication.getUncategorizedTransaction( + tenantId, + transactionId + ); + return res.status(200).send({ data }); + } 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; + const { id: accountId } = req.params; + const query = this.matchedQueryData(req); + + try { + const data = await this.cashflowApplication.getUncategorizedTransactions( + tenantId, + accountId, + query + ); + + 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 */ @@ -140,6 +344,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 new file mode 100644 index 000000000..29a596772 --- /dev/null +++ b/packages/server/src/database/migrations/20240228183404_create_uncateogrized_cashflow_transactions_table.js @@ -0,0 +1,28 @@ +exports.up = function (knex) { + return knex.schema.createTable( + 'uncategorized_cashflow_transactions', + (table) => { + table.increments('id'); + table.date('date').index(); + table.decimal('amount'); + table.string('currency_code'); + table.string('reference_no').index(); + table.string('payee'); + 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.string('plaid_transaction_id'); + table.timestamps(); + } + ); +}; + +exports.down = function (knex) { + return knex.schema.dropTableIfExists('uncategorized_cashflow_transactions'); +}; diff --git a/packages/server/src/database/migrations/20240304153926_add_uncategorized_transactions_column_to_accounts_table.js b/packages/server/src/database/migrations/20240304153926_add_uncategorized_transactions_column_to_accounts_table.js new file mode 100644 index 000000000..06d05f521 --- /dev/null +++ b/packages/server/src/database/migrations/20240304153926_add_uncategorized_transactions_column_to_accounts_table.js @@ -0,0 +1,10 @@ +exports.up = function (knex) { + return knex.schema.table('accounts', (table) => { + table.integer('uncategorized_transactions').defaultTo(0); + table.boolean('is_system_account').defaultTo(true); + table.boolean('is_feeds_active').defaultTo(false); + table.datetime('last_feeds_updated_at').nullable(); + }); +}; + +exports.down = function (knex) {}; diff --git a/packages/server/src/interfaces/CashFlow.ts b/packages/server/src/interfaces/CashFlow.ts index 40cd50896..499c526b0 100644 --- a/packages/server/src/interfaces/CashFlow.ts +++ b/packages/server/src/interfaces/CashFlow.ts @@ -233,3 +233,38 @@ export interface ICashflowTransactionSchema { } export interface ICashflowTransactionInput extends ICashflowTransactionSchema {} + +export interface ICategorizeCashflowTransactioDTO { + creditAccountId: 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; +} + + +export interface CreateUncategorizedTransactionDTO { + date: Date | string; + accountId: number; + amount: number; + currencyCode: string; + payee?: string; + description?: string; + referenceNo?: string | null; + plaidTransactionId?: string | null; +} diff --git a/packages/server/src/interfaces/CashflowService.ts b/packages/server/src/interfaces/CashflowService.ts index e279aec05..acce307db 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,39 @@ 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; +} + +export interface IGetUncategorizedTransactionsQuery { + page?: number; + pageSize?: number; +} diff --git a/packages/server/src/interfaces/Plaid.ts b/packages/server/src/interfaces/Plaid.ts index 9b19e37a9..a8ad469df 100644 --- a/packages/server/src/interfaces/Plaid.ts +++ b/packages/server/src/interfaces/Plaid.ts @@ -38,6 +38,7 @@ export interface PlaidTransaction { iso_currency_code: string; transaction_id: string; transaction_type: string; + payment_meta: { reference_number: string | null; payee: string | null }; } export interface PlaidFetchedTransactionsUpdates { 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/Account.ts b/packages/server/src/models/Account.ts index 9d4fb053e..7e0d8d6e4 100644 --- a/packages/server/src/models/Account.ts +++ b/packages/server/src/models/Account.ts @@ -196,6 +196,7 @@ export default class Account extends mixin(TenantModel, [ const Expense = require('models/Expense'); const ExpenseEntry = require('models/ExpenseCategory'); const ItemEntry = require('models/ItemEntry'); + const UncategorizedTransaction = require('models/UncategorizedCashflowTransaction'); return { /** @@ -305,6 +306,21 @@ export default class Account extends mixin(TenantModel, [ to: 'items_entries.sellAccountId', }, }, + + /** + * Associated uncategorized transactions. + */ + uncategorizedTransactions: { + relation: Model.HasManyRelation, + modelClass: UncategorizedTransaction.default, + join: { + from: 'accounts.id', + to: 'uncategorized_cashflow_transactions.accountId', + }, + filter: (query) => { + query.where('categorized', false); + }, + }, }; } 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..b2fce9a65 100644 --- a/packages/server/src/models/Expense.ts +++ b/packages/server/src/models/Expense.ts @@ -215,6 +215,10 @@ export default class Expense extends mixin(TenantModel, [ to: 'branches.id', }, }, + + /** + * + */ media: { relation: Model.ManyToManyRelation, modelClass: Media.default, diff --git a/packages/server/src/models/UncategorizedCashflowTransaction.ts b/packages/server/src/models/UncategorizedCashflowTransaction.ts new file mode 100644 index 000000000..928db9a4d --- /dev/null +++ b/packages/server/src/models/UncategorizedCashflowTransaction.ts @@ -0,0 +1,140 @@ +/* eslint-disable global-require */ +import TenantModel from 'models/TenantModel'; +import { Model, ModelOptions, QueryContext } from 'objection'; +import Account from './Account'; + +export default class UncategorizedCashflowTransaction extends TenantModel { + id!: number; + amount!: number; + categorized!: boolean; + accountId!: number; + + /** + * Table name. + */ + static get tableName() { + return 'uncategorized_cashflow_transactions'; + } + + /** + * Timestamps columns. + */ + static get timestamps() { + return ['createdAt', 'updatedAt']; + } + + /** + * Virtual attributes. + */ + static get virtualAttributes() { + return [ + 'withdrawal', + 'deposit', + 'isDepositTransaction', + 'isWithdrawalTransaction', + ]; + } + + /** + * Retrieves the withdrawal amount. + * @returns {number} + */ + public get withdrawal() { + return this.amount < 0 ? Math.abs(this.amount) : 0; + } + + /** + * Retrieves the deposit amount. + * @returns {number} + */ + 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; + } + + /** + * 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', + }, + }, + }; + } + + /** + * 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); + await this.updateUncategorizedTransactionCount(queryContext, true); + } + + /** + * Runs after update. + * @param {ModelOptions} opt + * @param {QueryContext} queryContext + */ + public async $afterUpdate( + opt: ModelOptions, + queryContext: QueryContext + ): Promise { + await super.$afterUpdate(opt, queryContext); + + if (this.id && this.categorized) { + await this.updateUncategorizedTransactionCount(queryContext, false); + } + } + + /** + * Runs after delete. + * @param {QueryContext} queryContext + */ + public async $afterDelete(queryContext: QueryContext) { + await super.$afterDelete(queryContext); + 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 6f0260a2b..b3bf85ddc 100644 --- a/packages/server/src/services/Banking/Plaid/PlaidSyncDB.ts +++ b/packages/server/src/services/Banking/Plaid/PlaidSyncDB.ts @@ -8,9 +8,9 @@ import { transformPlaidAccountToCreateAccount, 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'; +import { CashflowApplication } from '@/services/Cashflow/CashflowApplication'; const CONCURRENCY_ASYNC = 10; @@ -23,10 +23,10 @@ export class PlaidSyncDb { private createAccountService: CreateAccount; @Inject() - private createCashflowTransactionService: NewCashflowTransactionService; + private cashflowApp: CashflowApplication; @Inject() - private deleteCashflowTransactionService: DeleteCashflowTransactionService; + private deleteCashflowTransactionService: DeleteCashflowTransaction; /** * Syncs the plaid accounts to the system accounts. @@ -36,11 +36,14 @@ export class PlaidSyncDb { */ public async syncBankAccounts( tenantId: number, - plaidAccounts: PlaidAccount[] + plaidAccounts: PlaidAccount[], + institution: any ): Promise { - const accountCreateDTOs = R.map(transformPlaidAccountToCreateAccount)( - plaidAccounts - ); + const transformToPlaidAccounts = + transformPlaidAccountToCreateAccount(institution); + + const accountCreateDTOs = R.map(transformToPlaidAccounts)(plaidAccounts); + await bluebird.map( accountCreateDTOs, (createAccountDTO: any) => @@ -75,17 +78,18 @@ export class PlaidSyncDb { cashflowAccount.id, openingEquityBalance.id ); - const accountsCashflowDTO = R.map(transformTransaction)(plaidTranasctions); + const uncategorizedTransDTOs = + R.map(transformTransaction)(plaidTranasctions); // Creating account transaction queue. await bluebird.map( - accountsCashflowDTO, - (cashflowDTO) => - this.createCashflowTransactionService.newCashflowTransaction( + uncategorizedTransDTOs, + (uncategoriedDTO) => + this.cashflowApp.createUncategorizedTransaction( tenantId, - cashflowDTO + uncategoriedDTO ), - { concurrency: CONCURRENCY_ASYNC } + { concurrency: 1 } ); } @@ -157,4 +161,38 @@ export class PlaidSyncDb { await PlaidItem.query().findOne({ plaidItemId }).patch({ lastCursor }); } + + /** + * Updates the last feeds updated at of the given Plaid accounts ids. + * @param {number} tenantId + * @param {string[]} plaidAccountIds + */ + public async updateLastFeedsUpdatedAt( + tenantId: number, + plaidAccountIds: string[] + ) { + const { Account } = this.tenancy.models(tenantId); + + await Account.query().whereIn('plaid_account_id', plaidAccountIds).patch({ + lastFeedsUpdatedAt: new Date(), + }); + } + + /** + * Updates the accounts feed active status of the given Plaid accounts ids. + * @param {number} tenantId + * @param {number[]} plaidAccountIds + * @param {boolean} isFeedsActive + */ + public async updateAccountsFeedsActive( + tenantId: number, + plaidAccountIds: string[], + isFeedsActive: boolean = true + ) { + const { Account } = this.tenancy.models(tenantId); + + await Account.query().whereIn('plaid_account_id', plaidAccountIds).patch({ + isFeedsActive, + }); + } } diff --git a/packages/server/src/services/Banking/Plaid/PlaidUpdateTransactions.ts b/packages/server/src/services/Banking/Plaid/PlaidUpdateTransactions.ts index 8ac30b6f6..c740e4705 100644 --- a/packages/server/src/services/Banking/Plaid/PlaidUpdateTransactions.ts +++ b/packages/server/src/services/Banking/Plaid/PlaidUpdateTransactions.ts @@ -25,11 +25,19 @@ export class PlaidUpdateTransactions { const request = { access_token: accessToken }; const plaidInstance = new PlaidClientWrapper(); const { - data: { accounts }, + data: { accounts, item }, } = await plaidInstance.accountsGet(request); + const plaidAccountsIds = accounts.map((a) => a.account_id); + + const { + data: { institution }, + } = await plaidInstance.institutionsGetById({ + institution_id: item.institution_id, + country_codes: ['US', 'UK'], + }); // Update the DB. - await this.plaidSync.syncBankAccounts(tenantId, accounts); + await this.plaidSync.syncBankAccounts(tenantId, accounts, institution); await this.plaidSync.syncAccountsTransactions( tenantId, added.concat(modified) @@ -37,6 +45,12 @@ export class PlaidUpdateTransactions { await this.plaidSync.syncRemoveTransactions(tenantId, removed); await this.plaidSync.syncTransactionsCursor(tenantId, plaidItemId, cursor); + // Update the last feeds updated at of the updated accounts. + await this.plaidSync.updateLastFeedsUpdatedAt(tenantId, plaidAccountsIds); + + // Turn on the accounts feeds flag. + await this.plaidSync.updateAccountsFeedsActive(tenantId, plaidAccountsIds); + return { addedCount: added.length, modifiedCount: modified.length, diff --git a/packages/server/src/services/Banking/Plaid/utils.ts b/packages/server/src/services/Banking/Plaid/utils.ts index 1fe18bee7..c8a3cf528 100644 --- a/packages/server/src/services/Banking/Plaid/utils.ts +++ b/packages/server/src/services/Banking/Plaid/utils.ts @@ -1,7 +1,7 @@ import * as R from 'ramda'; import { + CreateUncategorizedTransactionDTO, IAccountCreateDTO, - ICashflowNewCommandDTO, PlaidAccount, PlaidTransaction, } from '@/interfaces'; @@ -11,51 +11,44 @@ import { * @param {PlaidAccount} plaidAccount * @returns {IAccountCreateDTO} */ -export const transformPlaidAccountToCreateAccount = ( - plaidAccount: PlaidAccount -): IAccountCreateDTO => { - return { - name: plaidAccount.name, - code: '', - description: plaidAccount.official_name, - currencyCode: plaidAccount.balances.iso_currency_code, - accountType: 'cash', - active: true, - plaidAccountId: plaidAccount.account_id, - bankBalance: plaidAccount.balances.current, - accountMask: plaidAccount.mask, - }; -}; +export const transformPlaidAccountToCreateAccount = R.curry( + (institution: any, plaidAccount: PlaidAccount): IAccountCreateDTO => { + return { + name: `${institution.name} - ${plaidAccount.name}`, + code: '', + description: plaidAccount.official_name, + currencyCode: plaidAccount.balances.iso_currency_code, + accountType: 'cash', + active: true, + plaidAccountId: plaidAccount.account_id, + bankBalance: plaidAccount.balances.current, + accountMask: plaidAccount.mask, + }; + } +); /** * Transformes the plaid transaction to cashflow create DTO. * @param {number} cashflowAccountId - Cashflow account ID. * @param {number} creditAccountId - Credit account ID. * @param {PlaidTransaction} plaidTranasction - Plaid transaction. - * @returns {ICashflowNewCommandDTO} + * @returns {CreateUncategorizedTransactionDTO} */ export const transformPlaidTrxsToCashflowCreate = R.curry( ( cashflowAccountId: number, creditAccountId: number, plaidTranasction: PlaidTransaction - ): ICashflowNewCommandDTO => { + ): CreateUncategorizedTransactionDTO => { return { date: plaidTranasction.date, - - transactionType: 'OwnerContribution', - description: plaidTranasction.name, - amount: plaidTranasction.amount, - exchangeRate: 1, + description: plaidTranasction.name, + payee: plaidTranasction.payment_meta?.payee, currencyCode: plaidTranasction.iso_currency_code, - creditAccountId, - cashflowAccountId, - - // transactionNumber: string; - // referenceNo: string; + accountId: cashflowAccountId, + referenceNo: plaidTranasction.payment_meta?.reference_number, plaidTransactionId: plaidTranasction.transaction_id, - publish: true, }; } ); diff --git a/packages/server/src/services/Cashflow/CashflowApplication.ts b/packages/server/src/services/Cashflow/CashflowApplication.ts new file mode 100644 index 000000000..6688c9016 --- /dev/null +++ b/packages/server/src/services/Cashflow/CashflowApplication.ts @@ -0,0 +1,213 @@ +import { Inject, Service } from 'typedi'; +import { DeleteCashflowTransaction } from './DeleteCashflowTransactionService'; +import { UncategorizeCashflowTransaction } from './UncategorizeCashflowTransaction'; +import { CategorizeCashflowTransaction } from './CategorizeCashflowTransaction'; +import { + CategorizeTransactionAsExpenseDTO, + CreateUncategorizedTransactionDTO, + ICashflowAccountsFilter, + ICashflowNewCommandDTO, + ICategorizeCashflowTransactioDTO, + 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; + + @Inject() + private categorizeTransactionService: CategorizeCashflowTransaction; + + @Inject() + private categorizeAsExpenseService: CategorizeTransactionAsExpense; + + @Inject() + private getUncategorizedTransactionsService: GetUncategorizedTransactions; + + @Inject() + private getUncategorizedTransactionService: GetUncategorizedTransaction; + + @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 + * @param {number} cashflowTransactionId + * @returns + */ + public deleteTransaction(tenantId: number, cashflowTransactionId: number) { + return this.deleteTransactionService.deleteCashflowTransaction( + tenantId, + cashflowTransactionId + ); + } + + /** + * 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 + * @param {CreateUncategorizedTransactionDTO} createUncategorizedTransactionDTO + * @returns {IUncategorizedCashflowTransaction} + */ + public createUncategorizedTransaction( + tenantId: number, + createUncategorizedTransactionDTO: CreateUncategorizedTransactionDTO + ) { + return this.createUncategorizedTransactionService.create( + tenantId, + createUncategorizedTransactionDTO + ); + } + + /** + * 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 + */ + public categorizeAsExpense( + tenantId: number, + cashflowTransactionId: number, + transactionDTO: CategorizeTransactionAsExpenseDTO + ) { + return this.categorizeAsExpenseService.categorize( + tenantId, + cashflowTransactionId, + transactionDTO + ); + } + + /** + * Retrieves the uncategorized cashflow transactions. + * @param {number} tenantId + */ + public getUncategorizedTransactions( + tenantId: number, + accountId: number, + query: IGetUncategorizedTransactionsQuery + ) { + return this.getUncategorizedTransactionsService.getTransactions( + tenantId, + accountId, + query + ); + } + + /** + * Retrieves specific uncategorized transaction. + * @param {number} tenantId + * @param {number} uncategorizedTransactionId + */ + public getUncategorizedTransaction( + tenantId: number, + uncategorizedTransactionId: number + ) { + return this.getUncategorizedTransactionService.getTransaction( + tenantId, + uncategorizedTransactionId + ); + } +} 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..3d19e1547 --- /dev/null +++ b/packages/server/src/services/Cashflow/CategorizeCashflowTransaction.ts @@ -0,0 +1,101 @@ +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'; +import { TransferAuthorizationGuaranteeDecision } from 'plaid'; + +@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); + + // 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. + 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).patchAndFetchById( + uncategorizedTransactionId, + { + 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..7a6c5c973 100644 --- a/packages/server/src/services/Cashflow/CommandCasflowValidator.ts +++ b/packages/server/src/services/Cashflow/CommandCasflowValidator.ts @@ -1,9 +1,14 @@ 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() export class CommandCashflowValidator { @@ -46,4 +51,52 @@ 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); + } + } + + /** + * + * @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/CreateUncategorizedTransaction.ts b/packages/server/src/services/Cashflow/CreateUncategorizedTransaction.ts new file mode 100644 index 000000000..ccb2aca25 --- /dev/null +++ b/packages/server/src/services/Cashflow/CreateUncategorizedTransaction.ts @@ -0,0 +1,40 @@ +import { Inject, Service } from 'typedi'; +import HasTenancyService from '../Tenancy/TenancyService'; +import UnitOfWork, { IsolationLevel } from '../UnitOfWork'; +import { Knex } from 'knex'; +import { CreateUncategorizedTransactionDTO } from '@/interfaces'; + +@Service() +export class CreateUncategorizedTransaction { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private uow: UnitOfWork; + + /** + * Creates an uncategorized cashflow transaction. + * @param {number} tenantId + * @param {CreateUncategorizedTransactionDTO} createDTO + */ + public create( + tenantId: number, + createDTO: CreateUncategorizedTransactionDTO + ) { + const { UncategorizedCashflowTransaction, Account } = + this.tenancy.models(tenantId); + + return this.uow.withTransaction( + tenantId, + async (trx: Knex.Transaction) => { + const transaction = await UncategorizedCashflowTransaction.query( + trx + ).insertAndFetch({ + ...createDTO, + }); + + return transaction; + }, + ); + } +} 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..64afd2194 100644 --- a/packages/server/src/services/Cashflow/GetCashflowTransactionsService.ts +++ b/packages/server/src/services/Cashflow/GetCashflowTransactionsService.ts @@ -4,17 +4,13 @@ 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() -export default class GetCashflowTransactionsService { +export class GetCashflowTransactionService { @Inject() private tenancy: HasTenancyService; - @Inject() - private i18nService: I18nService; - @Inject() private transfromer: TransformerInjectable; @@ -35,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/GetUncategorizedTransaction.ts b/packages/server/src/services/Cashflow/GetUncategorizedTransaction.ts new file mode 100644 index 000000000..82cf531a8 --- /dev/null +++ b/packages/server/src/services/Cashflow/GetUncategorizedTransaction.ts @@ -0,0 +1,36 @@ +import { Inject, Service } from 'typedi'; +import HasTenancyService from '../Tenancy/TenancyService'; +import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; +import { UncategorizedTransactionTransformer } from './UncategorizedTransactionTransformer'; + +@Service() +export class GetUncategorizedTransaction { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private transformer: TransformerInjectable; + + /** + * Retrieves specific uncategorized cashflow transaction. + * @param {number} tenantId - Tenant id. + * @param {number} uncategorizedTransactionId - Uncategorized transaction id. + */ + public async getTransaction( + tenantId: number, + uncategorizedTransactionId: number + ) { + const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId); + + const transaction = await UncategorizedCashflowTransaction.query() + .findById(uncategorizedTransactionId) + .withGraphFetched('account') + .throwIfNotFound(); + + return this.transformer.transform( + tenantId, + transaction, + new UncategorizedTransactionTransformer() + ); + } +} diff --git a/packages/server/src/services/Cashflow/GetUncategorizedTransactions.ts b/packages/server/src/services/Cashflow/GetUncategorizedTransactions.ts new file mode 100644 index 000000000..36606f582 --- /dev/null +++ b/packages/server/src/services/Cashflow/GetUncategorizedTransactions.ts @@ -0,0 +1,51 @@ +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 { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private transformer: TransformerInjectable; + + /** + * Retrieves the uncategorized cashflow transactions. + * @param {number} tenantId - Tenant id. + * @param {number} accountId - Account Id. + */ + 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(_query.page - 1, _query.pageSize); + + const data = await this.transformer.transform( + tenantId, + results, + new UncategorizedTransactionTransformer() + ); + return { + data, + pagination, + }; + } +} diff --git a/packages/server/src/services/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/UncategorizedTransactionTransformer.ts b/packages/server/src/services/Cashflow/UncategorizedTransactionTransformer.ts new file mode 100644 index 000000000..85d1a1fbb --- /dev/null +++ b/packages/server/src/services/Cashflow/UncategorizedTransactionTransformer.ts @@ -0,0 +1,65 @@ +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 [ + 'formattedAmount', + 'formattedDate', + 'formattetDepositAmount', + 'formattedWithdrawalAmount', + ]; + }; + + /** + * Formattes the transaction date. + * @param transaction + * @returns {string} + */ + public formattedDate(transaction) { + 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 + * @returns {string} + */ + protected formattetDepositAmount(transaction) { + if (transaction.isDepositTransaction) { + return formatNumber(transaction.deposit, { + currencyCode: transaction.currencyCode, + }); + } + return ''; + } + + /** + * Formatted withdrawal amount. + * @param transaction + * @returns {string} + */ + protected formattedWithdrawalAmount(transaction) { + if (transaction.isWithdrawalTransaction) { + return formatNumber(transaction.withdrawal, { + currencyCode: transaction.currencyCode, + }); + } + return ''; + } +} diff --git a/packages/server/src/services/Cashflow/constants.ts b/packages/server/src/services/Cashflow/constants.ts index 2e664a519..293275855 100644 --- a/packages/server/src/services/Cashflow/constants.ts +++ b/packages/server/src/services/Cashflow/constants.ts @@ -8,7 +8,10 @@ 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', + UNCATEGORIZED_TRANSACTION_TYPE_INVALID: 'UNCATEGORIZED_TRANSACTION_TYPE_INVALID' }; 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..7957b73a9 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.creditAccountId, + 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', }, /** diff --git a/packages/webapp/src/components/ContentTabs/ContentTabs.tsx b/packages/webapp/src/components/ContentTabs/ContentTabs.tsx new file mode 100644 index 000000000..58f844782 --- /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, + onClick, +}: 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/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/DrawersContainer.tsx b/packages/webapp/src/components/DrawersContainer.tsx index c8c04f63a..aeae5c4ec 100644 --- a/packages/webapp/src/components/DrawersContainer.tsx +++ b/packages/webapp/src/components/DrawersContainer.tsx @@ -23,6 +23,7 @@ import WarehouseTransferDetailDrawer from '@/containers/Drawers/WarehouseTransfe import TaxRateDetailsDrawer from '@/containers/TaxRates/drawers/TaxRateDetailsDrawer/TaxRateDetailsDrawer'; import { DRAWERS } from '@/constants/drawers'; +import CategorizeTransactionDrawer from '@/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionDrawer'; /** * Drawers container of the dashboard. @@ -61,6 +62,7 @@ export default function DrawersContainer() { name={DRAWERS.WAREHOUSE_TRANSFER_DETAILS} /> + ); } 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/constants/drawers.ts b/packages/webapp/src/constants/drawers.ts index 59237e4b4..2dc3e92e9 100644 --- a/packages/webapp/src/constants/drawers.ts +++ b/packages/webapp/src/constants/drawers.ts @@ -23,4 +23,5 @@ export enum DRAWERS { REFUND_VENDOR_CREDIT_DETAILS = 'refund-vendor-detail-drawer', WAREHOUSE_TRANSFER_DETAILS = 'warehouse-transfer-detail-drawer', TAX_RATE_DETAILS = 'tax-rate-detail-drawer', + CATEGORIZE_TRANSACTION = 'categorize-transaction', } 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/AccountTransactionsDetailsBar.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsDetailsBar.tsx index 7f4d64921..90e10eae0 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsDetailsBar.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsDetailsBar.tsx @@ -84,6 +84,18 @@ function AccountBankBalanceItem() { ); } +function AccountNumberItem() { + const { currentAccount } = useAccountTransactionsContext(); + + if (!currentAccount.account_mask) return null; + + return ( + + Account Number: xxx{currentAccount.account_mask} + + ); +} + function AccountTransactionsDetailsBarSkeleton() { return ( @@ -101,6 +113,7 @@ function AccountTransactionsDetailsContent() { return ( + 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..c2d40059d --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsFilterTabs.tsx @@ -0,0 +1,50 @@ +// @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, currentAccount } = + useAccountTransactionsContext(); + + const handleChange = (value) => { + setFilterTab(value); + }; + + const hasUncategorizedTransx = Boolean( + currentAccount.uncategorized_transactions, + ); + + return ( + + + {hasUncategorizedTransx && ( + + + {currentAccount.uncategorized_transactions} + {' '} + 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..43b4b706d 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsList.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsList.tsx @@ -1,16 +1,19 @@ // @ts-nocheck -import React from 'react'; -import styled from 'styled-components'; +import React, { Suspense } from 'react'; +import { Spinner } from '@blueprintjs/core'; import '@/style/pages/CashFlow/AccountTransactions/List.scss'; import { DashboardPageContent } from '@/components'; import AccountTransactionsActionsBar from './AccountTransactionsActionsBar'; -import AccountTransactionsDataTable from './AccountTransactionsDataTable'; -import { AccountTransactionsProvider } from './AccountTransactionsProvider'; +import { + AccountTransactionsProvider, + useAccountTransactionsContext, +} from './AccountTransactionsProvider'; import { AccountTransactionsDetailsBar } from './AccountTransactionsDetailsBar'; import { AccountTransactionsProgressBar } from './components'; +import { AccountTransactionsFilterTabs } from './AccountTransactionsFilterTabs'; /** * Account transactions list. @@ -23,9 +26,11 @@ function AccountTransactionsList() { - - - + + + }> + + ); @@ -33,11 +38,20 @@ function AccountTransactionsList() { export default AccountTransactionsList; -const CashflowTransactionsTableCard = styled.div` - border: 2px solid #f0f0f0; - border-radius: 10px; - padding: 30px 18px; - margin: 30px 15px; - background: #fff; - flex: 0 1; -`; +const AccountsTransactionsAll = React.lazy( + () => import('./AccountsTransactionsAll'), +); + +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..1b3c98a29 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsProvider.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsProvider.tsx @@ -1,20 +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, -} 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)); -} - /** * Account transctions provider. */ @@ -22,29 +14,12 @@ function AccountTransactionsProvider({ query, ...props }) { const { id } = useParams(); const accountId = parseInt(id, 10); - // 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, - }); - - // Memorized the cashflow account transactions. - const cashflowTransactions = React.useMemo( - () => - isCashflowTransactionsSuccess - ? flattenInfinityPages(cashflowTransactionsPages) - : [], - [cashflowTransactionsPages, isCashflowTransactionsSuccess], - ); + const [locationQuery, setLocationQuery] = useAppQueryString(); + const filterTab = locationQuery?.filter || 'all'; + const setFilterTab = (value: string) => { + setLocationQuery({ filter: value }); + }; // Fetch cashflow accounts. const { data: cashflowAccounts, @@ -53,40 +28,31 @@ 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, isCurrentAccountLoading, + + filterTab, + setFilterTab, }; return ( - ); } 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..b7ac12623 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsUncategorizeFilter.tsx @@ -0,0 +1,36 @@ +// @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; + + &.bp4-minimal:not([class*='bp4-intent-']) { + background: #fff; + border: 1px solid #e1e2e8; + + &.bp4-interactive:hover { + background-color: rgba(143, 153, 168, 0.05); + } + } +`; + +export function AccountTransactionsUncategorizeFilter() { + return ( + + + All (2) + + + Recognized (0) + + + ); +} 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..781f9b9b1 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsUncategorizedTable.tsx @@ -0,0 +1,129 @@ +// @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 withDrawerActions from '@/containers/Drawer/withDrawerActions'; + +import { useMemorizedColumnsWidths } from '@/hooks'; +import { + ActionsMenu, + useAccountUncategorizedTransactionsColumns, +} from './components'; +import { useAccountUncategorizedTransactionsContext } from './AllTransactionsUncategorizedBoot'; + +import { compose } from '@/utils'; +import { DRAWERS } from '@/constants/drawers'; + +/** + * Account transactions data table. + */ +function AccountTransactionsDataTable({ + // #withSettings + cashflowTansactionsTableSize, + + // #withDrawerActions + openDrawer, +}) { + // Retrieve table columns. + const columns = useAccountUncategorizedTransactionsColumns(); + + // Retrieve list context. + const { uncategorizedTransactions, isUncategorizedTransactionsLoading } = + useAccountUncategorizedTransactionsContext(); + + // Local storage memorizing columns widths. + const [initialColumnsWidths, , handleColumnResizing] = + useMemorizedColumnsWidths(TABLES.UNCATEGORIZED_CASHFLOW_TRANSACTION); + + // Handle cell click. + const handleCellClick = (cell, event) => { + openDrawer(DRAWERS.CATEGORIZE_TRANSACTION, { + uncategorizedTransactionId: cell.row.original.id, + }); + }; + + return ( + } + className="table-constrant" + /> + ); +} + +export default compose( + withSettings(({ cashflowTransactionsSettings }) => ({ + cashflowTansactionsTableSize: cashflowTransactionsSettings?.tableSize, + })), + 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..c598e4bdc --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountsTransactionsAll.tsx @@ -0,0 +1,34 @@ +// @ts-nocheck +import styled from 'styled-components'; + +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; +`; + +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..716712a0d --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AllTransactionsUncategorized.tsx @@ -0,0 +1,31 @@ +// @ts-nocheck +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; +`; + +const CashflowTransactionsTableCard = styled.div` + border: 2px solid #f0f0f0; + border-radius: 10px; + padding: 30px 18px; + background: #fff; + flex: 0 1; +`; + +export default function AllTransactionsUncategorized() { + return ( + + + + + + + + ); +} 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 18e75161e..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. */ @@ -131,7 +132,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/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionBoot.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionBoot.tsx new file mode 100644 index 000000000..6a7b8ea2b --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionBoot.tsx @@ -0,0 +1,63 @@ +// @ts-nocheck +import React from 'react'; +import { DrawerHeaderContent, DrawerLoading } from '@/components'; +import { DRAWERS } from '@/constants/drawers'; +import { + useAccounts, + useBranches, + useUncategorizedTransaction, +} from '@/hooks/query'; +import { useFeatureCan } from '@/hooks/state'; +import { Features } from '@/constants'; + +const CategorizeTransactionBootContext = React.createContext(); + +/** + * 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 ( + + + + + ); +} + +const useCategorizeTransactionBoot = () => + React.useContext(CategorizeTransactionBootContext); + +export { CategorizeTransactionBoot, useCategorizeTransactionBoot }; diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionContent.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionContent.tsx new file mode 100644 index 000000000..7716e8beb --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionContent.tsx @@ -0,0 +1,24 @@ +// @ts-nocheck +import styled from 'styled-components'; +import { DrawerBody } from '@/components'; +import { CategorizeTransactionBoot } from './CategorizeTransactionBoot'; +import { CategorizeTransactionForm } from './CategorizeTransactionForm'; + +export default function CategorizeTransactionContent({ + uncategorizedTransactionId, +}) { + return ( + + + + + + ); +} + +export const CategorizeTransactionDrawerBody = styled(DrawerBody)` + padding: 20px; + background-color: #fff; +`; diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionDrawer.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionDrawer.tsx new file mode 100644 index 000000000..388b6dfab --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionDrawer.tsx @@ -0,0 +1,37 @@ +// @ts-nocheck +import React, { lazy } from 'react'; +import { Drawer, DrawerSuspense } from '@/components'; +import withDrawers from '@/containers/Drawer/withDrawers'; + +import { compose } from '@/utils'; + +const CategorizeTransactionContent = lazy( + () => import('./CategorizeTransactionContent'), +); + +/** + * Categorize the uncategorized transaction drawer. + */ +function CategorizeTransactionDrawer({ + name, + // #withDrawer + isOpen, + payload: { uncategorizedTransactionId }, +}) { + return ( + + + + + + ); +} + +export default compose(withDrawers())(CategorizeTransactionDrawer); 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 new file mode 100644 index 000000000..8c6d0eb72 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionForm.schema.tsx @@ -0,0 +1,14 @@ +// @ts-nocheck +import * as Yup from 'yup'; + +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; diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionForm.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionForm.tsx new file mode 100644 index 000000000..a10387af6 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionForm.tsx @@ -0,0 +1,95 @@ +// @ts-nocheck +import { Formik, Form } from 'formik'; +import styled from 'styled-components'; +import { CreateCategorizeTransactionSchema } from './CategorizeTransactionForm.schema'; +import { CategorizeTransactionFormContent } from './CategorizeTransactionFormContent'; +import { CategorizeTransactionFormFooter } from './CategorizeTransactionFormFooter'; +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({ + // #withDrawerActions + closeDrawer, +}) { + const { uncategorizedTransactionId, uncategorizedTransaction } = + useCategorizeTransactionBoot(); + const { mutateAsync: categorizeTransaction } = useCategorizeTransaction(); + + // Callbacks handles form submit. + 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, + /** + * We only care about the fields in the form. Previously unfilled optional + * values such as `notes` come back from the API as null, so remove those + * as well. + */ + ...transformToCategorizeForm(uncategorizedTransaction), + }; + + return ( + + +
+ + + +
+
+ ); +} + +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 new file mode 100644 index 000000000..95d2bc974 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionFormContent.tsx @@ -0,0 +1,96 @@ +// @ts-nocheck +import React from 'react'; +import styled from 'styled-components'; +import { FormGroup } from '@blueprintjs/core'; +import { FFormGroup, FSelect, } from '@/components'; +import { getAddMoneyInOptions, getAddMoneyOutOptions } from '@/constants'; +import { useFormikContext } from 'formik'; +import { useCategorizeTransactionBoot } from './CategorizeTransactionBoot'; + +// Retrieves the add money in button options. +const MoneyInOptions = getAddMoneyInOptions(); +const MoneyOutOptions = getAddMoneyOutOptions(); + +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 ( + <> + + {uncategorizedTransaction.formatted_amount} + + + + + + + + + ); +} + +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 new file mode 100644 index 000000000..3f8d7009b --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransaction/drawers/CategorizeTransactionDrawer/CategorizeTransactionFormFooter.tsx @@ -0,0 +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); + }; + + 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 700578c63..0bafef8bb 100644 --- a/packages/webapp/src/hooks/query/cashflowAccounts.tsx +++ b/packages/webapp/src/hooks/query/cashflowAccounts.tsx @@ -104,8 +104,8 @@ export function useDeleteCashflowTransaction(props) { export function useAccountTransactionsInfinity( accountId, query, - axios, infinityProps, + axios, ) { const apiRequest = useApiRequest(); @@ -134,6 +134,45 @@ export function useAccountTransactionsInfinity( ); } +/** + * Retrieve account transactions infinity scrolling. + * @param {number} accountId + * @param {*} axios + * @returns + */ +export function useAccountUncategorizedTransactionsInfinity( + accountId, + query, + infinityProps, + axios, +) { + const apiRequest = useApiRequest(); + + return useInfiniteQuery( + [t.CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY, accountId], + async ({ pageParam = 1 }) => { + const response = await apiRequest.http({ + ...axios, + method: 'get', + url: `/api/cashflow/transactions/${accountId}/uncategorized`, + params: { page: pageParam, ...query }, + }); + return response.data; + }, + { + getPreviousPageParam: (firstPage) => firstPage.pagination.page - 1, + getNextPageParam: (lastPage) => { + const { pagination } = lastPage; + + return pagination.total > pagination.page_size * pagination.page + ? lastPage.pagination.page + 1 + : undefined; + }, + ...infinityProps, + }, + ); +} + /** * Refresh cashflow transactions infinity. */ @@ -172,3 +211,48 @@ export function useRefreshCashflowTransactions() { }, }; } + +/** + * Retrieves specific uncategorized transaction. + * @param {number} uncategorizedTranasctionId - + */ +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, + }, + ); +} + +/** + * 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 9d3446c77..5446282e2 100644 --- a/packages/webapp/src/hooks/query/types.tsx +++ b/packages/webapp/src/hooks/query/types.tsx @@ -77,7 +77,7 @@ const SALE_RECEIPTS = { SALE_RECEIPT: 'SALE_RECEIPT', SALE_RECEIPT_SMS_DETAIL: 'SALE_RECEIPT_SMS_DETAIL', NOTIFY_SALE_RECEIPT_BY_SMS: 'NOTIFY_SALE_RECEIPT_BY_SMS', - SALE_RECEIPT_MAIL_OPTIONS: 'SALE_RECEIPT_MAIL_OPTIONS' + SALE_RECEIPT_MAIL_OPTIONS: 'SALE_RECEIPT_MAIL_OPTIONS', }; const INVENTORY_ADJUSTMENTS = { @@ -115,7 +115,7 @@ const SALE_INVOICES = { BAD_DEBT: 'BAD_DEBT', CANCEL_BAD_DEBT: 'CANCEL_BAD_DEBT', SALE_INVOICE_PAYMENT_TRANSACTIONS: 'SALE_INVOICE_PAYMENT_TRANSACTIONS', - SALE_INVOICE_DEFAULT_OPTIONS: 'SALE_INVOICE_DEFAULT_OPTIONS' + SALE_INVOICE_DEFAULT_OPTIONS: 'SALE_INVOICE_DEFAULT_OPTIONS', }; const USERS = { @@ -200,6 +200,9 @@ const CASH_FLOW_ACCOUNTS = { CASH_FLOW_TRANSACTION: 'CASH_FLOW_TRANSACTION', CASHFLOW_ACCOUNT_TRANSACTIONS_INFINITY: 'CASHFLOW_ACCOUNT_TRANSACTIONS_INFINITY', + CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY: + 'CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY', + CASHFLOW_UNCAATEGORIZED_TRANSACTION: 'CASHFLOW_UNCAATEGORIZED_TRANSACTION', }; const TARNSACTIONS_LOCKING = {