diff --git a/packages/server/src/api/controllers/Cashflow/CashflowController.ts b/packages/server/src/api/controllers/Cashflow/CashflowController.ts index c6dfe5c29..42efa4c48 100644 --- a/packages/server/src/api/controllers/Cashflow/CashflowController.ts +++ b/packages/server/src/api/controllers/Cashflow/CashflowController.ts @@ -13,9 +13,9 @@ export default class CashflowController { router() { const router = Router(); + router.use(Container.get(CommandCashflowTransaction).router()); router.use(Container.get(GetCashflowTransaction).router()); router.use(Container.get(GetCashflowAccounts).router()); - router.use(Container.get(CommandCashflowTransaction).router()); router.use(Container.get(DeleteCashflowTransaction).router()); return router; diff --git a/packages/server/src/api/controllers/Cashflow/DeleteCashflowTransaction.ts b/packages/server/src/api/controllers/Cashflow/DeleteCashflowTransaction.ts index 0ddb6d74d..4d94022da 100644 --- a/packages/server/src/api/controllers/Cashflow/DeleteCashflowTransaction.ts +++ b/packages/server/src/api/controllers/Cashflow/DeleteCashflowTransaction.ts @@ -3,14 +3,14 @@ import { Router, Request, Response, NextFunction } from 'express'; import { param } from 'express-validator'; import BaseController from '../BaseController'; import { ServiceError } from '@/exceptions'; -import DeleteCashflowTransactionService from '../../../services/Cashflow/DeleteCashflowTransactionService'; +import { DeleteCashflowTransaction } from '../../../services/Cashflow/DeleteCashflowTransactionService'; import CheckPolicies from '@/api/middleware/CheckPolicies'; import { AbilitySubject, CashflowAction } from '@/interfaces'; @Service() -export default class DeleteCashflowTransaction extends BaseController { +export default class DeleteCashflowTransactionController extends BaseController { @Inject() - deleteCashflowService: DeleteCashflowTransactionService; + private deleteCashflowService: DeleteCashflowTransaction; /** * Controller router. diff --git a/packages/server/src/api/controllers/Cashflow/GetCashflowAccounts.ts b/packages/server/src/api/controllers/Cashflow/GetCashflowAccounts.ts index 59fdb91ba..b84dad4eb 100644 --- a/packages/server/src/api/controllers/Cashflow/GetCashflowAccounts.ts +++ b/packages/server/src/api/controllers/Cashflow/GetCashflowAccounts.ts @@ -11,10 +11,10 @@ import { AbilitySubject, CashflowAction } from '@/interfaces'; @Service() export default class GetCashflowAccounts extends BaseController { @Inject() - getCashflowAccountsService: GetCashflowAccountsService; + private getCashflowAccountsService: GetCashflowAccountsService; @Inject() - getCashflowTransactionsService: GetCashflowTransactionsService; + private getCashflowTransactionsService: GetCashflowTransactionsService; /** * Controller router. diff --git a/packages/server/src/api/controllers/Cashflow/GetCashflowTransaction.ts b/packages/server/src/api/controllers/Cashflow/GetCashflowTransaction.ts index 7cf8d2d8e..80d610f48 100644 --- a/packages/server/src/api/controllers/Cashflow/GetCashflowTransaction.ts +++ b/packages/server/src/api/controllers/Cashflow/GetCashflowTransaction.ts @@ -10,7 +10,7 @@ import { AbilitySubject, CashflowAction } from '@/interfaces'; @Service() export default class GetCashflowAccounts extends BaseController { @Inject() - getCashflowTransactionsService: GetCashflowTransactionsService; + private getCashflowTransactionsService: GetCashflowTransactionsService; /** * Controller router. diff --git a/packages/server/src/api/controllers/Cashflow/NewCashflowTransaction.ts b/packages/server/src/api/controllers/Cashflow/NewCashflowTransaction.ts index 91abfcf92..9c48934ea 100644 --- a/packages/server/src/api/controllers/Cashflow/NewCashflowTransaction.ts +++ b/packages/server/src/api/controllers/Cashflow/NewCashflowTransaction.ts @@ -6,18 +6,27 @@ import { ServiceError } from '@/exceptions'; import NewCashflowTransactionService from '@/services/Cashflow/NewCashflowTransactionService'; import CheckPolicies from '@/api/middleware/CheckPolicies'; import { AbilitySubject, CashflowAction } from '@/interfaces'; +import { CashflowApplication } from '@/services/Cashflow/CashflowApplication'; @Service() export default class NewCashflowTransactionController extends BaseController { @Inject() private newCashflowTranscationService: NewCashflowTransactionService; + @Inject() + private cashflowApplication: CashflowApplication; + /** * Router constructor. */ public router() { const router = Router(); + router.get( + '/transactions/uncategorized', + this.asyncMiddleware(this.getUncategorizedCashflowTransactions), + this.catchServiceErrors + ); router.post( '/transactions', CheckPolicies(CashflowAction.Create, AbilitySubject.Cashflow), @@ -26,13 +35,61 @@ export default class NewCashflowTransactionController extends BaseController { this.asyncMiddleware(this.newCashflowTransaction), this.catchServiceErrors ); + router.post( + '/transactions/:id/uncategorize', + this.revertCategorizedCashflowTransaction, + this.catchServiceErrors + ); + router.post( + '/transactions/:id/categorize', + this.categorizeCashflowTransactionValidationSchema, + this.validationResult, + this.categorizeCashflowTransaction, + this.catchServiceErrors + ); + router.post( + '/transaction/:id/categorize/expense', + this.categorizeAsExpenseValidationSchema, + this.validationResult, + this.categorizesCashflowTransactionAsExpense, + this.catchServiceErrors + ); return router; } + /** + * Categorize as expense validation schema. + */ + public get categorizeAsExpenseValidationSchema() { + return [ + check('expense_account_id').exists(), + check('date').isISO8601().exists(), + check('reference_no').optional(), + check('exchange_rate').optional().isFloat({ gt: 0 }).toFloat(), + ]; + } + + /** + * Categorize cashflow tranasction validation schema. + */ + public get categorizeCashflowTransactionValidationSchema() { + return [ + check('date').exists().isISO8601().toDate(), + + check('to_account_id').exists().isInt().toInt(), + check('from_account_id').exists().isInt().toInt(), + + check('transaction_type').exists(), + check('reference_no').optional(), + check('exchange_rate').optional().isFloat({ gt: 0 }).toFloat(), + check('description').optional(), + ]; + } + /** * New cashflow transaction validation schema. */ - get newTransactionValidationSchema() { + public get newTransactionValidationSchema() { return [ check('date').exists().isISO8601().toDate(), check('reference_no').optional({ nullable: true }).trim().escape(), @@ -48,12 +105,10 @@ export default class NewCashflowTransactionController extends BaseController { check('credit_account_id').exists().isInt().toInt(), check('exchange_rate').optional().isFloat({ gt: 0 }).toFloat(), - check('branch_id').optional({ nullable: true }).isNumeric().toInt(), - check('publish').default(false).isBoolean().toBoolean(), ]; - } + }√ /** * Creates a new cashflow transaction. @@ -76,7 +131,6 @@ export default class NewCashflowTransactionController extends BaseController { ownerContributionDTO, userId ); - return res.status(200).send({ id: cashflowTransaction.id, message: 'New cashflow transaction has been created successfully.', @@ -86,11 +140,118 @@ export default class NewCashflowTransactionController extends BaseController { } }; + /** + * Revert the categorized cashflow transaction. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + private revertCategorizedCashflowTransaction = async ( + req: Request, + res: Response, + next: NextFunction + ) => { + const { tenantId } = req; + const { id: cashflowTransactionId } = req.params; + + try { + const data= await this.cashflowApplication.uncategorizeTransaction( + tenantId, + cashflowTransactionId + ); + return res.status(200).send({ data }); + } catch (error) { + next(error); + } + }; + + /** + * Categorize the cashflow transaction. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + private categorizeCashflowTransaction = async ( + req: Request, + res: Response, + next: NextFunction + ) => { + const { tenantId } = req; + const { id: cashflowTransactionId } = req.params; + const cashflowTransaction = this.matchedBodyData(req); + + try { + await this.cashflowApplication.categorizeTransaction( + tenantId, + cashflowTransactionId, + cashflowTransaction + ); + return res.status(200).send({ + message: 'The cashflow transaction has been created successfully.', + }); + } catch (error) { + next(error); + } + }; + + /** + * Categorize the transaction as expense transaction. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + private categorizesCashflowTransactionAsExpense = async ( + req: Request, + res: Response, + next: NextFunction + ) => { + const { tenantId } = req; + const { id: cashflowTransactionId } = req.params; + const cashflowTransaction = this.matchedBodyData(req); + + try { + await this.cashflowApplication.categorizeAsExpense( + tenantId, + cashflowTransactionId, + cashflowTransaction + ); + return res.status(200).send({ + message: 'The cashflow transaction has been created successfully.', + }); + } catch (error) { + next(error); + } + }; + + /** + * Retrieves the uncategorized cashflow transactions. + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + public getUncategorizedCashflowTransactions = async ( + req: Request, + res: Response, + next: NextFunction + ) => { + const { tenantId } = req; + + try { + const data = await this.cashflowApplication.getUncategorizedTransactions( + tenantId + ); + + return res.status(200).send(data); + } catch (error) { + next(error); + } + }; + /** * Handle the service errors. * @param error - * @param req - * @param res + * @param {Request} req + * @param {res * @param next * @returns */ diff --git a/packages/server/src/database/migrations/20240228183404_create_uncateogrized_cashflow_transactions_table.js b/packages/server/src/database/migrations/20240228183404_create_uncateogrized_cashflow_transactions_table.js new file mode 100644 index 000000000..2d72041f3 --- /dev/null +++ b/packages/server/src/database/migrations/20240228183404_create_uncateogrized_cashflow_transactions_table.js @@ -0,0 +1,25 @@ +exports.up = function (knex) { + return knex.schema.createTable( + 'uncategorized_cashflow_transactions', + (table) => { + table.increments('id'); + table.date('date').index(); + table.decimal('amount'); + table.string('reference_no').index(); + table + .integer('account_id') + .unsigned() + .references('id') + .inTable('accounts'); + table.string('description'); + table.string('categorize_ref_type'); + table.integer('categorize_ref_id').unsigned(); + table.boolean('categorized').defaultTo(false); + table.timestamps(); + } + ); +}; + +exports.down = function (knex) { + return knex.schema.dropTableIfExists('uncategorized_cashflow_transactions'); +}; diff --git a/packages/server/src/database/migrations/20240228224316_add_categorized_transaction_id_column.js b/packages/server/src/database/migrations/20240228224316_add_categorized_transaction_id_column.js new file mode 100644 index 000000000..749cc53b6 --- /dev/null +++ b/packages/server/src/database/migrations/20240228224316_add_categorized_transaction_id_column.js @@ -0,0 +1,11 @@ +exports.up = function (knex) { + return knex.schema.table('expenses_transactions', (table) => { + table + .integer('categorized_transaction_id') + .unsigned() + .references('id') + .inTable('uncategorized_cashflow_transactions'); + }); +}; + +exports.down = function (knex) {}; diff --git a/packages/server/src/interfaces/CashFlow.ts b/packages/server/src/interfaces/CashFlow.ts index 40cd50896..8bcc1eafc 100644 --- a/packages/server/src/interfaces/CashFlow.ts +++ b/packages/server/src/interfaces/CashFlow.ts @@ -233,3 +233,27 @@ export interface ICashflowTransactionSchema { } export interface ICashflowTransactionInput extends ICashflowTransactionSchema {} + +export interface ICategorizeCashflowTransactioDTO { + fromAccountId: number; + toAccountId: number; + referenceNo: string; + transactionNumber: string; + transactionType: string; + exchangeRate: number; + description: string; + branchId: number; +} + +export interface IUncategorizedCashflowTransaction { + id?: number; + amount: number; + date: Date; + currencyCode: string; + accountId: number; + description: string; + referenceNo: string; + categorizeRefType: string; + categorizeRefId: number; + categorized: boolean; +} diff --git a/packages/server/src/interfaces/CashflowService.ts b/packages/server/src/interfaces/CashflowService.ts index e279aec05..5b446571d 100644 --- a/packages/server/src/interfaces/CashflowService.ts +++ b/packages/server/src/interfaces/CashflowService.ts @@ -1,5 +1,6 @@ import { Knex } from 'knex'; import { IAccount } from './Account'; +import { IUncategorizedCashflowTransaction } from './CashFlow'; export interface ICashflowAccountTransactionsFilter { page: number; @@ -124,8 +125,34 @@ export interface ICommandCashflowDeletedPayload { trx: Knex.Transaction; } +export interface ICashflowTransactionCategorizedPayload { + tenantId: number; + cashflowTransactionId: number; + cashflowTransaction: ICashflowTransaction; + trx: Knex.Transaction; +} +export interface ICashflowTransactionUncategorizingPayload { + tenantId: number; + uncategorizedTransaction: IUncategorizedCashflowTransaction; + trx: Knex.Transaction; +} +export interface ICashflowTransactionUncategorizedPayload { + tenantId: number; + uncategorizedTransaction: IUncategorizedCashflowTransaction; + oldUncategorizedTransaction: IUncategorizedCashflowTransaction; + trx: Knex.Transaction; +} + export enum CashflowAction { Create = 'Create', Delete = 'Delete', View = 'View', } + +export interface CategorizeTransactionAsExpenseDTO { + expenseAccountId: number; + exchangeRate: number; + referenceNo: string; + description: string; + branchId?: number; +} diff --git a/packages/server/src/loaders/eventEmitter.ts b/packages/server/src/loaders/eventEmitter.ts index 5e874a2ad..ccc28df3d 100644 --- a/packages/server/src/loaders/eventEmitter.ts +++ b/packages/server/src/loaders/eventEmitter.ts @@ -88,6 +88,7 @@ import { PlaidUpdateTransactionsOnItemCreatedSubscriber } from '@/services/Banki import { InvoiceChangeStatusOnMailSentSubscriber } from '@/services/Sales/Invoices/subscribers/InvoiceChangeStatusOnMailSentSubscriber'; import { SaleReceiptMarkClosedOnMailSentSubcriber } from '@/services/Sales/Receipts/subscribers/SaleReceiptMarkClosedOnMailSentSubcriber'; import { SaleEstimateMarkApprovedOnMailSent } from '@/services/Sales/Estimates/subscribers/SaleEstimateMarkApprovedOnMailSent'; +import { DeleteCashflowTransactionOnUncategorize } from '@/services/Cashflow/subscribers/DeleteCashflowTransactionOnUncategorize'; export default () => { return new EventPublisher(); @@ -212,6 +213,9 @@ export const susbcribers = () => { SyncItemTaxRateOnEditTaxSubscriber, // Plaid - PlaidUpdateTransactionsOnItemCreatedSubscriber + PlaidUpdateTransactionsOnItemCreatedSubscriber, + + // Cashflow + DeleteCashflowTransactionOnUncategorize, ]; }; diff --git a/packages/server/src/loaders/tenantModels.ts b/packages/server/src/loaders/tenantModels.ts index 2a3a3664f..c3d08ab6f 100644 --- a/packages/server/src/loaders/tenantModels.ts +++ b/packages/server/src/loaders/tenantModels.ts @@ -62,6 +62,7 @@ import TaxRate from 'models/TaxRate'; import TaxRateTransaction from 'models/TaxRateTransaction'; import Attachment from 'models/Attachment'; import PlaidItem from 'models/PlaidItem'; +import UncategorizedCashflowTransaction from 'models/UncategorizedCashflowTransaction'; export default (knex) => { const models = { @@ -126,7 +127,8 @@ export default (knex) => { TaxRate, TaxRateTransaction, Attachment, - PlaidItem + PlaidItem, + UncategorizedCashflowTransaction }; return mapValues(models, (model) => model.bindKnex(knex)); }; diff --git a/packages/server/src/models/CashflowTransaction.ts b/packages/server/src/models/CashflowTransaction.ts index d4743d4ce..4e47d0e2d 100644 --- a/packages/server/src/models/CashflowTransaction.ts +++ b/packages/server/src/models/CashflowTransaction.ts @@ -12,6 +12,7 @@ export default class CashflowTransaction extends TenantModel { transactionType: string; amount: number; exchangeRate: number; + uncategorize: boolean; /** * Table name. diff --git a/packages/server/src/models/Expense.ts b/packages/server/src/models/Expense.ts index ed756e2bb..967c9c734 100644 --- a/packages/server/src/models/Expense.ts +++ b/packages/server/src/models/Expense.ts @@ -182,6 +182,7 @@ export default class Expense extends mixin(TenantModel, [ const ExpenseCategory = require('models/ExpenseCategory'); const Media = require('models/Media'); const Branch = require('models/Branch'); + const UncategorizedCashflowTransaction = require('models/UncategorizedCashflowTransaction'); return { paymentAccount: { @@ -215,6 +216,10 @@ export default class Expense extends mixin(TenantModel, [ to: 'branches.id', }, }, + + /** + * + */ media: { relation: Model.ManyToManyRelation, modelClass: Media.default, @@ -230,6 +235,18 @@ export default class Expense extends mixin(TenantModel, [ query.where('model_name', 'Expense'); }, }, + + /** + * Retrieves the related uncategorized cashflow transaction. + */ + categorized: { + relation: Model.BelongsToOneRelation, + modelClass: UncategorizedCashflowTransaction.default, + join: { + from: 'expenses_transactions.categorizedTransactionId', + to: 'uncategorized_cashflow_transactions.id', + }, + } }; } diff --git a/packages/server/src/models/UncategorizedCashflowTransaction.ts b/packages/server/src/models/UncategorizedCashflowTransaction.ts new file mode 100644 index 000000000..ed00be47e --- /dev/null +++ b/packages/server/src/models/UncategorizedCashflowTransaction.ts @@ -0,0 +1,64 @@ +/* eslint-disable global-require */ +import TenantModel from 'models/TenantModel'; +import { Model } from 'objection'; + +export default class UncategorizedCashflowTransaction extends TenantModel { + amount: number; + /** + * Table name. + */ + static get tableName() { + return 'uncategorized_cashflow_transactions'; + } + + /** + * Timestamps columns. + */ + static get timestamps() { + return ['createdAt', 'updatedAt']; + } + + /** + * Retrieves the withdrawal amount. + * @returns {number} + */ + public withdrawal() { + return this.amount > 0 ? Math.abs(this.amount) : 0; + } + + /** + * Retrieves the deposit amount. + * @returns {number} + */ + public deposit() { + return this.amount < 0 ? Math.abs(this.amount) : 0; + } + + /** + * Virtual attributes. + */ + static get virtualAttributes() { + return ['withdrawal', 'deposit']; + } + + /** + * Relationship mapping. + */ + static get relationMappings() { + const Account = require('models/Account'); + + return { + /** + * Transaction may has associated to account. + */ + account: { + relation: Model.BelongsToOneRelation, + modelClass: Account.default, + join: { + from: 'uncategorized_cashflow_transactions.accountId', + to: 'accounts.id', + }, + }, + }; + } +} diff --git a/packages/server/src/services/Banking/Plaid/PlaidSyncDB.ts b/packages/server/src/services/Banking/Plaid/PlaidSyncDB.ts index 6f0260a2b..a9f9d14fd 100644 --- a/packages/server/src/services/Banking/Plaid/PlaidSyncDB.ts +++ b/packages/server/src/services/Banking/Plaid/PlaidSyncDB.ts @@ -9,7 +9,7 @@ import { transformPlaidTrxsToCashflowCreate, } from './utils'; import NewCashflowTransactionService from '@/services/Cashflow/NewCashflowTransactionService'; -import DeleteCashflowTransactionService from '@/services/Cashflow/DeleteCashflowTransactionService'; +import { DeleteCashflowTransaction } from '@/services/Cashflow/DeleteCashflowTransactionService'; import HasTenancyService from '@/services/Tenancy/TenancyService'; const CONCURRENCY_ASYNC = 10; @@ -26,7 +26,7 @@ export class PlaidSyncDb { private createCashflowTransactionService: NewCashflowTransactionService; @Inject() - private deleteCashflowTransactionService: DeleteCashflowTransactionService; + private deleteCashflowTransactionService: DeleteCashflowTransaction; /** * Syncs the plaid accounts to the system accounts. diff --git a/packages/server/src/services/Cashflow/CashflowApplication.ts b/packages/server/src/services/Cashflow/CashflowApplication.ts new file mode 100644 index 000000000..ee7548fee --- /dev/null +++ b/packages/server/src/services/Cashflow/CashflowApplication.ts @@ -0,0 +1,104 @@ +import { Inject, Service } from 'typedi'; +import { DeleteCashflowTransaction } from './DeleteCashflowTransactionService'; +import { UncategorizeCashflowTransaction } from './UncategorizeCashflowTransaction'; +import { CategorizeCashflowTransaction } from './CategorizeCashflowTransaction'; +import { + CategorizeTransactionAsExpenseDTO, + ICategorizeCashflowTransactioDTO, +} from '@/interfaces'; +import { CategorizeTransactionAsExpense } from './CategorizeTransactionAsExpense'; +import { GetUncategorizedTransactions } from './GetUncategorizedTransactions'; + +@Service() +export class CashflowApplication { + @Inject() + private deleteTransactionService: DeleteCashflowTransaction; + + @Inject() + private uncategorizeTransactionService: UncategorizeCashflowTransaction; + + @Inject() + private categorizeTransactionService: CategorizeCashflowTransaction; + + @Inject() + private categorizeAsExpenseService: CategorizeTransactionAsExpense; + + @Inject() + private getUncategorizedTransactionsService: GetUncategorizedTransactions; + + /** + * Deletes the given cashflow transaction. + * @param {number} tenantId + * @param {number} cashflowTransactionId + * @returns + */ + public deleteTransaction(tenantId: number, cashflowTransactionId: number) { + return this.deleteTransactionService.deleteCashflowTransaction( + tenantId, + cashflowTransactionId + ); + } + + /** + * Uncategorize the given cashflow transaction. + * @param {number} tenantId + * @param {number} cashflowTransactionId + * @returns + */ + public uncategorizeTransaction( + tenantId: number, + cashflowTransactionId: number + ) { + return this.uncategorizeTransactionService.uncategorize( + tenantId, + cashflowTransactionId + ); + } + + /** + * Categorize the given cashflow transaction. + * @param {number} tenantId + * @param {number} cashflowTransactionId + * @param {ICategorizeCashflowTransactioDTO} categorizeDTO + * @returns + */ + public categorizeTransaction( + tenantId: number, + cashflowTransactionId: number, + categorizeDTO: ICategorizeCashflowTransactioDTO + ) { + return this.categorizeTransactionService.categorize( + tenantId, + cashflowTransactionId, + categorizeDTO + ); + } + + /** + * Categorizes the given cashflow transaction as expense transaction. + * @param {number} tenantId + * @param {number} cashflowTransactionId + * @param {CategorizeTransactionAsExpenseDTO} transactionDTO + * @returns + */ + public categorizeAsExpense( + tenantId: number, + cashflowTransactionId: number, + transactionDTO: CategorizeTransactionAsExpenseDTO + ) { + return this.categorizeAsExpenseService.categorize( + tenantId, + cashflowTransactionId, + transactionDTO + ); + } + + /** + * Retrieves the uncategorized cashflow transactions. + * @param {number} tenantId + * @returns {} + */ + public getUncategorizedTransactions(tenantId: number) { + return this.getUncategorizedTransactionsService.getTransactions(tenantId); + } +} diff --git a/packages/server/src/services/Cashflow/CashflowTransactionJournalEntries.ts b/packages/server/src/services/Cashflow/CashflowTransactionJournalEntries.ts index 21df84f34..c1c590d55 100644 --- a/packages/server/src/services/Cashflow/CashflowTransactionJournalEntries.ts +++ b/packages/server/src/services/Cashflow/CashflowTransactionJournalEntries.ts @@ -1,11 +1,9 @@ import { Inject, Service } from 'typedi'; import { Knex } from 'knex'; -import * as R from 'ramda'; import { ILedgerEntry, ICashflowTransaction, AccountNormal, - ICashflowTransactionLine, } from '../../interfaces'; import { transformCashflowTransactionType, diff --git a/packages/server/src/services/Cashflow/CategorizeCashflowTransaction.ts b/packages/server/src/services/Cashflow/CategorizeCashflowTransaction.ts new file mode 100644 index 000000000..e965afede --- /dev/null +++ b/packages/server/src/services/Cashflow/CategorizeCashflowTransaction.ts @@ -0,0 +1,93 @@ +import { Inject, Service } from 'typedi'; +import HasTenancyService from '../Tenancy/TenancyService'; +import events from '@/subscribers/events'; +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import UnitOfWork from '../UnitOfWork'; +import { + ICashflowTransactionCategorizedPayload, + ICashflowTransactionUncategorizingPayload, + ICategorizeCashflowTransactioDTO, +} from '@/interfaces'; +import { Knex } from 'knex'; +import { transformCategorizeTransToCashflow } from './utils'; +import { CommandCashflowValidator } from './CommandCasflowValidator'; +import NewCashflowTransactionService from './NewCashflowTransactionService'; + +@Service() +export class CategorizeCashflowTransaction { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private eventPublisher: EventPublisher; + + @Inject() + private uow: UnitOfWork; + + @Inject() + private commandValidators: CommandCashflowValidator; + + @Inject() + private createCashflow: NewCashflowTransactionService; + + /** + * Categorize the given cashflow transaction. + * @param {number} tenantId + * @param {ICategorizeCashflowTransactioDTO} categorizeDTO + */ + public async categorize( + tenantId: number, + uncategorizedTransactionId: number, + categorizeDTO: ICategorizeCashflowTransactioDTO + ) { + const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId); + + // Retrieves the uncategorized transaction or throw an error. + const transaction = await UncategorizedCashflowTransaction.query() + .findById(uncategorizedTransactionId) + .throwIfNotFound(); + + // Validates the transaction shouldn't be categorized before. + this.commandValidators.validateTransactionShouldNotCategorized(transaction); + + // Edits the cashflow transaction under UOW env. + return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => { + // Triggers `onTransactionCategorizing` event. + await this.eventPublisher.emitAsync( + events.cashflow.onTransactionCategorizing, + { + tenantId, + trx, + } as ICashflowTransactionUncategorizingPayload + ); + // Transformes the categorize DTO to the cashflow transaction. + const cashflowTransactionDTO = transformCategorizeTransToCashflow( + transaction, + categorizeDTO + ); + // Creates a new cashflow transaction. + const cashflowTransaction = + await this.createCashflow.newCashflowTransaction( + tenantId, + cashflowTransactionDTO + ); + // Updates the uncategorized transaction as categorized. + await UncategorizedCashflowTransaction.query(trx) + .findById(uncategorizedTransactionId) + .patch({ + categorized: true, + categorizeRefType: 'CashflowTransaction', + categorizeRefId: cashflowTransaction.id, + }); + // Triggers `onCashflowTransactionCategorized` event. + await this.eventPublisher.emitAsync( + events.cashflow.onTransactionCategorized, + { + tenantId, + // cashflowTransaction, + trx, + } as ICashflowTransactionCategorizedPayload + ); + }); + } +} diff --git a/packages/server/src/services/Cashflow/CategorizeTransactionAsExpense.ts b/packages/server/src/services/Cashflow/CategorizeTransactionAsExpense.ts new file mode 100644 index 000000000..119f0cc7b --- /dev/null +++ b/packages/server/src/services/Cashflow/CategorizeTransactionAsExpense.ts @@ -0,0 +1,80 @@ +import { + CategorizeTransactionAsExpenseDTO, + ICashflowTransactionCategorizedPayload, +} from '@/interfaces'; +import { Inject, Service } from 'typedi'; +import UnitOfWork from '../UnitOfWork'; +import events from '@/subscribers/events'; +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import HasTenancyService from '../Tenancy/TenancyService'; +import { Knex } from 'knex'; +import { CreateExpense } from '../Expenses/CRUD/CreateExpense'; + +@Service() +export class CategorizeTransactionAsExpense { + @Inject() + private uow: UnitOfWork; + + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private eventPublisher: EventPublisher; + + @Inject() + private createExpenseService: CreateExpense; + + /** + * Categorize the transaction as expense transaction. + * @param {number} tenantId + * @param {number} cashflowTransactionId + * @param {CategorizeTransactionAsExpenseDTO} transactionDTO + */ + public async categorize( + tenantId: number, + cashflowTransactionId: number, + transactionDTO: CategorizeTransactionAsExpenseDTO + ) { + const { CashflowTransaction } = this.tenancy.models(tenantId); + + const transaction = await CashflowTransaction.query() + .findById(cashflowTransactionId) + .throwIfNotFound(); + + return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => { + // Triggers `onTransactionUncategorizing` event. + await this.eventPublisher.emitAsync( + events.cashflow.onTransactionCategorizingAsExpense, + { + tenantId, + trx, + } as ICashflowTransactionCategorizedPayload + ); + // Creates a new expense transaction. + const expenseTransaction = await this.createExpenseService.newExpense( + tenantId, + { + + }, + 1 + ); + // Updates the item on the storage and fetches the updated once. + const cashflowTransaction = await CashflowTransaction.query( + trx + ).patchAndFetchById(cashflowTransactionId, { + categorizeRefType: 'Expense', + categorizeRefId: expenseTransaction.id, + uncategorized: true, + }); + // Triggers `onTransactionUncategorized` event. + await this.eventPublisher.emitAsync( + events.cashflow.onTransactionCategorizedAsExpense, + { + tenantId, + cashflowTransaction, + trx, + } as ICashflowTransactionUncategorizedPayload + ); + }); + } +} diff --git a/packages/server/src/services/Cashflow/CommandCasflowValidator.ts b/packages/server/src/services/Cashflow/CommandCasflowValidator.ts index 07722e50e..aabdc6301 100644 --- a/packages/server/src/services/Cashflow/CommandCasflowValidator.ts +++ b/packages/server/src/services/Cashflow/CommandCasflowValidator.ts @@ -4,6 +4,7 @@ import { IAccount } from '@/interfaces'; import { getCashflowTransactionType } from './utils'; import { ServiceError } from '@/exceptions'; import { CASHFLOW_TRANSACTION_TYPE, ERRORS } from './constants'; +import CashflowTransaction from '@/models/CashflowTransaction'; @Service() export class CommandCashflowValidator { @@ -46,4 +47,28 @@ export class CommandCashflowValidator { } return transformedType; }; + + /** + * Validate the given transaction should be categorized. + * @param {CashflowTransaction} cashflowTransaction + */ + public validateTransactionShouldCategorized( + cashflowTransaction: CashflowTransaction + ) { + if (!cashflowTransaction.uncategorize) { + throw new ServiceError(ERRORS.TRANSACTION_ALREADY_CATEGORIZED); + } + } + + /** + * Validate the given transcation shouldn't be categorized. + * @param {CashflowTransaction} cashflowTransaction + */ + public validateTransactionShouldNotCategorized( + cashflowTransaction: CashflowTransaction + ) { + if (cashflowTransaction.uncategorize) { + throw new ServiceError(ERRORS.TRANSACTION_ALREADY_CATEGORIZED); + } + } } diff --git a/packages/server/src/services/Cashflow/DeleteCashflowTransactionService.ts b/packages/server/src/services/Cashflow/DeleteCashflowTransactionService.ts index e07073c3d..dd6c3002a 100644 --- a/packages/server/src/services/Cashflow/DeleteCashflowTransactionService.ts +++ b/packages/server/src/services/Cashflow/DeleteCashflowTransactionService.ts @@ -13,15 +13,15 @@ import UnitOfWork from '@/services/UnitOfWork'; import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; @Service() -export default class CommandCashflowTransactionService { +export class DeleteCashflowTransaction { @Inject() - tenancy: HasTenancyService; + private tenancy: HasTenancyService; @Inject() - eventPublisher: EventPublisher; + private eventPublisher: EventPublisher; @Inject() - uow: UnitOfWork; + private uow: UnitOfWork; /** * Deletes the cashflow transaction with associated journal entries. diff --git a/packages/server/src/services/Cashflow/GetCashflowTransactionsService.ts b/packages/server/src/services/Cashflow/GetCashflowTransactionsService.ts index eb22dd305..13852dd05 100644 --- a/packages/server/src/services/Cashflow/GetCashflowTransactionsService.ts +++ b/packages/server/src/services/Cashflow/GetCashflowTransactionsService.ts @@ -4,7 +4,6 @@ import { CashflowTransactionTransformer } from './CashflowTransactionTransformer import { ERRORS } from './constants'; import { ICashflowTransaction } from '@/interfaces'; import { ServiceError } from '@/exceptions'; -import I18nService from '@/services/I18n/I18nService'; import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; @Service() @@ -12,9 +11,6 @@ export default class GetCashflowTransactionsService { @Inject() private tenancy: HasTenancyService; - @Inject() - private i18nService: I18nService; - @Inject() private transfromer: TransformerInjectable; diff --git a/packages/server/src/services/Cashflow/GetUncategorizedTransactions.ts b/packages/server/src/services/Cashflow/GetUncategorizedTransactions.ts new file mode 100644 index 000000000..955a5538a --- /dev/null +++ b/packages/server/src/services/Cashflow/GetUncategorizedTransactions.ts @@ -0,0 +1,37 @@ +import { Inject, Service } from 'typedi'; +import HasTenancyService from '../Tenancy/TenancyService'; +import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; +import { UncategorizedTransactionTransformer } from './UncategorizedTransactionTransformer'; + +@Service() +export class GetUncategorizedTransactions { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private transformer: TransformerInjectable; + + /** + * Retrieves the uncategorized cashflow transactions. + * @param {number} tenantId + */ + public async getTransactions(tenantId: number) { + const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId); + + const { results, pagination } = + await UncategorizedCashflowTransaction.query() + .where('categorized', false) + .withGraphFetched('account') + .pagination(0, 10); + + const data = await this.transformer.transform( + tenantId, + results, + new UncategorizedTransactionTransformer() + ); + return { + data, + pagination, + }; + } +} diff --git a/packages/server/src/services/Cashflow/NewCashflowTransactionService.ts b/packages/server/src/services/Cashflow/NewCashflowTransactionService.ts index e8c53f5fc..5222a2664 100644 --- a/packages/server/src/services/Cashflow/NewCashflowTransactionService.ts +++ b/packages/server/src/services/Cashflow/NewCashflowTransactionService.ts @@ -1,11 +1,10 @@ import { Service, Inject } from 'typedi'; -import { isEmpty, pick } from 'lodash'; +import { pick } from 'lodash'; import { Knex } from 'knex'; import * as R from 'ramda'; import { ICashflowNewCommandDTO, ICashflowTransaction, - ICashflowTransactionLine, ICommandCashflowCreatedPayload, ICommandCashflowCreatingPayload, ICashflowTransactionInput, @@ -126,7 +125,7 @@ export default class NewCashflowTransactionService { tenantId: number, newTransactionDTO: ICashflowNewCommandDTO, userId?: number - ): Promise<{ cashflowTransaction: ICashflowTransaction }> => { + ): Promise => { const { CashflowTransaction, Account } = this.tenancy.models(tenantId); // Retrieves the cashflow account or throw not found error. @@ -175,7 +174,7 @@ export default class NewCashflowTransactionService { trx, } as ICommandCashflowCreatedPayload ); - return { cashflowTransaction }; + return cashflowTransaction; }); }; } diff --git a/packages/server/src/services/Cashflow/UncategorizeCashflowTransaction.ts b/packages/server/src/services/Cashflow/UncategorizeCashflowTransaction.ts new file mode 100644 index 000000000..ba6740685 --- /dev/null +++ b/packages/server/src/services/Cashflow/UncategorizeCashflowTransaction.ts @@ -0,0 +1,72 @@ +import { Knex } from 'knex'; +import { Inject, Service } from 'typedi'; +import HasTenancyService from '../Tenancy/TenancyService'; +import UnitOfWork from '../UnitOfWork'; +import events from '@/subscribers/events'; +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import { + ICashflowTransactionUncategorizedPayload, + ICashflowTransactionUncategorizingPayload, +} from '@/interfaces'; + +@Service() +export class UncategorizeCashflowTransaction { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private eventPublisher: EventPublisher; + + @Inject() + private uow: UnitOfWork; + + /** + * Uncategorizes the given cashflow transaction. + * @param {number} tenantId + * @param {number} cashflowTransactionId + */ + public async uncategorize( + tenantId: number, + uncategorizedTransactionId: number + ) { + const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId); + + const oldUncategorizedTransaction = + await UncategorizedCashflowTransaction.query() + .findById(uncategorizedTransactionId) + .throwIfNotFound(); + + // Updates the transaction under UOW. + return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => { + // Triggers `onTransactionUncategorizing` event. + await this.eventPublisher.emitAsync( + events.cashflow.onTransactionUncategorizing, + { + tenantId, + trx, + } as ICashflowTransactionUncategorizingPayload + ); + // Removes the ref relation with the related transaction. + const uncategorizedTransaction = + await UncategorizedCashflowTransaction.query(trx).updateAndFetchById( + uncategorizedTransactionId, + { + categorized: false, + categorizeRefId: null, + categorizeRefType: null, + } + ); + // Triggers `onTransactionUncategorized` event. + await this.eventPublisher.emitAsync( + events.cashflow.onTransactionUncategorized, + { + tenantId, + uncategorizedTransaction, + oldUncategorizedTransaction, + trx, + } as ICashflowTransactionUncategorizedPayload + ); + return uncategorizedTransaction; + }); + } +} diff --git a/packages/server/src/services/Cashflow/UncategorizeTransactionByRef.ts b/packages/server/src/services/Cashflow/UncategorizeTransactionByRef.ts new file mode 100644 index 000000000..f5590fb83 --- /dev/null +++ b/packages/server/src/services/Cashflow/UncategorizeTransactionByRef.ts @@ -0,0 +1,9 @@ +import { Service } from 'typedi'; +import { UncategorizeCashflowTransaction } from './UncategorizeCashflowTransaction'; + +@Service() +export class UncategorizeTransactionByRef { + private uncategorizeTransactionService: UncategorizeCashflowTransaction; + + public uncategorize(tenantId: number, refId: number, refType: string) {} +} diff --git a/packages/server/src/services/Cashflow/UncategorizedTransactionTransformer.ts b/packages/server/src/services/Cashflow/UncategorizedTransactionTransformer.ts new file mode 100644 index 000000000..2a4ceb4a4 --- /dev/null +++ b/packages/server/src/services/Cashflow/UncategorizedTransactionTransformer.ts @@ -0,0 +1,34 @@ +import { Transformer } from '@/lib/Transformer/Transformer'; +import { formatNumber } from '@/utils'; + +export class UncategorizedTransactionTransformer extends Transformer { + /** + * Include these attributes to sale invoice object. + * @returns {string[]} + */ + public includeAttributes = (): string[] => { + return ['formattetDepositAmount', 'formattedWithdrawalAmount']; + }; + + /** + * Formatted deposit amount. + * @param transaction + * @returns {string} + */ + protected formattetDepositAmount(transaction) { + return formatNumber(transaction.deposit, { + currencyCode: transaction.currencyCode, + }); + } + + /** + * Formatted withdrawal amount. + * @param transaction + * @returns {string} + */ + protected formattedWithdrawalAmount(transaction) { + return formatNumber(transaction.withdrawal, { + currencyCode: transaction.currencyCode, + }); + } +} diff --git a/packages/server/src/services/Cashflow/constants.ts b/packages/server/src/services/Cashflow/constants.ts index 2e664a519..4ec2d7004 100644 --- a/packages/server/src/services/Cashflow/constants.ts +++ b/packages/server/src/services/Cashflow/constants.ts @@ -8,7 +8,9 @@ export const ERRORS = { CREDIT_ACCOUNTS_IDS_NOT_FOUND: 'CREDIT_ACCOUNTS_IDS_NOT_FOUND', CREDIT_ACCOUNTS_HAS_INVALID_TYPE: 'CREDIT_ACCOUNTS_HAS_INVALID_TYPE', ACCOUNT_ID_HAS_INVALID_TYPE: 'ACCOUNT_ID_HAS_INVALID_TYPE', - ACCOUNT_HAS_ASSOCIATED_TRANSACTIONS: 'account_has_associated_transactions' + ACCOUNT_HAS_ASSOCIATED_TRANSACTIONS: 'account_has_associated_transactions', + TRANSACTION_ALREADY_CATEGORIZED: 'TRANSACTION_ALREADY_CATEGORIZED', + TRANSACTION_ALREADY_UNCATEGORIZED: 'TRANSACTION_ALREADY_UNCATEGORIZED' }; export enum CASHFLOW_DIRECTION { diff --git a/packages/server/src/services/Cashflow/subscribers/DeleteCashflowTransactionOnUncategorize.ts b/packages/server/src/services/Cashflow/subscribers/DeleteCashflowTransactionOnUncategorize.ts new file mode 100644 index 000000000..3715b769c --- /dev/null +++ b/packages/server/src/services/Cashflow/subscribers/DeleteCashflowTransactionOnUncategorize.ts @@ -0,0 +1,40 @@ +import { Inject, Service } from 'typedi'; +import events from '@/subscribers/events'; +import { ICashflowTransactionUncategorizedPayload } from '@/interfaces'; +import { DeleteCashflowTransaction } from '../DeleteCashflowTransactionService'; + +@Service() +export class DeleteCashflowTransactionOnUncategorize { + @Inject() + private deleteCashflowTransactionService: DeleteCashflowTransaction; + + /** + * Attaches events with handlers. + */ + public attach = (bus) => { + bus.subscribe( + events.cashflow.onTransactionUncategorized, + this.deleteCashflowTransactionOnUncategorize.bind(this) + ); + }; + + /** + * Deletes the cashflow transaction on uncategorize transaction. + * @param {ICashflowTransactionUncategorizedPayload} payload + */ + public async deleteCashflowTransactionOnUncategorize({ + tenantId, + oldUncategorizedTransaction, + trx, + }: ICashflowTransactionUncategorizedPayload) { + // Deletes the cashflow transaction. + if ( + oldUncategorizedTransaction.categorizeRefType === 'CashflowTransaction' + ) { + await this.deleteCashflowTransactionService.deleteCashflowTransaction( + tenantId, + oldUncategorizedTransaction.categorizeRefId + ); + } + } +} diff --git a/packages/server/src/services/Cashflow/utils.ts b/packages/server/src/services/Cashflow/utils.ts index 31af4b1bc..9d76f1862 100644 --- a/packages/server/src/services/Cashflow/utils.ts +++ b/packages/server/src/services/Cashflow/utils.ts @@ -1,13 +1,19 @@ -import { upperFirst, camelCase } from 'lodash'; +import { upperFirst, camelCase, omit } from 'lodash'; import { CASHFLOW_TRANSACTION_TYPE, CASHFLOW_TRANSACTION_TYPE_META, ICashflowTransactionTypeMeta, } from './constants'; +import { + ICashflowNewCommandDTO, + ICashflowTransaction, + ICategorizeCashflowTransactioDTO, + IUncategorizedCashflowTransaction, +} from '@/interfaces'; /** * Ensures the given transaction type to transformed to appropriate format. - * @param {string} type + * @param {string} type * @returns {string} */ export const transformCashflowTransactionType = (type) => { @@ -32,3 +38,29 @@ export function getCashflowTransactionType( export const getCashflowAccountTransactionsTypes = () => { return Object.values(CASHFLOW_TRANSACTION_TYPE_META).map((meta) => meta.type); }; + +/** + * Tranasformes the given uncategorized transaction and categorized DTO + * to cashflow create DTO. + * @param {IUncategorizedCashflowTransaction} uncategorizeModel + * @param {ICategorizeCashflowTransactioDTO} categorizeDTO + * @returns {ICashflowNewCommandDTO} + */ +export const transformCategorizeTransToCashflow = ( + uncategorizeModel: IUncategorizedCashflowTransaction, + categorizeDTO: ICategorizeCashflowTransactioDTO +): ICashflowNewCommandDTO => { + return { + date: uncategorizeModel.date, + referenceNo: categorizeDTO.referenceNo || uncategorizeModel.referenceNo, + description: categorizeDTO.description || uncategorizeModel.description, + cashflowAccountId: uncategorizeModel.accountId, + creditAccountId: categorizeDTO.fromAccountId || categorizeDTO.toAccountId, + exchangeRate: categorizeDTO.exchangeRate || 1, + currencyCode: uncategorizeModel.currencyCode, + amount: uncategorizeModel.amount, + transactionNumber: categorizeDTO.transactionNumber, + transactionType: categorizeDTO.transactionType, + publish: true, + }; +}; diff --git a/packages/server/src/subscribers/events.ts b/packages/server/src/subscribers/events.ts index bae27d1a6..0243cd172 100644 --- a/packages/server/src/subscribers/events.ts +++ b/packages/server/src/subscribers/events.ts @@ -392,6 +392,15 @@ export default { onTransactionDeleting: 'onCashflowTransactionDeleting', onTransactionDeleted: 'onCashflowTransactionDeleted', + + onTransactionCategorizing: 'onTransactionCategorizing', + onTransactionCategorized: 'onCashflowTransactionCategorized', + + onTransactionUncategorizing: 'onTransactionUncategorizing', + onTransactionUncategorized: 'onTransactionUncategorized', + + onTransactionCategorizingAsExpense: 'onTransactionCategorizingAsExpense', + onTransactionCategorizedAsExpense: 'onTransactionCategorizedAsExpense', }, /**