feat: cashflow transaction matching

This commit is contained in:
Ahmed Bouhuolia
2024-07-04 22:44:20 +02:00
parent 202179ec0b
commit 87f60f7461
10 changed files with 333 additions and 45 deletions

View File

@@ -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<string>}
*/
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';
}
}

View File

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

View File

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

View File

@@ -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<GetMatchedTransactionsFilter, 'transactionType'>
) {
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()
);
}
}

View File

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

View File

@@ -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 (
<Box className={styles.footer}>
<Group>
<Button
fill
type={'submit'}
intent={Intent.PRIMARY}
loading={isSubmitting}
>
Submit
</Button>
</Group>
</Box>
);
}
function ReconcileMatchingType() {
const { setFieldValue, values } =
useFormikContext<MatchingReconcileFormValues>();
@@ -135,7 +125,7 @@ function ReconcileMatchingType() {
);
}
export function CreateReconcileTransactionContent() {
function CreateReconcileTransactionContent() {
const { accounts, branches } = useMatchingReconcileTransactionBoot();
return (
@@ -197,3 +187,22 @@ export function CreateReconcileTransactionContent() {
</Box>
);
}
function MatchingReconcileTransactionFooter() {
const { isSubmitting } = useFormikContext();
return (
<Box className={styles.footer}>
<Group>
<Button
fill
type={'submit'}
intent={Intent.PRIMARY}
loading={isSubmitting}
>
Submit
</Button>
</Group>
</Box>
);
}

View File

@@ -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}
>
<Formik initialValues={initialValues} onSubmit={handleSubmit}>
<>
<MatchingBankTransactionContent />
{openReconcileMatchingTransaction && (
<MatchingReconcileTransactionForm />
)}
{!openReconcileMatchingTransaction && <MatchTransactionFooter />}
</>
<MatchingBankTransactionFormContent />
</Formik>
</MatchingTransactionBoot>
);
}
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 | {
refId: number;
refType: string;
}>(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 (
<>
<MatchingBankTransactionContent />
{openReconcileMatchingTransaction && (
<MatchingReconcileTransactionForm
onSubmitSuccess={handleReconcileFormSubmitSuccess}
/>
)}
{!openReconcileMatchingTransaction && <MatchTransactionFooter />}
</>
);
},
);
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}
/>
))}
</Stack>

View File

@@ -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<any>;
perfectMatchesCount: number;
perfectMatches: Array<any>;
@@ -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 <RuleFormBootContext.Provider value={provider} {...props} />;

View File

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

View File

@@ -58,6 +58,8 @@ export function useCreateCashflowTransaction(props) {
onSuccess: () => {
// Invalidate queries.
commonInvalidateQueries(queryClient);
queryClient.invalidateQueries('BANK_TRANSACTION_MATCHES');
},
...props,
},