mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-02-19 22:30:31 +00:00
feat: auto fill categorize form from recognized transaction
This commit is contained in:
@@ -15,6 +15,10 @@ export class RecognizedTransactionsController extends BaseController {
|
|||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.get('/', this.getRecognizedTransactions.bind(this));
|
router.get('/', this.getRecognizedTransactions.bind(this));
|
||||||
|
router.get(
|
||||||
|
'/transactions/:uncategorizedTransactionId',
|
||||||
|
this.getRecognizedTransaction.bind(this)
|
||||||
|
);
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
@@ -44,4 +48,30 @@ export class RecognizedTransactionsController extends BaseController {
|
|||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the recognized transaction of the ginen uncategorized transaction.
|
||||||
|
* @param {Request} req
|
||||||
|
* @param {Response} res
|
||||||
|
* @param {NextFunction} next
|
||||||
|
* @returns {Promise<Response|null>}
|
||||||
|
*/
|
||||||
|
async getRecognizedTransaction(
|
||||||
|
req: Request<{ uncategorizedTransactionId: number }>,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
|
const { tenantId } = req;
|
||||||
|
const { uncategorizedTransactionId } = req.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await this.cashflowApplication.getRecognizedTransaction(
|
||||||
|
tenantId,
|
||||||
|
uncategorizedTransactionId
|
||||||
|
);
|
||||||
|
return res.status(200).send({ data });
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
22
packages/server/src/services/Banking/Matching/_utils.ts
Normal file
22
packages/server/src/services/Banking/Matching/_utils.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import moment from 'moment';
|
||||||
|
import * as R from 'ramda';
|
||||||
|
import UncategorizedCashflowTransaction from '@/models/UncategorizedCashflowTransaction';
|
||||||
|
import { MatchedTransactionPOJO } from './types';
|
||||||
|
|
||||||
|
export const sortClosestMatchTransactions = (
|
||||||
|
uncategorizedTransaction: UncategorizedCashflowTransaction,
|
||||||
|
matches: MatchedTransactionPOJO[]
|
||||||
|
) => {
|
||||||
|
return R.sortWith([
|
||||||
|
// Sort by amount difference (closest to uncategorized transaction amount first)
|
||||||
|
R.ascend((match: MatchedTransactionPOJO) =>
|
||||||
|
Math.abs(match.amount - uncategorizedTransaction.amount)
|
||||||
|
),
|
||||||
|
// Sort by date difference (closest to uncategorized transaction date first)
|
||||||
|
R.ascend((match: MatchedTransactionPOJO) =>
|
||||||
|
Math.abs(
|
||||||
|
moment(match.date).diff(moment(uncategorizedTransaction.date), 'days')
|
||||||
|
)
|
||||||
|
),
|
||||||
|
])(matches);
|
||||||
|
};
|
||||||
@@ -20,6 +20,7 @@ import NewCashflowTransactionService from './NewCashflowTransactionService';
|
|||||||
import GetCashflowAccountsService from './GetCashflowAccountsService';
|
import GetCashflowAccountsService from './GetCashflowAccountsService';
|
||||||
import { GetCashflowTransactionService } from './GetCashflowTransactionsService';
|
import { GetCashflowTransactionService } from './GetCashflowTransactionsService';
|
||||||
import { GetRecognizedTransactionsService } from './GetRecongizedTransactions';
|
import { GetRecognizedTransactionsService } from './GetRecongizedTransactions';
|
||||||
|
import { GetRecognizedTransactionService } from './GetRecognizedTransaction';
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class CashflowApplication {
|
export class CashflowApplication {
|
||||||
@@ -56,6 +57,9 @@ export class CashflowApplication {
|
|||||||
@Inject()
|
@Inject()
|
||||||
private getRecognizedTranasctionsService: GetRecognizedTransactionsService;
|
private getRecognizedTranasctionsService: GetRecognizedTransactionsService;
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
private getRecognizedTransactionService: GetRecognizedTransactionService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new cashflow transaction.
|
* Creates a new cashflow transaction.
|
||||||
* @param {number} tenantId
|
* @param {number} tenantId
|
||||||
@@ -234,4 +238,20 @@ export class CashflowApplication {
|
|||||||
filter
|
filter
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the recognized transaction of the given uncategorized transaction.
|
||||||
|
* @param {number} tenantId
|
||||||
|
* @param {number} uncategorizedTransactionId
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
public getRecognizedTransaction(
|
||||||
|
tenantId: number,
|
||||||
|
uncategorizedTransactionId: number
|
||||||
|
) {
|
||||||
|
return this.getRecognizedTransactionService.getRecognizedTransaction(
|
||||||
|
tenantId,
|
||||||
|
uncategorizedTransactionId
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { Inject, Service } from 'typedi';
|
||||||
|
import HasTenancyService from '../Tenancy/TenancyService';
|
||||||
|
import { GetRecognizedTransactionTransformer } from './GetRecognizedTransactionTransformer';
|
||||||
|
import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable';
|
||||||
|
|
||||||
|
@Service()
|
||||||
|
export class GetRecognizedTransactionService {
|
||||||
|
@Inject()
|
||||||
|
private tenancy: HasTenancyService;
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
private transformer: TransformerInjectable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the recognized transaction of the given uncategorized transaction.
|
||||||
|
* @param {number} tenantId
|
||||||
|
* @param {number} uncategorizedTransactionId
|
||||||
|
*/
|
||||||
|
public async getRecognizedTransaction(
|
||||||
|
tenantId: number,
|
||||||
|
uncategorizedTransactionId: number
|
||||||
|
) {
|
||||||
|
const { UncategorizedCashflowTransaction } = this.tenancy.models(tenantId);
|
||||||
|
|
||||||
|
const uncategorizedTransaction =
|
||||||
|
await UncategorizedCashflowTransaction.query()
|
||||||
|
.findById(uncategorizedTransactionId)
|
||||||
|
.withGraphFetched('matchedBankTransactions')
|
||||||
|
.withGraphFetched('recognizedTransaction.assignAccount')
|
||||||
|
.withGraphFetched('recognizedTransaction.bankRule')
|
||||||
|
.withGraphFetched('account')
|
||||||
|
.throwIfNotFound();
|
||||||
|
|
||||||
|
return this.transformer.transform(
|
||||||
|
tenantId,
|
||||||
|
uncategorizedTransaction,
|
||||||
|
new GetRecognizedTransactionTransformer()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import { useAccounts, useBranches } from '@/hooks/query';
|
|||||||
import { useFeatureCan } from '@/hooks/state';
|
import { useFeatureCan } from '@/hooks/state';
|
||||||
import { Features } from '@/constants';
|
import { Features } from '@/constants';
|
||||||
import { Spinner } from '@blueprintjs/core';
|
import { Spinner } from '@blueprintjs/core';
|
||||||
|
import { useGetRecognizedBankTransaction } from '@/hooks/query/bank-rules';
|
||||||
|
import { useCategorizeTransactionTabsBoot } from '@/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionTabsBoot';
|
||||||
|
|
||||||
interface CategorizeTransactionBootProps {
|
interface CategorizeTransactionBootProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -17,6 +19,8 @@ interface CategorizeTransactionBootValue {
|
|||||||
isBranchesLoading: boolean;
|
isBranchesLoading: boolean;
|
||||||
isAccountsLoading: boolean;
|
isAccountsLoading: boolean;
|
||||||
primaryBranch: any;
|
primaryBranch: any;
|
||||||
|
recognizedTranasction: any;
|
||||||
|
isRecognizedTransactionLoading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CategorizeTransactionBootContext =
|
const CategorizeTransactionBootContext =
|
||||||
@@ -30,6 +34,9 @@ const CategorizeTransactionBootContext =
|
|||||||
function CategorizeTransactionBoot({
|
function CategorizeTransactionBoot({
|
||||||
...props
|
...props
|
||||||
}: CategorizeTransactionBootProps) {
|
}: CategorizeTransactionBootProps) {
|
||||||
|
const { uncategorizedTransaction, uncategorizedTransactionId } =
|
||||||
|
useCategorizeTransactionTabsBoot();
|
||||||
|
|
||||||
// Detarmines whether the feature is enabled.
|
// Detarmines whether the feature is enabled.
|
||||||
const { featureCan } = useFeatureCan();
|
const { featureCan } = useFeatureCan();
|
||||||
const isBranchFeatureCan = featureCan(Features.Branches);
|
const isBranchFeatureCan = featureCan(Features.Branches);
|
||||||
@@ -42,6 +49,14 @@ function CategorizeTransactionBoot({
|
|||||||
{},
|
{},
|
||||||
{ enabled: isBranchFeatureCan },
|
{ enabled: isBranchFeatureCan },
|
||||||
);
|
);
|
||||||
|
// Fetches the recognized transaction.
|
||||||
|
const {
|
||||||
|
data: recognizedTranasction,
|
||||||
|
isLoading: isRecognizedTransactionLoading,
|
||||||
|
} = useGetRecognizedBankTransaction(uncategorizedTransactionId, {
|
||||||
|
enabled: !!uncategorizedTransaction.is_recognized,
|
||||||
|
});
|
||||||
|
|
||||||
// Retrieves the primary branch.
|
// Retrieves the primary branch.
|
||||||
const primaryBranch = useMemo(
|
const primaryBranch = useMemo(
|
||||||
() => branches?.find((b) => b.primary) || first(branches),
|
() => branches?.find((b) => b.primary) || first(branches),
|
||||||
@@ -54,6 +69,8 @@ function CategorizeTransactionBoot({
|
|||||||
isBranchesLoading,
|
isBranchesLoading,
|
||||||
isAccountsLoading,
|
isAccountsLoading,
|
||||||
primaryBranch,
|
primaryBranch,
|
||||||
|
recognizedTranasction,
|
||||||
|
isRecognizedTransactionLoading,
|
||||||
};
|
};
|
||||||
const isLoading = isBranchesLoading || isAccountsLoading;
|
const isLoading = isBranchesLoading || isAccountsLoading;
|
||||||
|
|
||||||
|
|||||||
@@ -6,16 +6,14 @@ import { CreateCategorizeTransactionSchema } from './CategorizeTransactionForm.s
|
|||||||
import { CategorizeTransactionFormContent } from './CategorizeTransactionFormContent';
|
import { CategorizeTransactionFormContent } from './CategorizeTransactionFormContent';
|
||||||
import { CategorizeTransactionFormFooter } from './CategorizeTransactionFormFooter';
|
import { CategorizeTransactionFormFooter } from './CategorizeTransactionFormFooter';
|
||||||
import { useCategorizeTransaction } from '@/hooks/query';
|
import { useCategorizeTransaction } from '@/hooks/query';
|
||||||
import { useCategorizeTransactionBoot } from './CategorizeTransactionBoot';
|
|
||||||
import {
|
import {
|
||||||
transformToCategorizeForm,
|
|
||||||
defaultInitialValues,
|
|
||||||
tranformToRequest,
|
tranformToRequest,
|
||||||
|
useCategorizeTransactionFormInitialValues,
|
||||||
} from './_utils';
|
} from './_utils';
|
||||||
import { compose } from '@/utils';
|
|
||||||
import { withBankingActions } from '@/containers/CashFlow/withBankingActions';
|
import { withBankingActions } from '@/containers/CashFlow/withBankingActions';
|
||||||
import { AppToaster } from '@/components';
|
import { AppToaster } from '@/components';
|
||||||
import { useCategorizeTransactionTabsBoot } from '@/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionTabsBoot';
|
import { useCategorizeTransactionTabsBoot } from '@/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionTabsBoot';
|
||||||
|
import { compose } from '@/utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Categorize cashflow transaction form dialog content.
|
* Categorize cashflow transaction form dialog content.
|
||||||
@@ -24,11 +22,12 @@ function CategorizeTransactionFormRoot({
|
|||||||
// #withBankingActions
|
// #withBankingActions
|
||||||
closeMatchingTransactionAside,
|
closeMatchingTransactionAside,
|
||||||
}) {
|
}) {
|
||||||
const { uncategorizedTransactionId, uncategorizedTransaction } =
|
const { uncategorizedTransactionId } = useCategorizeTransactionTabsBoot();
|
||||||
useCategorizeTransactionTabsBoot();
|
|
||||||
const { primaryBranch } = useCategorizeTransactionBoot();
|
|
||||||
const { mutateAsync: categorizeTransaction } = useCategorizeTransaction();
|
const { mutateAsync: categorizeTransaction } = useCategorizeTransaction();
|
||||||
|
|
||||||
|
// Form initial values in create and edit mode.
|
||||||
|
const initialValues = useCategorizeTransactionFormInitialValues();
|
||||||
|
|
||||||
// Callbacks handles form submit.
|
// Callbacks handles form submit.
|
||||||
const handleFormSubmit = (values, { setSubmitting, setErrors }) => {
|
const handleFormSubmit = (values, { setSubmitting, setErrors }) => {
|
||||||
const transformedValues = tranformToRequest(values);
|
const transformedValues = tranformToRequest(values);
|
||||||
@@ -62,19 +61,6 @@ function CategorizeTransactionFormRoot({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
// Form initial values in create and edit mode.
|
|
||||||
const initialValues = {
|
|
||||||
...defaultInitialValues,
|
|
||||||
/**
|
|
||||||
* We only care about the fields in the form. Previously unfilled optional
|
|
||||||
* values such as `notes` come back from the API as null, so remove those
|
|
||||||
* as well.
|
|
||||||
*/
|
|
||||||
...transformToCategorizeForm(uncategorizedTransaction),
|
|
||||||
|
|
||||||
/** Assign the primary branch id as default value. */
|
|
||||||
branchId: primaryBranch?.id || null,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DivRoot>
|
<DivRoot>
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export default function CategorizeTransactionOtherIncome() {
|
|||||||
</FFormGroup>
|
</FFormGroup>
|
||||||
|
|
||||||
<FFormGroup name={'referenceNo'} label={'Reference No.'} fastField inline>
|
<FFormGroup name={'referenceNo'} label={'Reference No.'} fastField inline>
|
||||||
<FInputGroup name={'reference_no'} fill />
|
<FInputGroup name={'referenceNo'} fill />
|
||||||
</FFormGroup>
|
</FFormGroup>
|
||||||
|
|
||||||
<FFormGroup name={'description'} label={'Description'} fastField inline>
|
<FFormGroup name={'description'} label={'Description'} fastField inline>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
// @ts-nocheck
|
import * as R from 'ramda';
|
||||||
import { transformToForm, transfromToSnakeCase } from '@/utils';
|
import { transformToForm, transfromToSnakeCase } from '@/utils';
|
||||||
|
import { useCategorizeTransactionTabsBoot } from '@/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionTabsBoot';
|
||||||
|
import { useCategorizeTransactionBoot } from './CategorizeTransactionBoot';
|
||||||
|
|
||||||
// Default initial form values.
|
// Default initial form values.
|
||||||
export const defaultInitialValues = {
|
export const defaultInitialValues = {
|
||||||
@@ -14,8 +16,11 @@ export const defaultInitialValues = {
|
|||||||
branchId: '',
|
branchId: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const transformToCategorizeForm = (uncategorizedTransaction) => {
|
export const transformToCategorizeForm = (
|
||||||
const defaultValues = {
|
uncategorizedTransaction: any,
|
||||||
|
recognizedTransaction?: any,
|
||||||
|
) => {
|
||||||
|
let defaultValues = {
|
||||||
debitAccountId: uncategorizedTransaction.account_id,
|
debitAccountId: uncategorizedTransaction.account_id,
|
||||||
transactionType: uncategorizedTransaction.is_deposit_transaction
|
transactionType: uncategorizedTransaction.is_deposit_transaction
|
||||||
? 'other_income'
|
? 'other_income'
|
||||||
@@ -23,10 +28,51 @@ export const transformToCategorizeForm = (uncategorizedTransaction) => {
|
|||||||
amount: uncategorizedTransaction.amount,
|
amount: uncategorizedTransaction.amount,
|
||||||
date: uncategorizedTransaction.date,
|
date: uncategorizedTransaction.date,
|
||||||
};
|
};
|
||||||
|
if (recognizedTransaction) {
|
||||||
|
const recognizedDefaults = getRecognizedTransactionDefaultValues(
|
||||||
|
recognizedTransaction,
|
||||||
|
);
|
||||||
|
defaultValues = R.merge(defaultValues, recognizedDefaults);
|
||||||
|
}
|
||||||
return transformToForm(defaultValues, defaultInitialValues);
|
return transformToForm(defaultValues, defaultInitialValues);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getRecognizedTransactionDefaultValues = (
|
||||||
|
recognizedTransaction: any,
|
||||||
|
) => {
|
||||||
|
return {
|
||||||
|
creditAccountId: recognizedTransaction.assignedAccountId || '',
|
||||||
|
// transactionType: recognizedTransaction.assignCategory,
|
||||||
|
referenceNo: recognizedTransaction.referenceNo || '',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const tranformToRequest = (formValues) => {
|
export const tranformToRequest = (formValues: Record<string, any>) => {
|
||||||
return transfromToSnakeCase(formValues);
|
return transfromToSnakeCase(formValues);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Categorize transaction form initial values.
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const useCategorizeTransactionFormInitialValues = () => {
|
||||||
|
const { primaryBranch, recognizedTranasction } =
|
||||||
|
useCategorizeTransactionBoot();
|
||||||
|
const { uncategorizedTransaction } = useCategorizeTransactionTabsBoot();
|
||||||
|
|
||||||
|
return {
|
||||||
|
...defaultInitialValues,
|
||||||
|
/**
|
||||||
|
* We only care about the fields in the form. Previously unfilled optional
|
||||||
|
* values such as `notes` come back from the API as null, so remove those
|
||||||
|
* as well.
|
||||||
|
*/
|
||||||
|
...transformToCategorizeForm(
|
||||||
|
uncategorizedTransaction,
|
||||||
|
recognizedTranasction,
|
||||||
|
),
|
||||||
|
|
||||||
|
/** Assign the primary branch id as default value. */
|
||||||
|
branchId: primaryBranch?.id || null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import t from './types';
|
|||||||
const QUERY_KEY = {
|
const QUERY_KEY = {
|
||||||
BANK_RULES: 'BANK_RULE',
|
BANK_RULES: 'BANK_RULE',
|
||||||
BANK_TRANSACTION_MATCHES: 'BANK_TRANSACTION_MATCHES',
|
BANK_TRANSACTION_MATCHES: 'BANK_TRANSACTION_MATCHES',
|
||||||
|
RECOGNIZED_BANK_TRANSACTION: 'RECOGNIZED_BANK_TRANSACTION',
|
||||||
EXCLUDED_BANK_TRANSACTIONS_INFINITY: 'EXCLUDED_BANK_TRANSACTIONS_INFINITY',
|
EXCLUDED_BANK_TRANSACTIONS_INFINITY: 'EXCLUDED_BANK_TRANSACTIONS_INFINITY',
|
||||||
RECOGNIZED_BANK_TRANSACTIONS_INFINITY:
|
RECOGNIZED_BANK_TRANSACTIONS_INFINITY:
|
||||||
'RECOGNIZED_BANK_TRANSACTIONS_INFINITY',
|
'RECOGNIZED_BANK_TRANSACTIONS_INFINITY',
|
||||||
@@ -318,6 +319,30 @@ export function useMatchUncategorizedTransaction(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GetRecognizedBankTransactionRes {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REtrieves the given recognized bank transaction.
|
||||||
|
* @param {number} uncategorizedTransactionId
|
||||||
|
* @param {UseQueryOptions<GetRecognizedBankTransactionRes, Error>} options
|
||||||
|
* @returns {UseQueryResult<GetRecognizedBankTransactionRes, Error>}
|
||||||
|
*/
|
||||||
|
export function useGetRecognizedBankTransaction(
|
||||||
|
uncategorizedTransactionId: number,
|
||||||
|
options?: UseQueryOptions<GetRecognizedBankTransactionRes, Error>,
|
||||||
|
): UseQueryResult<GetRecognizedBankTransactionRes, Error> {
|
||||||
|
const apiRequest = useApiRequest();
|
||||||
|
|
||||||
|
return useQuery<GetRecognizedBankTransactionRes, Error>(
|
||||||
|
[QUERY_KEY.RECOGNIZED_BANK_TRANSACTION, uncategorizedTransactionId],
|
||||||
|
() =>
|
||||||
|
apiRequest
|
||||||
|
.get(`/banking/recognized/transactions/${uncategorizedTransactionId}`)
|
||||||
|
.then((res) => transformToCamelCase(res.data?.data)),
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user