From 87f60f746186e47c9e6dbecbb81576074c135533 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Thu, 4 Jul 2024 22:44:20 +0200 Subject: [PATCH] feat: cashflow transaction matching --- ...etMatchedTransactionCashflowTransformer.ts | 123 ++++++++++++++++++ ...etMatchedTransactionInvoicesTransformer.ts | 4 +- .../Matching/GetMatchedTransactions.ts | 5 + .../GetMatchedTransactionsByCashflow.ts | 67 ++++++++++ .../Matching/MatchTransactionsTypes.ts | 6 + .../MatchingReconcileTransactionForm.tsx | 55 ++++---- .../MatchingTransaction.tsx | 92 ++++++++++--- .../MatchingTransactionBoot.tsx | 22 +++- .../CategorizeTransactionAside/utils.ts | 2 +- .../src/hooks/query/cashflowAccounts.tsx | 2 + 10 files changed, 333 insertions(+), 45 deletions(-) create mode 100644 packages/server/src/services/Banking/Matching/GetMatchedTransactionCashflowTransformer.ts create mode 100644 packages/server/src/services/Banking/Matching/GetMatchedTransactionsByCashflow.ts diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionCashflowTransformer.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionCashflowTransformer.ts new file mode 100644 index 000000000..7beb6ae99 --- /dev/null +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionCashflowTransformer.ts @@ -0,0 +1,123 @@ +import { Transformer } from '@/lib/Transformer/Transformer'; + +export class GetMatchedTransactionCashflowTransformer extends Transformer { + /** + * Include these attributes to sale credit note object. + * @returns {Array} + */ + public includeAttributes = (): string[] => { + return [ + 'referenceNo', + 'amount', + 'amountFormatted', + 'transactionNo', + 'date', + 'dateFormatted', + 'transactionId', + 'transactionNo', + 'transactionType', + 'transsactionTypeFormatted', + 'referenceId', + 'referenceType', + ]; + }; + + /** + * Exclude all attributes. + * @returns {Array} + */ + public excludeAttributes = (): string[] => { + return ['*']; + }; + + /** + * Retrieve the invoice reference number. + * @returns {string} + */ + protected referenceNo(invoice) { + return invoice.referenceNo; + } + + /** + * Retrieve the transaction amount. + * @param transaction + * @returns {number} + */ + protected amount(transaction) { + return transaction.amount; + } + + /** + * Retrieve the transaction formatted amount. + * @param transaction + * @returns {string} + */ + protected amountFormatted(transaction) { + return this.formatNumber(transaction.amount, { + currencyCode: transaction.currencyCode, + money: true, + }); + } + + /** + * Retrieve the date of the invoice. + * @param invoice + * @returns {Date} + */ + protected date(transaction) { + return transaction.date; + } + + /** + * Format the date of the invoice. + * @param invoice + * @returns {string} + */ + protected dateFormatted(transaction) { + return this.formatDate(transaction.date); + } + + /** + * Retrieve the transaction ID of the invoice. + * @param invoice + * @returns {number} + */ + protected transactionId(transaction) { + return transaction.id; + } + + /** + * Retrieve the invoice transaction number. + * @param invoice + * @returns {string} + */ + protected transactionNo(transaction) { + return transaction.transactionNumber; + } + + /** + * Retrieve the invoice transaction type. + * @param invoice + * @returns {String} + */ + protected transactionType(transaction) { + return transaction.transactionType; + } + + /** + * Retrieve the invoice formatted transaction type. + * @param invoice + * @returns {string} + */ + protected transsactionTypeFormatted(transaction) { + return transaction.transactionTypeFormatted; + } + + protected referenceId(transaction) { + return transaction.id; + } + + protected referenceType() { + return 'CashflowTransaction'; + } +} diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionInvoicesTransformer.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionInvoicesTransformer.ts index ed2dcfaa2..66814f7b6 100644 --- a/packages/server/src/services/Banking/Matching/GetMatchedTransactionInvoicesTransformer.ts +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionInvoicesTransformer.ts @@ -49,7 +49,7 @@ export class GetMatchedTransactionInvoicesTransformer extends Transformer { * @param invoice * @returns {string} */ - protected formatAmount(invoice) { + protected amountFormatted(invoice) { return this.formatNumber(invoice.dueAmount, { currencyCode: invoice.currencyCode, money: true, @@ -79,7 +79,7 @@ export class GetMatchedTransactionInvoicesTransformer extends Transformer { * @param invoice * @returns {number} */ - protected getTransactionId(invoice) { + protected transactionId(invoice) { return invoice.id; } /** diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactions.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactions.ts index 43f5ba532..32673fe4d 100644 --- a/packages/server/src/services/Banking/Matching/GetMatchedTransactions.ts +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactions.ts @@ -8,6 +8,7 @@ import { GetMatchedTransactionsByBills } from './GetMatchedTransactionsByBills'; import { GetMatchedTransactionsByManualJournals } from './GetMatchedTransactionsByManualJournals'; import HasTenancyService from '@/services/Tenancy/TenancyService'; import { sortClosestMatchTransactions } from './_utils'; +import { GetMatchedTransactionsByCashflow } from './GetMatchedTransactionsByCashflow'; @Service() export class GetMatchedTransactions { @@ -26,6 +27,9 @@ export class GetMatchedTransactions { @Inject() private getMatchedExpensesService: GetMatchedTransactionsByExpenses; + @Inject() + private getMatchedCashflowService: GetMatchedTransactionsByCashflow; + /** * Registered matched transactions types. */ @@ -35,6 +39,7 @@ export class GetMatchedTransactions { { type: 'Bill', service: this.getMatchedBillsService }, { type: 'Expense', service: this.getMatchedExpensesService }, { type: 'ManualJournal', service: this.getMatchedManualJournalService }, + { type: 'Cashflow', service: this.getMatchedCashflowService }, ]; } diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByCashflow.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByCashflow.ts new file mode 100644 index 000000000..b59a44a40 --- /dev/null +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByCashflow.ts @@ -0,0 +1,67 @@ +import { Inject, Service } from 'typedi'; +import { initialize } from 'objection'; +import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; +import { GetMatchedTransactionsByType } from './GetMatchedTransactionsByType'; +import { GetMatchedTransactionCashflowTransformer } from './GetMatchedTransactionCashflowTransformer'; +import { GetMatchedTransactionsFilter } from './types'; + +@Service() +export class GetMatchedTransactionsByCashflow extends GetMatchedTransactionsByType { + @Inject() + private transformer: TransformerInjectable; + + /** + * Retrieve the matched transactions of cash flow. + * @param {number} tenantId + * @param {GetMatchedTransactionsFilter} filter + * @returns + */ + async getMatchedTransactions( + tenantId: number, + filter: Omit + ) { + const { CashflowTransaction, MatchedBankTransaction } = + this.tenancy.models(tenantId); + const knex = this.tenancy.knex(tenantId); + + // Initialize the ORM models metadata. + await initialize(knex, [CashflowTransaction, MatchedBankTransaction]); + + const transactions = await CashflowTransaction.query() + .withGraphJoined('matchedBankTransaction') + .whereNull('matchedBankTransaction.id'); + + return this.transformer.transform( + tenantId, + transactions, + new GetMatchedTransactionCashflowTransformer() + ); + } + + /** + * Retrieves the matched transaction of cash flow. + * @param {number} tenantId + * @param {number} transactionId + * @returns + */ + async getMatchedTransaction(tenantId: number, transactionId: number) { + const { CashflowTransaction, MatchedBankTransaction } = + this.tenancy.models(tenantId); + const knex = this.tenancy.knex(tenantId); + + // Initialize the ORM models metadata. + await initialize(knex, [CashflowTransaction, MatchedBankTransaction]); + + const transactions = await CashflowTransaction.query() + .findById(transactionId) + .withGraphJoined('matchedBankTransaction') + .whereNull('matchedBankTransaction.id') + .throwIfNotFound(); + + return this.transformer.transform( + tenantId, + transactions, + new GetMatchedTransactionCashflowTransformer() + ); + } +} diff --git a/packages/server/src/services/Banking/Matching/MatchTransactionsTypes.ts b/packages/server/src/services/Banking/Matching/MatchTransactionsTypes.ts index 6c5c938d4..d90db1a36 100644 --- a/packages/server/src/services/Banking/Matching/MatchTransactionsTypes.ts +++ b/packages/server/src/services/Banking/Matching/MatchTransactionsTypes.ts @@ -4,6 +4,8 @@ import { GetMatchedTransactionsByBills } from './GetMatchedTransactionsByBills'; import { GetMatchedTransactionsByManualJournals } from './GetMatchedTransactionsByManualJournals'; import { MatchTransactionsTypesRegistry } from './MatchTransactionsTypesRegistry'; import { GetMatchedTransactionsByInvoices } from './GetMatchedTransactionsByInvoices'; +import { GetMatchedTransactionCashflowTransformer } from './GetMatchedTransactionCashflowTransformer'; +import { GetMatchedTransactionsByCashflow } from './GetMatchedTransactionsByCashflow'; @Service() export class MatchTransactionsTypes { @@ -25,6 +27,10 @@ export class MatchTransactionsTypes { type: 'ManualJournal', service: GetMatchedTransactionsByManualJournals, }, + { + type: 'CashflowTransaction', + service: GetMatchedTransactionsByCashflow, + }, ]; } diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingReconcileTransactionAside/MatchingReconcileTransactionForm.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingReconcileTransactionAside/MatchingReconcileTransactionForm.tsx index 65a185de1..7255fb726 100644 --- a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingReconcileTransactionAside/MatchingReconcileTransactionForm.tsx +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingReconcileTransactionAside/MatchingReconcileTransactionForm.tsx @@ -25,11 +25,18 @@ import { import { useCreateCashflowTransaction } from '@/hooks/query'; import { useAccountTransactionsContext } from '../../AccountTransactions/AccountTransactionsProvider'; import { MatchingReconcileFormSchema } from './MatchingReconcileTransactionForm.schema'; -import { initialValues } from './_utils'; +import { initialValues, transformToReq } from './_utils'; + +interface MatchingReconcileTransactionFormProps { + onSubmitSuccess?: (values: any) => void; +} function MatchingReconcileTransactionFormRoot({ closeReconcileMatchingTransaction, -}) { + + // #props¿ + onSubmitSuccess, +}: MatchingReconcileTransactionFormProps) { // Mutation create cashflow transaction. const { mutateAsync: createCashflowTransactionMutate } = useCreateCashflowTransaction(); @@ -47,7 +54,7 @@ function MatchingReconcileTransactionFormRoot({ const _values = transformToReq(values, accountId); createCashflowTransactionMutate(_values) - .then(() => { + .then((res) => { setSubmitting(false); AppToaster.show({ @@ -55,6 +62,8 @@ function MatchingReconcileTransactionFormRoot({ intent: Intent.SUCCESS, }); closeReconcileMatchingTransaction(); + onSubmitSuccess && + onSubmitSuccess({ id: res.data.id, type: 'CashflowTransaction' }); }) .catch((error) => { setSubmitting(false); @@ -97,25 +106,6 @@ export const MatchingReconcileTransactionForm = R.compose(withBankingActions)( MatchingReconcileTransactionFormRoot, ); -export function MatchingReconcileTransactionFooter() { - const { isSubmitting } = useFormikContext(); - - return ( - - - - - - ); -} - function ReconcileMatchingType() { const { setFieldValue, values } = useFormikContext(); @@ -135,7 +125,7 @@ function ReconcileMatchingType() { ); } -export function CreateReconcileTransactionContent() { +function CreateReconcileTransactionContent() { const { accounts, branches } = useMatchingReconcileTransactionBoot(); return ( @@ -197,3 +187,22 @@ export function CreateReconcileTransactionContent() { ); } + +function MatchingReconcileTransactionFooter() { + const { isSubmitting } = useFormikContext(); + + return ( + + + + + + ); +} diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingTransaction.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingTransaction.tsx index 978d02337..5331dadb0 100644 --- a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingTransaction.tsx +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingTransaction.tsx @@ -1,6 +1,7 @@ // @ts-nocheck import { isEmpty } from 'lodash'; import * as R from 'ramda'; +import { useEffect, useState } from 'react'; import { AnchorButton, Button, Intent, Tag, Text } from '@blueprintjs/core'; import { FastField, FastFieldProps, Formik, useFormikContext } from 'formik'; import { AppToaster, Box, FormatNumber, Group, Stack } from '@/components'; @@ -39,9 +40,6 @@ const initialValues = { function MatchingBankTransactionRoot({ // #withBankingActions closeMatchingTransactionAside, - - // #withBanking - openReconcileMatchingTransaction, }) { const { uncategorizedTransactionId } = useCategorizeTransactionTabsBoot(); const { mutateAsync: matchTransaction } = useMatchUncategorizedTransaction(); @@ -84,25 +82,89 @@ function MatchingBankTransactionRoot({ uncategorizedTransactionId={uncategorizedTransactionId} > - <> - - - {openReconcileMatchingTransaction && ( - - )} - {!openReconcileMatchingTransaction && } - + ); } -export const MatchingBankTransaction = R.compose( +export const MatchingBankTransaction = R.compose(withBankingActions)( + MatchingBankTransactionRoot, +); + +/** + * Matching bank transaction form content. + * @returns {React.ReactNode} + */ +const MatchingBankTransactionFormContent = R.compose( withBankingActions, withBanking(({ openReconcileMatchingTransaction }) => ({ openReconcileMatchingTransaction, })), -)(MatchingBankTransactionRoot); +)( + ({ + // #withBankingActions + closeMatchingTransactionAside, + + // #withBanking + openReconcileMatchingTransaction, + }) => { + const { + isMatchingTransactionsFetching, + isMatchingTransactionsSuccess, + matches, + } = useMatchingTransactionBoot(); + const [pending, setPending] = useState(null); + + const { setFieldValue } = useFormikContext(); + + // This effect is responsible for automatically marking a transaction as matched + // when the matching process is successful and not currently fetching. + useEffect(() => { + if ( + pending && + isMatchingTransactionsSuccess && + !isMatchingTransactionsFetching + ) { + const foundMatch = matches?.find( + (m) => + m.referenceType === pending?.refType && + m.referenceId === pending?.refId, + ); + if (foundMatch) { + setFieldValue(`matched.${pending.refType}-${pending.refId}`, true); + } + setPending(null); + } + }, [ + isMatchingTransactionsFetching, + isMatchingTransactionsSuccess, + matches, + pending, + setFieldValue, + ]); + + const handleReconcileFormSubmitSuccess = (payload) => { + setPending({ refId: payload.id, refType: payload.type }); + }; + + return ( + <> + + + {openReconcileMatchingTransaction && ( + + )} + {!openReconcileMatchingTransaction && } + + ); + }, +); function MatchingBankTransactionContent() { return ( @@ -178,8 +240,8 @@ function PossibleMatchingTransactions() { 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/containers/CashFlow/CategorizeTransactionAside/MatchingTransactionBoot.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingTransactionBoot.tsx index ff14cafe1..51ad9beb1 100644 --- a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingTransactionBoot.tsx +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingTransactionBoot.tsx @@ -1,9 +1,12 @@ -import { defaultTo } from 'lodash'; import React, { createContext } from 'react'; +import { defaultTo } from 'lodash'; +import * as R from 'ramda'; import { useGetBankTransactionsMatches } from '@/hooks/query/bank-rules'; interface MatchingTransactionBootValues { isMatchingTransactionsLoading: boolean; + isMatchingTransactionsFetching: boolean; + isMatchingTransactionsSuccess: boolean; possibleMatches: Array; perfectMatchesCount: number; perfectMatches: Array; @@ -26,13 +29,24 @@ function MatchingTransactionBoot({ const { data: matchingTransactions, isLoading: isMatchingTransactionsLoading, + isFetching: isMatchingTransactionsFetching, + isSuccess: isMatchingTransactionsSuccess, } = useGetBankTransactionsMatches(uncategorizedTransactionId); + const possibleMatches = defaultTo(matchingTransactions?.possibleMatches, []); + const perfectMatchesCount = matchingTransactions?.perfectMatches?.length || 0; + const perfectMatches = defaultTo(matchingTransactions?.perfectMatches, []); + + const matches = R.concat(perfectMatches, possibleMatches); + const provider = { isMatchingTransactionsLoading, - possibleMatches: defaultTo(matchingTransactions?.possibleMatches, []), - perfectMatchesCount: matchingTransactions?.perfectMatches?.length || 0, - perfectMatches: defaultTo(matchingTransactions?.perfectMatches, []), + isMatchingTransactionsFetching, + isMatchingTransactionsSuccess, + possibleMatches, + perfectMatchesCount, + perfectMatches, + matches, } as MatchingTransactionBootValues; return ; diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/utils.ts b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/utils.ts index 6cb13f0b0..267e2cae2 100644 --- a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/utils.ts +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/utils.ts @@ -24,7 +24,7 @@ export const useGetPendingAmountMatched = () => { return useMemo(() => { const matchedItems = [...perfectMatches, ...possibleMatches].filter( (match) => { - const key = `${match.transactionType}-${match.transactionId}`; + const key = `${match.referenceType}-${match.referenceId}`; return values.matched[key]; }, ); diff --git a/packages/webapp/src/hooks/query/cashflowAccounts.tsx b/packages/webapp/src/hooks/query/cashflowAccounts.tsx index a44200961..656d7cccf 100644 --- a/packages/webapp/src/hooks/query/cashflowAccounts.tsx +++ b/packages/webapp/src/hooks/query/cashflowAccounts.tsx @@ -58,6 +58,8 @@ export function useCreateCashflowTransaction(props) { onSuccess: () => { // Invalidate queries. commonInvalidateQueries(queryClient); + + queryClient.invalidateQueries('BANK_TRANSACTION_MATCHES'); }, ...props, },