From 202179ec0b40124cd65882881d4cc3deb2f563ca Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Thu, 4 Jul 2024 19:21:05 +0200 Subject: [PATCH 1/6] feat: reconcile matching transactions --- .../src/components/Aside/Aside.module.scss | 1 + .../webapp/src/components/Aside/Aside.tsx | 33 ++- .../components/ContentTabs/ContentTabs.tsx | 22 +- .../CategorizeTransactionAside.tsx | 12 +- .../MatchingReconcileTransactionBoot.tsx | 43 ++++ ...tchingReconcileTransactionForm.module.scss | 43 ++++ ...MatchingReconcileTransactionForm.schema.ts | 10 + .../MatchingReconcileTransactionForm.tsx | 199 ++++++++++++++++++ .../_types.ts | 9 + .../_utils.ts | 30 +++ .../MatchingTransaction.tsx | 35 ++- .../src/containers/CashFlow/withBanking.ts | 2 + .../containers/CashFlow/withBankingActions.ts | 8 + .../src/store/banking/banking.reducer.ts | 12 ++ 14 files changed, 440 insertions(+), 19 deletions(-) create mode 100644 packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingReconcileTransactionAside/MatchingReconcileTransactionBoot.tsx create mode 100644 packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingReconcileTransactionAside/MatchingReconcileTransactionForm.module.scss create mode 100644 packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingReconcileTransactionAside/MatchingReconcileTransactionForm.schema.ts create mode 100644 packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingReconcileTransactionAside/MatchingReconcileTransactionForm.tsx create mode 100644 packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingReconcileTransactionAside/_types.ts create mode 100644 packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingReconcileTransactionAside/_utils.ts diff --git a/packages/webapp/src/components/Aside/Aside.module.scss b/packages/webapp/src/components/Aside/Aside.module.scss index 742d0c4c58..73ee0e2991 100644 --- a/packages/webapp/src/components/Aside/Aside.module.scss +++ b/packages/webapp/src/components/Aside/Aside.module.scss @@ -21,4 +21,5 @@ flex-direction: column; flex: 1 1 auto; background-color: #fff; + overflow-y: auto; } \ No newline at end of file diff --git a/packages/webapp/src/components/Aside/Aside.tsx b/packages/webapp/src/components/Aside/Aside.tsx index 7967cd2b33..a9f8e25e97 100644 --- a/packages/webapp/src/components/Aside/Aside.tsx +++ b/packages/webapp/src/components/Aside/Aside.tsx @@ -1,13 +1,16 @@ import { Button, Classes } from '@blueprintjs/core'; -import { Box, Group } from '../Layout'; +import clsx from 'classnames'; +import { Box, BoxProps, Group } from '../Layout'; import { Icon } from '../Icon'; import styles from './Aside.module.scss'; -interface AsideProps { +interface AsideProps extends BoxProps { title?: string; onClose?: () => void; children?: React.ReactNode; hideCloseButton?: boolean; + classNames?: Record; + className?: string; } export function Aside({ @@ -15,13 +18,15 @@ export function Aside({ onClose, children, hideCloseButton, + classNames, + className }: AsideProps) { const handleClose = () => { onClose && onClose(); }; return ( - - + + {title} {hideCloseButton !== true && ( @@ -34,7 +39,23 @@ export function Aside({ /> )} - {children} + + {children} ); -} \ No newline at end of file +} + +interface AsideContentProps extends BoxProps {} + +function AsideContent({ ...props }: AsideContentProps) { + return ; +} + +interface AsideFooterProps extends BoxProps {} + +function AsideFooter({ ...props }: AsideFooterProps) { + return ; +} + +Aside.Body = AsideContent; +Aside.Footer = AsideFooter; diff --git a/packages/webapp/src/components/ContentTabs/ContentTabs.tsx b/packages/webapp/src/components/ContentTabs/ContentTabs.tsx index 58f8447828..f752918f24 100644 --- a/packages/webapp/src/components/ContentTabs/ContentTabs.tsx +++ b/packages/webapp/src/components/ContentTabs/ContentTabs.tsx @@ -19,6 +19,12 @@ const ContentTabItemRoot = styled.button` text-align: left; cursor: pointer; + ${(props) => + props.small && + ` + padding: 8px 10px; + `} + ${(props) => props.active && ` @@ -55,6 +61,8 @@ interface ContentTabsItemProps { title?: React.ReactNode; description?: React.ReactNode; active?: boolean; + className?: string; + small?: booean; } const ContentTabsItem = ({ @@ -62,11 +70,18 @@ const ContentTabsItem = ({ description, active, onClick, + small, + className, }: ContentTabsItemProps) => { return ( - + {title} - {description} + {description && {description}} ); }; @@ -77,6 +92,7 @@ interface ContentTabsProps { onChange?: (value: string) => void; children?: React.ReactNode; className?: string; + small?: boolean; } export function ContentTabs({ @@ -85,6 +101,7 @@ export function ContentTabs({ onChange, children, className, + small, }: ContentTabsProps) { const [localValue, handleItemChange] = useUncontrolled({ initialValue, @@ -102,6 +119,7 @@ export function ContentTabs({ {...tab.props} active={localValue === tab.props.id} onClick={() => handleItemChange(tab.props?.id)} + small={small} /> ))} diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionAside.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionAside.tsx index 463cfe461f..7203085f16 100644 --- a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionAside.tsx +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionAside.tsx @@ -28,11 +28,13 @@ function CategorizeTransactionAsideRoot({ } return ( ); } diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingReconcileTransactionAside/MatchingReconcileTransactionBoot.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingReconcileTransactionAside/MatchingReconcileTransactionBoot.tsx new file mode 100644 index 0000000000..e2f74a3d62 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingReconcileTransactionAside/MatchingReconcileTransactionBoot.tsx @@ -0,0 +1,43 @@ +import { useAccounts, useBranches } from '@/hooks/query'; +import { Spinner } from '@blueprintjs/core'; +import React from 'react'; + +interface MatchingReconcileTransactionBootProps { + children: React.ReactNode; +} +interface MatchingReconcileTransactionBootValue {} + +const MatchingReconcileTransactionBootContext = + React.createContext( + {} as MatchingReconcileTransactionBootValue, + ); + +export function MatchingReconcileTransactionBoot({ + children, +}: MatchingReconcileTransactionBootProps) { + const { data: accounts, isLoading: isAccountsLoading } = useAccounts({}, {}); + const { data: branches, isLoading: isBranchesLoading } = useBranches({}, {}); + + const provider = { + accounts, + branches, + isAccountsLoading, + isBranchesLoading, + }; + const isLoading = isAccountsLoading || isBranchesLoading; + + if (isLoading) { + return ; + } + + return ( + + {children} + + ); +} + +export const useMatchingReconcileTransactionBoot = () => + React.useContext( + MatchingReconcileTransactionBootContext, + ); diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingReconcileTransactionAside/MatchingReconcileTransactionForm.module.scss b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingReconcileTransactionAside/MatchingReconcileTransactionForm.module.scss new file mode 100644 index 0000000000..65dd9e6840 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingReconcileTransactionAside/MatchingReconcileTransactionForm.module.scss @@ -0,0 +1,43 @@ + + + +.content{ + padding: 18px; + display: flex; + flex-direction: column; + gap: 12px; + flex: 1; +} + +.footer { + padding: 11px 20px; + border-top: 1px solid #ced4db; +} + +.form{ + display: flex; + flex-direction: column; + flex: 1 1 0; + + :global .bp4-form-group{ + margin-bottom: 0; + } + :global .bp4-input { + line-height: 30px; + height: 30px; + } +} + +.asideContent{ + background: #F6F7F9; + height: 335px; +} + +.asideRoot { + flex: 1 1 0; + box-shadow: 0 0 0 1px rgba(17,20,24,.1),0 1px 1px rgba(17,20,24,.2),0 2px 6px rgba(17,20,24,.2); +} + +.asideFooter { + background: #F6F7F9; +} \ No newline at end of file diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingReconcileTransactionAside/MatchingReconcileTransactionForm.schema.ts b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingReconcileTransactionAside/MatchingReconcileTransactionForm.schema.ts new file mode 100644 index 0000000000..107444d445 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingReconcileTransactionAside/MatchingReconcileTransactionForm.schema.ts @@ -0,0 +1,10 @@ +import * as Yup from 'yup'; + +export const MatchingReconcileFormSchema = Yup.object().shape({ + type: Yup.string().required().label('Type'), + date: Yup.string().required().label('Date'), + amount: Yup.string().required().label('Amount'), + memo: Yup.string().required().label('Memo'), + referenceNo: Yup.string().label('Refernece #'), + category: Yup.string().required().label('Categogry'), +}); diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingReconcileTransactionAside/MatchingReconcileTransactionForm.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingReconcileTransactionAside/MatchingReconcileTransactionForm.tsx new file mode 100644 index 0000000000..65a185de1a --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingReconcileTransactionAside/MatchingReconcileTransactionForm.tsx @@ -0,0 +1,199 @@ +// @ts-nocheck +import * as R from 'ramda'; +import { Button, Intent, Position, Tag } from '@blueprintjs/core'; +import { Form, Formik, FormikValues, useFormikContext } from 'formik'; +import { + AccountsSelect, + AppToaster, + Box, + BranchSelect, + FDateInput, + FFormGroup, + FInputGroup, + FMoneyInputGroup, + Group, +} from '@/components'; +import { Aside } from '@/components/Aside/Aside'; +import { momentFormatter } from '@/utils'; +import styles from './MatchingReconcileTransactionForm.module.scss'; +import { ContentTabs } from '@/components/ContentTabs'; +import { withBankingActions } from '../../withBankingActions'; +import { + MatchingReconcileTransactionBoot, + useMatchingReconcileTransactionBoot, +} from './MatchingReconcileTransactionBoot'; +import { useCreateCashflowTransaction } from '@/hooks/query'; +import { useAccountTransactionsContext } from '../../AccountTransactions/AccountTransactionsProvider'; +import { MatchingReconcileFormSchema } from './MatchingReconcileTransactionForm.schema'; +import { initialValues } from './_utils'; + +function MatchingReconcileTransactionFormRoot({ + closeReconcileMatchingTransaction, +}) { + // Mutation create cashflow transaction. + const { mutateAsync: createCashflowTransactionMutate } = + useCreateCashflowTransaction(); + + const { accountId } = useAccountTransactionsContext(); + + const handleAsideClose = () => { + closeReconcileMatchingTransaction(); + }; + const handleSubmit = ( + values: MatchingReconcileTransactionValues, + { setSubmitting }: FormikValues, + ) => { + setSubmitting(true); + const _values = transformToReq(values, accountId); + + createCashflowTransactionMutate(_values) + .then(() => { + setSubmitting(false); + + AppToaster.show({ + message: 'The transaction has been created.', + intent: Intent.SUCCESS, + }); + closeReconcileMatchingTransaction(); + }) + .catch((error) => { + setSubmitting(false); + + AppToaster.show({ + message: 'Something went wrong.', + intent: Intent.DANGER, + }); + }); + }; + + return ( + + ); +} + +export const MatchingReconcileTransactionForm = R.compose(withBankingActions)( + MatchingReconcileTransactionFormRoot, +); + +export function MatchingReconcileTransactionFooter() { + const { isSubmitting } = useFormikContext(); + + return ( + + + + + + ); +} + +function ReconcileMatchingType() { + const { setFieldValue, values } = + useFormikContext(); + + const handleChange = (value: string) => { + setFieldValue('type', value); + }; + return ( + + + + + ); +} + +export function CreateReconcileTransactionContent() { + const { accounts, branches } = useMatchingReconcileTransactionBoot(); + + return ( + + + + + date.toLocaleString()} + popoverProps={{ + position: Position.LEFT, + }} + inputProps={{ fill: true }} + fill + /> + + + Required} + > + + + + Required} + > + + + + Required} + > + + + + + + + + + + + + ); +} diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingReconcileTransactionAside/_types.ts b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingReconcileTransactionAside/_types.ts new file mode 100644 index 0000000000..1817497e1d --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingReconcileTransactionAside/_types.ts @@ -0,0 +1,9 @@ +export interface MatchingReconcileTransactionValues { + type: string; + date: string; + amount: string; + memo: string; + referenceNo: string; + category: string; + branchId: string; +} \ No newline at end of file diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingReconcileTransactionAside/_utils.ts b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingReconcileTransactionAside/_utils.ts new file mode 100644 index 0000000000..2a747b7cf1 --- /dev/null +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingReconcileTransactionAside/_utils.ts @@ -0,0 +1,30 @@ +import { MatchingReconcileTransactionValues } from './_types'; + +export const transformToReq = ( + values: MatchingReconcileTransactionValues, + bankAccountId: number, +) => { + return { + date: values.date, + reference_no: values.referenceNo, + transaction_type: + values.type === 'deposit' ? 'other_income' : 'other_expense', + description: values.memo, + amount: values.amount, + credit_account_id: values.category, + cashflow_account_id: bankAccountId, + branch_id: values.branchId, + publish: true, + }; +}; + + +export const initialValues = { + type: 'deposit', + date: '', + amount: '', + memo: '', + referenceNo: '', + category: '', + branchId: '', +}; diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingTransaction.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingTransaction.tsx index 8aef55e8e9..978d02337b 100644 --- a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingTransaction.tsx +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingTransaction.tsx @@ -25,6 +25,8 @@ import { withBankingActions, } from '../withBankingActions'; import styles from './CategorizeTransactionAside.module.scss'; +import { MatchingReconcileTransactionForm } from './MatchingReconcileTransactionAside/MatchingReconcileTransactionForm'; +import { withBanking } from '../withBanking'; const initialValues = { matched: {}, @@ -37,6 +39,9 @@ const initialValues = { function MatchingBankTransactionRoot({ // #withBankingActions closeMatchingTransactionAside, + + // #withBanking + openReconcileMatchingTransaction, }) { const { uncategorizedTransactionId } = useCategorizeTransactionTabsBoot(); const { mutateAsync: matchTransaction } = useMatchUncategorizedTransaction(); @@ -81,16 +86,23 @@ function MatchingBankTransactionRoot({ <> - + + {openReconcileMatchingTransaction && ( + + )} + {!openReconcileMatchingTransaction && } ); } -export const MatchingBankTransaction = R.compose(withBankingActions)( - MatchingBankTransactionRoot, -); +export const MatchingBankTransaction = R.compose( + withBankingActions, + withBanking(({ openReconcileMatchingTransaction }) => ({ + openReconcileMatchingTransaction, + })), +)(MatchingBankTransactionRoot); function MatchingBankTransactionContent() { return ( @@ -212,7 +224,10 @@ interface MatchTransctionFooterProps extends WithBankingActionsProps {} * @returns {React.ReactNode} */ const MatchTransactionFooter = R.compose(withBankingActions)( - ({ closeMatchingTransactionAside }: MatchTransctionFooterProps) => { + ({ + closeMatchingTransactionAside, + openReconcileMatchingTransaction, + }: MatchTransctionFooterProps) => { const { submitForm, isSubmitting } = useFormikContext(); const totalPending = useGetPendingAmountMatched(); const showReconcileLink = useIsShowReconcileTransactionLink(); @@ -224,13 +239,21 @@ const MatchTransactionFooter = R.compose(withBankingActions)( const handleSubmitBtnClick = () => { submitForm(); }; + const handleReconcileTransaction = () => { + openReconcileMatchingTransaction(); + }; return ( {showReconcileLink && ( - + Add Reconcile Transaction + )} diff --git a/packages/webapp/src/containers/CashFlow/withBanking.ts b/packages/webapp/src/containers/CashFlow/withBanking.ts index 93e056a29b..620e4dc899 100644 --- a/packages/webapp/src/containers/CashFlow/withBanking.ts +++ b/packages/webapp/src/containers/CashFlow/withBanking.ts @@ -8,6 +8,8 @@ export const withBanking = (mapState) => { openMatchingTransactionAside: state.plaid.openMatchingTransactionAside, selectedUncategorizedTransactionId: state.plaid.uncategorizedTransactionIdForMatching, + openReconcileMatchingTransaction: + state.plaid.openReconcileMatchingTransaction, }; return mapState ? mapState(mapped, state, props) : mapped; }; diff --git a/packages/webapp/src/containers/CashFlow/withBankingActions.ts b/packages/webapp/src/containers/CashFlow/withBankingActions.ts index 5a7f86ea52..06ef0f8165 100644 --- a/packages/webapp/src/containers/CashFlow/withBankingActions.ts +++ b/packages/webapp/src/containers/CashFlow/withBankingActions.ts @@ -2,6 +2,8 @@ import { connect } from 'react-redux'; import { closeMatchingTransactionAside, setUncategorizedTransactionIdForMatching, + openReconcileMatchingTransaction, + closeReconcileMatchingTransaction, } from '@/store/banking/banking.reducer'; export interface WithBankingActionsProps { @@ -9,6 +11,8 @@ export interface WithBankingActionsProps { setUncategorizedTransactionIdForMatching: ( uncategorizedTransactionId: number, ) => void; + openReconcileMatchingTransaction: () => void; + closeReconcileMatchingTransaction: () => void; } const mapDipatchToProps = (dispatch: any): WithBankingActionsProps => ({ @@ -20,6 +24,10 @@ const mapDipatchToProps = (dispatch: any): WithBankingActionsProps => ({ dispatch( setUncategorizedTransactionIdForMatching(uncategorizedTransactionId), ), + openReconcileMatchingTransaction: () => + dispatch(openReconcileMatchingTransaction()), + closeReconcileMatchingTransaction: () => + dispatch(closeReconcileMatchingTransaction()), }); export const withBankingActions = connect< diff --git a/packages/webapp/src/store/banking/banking.reducer.ts b/packages/webapp/src/store/banking/banking.reducer.ts index d77a146aa2..da7eb86ebb 100644 --- a/packages/webapp/src/store/banking/banking.reducer.ts +++ b/packages/webapp/src/store/banking/banking.reducer.ts @@ -4,6 +4,7 @@ interface StorePlaidState { plaidToken: string; openMatchingTransactionAside: boolean; uncategorizedTransactionIdForMatching: number | null; + openReconcileMatchingTransaction: boolean; } export const PlaidSlice = createSlice({ @@ -12,6 +13,7 @@ export const PlaidSlice = createSlice({ plaidToken: '', openMatchingTransactionAside: false, uncategorizedTransactionIdForMatching: null, + openReconcileMatchingTransaction: false, } as StorePlaidState, reducers: { setPlaidId: (state: StorePlaidState, action: PayloadAction) => { @@ -34,6 +36,14 @@ export const PlaidSlice = createSlice({ state.openMatchingTransactionAside = false; state.uncategorizedTransactionIdForMatching = null; }, + + openReconcileMatchingTransaction: (state: StorePlaidState) => { + state.openReconcileMatchingTransaction = true; + }, + + closeReconcileMatchingTransaction: (state: StorePlaidState) => { + state.openReconcileMatchingTransaction = false; + }, }, }); @@ -42,6 +52,8 @@ export const { resetPlaidId, setUncategorizedTransactionIdForMatching, closeMatchingTransactionAside, + openReconcileMatchingTransaction, + closeReconcileMatchingTransaction, } = PlaidSlice.actions; export const getPlaidToken = (state: any) => state.plaid.plaidToken; From 87f60f746186e47c9e6dbecbb81576074c135533 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Thu, 4 Jul 2024 22:44:20 +0200 Subject: [PATCH 2/6] 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 0000000000..7beb6ae99e --- /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 ed2dcfaa24..66814f7b62 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 43f5ba532c..32673fe4d5 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 0000000000..b59a44a402 --- /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 6c5c938d49..d90db1a364 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 65a185de1a..7255fb726c 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 978d02337b..5331dadb05 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 ff14cafe16..51ad9beb16 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 6cb13f0b05..267e2cae2b 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 a442009614..656d7cccf9 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, }, From cd9039fe16c943d9e8b3b4d0a395d151f2c8a521 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sat, 6 Jul 2024 16:10:34 +0200 Subject: [PATCH 3/6] fix(server): match transactions query --- .../server/src/models/CashflowTransaction.ts | 11 ++ .../GetMatchedTransactionBillsTransformer.ts | 28 ++++ ...etMatchedTransactionCashflowTransformer.ts | 19 +++ ...etMatchedTransactionExpensesTransformer.ts | 28 ++++ ...etMatchedTransactionInvoicesTransformer.ts | 27 ++++ ...hedTransactionManualJournalsTransformer.ts | 42 +++++- .../Matching/GetMatchedTransactions.ts | 3 +- .../Matching/GetMatchedTransactionsByBills.ts | 20 ++- .../GetMatchedTransactionsByCashflow.ts | 19 ++- .../GetMatchedTransactionsByExpenses.ts | 25 +++- .../GetMatchedTransactionsByInvoices.ts | 24 +++- .../GetMatchedTransactionsByManualJournals.ts | 23 ++- .../Banking/Matching/MatchTransactions.ts | 8 +- .../src/services/Banking/Matching/_utils.ts | 9 ++ .../CategorizeTransactionAside.tsx | 10 ++ .../MatchingReconcileTransactionForm.tsx | 134 ++++++++++++++---- .../MatchingTransaction.tsx | 21 ++- .../CategorizeTransactionAside/utils.ts | 4 +- .../src/containers/CashFlow/withBanking.ts | 5 +- .../containers/CashFlow/withBankingActions.ts | 6 +- .../src/store/banking/banking.reducer.ts | 18 ++- 21 files changed, 413 insertions(+), 71 deletions(-) diff --git a/packages/server/src/models/CashflowTransaction.ts b/packages/server/src/models/CashflowTransaction.ts index c5aadbccb9..dca2355f2b 100644 --- a/packages/server/src/models/CashflowTransaction.ts +++ b/packages/server/src/models/CashflowTransaction.ts @@ -95,6 +95,17 @@ export default class CashflowTransaction extends TenantModel { return !!this.uncategorizedTransaction; } + /** + * Model modifiers. + */ + static get modifiers() { + return { + published(query) { + query.whereNot('published_at', null); + }, + }; + } + /** * Relationship mapping. */ diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionBillsTransformer.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionBillsTransformer.ts index f5dd9eaa01..4f833c599e 100644 --- a/packages/server/src/services/Banking/Matching/GetMatchedTransactionBillsTransformer.ts +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionBillsTransformer.ts @@ -17,6 +17,9 @@ export class GetMatchedTransactionBillsTransformer extends Transformer { 'transactionNo', 'transactionType', 'transsactionTypeFormatted', + 'transactionNormal', + 'referenceId', + 'referenceType', ]; }; @@ -100,4 +103,29 @@ export class GetMatchedTransactionBillsTransformer extends Transformer { protected transsactionTypeFormatted() { return 'Bill'; } + + /** + * Retrieves the bill transaction normal (debit or credit). + * @returns {string} + */ + protected transactionNormal() { + return 'credit'; + } + + /** + * Retrieve the match transaction reference id. + * @param bill + * @returns {number} + */ + protected referenceId(bill) { + return bill.id; + } + + /** + * Retrieve the match transaction referenece type. + * @returns {string} + */ + protected referenceType() { + return 'Bill'; + } } diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionCashflowTransformer.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionCashflowTransformer.ts index 7beb6ae99e..cd40951ffd 100644 --- a/packages/server/src/services/Banking/Matching/GetMatchedTransactionCashflowTransformer.ts +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionCashflowTransformer.ts @@ -17,6 +17,7 @@ export class GetMatchedTransactionCashflowTransformer extends Transformer { 'transactionNo', 'transactionType', 'transsactionTypeFormatted', + 'transactionNormal', 'referenceId', 'referenceType', ]; @@ -113,10 +114,28 @@ export class GetMatchedTransactionCashflowTransformer extends Transformer { return transaction.transactionTypeFormatted; } + /** + * Retrieve the cashflow transaction normal (credit or debit). + * @param transaction + * @returns {string} + */ + protected transactionNormal(transaction) { + return transaction.isCashCredit ? 'credit' : 'debit'; + } + + /** + * Retrieves the cashflow transaction reference id. + * @param transaction + * @returns {number} + */ protected referenceId(transaction) { return transaction.id; } + /** + * Retrieves the cashflow transaction reference type. + * @returns {string} + */ protected referenceType() { return 'CashflowTransaction'; } diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionExpensesTransformer.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionExpensesTransformer.ts index d6f71e7056..a77dcffb0f 100644 --- a/packages/server/src/services/Banking/Matching/GetMatchedTransactionExpensesTransformer.ts +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionExpensesTransformer.ts @@ -17,6 +17,9 @@ export class GetMatchedTransactionExpensesTransformer extends Transformer { 'transactionNo', 'transactionType', 'transsactionTypeFormatted', + 'transactionNormal', + 'referenceType', + 'referenceId', ]; }; @@ -111,4 +114,29 @@ export class GetMatchedTransactionExpensesTransformer extends Transformer { protected transsactionTypeFormatted() { return 'Expense'; } + + /** + * Retrieve the expense transaction normal (credit or debit). + * @returns {string} + */ + protected transactionNormal() { + return 'credit'; + } + + /** + * Retrieve the transaction reference type. + * @returns {string} + */ + protected referenceType() { + return 'Expense'; + } + + /** + * Retrieve the transaction reference id. + * @param transaction + * @returns {number} + */ + protected referenceId(transaction) { + return transaction.id; + } } diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionInvoicesTransformer.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionInvoicesTransformer.ts index 66814f7b62..dd5de9bfb8 100644 --- a/packages/server/src/services/Banking/Matching/GetMatchedTransactionInvoicesTransformer.ts +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionInvoicesTransformer.ts @@ -17,6 +17,9 @@ export class GetMatchedTransactionInvoicesTransformer extends Transformer { 'transactionNo', 'transactionType', 'transsactionTypeFormatted', + 'transactionNormal', + 'referenceType', + 'referenceId' ]; }; @@ -108,4 +111,28 @@ export class GetMatchedTransactionInvoicesTransformer extends Transformer { protected transsactionTypeFormatted(invoice) { return 'Sale invoice'; } + + /** + * Retrieve the transaction normal of invoice (credit or debit). + * @returns {string} + */ + protected transactionNormal() { + return 'debit'; + } + + /** + * Retrieve the transaction reference type. + * @returns {string} + */ protected referenceType() { + return 'SaleInvoice'; + } + + /** + * Retrieve the transaction reference id. + * @param transaction + * @returns {number} + */ + protected referenceId(transaction) { + return transaction.id; + } } diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionManualJournalsTransformer.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionManualJournalsTransformer.ts index 9b19b01a02..11ab194a01 100644 --- a/packages/server/src/services/Banking/Matching/GetMatchedTransactionManualJournalsTransformer.ts +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionManualJournalsTransformer.ts @@ -1,4 +1,6 @@ +import { sumBy } from 'lodash'; import { Transformer } from '@/lib/Transformer/Transformer'; +import { AccountNormal } from '@/interfaces'; export class GetMatchedTransactionManualJournalsTransformer extends Transformer { /** @@ -17,6 +19,9 @@ export class GetMatchedTransactionManualJournalsTransformer extends Transformer 'transactionNo', 'transactionType', 'transsactionTypeFormatted', + 'transactionNormal', + 'referenceType', + 'referenceId', ]; }; @@ -37,13 +42,20 @@ export class GetMatchedTransactionManualJournalsTransformer extends Transformer return manualJournal.referenceNo; } + protected total(manualJournal) { + const credit = sumBy(manualJournal?.entries, 'credit'); + const debit = sumBy(manualJournal?.entries, 'debit'); + + return debit - credit; + } + /** * Retrieves the manual journal amount. * @param manualJournal * @returns {number} */ protected amount(manualJournal) { - return manualJournal.amount; + return Math.abs(this.total(manualJournal)); } /** @@ -107,5 +119,31 @@ export class GetMatchedTransactionManualJournalsTransformer extends Transformer protected transsactionTypeFormatted() { return 'Manual Journal'; } -} + /** + * Retrieve the manual journal transaction normal (credit or debit). + * @returns {string} + */ + protected transactionNormal(transaction) { + const amount = this.total(transaction); + + return amount >= 0 ? AccountNormal.DEBIT : AccountNormal.CREDIT; + } + + /** + * Retrieve the manual journal reference type. + * @returns {string} + */ + protected referenceType() { + return 'ManualJournal'; + } + + /** + * Retrieves the manual journal reference id. + * @param transaction + * @returns {number} + */ + protected referenceId(transaction) { + return transaction.id; + } +} diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactions.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactions.ts index 32673fe4d5..f0cfdcaedc 100644 --- a/packages/server/src/services/Banking/Matching/GetMatchedTransactions.ts +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactions.ts @@ -9,6 +9,7 @@ import { GetMatchedTransactionsByManualJournals } from './GetMatchedTransactions import HasTenancyService from '@/services/Tenancy/TenancyService'; import { sortClosestMatchTransactions } from './_utils'; import { GetMatchedTransactionsByCashflow } from './GetMatchedTransactionsByCashflow'; +import { GetMatchedTransactionsByInvoices } from './GetMatchedTransactionsByInvoices'; @Service() export class GetMatchedTransactions { @@ -16,7 +17,7 @@ export class GetMatchedTransactions { private tenancy: HasTenancyService; @Inject() - private getMatchedInvoicesService: GetMatchedTransactionsByExpenses; + private getMatchedInvoicesService: GetMatchedTransactionsByInvoices; @Inject() private getMatchedBillsService: GetMatchedTransactionsByBills; diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByBills.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByBills.ts index 4796f75986..394fecba5c 100644 --- a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByBills.ts +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByBills.ts @@ -1,4 +1,5 @@ import { Inject, Service } from 'typedi'; +import { initialize } from 'objection'; import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; import { GetMatchedTransactionBillsTransformer } from './GetMatchedTransactionBillsTransformer'; import { GetMatchedTransactionsFilter, MatchedTransactionPOJO } from './types'; @@ -22,10 +23,25 @@ export class GetMatchedTransactionsByBills extends GetMatchedTransactionsByType tenantId: number, filter: GetMatchedTransactionsFilter ) { - const { Bill } = this.tenancy.models(tenantId); + const { Bill, MatchedBankTransaction } = this.tenancy.models(tenantId); + const knex = this.tenancy.knex(tenantId); + + // Initialize the models metadata. + await initialize(knex, [Bill, MatchedBankTransaction]); + // Retrieves the bill matches. const bills = await Bill.query().onBuild((q) => { - q.whereNotExists(Bill.relatedQuery('matchedBankTransaction')); + q.withGraphJoined('matchedBankTransaction'); + q.whereNull('matchedBankTransaction.id'); + q.modify('published'); + + if (filter.fromDate) { + q.where('billDate', '>=', filter.fromDate); + } + if (filter.toDate) { + q.where('billDate', '<=', filter.toDate); + } + q.orderBy('billDate', 'DESC'); }); return this.transformer.transform( diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByCashflow.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByCashflow.ts index b59a44a402..1db6dd04f6 100644 --- a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByCashflow.ts +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByCashflow.ts @@ -27,9 +27,22 @@ export class GetMatchedTransactionsByCashflow extends GetMatchedTransactionsByTy // Initialize the ORM models metadata. await initialize(knex, [CashflowTransaction, MatchedBankTransaction]); - const transactions = await CashflowTransaction.query() - .withGraphJoined('matchedBankTransaction') - .whereNull('matchedBankTransaction.id'); + const transactions = await CashflowTransaction.query().onBuild((q) => { + // Not matched to bank transaction. + q.withGraphJoined('matchedBankTransaction'); + q.whereNull('matchedBankTransaction.id'); + + // Published. + q.modify('published'); + + if (filter.fromDate) { + q.where('date', '>=', filter.fromDate); + } + if (filter.toDate) { + q.where('date', '<=', filter.toDate); + } + q.orderBy('date', 'DESC'); + }); return this.transformer.transform( tenantId, diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByExpenses.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByExpenses.ts index 8996c4b91c..39db88cf1d 100644 --- a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByExpenses.ts +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByExpenses.ts @@ -1,4 +1,5 @@ import { Inject, Service } from 'typedi'; +import { initialize } from 'objection'; import { GetMatchedTransactionsFilter, MatchedTransactionPOJO } from './types'; import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; import HasTenancyService from '@/services/Tenancy/TenancyService'; @@ -23,22 +24,34 @@ export class GetMatchedTransactionsByExpenses extends GetMatchedTransactionsByTy tenantId: number, filter: GetMatchedTransactionsFilter ) { - const { Expense } = this.tenancy.models(tenantId); + const { Expense, MatchedBankTransaction } = this.tenancy.models(tenantId); + const knex = this.tenancy.knex(tenantId); + + // Initialize the models metadata. + await initialize(knex, [Expense, MatchedBankTransaction]); + // Retrieve the expense matches. const expenses = await Expense.query().onBuild((query) => { - query.whereNotExists(Expense.relatedQuery('matchedBankTransaction')); + // Filter out the not matched to bank transactions. + query.withGraphJoined('matchedBankTransaction'); + query.whereNull('matchedBankTransaction.id'); + + // Filter the published onyl + query.modify('filterByPublished'); + if (filter.fromDate) { - query.where('payment_date', '>=', filter.fromDate); + query.where('paymentDate', '>=', filter.fromDate); } if (filter.toDate) { - query.where('payment_date', '<=', filter.toDate); + query.where('paymentDate', '<=', filter.toDate); } if (filter.minAmount) { - query.where('total_amount', '>=', filter.minAmount); + query.where('totalAmount', '>=', filter.minAmount); } if (filter.maxAmount) { - query.where('total_amount', '<=', filter.maxAmount); + query.where('totalAmount', '<=', filter.maxAmount); } + query.orderBy('paymentDate', 'DESC'); }); return this.transformer.transform( tenantId, diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByInvoices.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByInvoices.ts index 141d22fe1f..88cc72c1ee 100644 --- a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByInvoices.ts +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByInvoices.ts @@ -1,3 +1,5 @@ +import { Inject, Service } from 'typedi'; +import { initialize } from 'objection'; import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; import { GetMatchedTransactionInvoicesTransformer } from './GetMatchedTransactionInvoicesTransformer'; import { @@ -6,7 +8,6 @@ import { MatchedTransactionsPOJO, } from './types'; import HasTenancyService from '@/services/Tenancy/TenancyService'; -import { Inject, Service } from 'typedi'; import { GetMatchedTransactionsByType } from './GetMatchedTransactionsByType'; @Service() @@ -27,10 +28,27 @@ export class GetMatchedTransactionsByInvoices extends GetMatchedTransactionsByTy tenantId: number, filter: GetMatchedTransactionsFilter ): Promise { - const { SaleInvoice } = this.tenancy.models(tenantId); + const { SaleInvoice, MatchedBankTransaction } = + this.tenancy.models(tenantId); + const knex = this.tenancy.knex(tenantId); + // Initialize the models metadata. + await initialize(knex, [SaleInvoice, MatchedBankTransaction]); + + // Retrieve the invoices that not matched, unpaid. const invoices = await SaleInvoice.query().onBuild((q) => { - q.whereNotExists(SaleInvoice.relatedQuery('matchedBankTransaction')); + q.withGraphJoined('matchedBankTransaction'); + q.whereNull('matchedBankTransaction.id'); + q.modify('unpaid'); + q.modify('published'); + + if (filter.fromDate) { + q.where('invoiceDate', '>=', filter.fromDate); + } + if (filter.toDate) { + q.where('invoiceDate', '<=', filter.toDate); + } + q.orderBy('invoiceDate', 'DESC'); }); return this.transformer.transform( diff --git a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByManualJournals.ts b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByManualJournals.ts index 2aa6341aff..42dae0988b 100644 --- a/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByManualJournals.ts +++ b/packages/server/src/services/Banking/Matching/GetMatchedTransactionsByManualJournals.ts @@ -1,4 +1,5 @@ import { Inject, Service } from 'typedi'; +import { initialize } from 'objection'; import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; import { GetMatchedTransactionManualJournalsTransformer } from './GetMatchedTransactionManualJournalsTransformer'; import { GetMatchedTransactionsByType } from './GetMatchedTransactionsByType'; @@ -19,12 +20,26 @@ export class GetMatchedTransactionsByManualJournals extends GetMatchedTransactio tenantId: number, filter: Omit ) { - const { ManualJournal } = this.tenancy.models(tenantId); + const { ManualJournal, ManualJournalEntry, MatchedBankTransaction } = + this.tenancy.models(tenantId); + const knex = this.tenancy.knex(tenantId); + + await initialize(knex, [ + ManualJournal, + ManualJournalEntry, + MatchedBankTransaction, + ]); + const accountId = 1000; const manualJournals = await ManualJournal.query().onBuild((query) => { - query.whereNotExists( - ManualJournal.relatedQuery('matchedBankTransaction') - ); + query.withGraphJoined('matchedBankTransaction'); + query.whereNull('matchedBankTransaction.id'); + + query.withGraphJoined('entries'); + query.where('entries.accountId', accountId); + + query.modify('filterByPublished'); + if (filter.fromDate) { query.where('date', '>=', filter.fromDate); } diff --git a/packages/server/src/services/Banking/Matching/MatchTransactions.ts b/packages/server/src/services/Banking/Matching/MatchTransactions.ts index c91cb152d9..a85fb6a988 100644 --- a/packages/server/src/services/Banking/Matching/MatchTransactions.ts +++ b/packages/server/src/services/Banking/Matching/MatchTransactions.ts @@ -1,4 +1,4 @@ -import { isEmpty, sumBy } from 'lodash'; +import { isEmpty } from 'lodash'; import { Knex } from 'knex'; import { Inject, Service } from 'typedi'; import { PromisePool } from '@supercharge/promise-pool'; @@ -14,6 +14,7 @@ import { } from './types'; import { MatchTransactionsTypes } from './MatchTransactionsTypes'; import { ServiceError } from '@/exceptions'; +import { sumMatchTranasctions } from './_utils'; @Service() export class MatchBankTransactions { @@ -90,9 +91,8 @@ export class MatchBankTransactions { throw new ServiceError(error); } // Calculate the total given matching transactions. - const totalMatchedTranasctions = sumBy( - validatationResult.results, - 'amount' + const totalMatchedTranasctions = sumMatchTranasctions( + validatationResult.results ); // Validates the total given matching transcations whether is not equal // uncategorized transaction amount. diff --git a/packages/server/src/services/Banking/Matching/_utils.ts b/packages/server/src/services/Banking/Matching/_utils.ts index 89a316a4bb..67e7b00421 100644 --- a/packages/server/src/services/Banking/Matching/_utils.ts +++ b/packages/server/src/services/Banking/Matching/_utils.ts @@ -20,3 +20,12 @@ export const sortClosestMatchTransactions = ( ), ])(matches); }; + +export const sumMatchTranasctions = (transactions: Array) => { + return transactions.reduce( + (total, item) => + total + + (item.transactionNormal === 'debit' ? 1 : -1) * parseFloat(item.amount), + 0 + ); +}; diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionAside.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionAside.tsx index 7203085f16..bc228471dd 100644 --- a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionAside.tsx +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/CategorizeTransactionAside.tsx @@ -8,16 +8,26 @@ import { } from '../withBankingActions'; import { CategorizeTransactionTabsBoot } from './CategorizeTransactionTabsBoot'; import { withBanking } from '../withBanking'; +import { useEffect } from 'react'; interface CategorizeTransactionAsideProps extends WithBankingActionsProps {} function CategorizeTransactionAsideRoot({ // #withBankingActions closeMatchingTransactionAside, + closeReconcileMatchingTransaction, // #withBanking selectedUncategorizedTransactionId, }: CategorizeTransactionAsideProps) { + // + useEffect( + () => () => { + closeReconcileMatchingTransaction(); + }, + [closeReconcileMatchingTransaction], + ); + const handleClose = () => { closeMatchingTransactionAside(); }; diff --git a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingReconcileTransactionAside/MatchingReconcileTransactionForm.tsx b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingReconcileTransactionAside/MatchingReconcileTransactionForm.tsx index 7255fb726c..3ae33fd684 100644 --- a/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingReconcileTransactionAside/MatchingReconcileTransactionForm.tsx +++ b/packages/webapp/src/containers/CashFlow/CategorizeTransactionAside/MatchingReconcileTransactionAside/MatchingReconcileTransactionForm.tsx @@ -1,7 +1,14 @@ // @ts-nocheck import * as R from 'ramda'; import { Button, Intent, Position, Tag } from '@blueprintjs/core'; -import { Form, Formik, FormikValues, useFormikContext } from 'formik'; +import { + Form, + Formik, + FormikHelpers, + FormikValues, + useFormikContext, +} from 'formik'; +import moment from 'moment'; import { AccountsSelect, AppToaster, @@ -26,6 +33,7 @@ import { useCreateCashflowTransaction } from '@/hooks/query'; import { useAccountTransactionsContext } from '../../AccountTransactions/AccountTransactionsProvider'; import { MatchingReconcileFormSchema } from './MatchingReconcileTransactionForm.schema'; import { initialValues, transformToReq } from './_utils'; +import { withBanking } from '../../withBanking'; interface MatchingReconcileTransactionFormProps { onSubmitSuccess?: (values: any) => void; @@ -33,6 +41,7 @@ interface MatchingReconcileTransactionFormProps { function MatchingReconcileTransactionFormRoot({ closeReconcileMatchingTransaction, + reconcileMatchingTransactionPendingAmount, // #props¿ onSubmitSuccess, @@ -43,12 +52,17 @@ function MatchingReconcileTransactionFormRoot({ const { accountId } = useAccountTransactionsContext(); + // Handles the aside close. const handleAsideClose = () => { closeReconcileMatchingTransaction(); }; + // Handle the form submitting. const handleSubmit = ( values: MatchingReconcileTransactionValues, - { setSubmitting }: FormikValues, + { + setSubmitting, + setErrors, + }: FormikHelpers, ) => { setSubmitting(true); const _values = transformToReq(values, accountId); @@ -67,14 +81,31 @@ function MatchingReconcileTransactionFormRoot({ }) .catch((error) => { setSubmitting(false); - - AppToaster.show({ - message: 'Something went wrong.', - intent: Intent.DANGER, - }); + if ( + error.response.data?.errors?.find( + (e) => e.type === 'BRANCH_ID_REQUIRED', + ) + ) { + setErrors({ + branchId: 'The branch is required.', + }); + } else { + AppToaster.show({ + message: 'Something went wrong.', + intent: Intent.DANGER, + }); + } }); }; + const _initialValues = { + ...initialValues, + amount: Math.abs(reconcileMatchingTransactionPendingAmount) || 0, + date: moment().format('YYYY-MM-DD'), + type: + reconcileMatchingTransactionPendingAmount > 0 ? 'deposit' : 'withdrawal', + }; + return (