diff --git a/packages/server/src/interfaces/Account.ts b/packages/server/src/interfaces/Account.ts index 43ba31444..b1a880b80 100644 --- a/packages/server/src/interfaces/Account.ts +++ b/packages/server/src/interfaces/Account.ts @@ -66,7 +66,9 @@ export interface IAccountTransaction { referenceId: number; referenceNumber?: string; + transactionNumber?: string; + transactionType?: string; note?: string; diff --git a/packages/server/src/interfaces/FinancialReports/CashflowAccountTransactions/index.ts b/packages/server/src/interfaces/FinancialReports/CashflowAccountTransactions/index.ts index afc98f7dc..3372d73fd 100644 --- a/packages/server/src/interfaces/FinancialReports/CashflowAccountTransactions/index.ts +++ b/packages/server/src/interfaces/FinancialReports/CashflowAccountTransactions/index.ts @@ -29,4 +29,9 @@ export interface ICashflowAccountTransaction { date: Date; formattedDate: string; + + status: string; + formattedStatus: string; + + uncategorizedTransactionId: number; } diff --git a/packages/server/src/interfaces/Ledger.ts b/packages/server/src/interfaces/Ledger.ts index 2ab52a631..d7045eb41 100644 --- a/packages/server/src/interfaces/Ledger.ts +++ b/packages/server/src/interfaces/Ledger.ts @@ -40,6 +40,8 @@ export interface ILedgerEntry { date: Date | string; transactionType: string; + transactionSubType: string; + transactionId: number; transactionNumber?: string; diff --git a/packages/server/src/loaders/eventEmitter.ts b/packages/server/src/loaders/eventEmitter.ts index 01c43e6c8..21d20c387 100644 --- a/packages/server/src/loaders/eventEmitter.ts +++ b/packages/server/src/loaders/eventEmitter.ts @@ -111,6 +111,7 @@ import { ValidateMatchingOnCashflowDelete } from '@/services/Banking/Matching/ev import { RecognizeSyncedBankTranasctions } from '@/services/Banking/Plaid/subscribers/RecognizeSyncedBankTransactions'; import { UnlinkBankRuleOnDeleteBankRule } from '@/services/Banking/Rules/events/UnlinkBankRuleOnDeleteBankRule'; import { DecrementUncategorizedTransactionOnMatching } from '@/services/Banking/Matching/events/DecrementUncategorizedTransactionsOnMatch'; +import { DecrementUncategorizedTransactionOnExclude } from '@/services/Banking/Exclude/events/DecrementUncategorizedTransactionOnExclude'; export default () => { return new EventPublisher(); @@ -260,6 +261,7 @@ export const susbcribers = () => { TriggerRecognizedTransactions, UnlinkBankRuleOnDeleteBankRule, DecrementUncategorizedTransactionOnMatching, + DecrementUncategorizedTransactionOnExclude, // Validate matching ValidateMatchingOnCashflowDelete, diff --git a/packages/server/src/models/CashflowTransaction.ts b/packages/server/src/models/CashflowTransaction.ts index dca2355f2..f1cb8dd7a 100644 --- a/packages/server/src/models/CashflowTransaction.ts +++ b/packages/server/src/models/CashflowTransaction.ts @@ -5,9 +5,9 @@ import { getCashflowAccountTransactionsTypes, getCashflowTransactionType, } from '@/services/Cashflow/utils'; -import AccountTransaction from './AccountTransaction'; import { CASHFLOW_DIRECTION } from '@/services/Cashflow/constants'; import { getTransactionTypeLabel } from '@/utils/transactions-types'; + export default class CashflowTransaction extends TenantModel { transactionType: string; amount: number; @@ -100,9 +100,26 @@ export default class CashflowTransaction extends TenantModel { */ static get modifiers() { return { + /** + * Filter the published transactions. + */ published(query) { query.whereNot('published_at', null); }, + + /** + * Filter the not categorized transactions. + */ + notCategorized(query) { + query.whereNull('cashflowTransactions.uncategorizedTransactionId'); + }, + + /** + * Filter the categorized transactions. + */ + categorized(query) { + query.whereNotNull('cashflowTransactions.uncategorizedTransactionId'); + }, }; } diff --git a/packages/server/src/services/Accounting/utils.ts b/packages/server/src/services/Accounting/utils.ts index ee675f09c..2edc25b97 100644 --- a/packages/server/src/services/Accounting/utils.ts +++ b/packages/server/src/services/Accounting/utils.ts @@ -19,6 +19,8 @@ export const transformLedgerEntryToTransaction = ( referenceId: entry.transactionId, transactionNumber: entry.transactionNumber, + transactionType: entry.transactionSubType, + referenceNumber: entry.referenceNumber, note: entry.note, diff --git a/packages/server/src/services/Banking/BankAccounts/GetBankAccountSummary.ts b/packages/server/src/services/Banking/BankAccounts/GetBankAccountSummary.ts index 569222576..cfa3c8d4c 100644 --- a/packages/server/src/services/Banking/BankAccounts/GetBankAccountSummary.ts +++ b/packages/server/src/services/Banking/BankAccounts/GetBankAccountSummary.ts @@ -19,11 +19,13 @@ export class GetBankAccountSummary { Account, UncategorizedCashflowTransaction, RecognizedBankTransaction, + MatchedBankTransaction, } = this.tenancy.models(tenantId); await initialize(knex, [ UncategorizedCashflowTransaction, RecognizedBankTransaction, + MatchedBankTransaction, ]); const bankAccount = await Account.query() .findById(bankAccountId) diff --git a/packages/server/src/services/Banking/Exclude/ExcludeBankTransaction.ts b/packages/server/src/services/Banking/Exclude/ExcludeBankTransaction.ts index 7a1938199..16a25433c 100644 --- a/packages/server/src/services/Banking/Exclude/ExcludeBankTransaction.ts +++ b/packages/server/src/services/Banking/Exclude/ExcludeBankTransaction.ts @@ -2,6 +2,12 @@ import HasTenancyService from '@/services/Tenancy/TenancyService'; import UnitOfWork from '@/services/UnitOfWork'; import { Inject, Service } from 'typedi'; import { validateTransactionNotCategorized } from './utils'; +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import events from '@/subscribers/events'; +import { + IBankTransactionUnexcludedEventPayload, + IBankTransactionUnexcludingEventPayload, +} from './_types'; @Service() export class ExcludeBankTransaction { @@ -11,6 +17,9 @@ export class ExcludeBankTransaction { @Inject() private uow: UnitOfWork; + @Inject() + private eventPublisher: EventPublisher; + /** * Marks the given bank transaction as excluded. * @param {number} tenantId @@ -31,11 +40,23 @@ export class ExcludeBankTransaction { validateTransactionNotCategorized(oldUncategorizedTransaction); return this.uow.withTransaction(tenantId, async (trx) => { + await this.eventPublisher.emitAsync(events.bankTransactions.onExcluding, { + tenantId, + uncategorizedTransactionId, + trx, + } as IBankTransactionUnexcludingEventPayload); + await UncategorizedCashflowTransaction.query(trx) .findById(uncategorizedTransactionId) .patch({ excludedAt: new Date(), }); + + await this.eventPublisher.emitAsync(events.bankTransactions.onExcluded, { + tenantId, + uncategorizedTransactionId, + trx, + } as IBankTransactionUnexcludedEventPayload); }); } } diff --git a/packages/server/src/services/Banking/Exclude/UnexcludeBankTransaction.ts b/packages/server/src/services/Banking/Exclude/UnexcludeBankTransaction.ts index 46148b81b..bfa6ebb67 100644 --- a/packages/server/src/services/Banking/Exclude/UnexcludeBankTransaction.ts +++ b/packages/server/src/services/Banking/Exclude/UnexcludeBankTransaction.ts @@ -2,6 +2,12 @@ import HasTenancyService from '@/services/Tenancy/TenancyService'; import UnitOfWork from '@/services/UnitOfWork'; import { Inject, Service } from 'typedi'; import { validateTransactionNotCategorized } from './utils'; +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import events from '@/subscribers/events'; +import { + IBankTransactionExcludedEventPayload, + IBankTransactionExcludingEventPayload, +} from './_types'; @Service() export class UnexcludeBankTransaction { @@ -11,6 +17,9 @@ export class UnexcludeBankTransaction { @Inject() private uow: UnitOfWork; + @Inject() + private eventPublisher: EventPublisher; + /** * Marks the given bank transaction as excluded. * @param {number} tenantId @@ -31,11 +40,21 @@ export class UnexcludeBankTransaction { validateTransactionNotCategorized(oldUncategorizedTransaction); return this.uow.withTransaction(tenantId, async (trx) => { + await this.eventPublisher.emitAsync(events.bankTransactions.onExcluding, { + tenantId, + uncategorizedTransactionId, + } as IBankTransactionExcludingEventPayload); + await UncategorizedCashflowTransaction.query(trx) .findById(uncategorizedTransactionId) .patch({ excludedAt: null, }); + + await this.eventPublisher.emitAsync(events.bankTransactions.onExcluded, { + tenantId, + uncategorizedTransactionId, + } as IBankTransactionExcludedEventPayload); }); } } diff --git a/packages/server/src/services/Banking/Exclude/_types.ts b/packages/server/src/services/Banking/Exclude/_types.ts index d8a5188a7..c7aa571fd 100644 --- a/packages/server/src/services/Banking/Exclude/_types.ts +++ b/packages/server/src/services/Banking/Exclude/_types.ts @@ -1,6 +1,30 @@ +import { Knex } from "knex"; export interface ExcludedBankTransactionsQuery { page?: number; pageSize?: number; accountId?: number; -} \ No newline at end of file +} + +export interface IBankTransactionUnexcludingEventPayload { + tenantId: number; + uncategorizedTransactionId: number; + trx?: Knex.Transaction +} + +export interface IBankTransactionUnexcludedEventPayload { + tenantId: number; + uncategorizedTransactionId: number; + trx?: Knex.Transaction +} + +export interface IBankTransactionExcludingEventPayload { + tenantId: number; + uncategorizedTransactionId: number; + trx?: Knex.Transaction +} +export interface IBankTransactionExcludedEventPayload { + tenantId: number; + uncategorizedTransactionId: number; + trx?: Knex.Transaction +} diff --git a/packages/server/src/services/Banking/Exclude/events/DecrementUncategorizedTransactionOnExclude.ts b/packages/server/src/services/Banking/Exclude/events/DecrementUncategorizedTransactionOnExclude.ts new file mode 100644 index 000000000..6f3aeba31 --- /dev/null +++ b/packages/server/src/services/Banking/Exclude/events/DecrementUncategorizedTransactionOnExclude.ts @@ -0,0 +1,68 @@ +import { Inject, Service } from 'typedi'; +import events from '@/subscribers/events'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { + IBankTransactionExcludedEventPayload, + IBankTransactionUnexcludedEventPayload, +} from '../_types'; + +@Service() +export class DecrementUncategorizedTransactionOnExclude { + @Inject() + private tenancy: HasTenancyService; + /** + * Constructor method. + */ + public attach(bus) { + bus.subscribe( + events.bankTransactions.onExcluded, + this.decrementUnCategorizedTransactionsOnExclude.bind(this) + ); + bus.subscribe( + events.bankTransactions.onUnexcluded, + this.incrementUnCategorizedTransactionsOnUnexclude.bind(this) + ); + } + + /** + * Validates the cashflow transaction whether matched with bank transaction on deleting. + * @param {IManualJournalDeletingPayload} + */ + public async decrementUnCategorizedTransactionsOnExclude({ + tenantId, + uncategorizedTransactionId, + trx, + }: IBankTransactionExcludedEventPayload) { + const { UncategorizedCashflowTransaction, Account } = + this.tenancy.models(tenantId); + + const transaction = await UncategorizedCashflowTransaction.query( + trx + ).findById(uncategorizedTransactionId); + + await Account.query(trx) + .findById(transaction.accountId) + .decrement('uncategorizedTransactions', 1); + } + + /** + * Validates the cashflow transaction whether matched with bank transaction on deleting. + * @param {IManualJournalDeletingPayload} + */ + public async incrementUnCategorizedTransactionsOnUnexclude({ + tenantId, + uncategorizedTransactionId, + trx, + }: IBankTransactionUnexcludedEventPayload) { + const { UncategorizedCashflowTransaction, Account } = + this.tenancy.models(tenantId); + + const transaction = await UncategorizedCashflowTransaction.query().findById( + uncategorizedTransactionId + ); + // + await Account.query(trx) + .findById(transaction.accountId) + .increment('uncategorizedTransactions', 1); + } +} diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByCashflow.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByCashflow.ts index 1db6dd04f..ef8887ae7 100644 --- a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByCashflow.ts +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByCashflow.ts @@ -32,6 +32,9 @@ export class GetMatchedTransactionsByCashflow extends GetMatchedTransactionsByTy q.withGraphJoined('matchedBankTransaction'); q.whereNull('matchedBankTransaction.id'); + // Not categorized. + q.modify('notCategorized'); + // Published. q.modify('published'); @@ -69,6 +72,8 @@ export class GetMatchedTransactionsByCashflow extends GetMatchedTransactionsByTy .findById(transactionId) .withGraphJoined('matchedBankTransaction') .whereNull('matchedBankTransaction.id') + .modify('notCategorized') + .modify('published') .throwIfNotFound(); return this.transformer.transform( diff --git a/packages/server/src/services/Banking/Matching/events/DecrementUncategorizedTransactionsOnMatch.ts b/packages/server/src/services/Banking/Matching/events/DecrementUncategorizedTransactionsOnMatch.ts index d4409962c..67dda577d 100644 --- a/packages/server/src/services/Banking/Matching/events/DecrementUncategorizedTransactionsOnMatch.ts +++ b/packages/server/src/services/Banking/Matching/events/DecrementUncategorizedTransactionsOnMatch.ts @@ -31,7 +31,6 @@ export class DecrementUncategorizedTransactionOnMatching { public async decrementUnCategorizedTransactionsOnMatching({ tenantId, uncategorizedTransactionId, - matchTransactionsDTO, trx, }: IBankTransactionMatchedEventPayload) { const { UncategorizedCashflowTransaction, Account } = @@ -64,6 +63,6 @@ export class DecrementUncategorizedTransactionOnMatching { // await Account.query(trx) .findById(transaction.accountId) - .decrement('uncategorizedTransactions', 1); + .increment('uncategorizedTransactions', 1); } } diff --git a/packages/server/src/services/Cashflow/CashflowTransactionJournalEntries.ts b/packages/server/src/services/Cashflow/CashflowTransactionJournalEntries.ts index c1c590d55..55ba10563 100644 --- a/packages/server/src/services/Cashflow/CashflowTransactionJournalEntries.ts +++ b/packages/server/src/services/Cashflow/CashflowTransactionJournalEntries.ts @@ -34,11 +34,12 @@ export default class CashflowTransactionJournalEntries { currencyCode: transaction.currencyCode, exchangeRate: transaction.exchangeRate, - transactionType: transformCashflowTransactionType( - transaction.transactionType - ), + transactionType: 'CashflowTransaction', transactionId: transaction.id, transactionNumber: transaction.transactionNumber, + transactionSubType: transformCashflowTransactionType( + transaction.transactionType + ), referenceNumber: transaction.referenceNo, note: transaction.description, @@ -161,12 +162,10 @@ export default class CashflowTransactionJournalEntries { cashflowTransactionId: number, trx?: Knex.Transaction ): Promise => { - const transactionTypes = getCashflowAccountTransactionsTypes(); - await this.ledgerStorage.deleteByReference( tenantId, cashflowTransactionId, - transactionTypes, + 'CashflowTransaction', trx ); }; diff --git a/packages/server/src/services/Cashflow/NewCashflowTransactionService.ts b/packages/server/src/services/Cashflow/NewCashflowTransactionService.ts index ecc0d3267..8a5b15be0 100644 --- a/packages/server/src/services/Cashflow/NewCashflowTransactionService.ts +++ b/packages/server/src/services/Cashflow/NewCashflowTransactionService.ts @@ -101,6 +101,7 @@ export default class NewCashflowTransactionService { ...fromDTO, transactionNumber, currencyCode: cashflowAccount.currencyCode, + exchangeRate: fromDTO?.exchangeRate || 1, transactionType: transformCashflowTransactionType( fromDTO.transactionType ), diff --git a/packages/server/src/services/FinancialStatements/CashflowAccountTransactions/CashflowAccountTransactions.ts b/packages/server/src/services/FinancialStatements/CashflowAccountTransactions/CashflowAccountTransactions.ts index 7fc1d00e2..7ca2694cf 100644 --- a/packages/server/src/services/FinancialStatements/CashflowAccountTransactions/CashflowAccountTransactions.ts +++ b/packages/server/src/services/FinancialStatements/CashflowAccountTransactions/CashflowAccountTransactions.ts @@ -1,20 +1,20 @@ import R from 'ramda'; import moment from 'moment'; +import { first, isEmpty } from 'lodash'; import { ICashflowAccountTransaction, ICashflowAccountTransactionsQuery, - INumberFormatQuery, } from '@/interfaces'; import FinancialSheet from '../FinancialSheet'; import { runningAmount } from 'utils'; +import { CashflowAccountTransactionsRepo } from './CashflowAccountTransactionsRepo'; +import { BankTransactionStatus } from './constants'; +import { formatBankTransactionsStatus } from './utils'; -export default class CashflowAccountTransactionReport extends FinancialSheet { - private transactions: any; - private openingBalance: number; +export class CashflowAccountTransactionReport extends FinancialSheet { private runningBalance: any; - private numberFormat: INumberFormatQuery; - private baseCurrency: string; private query: ICashflowAccountTransactionsQuery; + private repo: CashflowAccountTransactionsRepo; /** * Constructor method. @@ -23,19 +23,61 @@ export default class CashflowAccountTransactionReport extends FinancialSheet { * @param {ICashflowAccountTransactionsQuery} query - */ constructor( - transactions, - openingBalance: number, + repo: CashflowAccountTransactionsRepo, query: ICashflowAccountTransactionsQuery ) { super(); - this.transactions = transactions; - this.openingBalance = openingBalance; - - this.runningBalance = runningAmount(this.openingBalance); + this.repo = repo; this.query = query; - this.numberFormat = query.numberFormat; - this.baseCurrency = 'USD'; + this.runningBalance = runningAmount(this.repo.openingBalance); + } + + /** + * Retrieves the transaction status. + * @param {} transaction + * @returns {BankTransactionStatus} + */ + private getTransactionStatus(transaction: any): BankTransactionStatus { + const categorizedTrans = this.repo.uncategorizedTransactionsMapByRef.get( + `${transaction.referenceType}-${transaction.referenceId}` + ); + const matchedTrans = this.repo.matchedBankTransactionsMapByRef.get( + `${transaction.referenceType}-${transaction.referenceId}` + ); + if (!isEmpty(categorizedTrans)) { + return BankTransactionStatus.Categorized; + } else if (!isEmpty(matchedTrans)) { + return BankTransactionStatus.Matched; + } else { + return BankTransactionStatus.Manual; + } + } + + /** + * Retrieves the uncategoized transaction id from the given transaction. + * @param transaction + * @returns {number|null} + */ + private getUncategorizedTransId(transaction: any): number { + // The given transaction would be categorized, matched or not, so we'd take a look at + // the categorized transaction first to get the id if not exist, then should look at the matched + // transaction if not exist too, so the given transaction has no uncategorized transaction id. + const categorizedTrans = this.repo.uncategorizedTransactionsMapByRef.get( + `${transaction.referenceType}-${transaction.referenceId}` + ); + const matchedTrans = this.repo.matchedBankTransactionsMapByRef.get( + `${transaction.referenceType}-${transaction.referenceId}` + ); + // Relation between the transaction and matching always been one-to-one. + const firstCategorizedTrans = first(categorizedTrans); + const firstMatchedTrans = first(matchedTrans); + + return ( + (firstCategorizedTrans?.id || + firstMatchedTrans?.uncategorizedTransactionId || + null + ); } /** @@ -44,6 +86,10 @@ export default class CashflowAccountTransactionReport extends FinancialSheet { * @returns {ICashflowAccountTransaction} */ private transactionNode = (transaction: any): ICashflowAccountTransaction => { + const status = this.getTransactionStatus(transaction); + const uncategorizedTransactionId = + this.getUncategorizedTransId(transaction); + return { date: transaction.date, formattedDate: moment(transaction.date).format('YYYY-MM-DD'), @@ -67,6 +113,9 @@ export default class CashflowAccountTransactionReport extends FinancialSheet { balance: 0, formattedBalance: '', + status, + formattedStatus: formatBankTransactionsStatus(status), + uncategorizedTransactionId, }; }; @@ -146,6 +195,6 @@ export default class CashflowAccountTransactionReport extends FinancialSheet { * @returns {ICashflowAccountTransaction[]} */ public reportData(): ICashflowAccountTransaction[] { - return this.transactionsNode(this.transactions); + return this.transactionsNode(this.repo.transactions); } } diff --git a/packages/server/src/services/FinancialStatements/CashflowAccountTransactions/CashflowAccountTransactionsRepo.ts b/packages/server/src/services/FinancialStatements/CashflowAccountTransactions/CashflowAccountTransactionsRepo.ts index 99456fa3a..7bb50373d 100644 --- a/packages/server/src/services/FinancialStatements/CashflowAccountTransactions/CashflowAccountTransactionsRepo.ts +++ b/packages/server/src/services/FinancialStatements/CashflowAccountTransactions/CashflowAccountTransactionsRepo.ts @@ -1,30 +1,59 @@ -import { Service, Inject } from 'typedi'; -import HasTenancyService from '@/services/Tenancy/TenancyService'; -import { ICashflowAccountTransactionsQuery, IPaginationMeta } from '@/interfaces'; +import * as R from 'ramda'; +import { ICashflowAccountTransactionsQuery } from '@/interfaces'; +import { + groupMatchedBankTransactions, + groupUncategorizedTransactions, +} from './utils'; -@Service() -export default class CashflowAccountTransactionsRepo { - @Inject() - private tenancy: HasTenancyService; +export class CashflowAccountTransactionsRepo { + private models: any; + public query: ICashflowAccountTransactionsQuery; + public transactions: any; + public uncategorizedTransactions: any; + public uncategorizedTransactionsMapByRef: Map; + public matchedBankTransactions: any; + public matchedBankTransactionsMapByRef: Map; + public pagination: any; + public openingBalance: any; + + /** + * Constructor method. + * @param {any} models + * @param {ICashflowAccountTransactionsQuery} query + */ + constructor(models: any, query: ICashflowAccountTransactionsQuery) { + this.models = models; + this.query = query; + } + + /** + * Async initalize the resources. + */ + async asyncInit() { + await this.initCashflowAccountTransactions(); + await this.initCashflowAccountOpeningBalance(); + await this.initCategorizedTransactions(); + await this.initMatchedTransactions(); + } /** * Retrieve the cashflow account transactions. * @param {number} tenantId - * @param {ICashflowAccountTransactionsQuery} query - */ - async getCashflowAccountTransactions( - tenantId: number, - query: ICashflowAccountTransactionsQuery - ) { - const { AccountTransaction } = this.tenancy.models(tenantId); + async initCashflowAccountTransactions() { + const { AccountTransaction } = this.models; - return AccountTransaction.query() - .where('account_id', query.accountId) + const { results, pagination } = await AccountTransaction.query() + .where('account_id', this.query.accountId) .orderBy([ { column: 'date', order: 'desc' }, { column: 'created_at', order: 'desc' }, ]) - .pagination(query.page - 1, query.pageSize); + .pagination(this.query.page - 1, this.query.pageSize); + + this.transactions = results; + this.pagination = pagination; } /** @@ -34,22 +63,18 @@ export default class CashflowAccountTransactionsRepo { * @param {IPaginationMeta} pagination * @return {Promise} */ - async getCashflowAccountOpeningBalance( - tenantId: number, - accountId: number, - pagination: IPaginationMeta - ): Promise { - const { AccountTransaction } = this.tenancy.models(tenantId); + async initCashflowAccountOpeningBalance(): Promise { + const { AccountTransaction } = this.models; // Retrieve the opening balance of credit and debit balances. const openingBalancesSubquery = AccountTransaction.query() - .where('account_id', accountId) + .where('account_id', this.query.accountId) .orderBy([ { column: 'date', order: 'desc' }, { column: 'created_at', order: 'desc' }, ]) - .limit(pagination.total) - .offset(pagination.pageSize * (pagination.page - 1)); + .limit(this.pagination.total) + .offset(this.pagination.pageSize * (this.pagination.page - 1)); // Sumation of credit and debit balance. const openingBalances = await AccountTransaction.query() @@ -60,6 +85,43 @@ export default class CashflowAccountTransactionsRepo { const openingBalance = openingBalances.debit - openingBalances.credit; - return openingBalance; + this.openingBalance = openingBalance; + } + + /** + * Initialize the uncategorized transactions of the bank account. + */ + async initCategorizedTransactions() { + const { UncategorizedCashflowTransaction } = this.models; + const refs = this.transactions.map((t) => [t.referenceType, t.referenceId]); + + const uncategorizedTransactions = + await UncategorizedCashflowTransaction.query().whereIn( + ['categorizeRefType', 'categorizeRefId'], + refs + ); + + this.uncategorizedTransactions = uncategorizedTransactions; + this.uncategorizedTransactionsMapByRef = groupUncategorizedTransactions( + uncategorizedTransactions + ); + } + + /** + * Initialize the matched bank transactions of the bank account. + */ + async initMatchedTransactions(): Promise { + const { MatchedBankTransaction } = this.models; + const refs = this.transactions.map((t) => [t.referenceType, t.referenceId]); + + const matchedBankTransactions = + await MatchedBankTransaction.query().whereIn( + ['referenceType', 'referenceId'], + refs + ); + this.matchedBankTransactions = matchedBankTransactions; + this.matchedBankTransactionsMapByRef = groupMatchedBankTransactions( + matchedBankTransactions + ); } } diff --git a/packages/server/src/services/FinancialStatements/CashflowAccountTransactions/CashflowAccountTransactionsService.ts b/packages/server/src/services/FinancialStatements/CashflowAccountTransactions/CashflowAccountTransactionsService.ts index f3035a148..adf4a519e 100644 --- a/packages/server/src/services/FinancialStatements/CashflowAccountTransactions/CashflowAccountTransactionsService.ts +++ b/packages/server/src/services/FinancialStatements/CashflowAccountTransactions/CashflowAccountTransactionsService.ts @@ -1,26 +1,16 @@ import { Service, Inject } from 'typedi'; -import { includes } from 'lodash'; import * as qim from 'qim'; import { ICashflowAccountTransactionsQuery, IAccount } from '@/interfaces'; import TenancyService from '@/services/Tenancy/TenancyService'; import FinancialSheet from '../FinancialSheet'; -import CashflowAccountTransactionsRepo from './CashflowAccountTransactionsRepo'; -import CashflowAccountTransactionsReport from './CashflowAccountTransactions'; -import { ACCOUNT_TYPE } from '@/data/AccountTypes'; -import { ServiceError } from '@/exceptions'; -import { ERRORS } from './constants'; +import { CashflowAccountTransactionReport } from './CashflowAccountTransactions'; import I18nService from '@/services/I18n/I18nService'; +import { CashflowAccountTransactionsRepo } from './CashflowAccountTransactionsRepo'; @Service() export default class CashflowAccountTransactionsService extends FinancialSheet { @Inject() - tenancy: TenancyService; - - @Inject() - cashflowTransactionsRepo: CashflowAccountTransactionsRepo; - - @Inject() - i18nService: I18nService; + private tenancy: TenancyService; /** * Defaults balance sheet filter query. @@ -50,59 +40,24 @@ export default class CashflowAccountTransactionsService extends FinancialSheet { tenantId: number, query: ICashflowAccountTransactionsQuery ) { - const { Account } = this.tenancy.models(tenantId); + const models = this.tenancy.models(tenantId); const parsedQuery = { ...this.defaultQuery, ...query }; - // Retrieve the given account or throw not found service error. - const account = await Account.query().findById(parsedQuery.accountId); - - // Validates the cashflow account type. - this.validateCashflowAccountType(account); - - // Retrieve the cashflow account transactions. - const { results: transactions, pagination } = - await this.cashflowTransactionsRepo.getCashflowAccountTransactions( - tenantId, - parsedQuery - ); - // Retrieve the cashflow account opening balance. - const openingBalance = - await this.cashflowTransactionsRepo.getCashflowAccountOpeningBalance( - tenantId, - parsedQuery.accountId, - pagination - ); - // Retrieve the computed report. - const report = new CashflowAccountTransactionsReport( - transactions, - openingBalance, + // Initalize the bank transactions report repository. + const cashflowTransactionsRepo = new CashflowAccountTransactionsRepo( + models, parsedQuery ); - const reportTranasctions = report.reportData(); + await cashflowTransactionsRepo.asyncInit(); - return { - transactions: this.i18nService.i18nApply( - [[qim.$each, 'formattedTransactionType']], - reportTranasctions, - tenantId - ), - pagination, - }; - } + // Retrieve the computed report. + const report = new CashflowAccountTransactionReport( + cashflowTransactionsRepo, + parsedQuery + ); + const transactions = report.reportData(); + const pagination = cashflowTransactionsRepo.pagination; - /** - * Validates the cashflow account type. - * @param {IAccount} account - - */ - private validateCashflowAccountType(account: IAccount) { - const cashflowTypes = [ - ACCOUNT_TYPE.CASH, - ACCOUNT_TYPE.CREDIT_CARD, - ACCOUNT_TYPE.BANK, - ]; - - if (!includes(cashflowTypes, account.accountType)) { - throw new ServiceError(ERRORS.ACCOUNT_ID_HAS_INVALID_TYPE); - } + return { transactions, pagination }; } } diff --git a/packages/server/src/services/FinancialStatements/CashflowAccountTransactions/constants.ts b/packages/server/src/services/FinancialStatements/CashflowAccountTransactions/constants.ts index bf0a0eead..298cbe801 100644 --- a/packages/server/src/services/FinancialStatements/CashflowAccountTransactions/constants.ts +++ b/packages/server/src/services/FinancialStatements/CashflowAccountTransactions/constants.ts @@ -1,3 +1,9 @@ export const ERRORS = { ACCOUNT_ID_HAS_INVALID_TYPE: 'ACCOUNT_ID_HAS_INVALID_TYPE', }; + +export enum BankTransactionStatus { + Categorized = 'categorized', + Matched = 'matched', + Manual = 'manual', +} diff --git a/packages/server/src/services/FinancialStatements/CashflowAccountTransactions/utils.ts b/packages/server/src/services/FinancialStatements/CashflowAccountTransactions/utils.ts new file mode 100644 index 000000000..a1e47d4f4 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/CashflowAccountTransactions/utils.ts @@ -0,0 +1,40 @@ +import * as R from 'ramda'; + +export const groupUncategorizedTransactions = ( + uncategorizedTransactions: any +): Map => { + return new Map( + R.toPairs( + R.groupBy( + (transaction) => + `${transaction.categorizeRefType}-${transaction.categorizeRefId}`, + uncategorizedTransactions + ) + ) + ); +}; + +export const groupMatchedBankTransactions = ( + uncategorizedTransactions: any +): Map => { + return new Map( + R.toPairs( + R.groupBy( + (transaction) => + `${transaction.referenceType}-${transaction.referenceId}`, + uncategorizedTransactions + ) + ) + ); +}; + +export const formatBankTransactionsStatus = (status) => { + switch (status) { + case 'categorized': + return 'Categorized'; + case 'matched': + return 'Matched'; + case 'manual': + return 'Manual'; + } +}; diff --git a/packages/server/src/subscribers/events.ts b/packages/server/src/subscribers/events.ts index 507fe9e52..e4c60d537 100644 --- a/packages/server/src/subscribers/events.ts +++ b/packages/server/src/subscribers/events.ts @@ -639,4 +639,12 @@ export default { onUnmatching: 'onBankTransactionUnmathcing', onUnmatched: 'onBankTransactionUnmathced', }, + + bankTransactions: { + onExcluding: 'onBankTransactionExclude', + onExcluded: 'onBankTransactionExcluded', + + onUnexcluding: 'onBankTransactionExcluding', + onUnexcluded: 'onBankTransactionExcluded', + }, }; diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsDataTable.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsDataTable.tsx index 3db6e8c17..1c37a28f1 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsDataTable.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsDataTable.tsx @@ -1,6 +1,7 @@ // @ts-nocheck import React from 'react'; import styled from 'styled-components'; +import { Intent } from '@blueprintjs/core'; import { DataTable, @@ -9,6 +10,7 @@ import { TableSkeletonHeader, TableVirtualizedListRows, FormattedMessage as T, + AppToaster, } from '@/components'; import { TABLES } from '@/constants/tables'; @@ -19,9 +21,11 @@ import withDrawerActions from '@/containers/Drawer/withDrawerActions'; import { useMemorizedColumnsWidths } from '@/hooks'; import { useAccountTransactionsColumns, ActionsMenu } from './components'; import { useAccountTransactionsAllContext } from './AccountTransactionsAllBoot'; +import { useUnmatchMatchedUncategorizedTransaction } from '@/hooks/query/bank-rules'; import { handleCashFlowTransactionType } from './utils'; import { compose } from '@/utils'; +import { useUncategorizeTransaction } from '@/hooks/query'; /** * Account transactions data table. @@ -43,14 +47,14 @@ function AccountTransactionsDataTable({ const { cashflowTransactions, isCashFlowTransactionsLoading } = useAccountTransactionsAllContext(); + const { mutateAsync: uncategorizeTransaction } = useUncategorizeTransaction(); + const { mutateAsync: unmatchTransaction } = + useUnmatchMatchedUncategorizedTransaction(); + // Local storage memorizing columns widths. const [initialColumnsWidths, , handleColumnResizing] = useMemorizedColumnsWidths(TABLES.CASHFLOW_Transactions); - // handle delete transaction - const handleDeleteTransaction = ({ reference_id }) => { - openAlert('account-transaction-delete', { referenceId: reference_id }); - }; // Handle view details action. const handleViewDetailCashflowTransaction = (referenceType) => { handleCashFlowTransactionType(referenceType, openDrawer); @@ -60,6 +64,38 @@ function AccountTransactionsDataTable({ const referenceType = cell.row.original; handleCashFlowTransactionType(referenceType, openDrawer); }; + // Handles the unmatching the matched transaction. + const handleUnmatchTransaction = (transaction) => { + unmatchTransaction({ id: transaction.uncategorized_transaction_id }) + .then(() => { + AppToaster.show({ + message: 'The bank transaction has been unmatched.', + intent: Intent.SUCCESS, + }); + }) + .catch(() => { + AppToaster.show({ + message: 'Something went wrong.', + intent: Intent.DANGER, + }); + }); + }; + // Handle uncategorize transaction. + const handleUncategorizeTransaction = (transaction) => { + uncategorizeTransaction(transaction.uncategorized_transaction_id) + .then(() => { + AppToaster.show({ + message: 'The bank transaction has been uncategorized.', + intent: Intent.SUCCESS, + }); + }) + .catch(() => { + AppToaster.show({ + message: 'Something went wrong.', + intent: Intent.DANGER, + }); + }); + }; return ( ); diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsUncategorizedTable.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsUncategorizedTable.tsx index 5fbe79118..e54c11f9d 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsUncategorizedTable.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/AccountTransactionsUncategorizedTable.tsx @@ -12,19 +12,17 @@ import { AppToaster, } from '@/components'; import { TABLES } from '@/constants/tables'; +import { ActionsMenu } from './UncategorizedTransactions/components'; import withSettings from '@/containers/Settings/withSettings'; import { withBankingActions } from '../withBankingActions'; import { useMemorizedColumnsWidths } from '@/hooks'; -import { - ActionsMenu, - useAccountUncategorizedTransactionsColumns, -} from './components'; +import { useAccountUncategorizedTransactionsColumns } from './components'; import { useAccountUncategorizedTransactionsContext } from './AllTransactionsUncategorizedBoot'; +import { useExcludeUncategorizedTransaction } from '@/hooks/query/bank-rules'; import { compose } from '@/utils'; -import { useExcludeUncategorizedTransaction } from '@/hooks/query/bank-rules'; /** * Account transactions data table. diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/UncategorizedTransactions/components.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/UncategorizedTransactions/components.tsx new file mode 100644 index 000000000..58735ec11 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/UncategorizedTransactions/components.tsx @@ -0,0 +1,25 @@ +// @ts-nocheck +import { Menu, MenuItem, MenuDivider } from '@blueprintjs/core'; +import { Icon } from '@/components'; +import { safeCallback } from '@/utils'; + +export function ActionsMenu({ + payload: { onCategorize, onExclude }, + row: { original }, +}) { + return ( + + } + text={'Categorize'} + onClick={safeCallback(onCategorize, original)} + /> + + } + /> + + ); +} diff --git a/packages/webapp/src/containers/CashFlow/AccountTransactions/components.tsx b/packages/webapp/src/containers/CashFlow/AccountTransactions/components.tsx index 2829e25dd..d69196163 100644 --- a/packages/webapp/src/containers/CashFlow/AccountTransactions/components.tsx +++ b/packages/webapp/src/containers/CashFlow/AccountTransactions/components.tsx @@ -5,44 +5,57 @@ import { Intent, Menu, MenuItem, - MenuDivider, Tag, - Popover, PopoverInteractionKind, Position, Tooltip, + MenuDivider, } from '@blueprintjs/core'; -import { - Box, - Can, - FormatDateCell, - Icon, - MaterialProgressBar, -} from '@/components'; +import { Box, FormatDateCell, Icon, MaterialProgressBar } from '@/components'; import { useAccountTransactionsContext } from './AccountTransactionsProvider'; import { safeCallback } from '@/utils'; export function ActionsMenu({ - payload: { onCategorize, onExclude }, + payload: { onUncategorize, onUnmatch }, row: { original }, }) { return ( - } - text={'Categorize'} - onClick={safeCallback(onCategorize, original)} - /> - - } - /> + {original.status === 'categorized' && ( + } + text={'Uncategorize'} + onClick={safeCallback(onUncategorize, original)} + /> + )} + {original.status === 'matched' && ( + } + onClick={safeCallback(onUnmatch, original)} + /> + )} ); } +const allTransactionsStatusAccessor = (transaction) => { + return ( + + {transaction.formatted_status} + + ); +}; + /** * Retrieve account transctions table columns. */ @@ -70,7 +83,7 @@ export function useAccountTransactionsColumns() { }, { id: 'transaction_number', - Header: intl.get('transaction_number'), + Header: 'Transaction #', accessor: 'transaction_number', width: 160, className: 'transaction_number', @@ -79,13 +92,18 @@ export function useAccountTransactionsColumns() { }, { id: 'reference_number', - Header: intl.get('reference_no'), + Header: 'Ref.#', accessor: 'reference_number', width: 160, className: 'reference_number', clickable: true, textOverview: true, }, + { + id: 'status', + Header: 'Status', + accessor: allTransactionsStatusAccessor, + }, { id: 'deposit', Header: intl.get('cash_flow.label.deposit'), @@ -116,16 +134,6 @@ export function useAccountTransactionsColumns() { align: 'right', clickable: true, }, - { - id: 'balance', - Header: intl.get('balance'), - accessor: 'formatted_balance', - className: 'balance', - width: 150, - textOverview: true, - clickable: true, - align: 'right', - }, ], [], ); @@ -204,7 +212,7 @@ export function useAccountUncategorizedTransactionsColumns() { }, { id: 'reference_number', - Header: intl.get('reference_no'), + Header: 'Ref.#', accessor: 'reference_number', width: 50, className: 'reference_number', diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingTransaction.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingTransaction.tsx index 308fe36b7..a96368e70 100644 --- a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingTransaction.tsx +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingTransaction.tsx @@ -212,8 +212,8 @@ function PerfectMatchingTransactions() { key={index} label={`${match.transsactionTypeFormatted} for ${match.amountFormatted}`} date={match.dateFormatted} - transactionId={match.transactionId} - transactionType={match.transactionType} + transactionId={match.referenceId} + transactionType={match.referenceType} /> ))} diff --git a/packages/webapp/src/hooks/query/bank-rules.ts b/packages/webapp/src/hooks/query/bank-rules.ts index 9608f7496..7f489f92a 100644 --- a/packages/webapp/src/hooks/query/bank-rules.ts +++ b/packages/webapp/src/hooks/query/bank-rules.ts @@ -37,7 +37,7 @@ interface CreateBankRuleResponse {} /** * Creates a new bank rule. * @param {UseMutationOptions} options - - * @returns {UseMutationResult} + * @returns {UseMutationResult}TCHES */ export function useCreateBankRule( options?: UseMutationOptions< @@ -322,6 +322,46 @@ export function useMatchUncategorizedTransaction( queryClient.invalidateQueries( t.CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY, ); + queryClient.invalidateQueries(t.CASHFLOW_ACCOUNT_TRANSACTIONS_INFINITY); + }, + ...props, + }); +} + +interface UnmatchUncategorizedTransactionValues { + id: number; +} +interface UnmatchUncategorizedTransactionRes {} + +/** + * Unmatch the given matched uncategorized transaction. + * @param {UseMutationOptions} props + * @returns {UseMutationResult} + */ +export function useUnmatchMatchedUncategorizedTransaction( + props?: UseMutationOptions< + UnmatchUncategorizedTransactionRes, + Error, + UnmatchUncategorizedTransactionValues + >, +): UseMutationResult< + UnmatchUncategorizedTransactionRes, + Error, + UnmatchUncategorizedTransactionValues +> { + const queryClient = useQueryClient(); + const apiRequest = useApiRequest(); + + return useMutation< + UnmatchUncategorizedTransactionRes, + Error, + UnmatchUncategorizedTransactionValues + >(({ id }) => apiRequest.post(`/banking/matches/unmatch/${id}`), { + onSuccess: (res, id) => { + queryClient.invalidateQueries( + t.CASHFLOW_ACCOUNT_UNCATEGORIZED_TRANSACTIONS_INFINITY, + ); + queryClient.invalidateQueries(t.CASHFLOW_ACCOUNT_TRANSACTIONS_INFINITY); }, ...props, }); diff --git a/packages/webapp/src/static/json/icons.tsx b/packages/webapp/src/static/json/icons.tsx index c39e72f30..2e3d19ed7 100644 --- a/packages/webapp/src/static/json/icons.tsx +++ b/packages/webapp/src/static/json/icons.tsx @@ -629,4 +629,10 @@ export default { ], viewBox: '0 0 16 16', }, + unlink: { + path: [ + 'M11.9975 0.00500107C14.2061 0.00500107 15.995 1.79388 15.995 4.0025C15.995 5.11181 15.5353 6.0912 14.8058 6.81075L14.8257 6.83074L13.8264 7.83011L13.8064 7.81012C13.2562 8.36798 12.5482 8.76807 11.7539 8.92548L10.8249 7.99643L12.4073 6.401L13.4066 5.40163L13.3966 5.39163C13.7564 5.03186 13.9963 4.54217 13.9963 3.99251C13.9963 2.8932 13.0968 1.99376 11.9975 1.99376C11.4479 1.99376 10.9582 2.23361 10.5984 2.59338L10.5884 2.58339L8.0001 5.17168L7.07559 4.24717C7.23518 3.45247 7.63943 2.74409 8.18989 2.19363L8.1699 2.17365L9.16928 1.17427L9.18926 1.19426C9.90882 0.464714 10.8982 0.00500107 11.9975 0.00500107ZM2.29289 2.29289C2.68341 1.90237 3.31657 1.90237 3.7071 2.29289L13.7071 12.2929C14.0976 12.6834 14.0976 13.3166 13.7071 13.7071C13.3166 14.0976 12.6834 14.0976 12.2929 13.7071L8.93565 10.3499C8.97565 10.562 8.99938 10.7781 8.99938 10.9981C8.99938 12.0974 8.53966 13.0868 7.81012 13.8064L7.83011 13.8263L6.83073 14.8257L6.81074 14.8057C6.09119 15.5353 5.10181 15.995 4.0025 15.995C1.79388 15.995 0.00499688 14.2061 0.00499688 11.9975C0.00499688 10.8982 0.464709 9.90879 1.19425 9.18924L1.17427 9.16925L2.17364 8.16988L2.19363 8.18986C2.91318 7.46032 3.90256 7.00061 5.00187 7.00061C5.2251 7.00061 5.44064 7.02369 5.65087 7.06509L2.29289 3.7071C1.90236 3.31658 1.90236 2.68341 2.29289 2.29289ZM8.00244 9.41666L8.707 10.1212L5.41162 13.4166L5.40162 13.4066C5.04185 13.7664 4.55216 14.0062 4.0025 14.0062C2.90319 14.0062 2.00375 13.1068 2.00375 12.0075C2.00375 11.4578 2.2436 10.9681 2.60337 10.6084L2.59338 10.5984L5.88876 7.30298L6.58333 7.99755L4.29231 10.2886C4.11243 10.4685 4.00249 10.7183 4.00249 10.9981C4.00249 11.5478 4.45221 11.9975 5.00187 11.9975C5.28169 11.9975 5.53154 11.8876 5.71143 11.7077L8.00244 9.41666ZM8.70466 5.87623L10.1238 7.29534L11.7077 5.71143C11.8876 5.53154 11.9975 5.2817 11.9975 5.00187C11.9975 4.45222 11.5478 4.0025 10.9981 4.0025C10.7183 4.0025 10.4685 4.11243 10.2886 4.29232L8.70466 5.87623Z', + ], + viewBox: '0 0 16 16', + }, };