fix: bank transactions report

This commit is contained in:
Ahmed Bouhuolia
2024-07-07 22:11:57 +02:00
parent b7487f19d3
commit 9a5befbee7
28 changed files with 560 additions and 158 deletions

View File

@@ -66,7 +66,9 @@ export interface IAccountTransaction {
referenceId: number;
referenceNumber?: string;
transactionNumber?: string;
transactionType?: string;
note?: string;

View File

@@ -29,4 +29,9 @@ export interface ICashflowAccountTransaction {
date: Date;
formattedDate: string;
status: string;
formattedStatus: string;
uncategorizedTransactionId: number;
}

View File

@@ -40,6 +40,8 @@ export interface ILedgerEntry {
date: Date | string;
transactionType: string;
transactionSubType: string;
transactionId: number;
transactionNumber?: string;

View File

@@ -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,

View File

@@ -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');
},
};
}

View File

@@ -19,6 +19,8 @@ export const transformLedgerEntryToTransaction = (
referenceId: entry.transactionId,
transactionNumber: entry.transactionNumber,
transactionType: entry.transactionSubType,
referenceNumber: entry.referenceNumber,
note: entry.note,

View File

@@ -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)

View File

@@ -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);
});
}
}

View File

@@ -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);
});
}
}

View File

@@ -1,6 +1,30 @@
import { Knex } from "knex";
export interface ExcludedBankTransactionsQuery {
page?: number;
pageSize?: number;
accountId?: number;
}
}
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
}

View File

@@ -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);
}
}

View File

@@ -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(

View File

@@ -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);
}
}

View File

@@ -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<void> => {
const transactionTypes = getCashflowAccountTransactionsTypes();
await this.ledgerStorage.deleteByReference(
tenantId,
cashflowTransactionId,
transactionTypes,
'CashflowTransaction',
trx
);
};

View File

@@ -101,6 +101,7 @@ export default class NewCashflowTransactionService {
...fromDTO,
transactionNumber,
currencyCode: cashflowAccount.currencyCode,
exchangeRate: fromDTO?.exchangeRate || 1,
transactionType: transformCashflowTransactionType(
fromDTO.transactionType
),

View File

@@ -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);
}
}

View File

@@ -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<string, any>;
public matchedBankTransactions: any;
public matchedBankTransactionsMapByRef: Map<string, any>;
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<number>}
*/
async getCashflowAccountOpeningBalance(
tenantId: number,
accountId: number,
pagination: IPaginationMeta
): Promise<number> {
const { AccountTransaction } = this.tenancy.models(tenantId);
async initCashflowAccountOpeningBalance(): Promise<void> {
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<void> {
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
);
}
}

View File

@@ -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 };
}
}

View File

@@ -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',
}

View File

@@ -0,0 +1,40 @@
import * as R from 'ramda';
export const groupUncategorizedTransactions = (
uncategorizedTransactions: any
): Map<string, any> => {
return new Map(
R.toPairs(
R.groupBy(
(transaction) =>
`${transaction.categorizeRefType}-${transaction.categorizeRefId}`,
uncategorizedTransactions
)
)
);
};
export const groupMatchedBankTransactions = (
uncategorizedTransactions: any
): Map<string, any> => {
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';
}
};

View File

@@ -639,4 +639,12 @@ export default {
onUnmatching: 'onBankTransactionUnmathcing',
onUnmatched: 'onBankTransactionUnmathced',
},
bankTransactions: {
onExcluding: 'onBankTransactionExclude',
onExcluded: 'onBankTransactionExcluded',
onUnexcluding: 'onBankTransactionExcluding',
onUnexcluded: 'onBankTransactionExcluded',
},
};

View File

@@ -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 (
<CashflowTransactionsTable
@@ -87,7 +123,8 @@ function AccountTransactionsDataTable({
className="table-constrant"
payload={{
onViewDetails: handleViewDetailCashflowTransaction,
onDelete: handleDeleteTransaction,
onUncategorize: handleUncategorizeTransaction,
onUnmatch: handleUnmatchTransaction,
}}
/>
);

View File

@@ -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.

View File

@@ -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 (
<Menu>
<MenuItem
icon={<Icon icon="reader-18" />}
text={'Categorize'}
onClick={safeCallback(onCategorize, original)}
/>
<MenuDivider />
<MenuItem
text={'Exclude'}
onClick={safeCallback(onExclude, original)}
icon={<Icon icon="disable" iconSize={16} />}
/>
</Menu>
);
}

View File

@@ -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 (
<Menu>
<MenuItem
icon={<Icon icon="reader-18" />}
text={'Categorize'}
onClick={safeCallback(onCategorize, original)}
/>
<MenuDivider />
<MenuItem
text={'Exclude'}
onClick={safeCallback(onExclude, original)}
icon={<Icon icon="disable" iconSize={16} />}
/>
{original.status === 'categorized' && (
<MenuItem
icon={<Icon icon="reader-18" />}
text={'Uncategorize'}
onClick={safeCallback(onUncategorize, original)}
/>
)}
{original.status === 'matched' && (
<MenuItem
text={'Unmatch'}
icon={<Icon icon="unlink" iconSize={16} />}
onClick={safeCallback(onUnmatch, original)}
/>
)}
</Menu>
);
}
const allTransactionsStatusAccessor = (transaction) => {
return (
<Tag
intent={
transaction.status === 'categorized'
? Intent.SUCCESS
: transaction.status === 'matched'
? Intent.SUCCESS
: Intent.NONE
}
minimal={transaction.status === 'manual'}
>
{transaction.formatted_status}
</Tag>
);
};
/**
* 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',

View File

@@ -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}
/>
))}
</Stack>

View File

@@ -37,7 +37,7 @@ interface CreateBankRuleResponse {}
/**
* Creates a new bank rule.
* @param {UseMutationOptions<CreateBankRuleValues, Error, CreateBankRuleValues>} options -
* @returns {UseMutationResult<CreateBankRuleValues, Error, CreateBankRuleValues>}
* @returns {UseMutationResult<CreateBankRuleValues, Error, CreateBankRuleValues>}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<UnmatchUncategorizedTransactionRes, Error, UnmatchUncategorizedTransactionValues>} props
* @returns {UseMutationResult<UnmatchUncategorizedTransactionRes, Error, UnmatchUncategorizedTransactionValues>}
*/
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,
});

View File

@@ -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',
},
};