feat: Auto re-calculate the items rate once changing the invoice exchange rate.

This commit is contained in:
Ahmed Bouhuolia
2023-10-16 19:14:27 +02:00
parent 1ed1c9ea1d
commit 9531730d7a
32 changed files with 473 additions and 1010 deletions

View File

@@ -0,0 +1,63 @@
// @ts-nocheck
import { Dialog, DialogSuspense, FormattedMessage as T } from '@/components';
import withDialogRedux from '@/components/DialogReduxConnect';
import withDialogActions from '@/containers/Dialog/withDialogActions';
import { compose } from '@/utils';
import { Button, Classes, Intent } from '@blueprintjs/core';
/**
* Invoice number dialog.
*/
function InvoiceExchangeRateChangeDialog({
dialogName,
payload: { initialFormValues },
isOpen,
onConfirm,
// #withDialogActions
closeDialog,
}) {
const handleConfirm = () => {
closeDialog(dialogName);
};
return (
<Dialog
title={'Please take care of the following'}
name={dialogName}
autoFocus={true}
canEscapeKeyClose={true}
isOpen={isOpen}
onClose={() => {}}
>
<DialogSuspense>
<div className={Classes.DIALOG_BODY}>
<p>
You have changed customers's currency after adding items to the
Invoice.
</p>
<p>
The item rates have been adjusted to the new currency using exchange
rate feeds.
</p>
<p>
Before saving the transaction, ensure that the item rates align with
the current exchange rate of the newly selected currency.
</p>
</div>
<div className={Classes.DIALOG_FOOTER}>
<Button onClick={handleConfirm} intent={Intent.PRIMARY}>
Ok
</Button>
</div>
</DialogSuspense>
</Dialog>
);
}
export default compose(
withDialogRedux(),
withDialogActions,
)(InvoiceExchangeRateChangeDialog);

View File

@@ -0,0 +1,16 @@
// @ts-nocheck
import { DialogsName } from '@/constants/dialogs';
import React from 'react';
const InvoiceExchangeRateChangeAlert = React.lazy(
() => import('./InvoiceExchangeRateChangeDialog'),
);
const Dialogs = [
{
name: DialogsName.InvoiceExchangeRateChangeNotice,
component: InvoiceExchangeRateChangeAlert,
},
];
export default Dialogs;

View File

@@ -34,7 +34,7 @@ import {
transformValueToRequest,
resetFormState,
} from './utils';
import { InvoiceNoSyncSettingsToForm } from './components';
import { InvoiceExchangeRateSync, InvoiceNoSyncSettingsToForm } from './components';
/**
* Invoice form.
@@ -180,6 +180,7 @@ function InvoiceForm({
{/*---------- Effects ----------*/}
<InvoiceNoSyncSettingsToForm />
<InvoiceExchangeRateSync />
</Form>
</Formik>
</div>

View File

@@ -23,7 +23,10 @@ import {
handleDateChange,
} from '@/utils';
import { CLASSES } from '@/constants/classes';
import { customerNameFieldShouldUpdate } from './utils';
import {
customerNameFieldShouldUpdate,
useInvoiceEntriesOnExchangeRateChange,
} from './utils';
import { useInvoiceFormContext } from './InvoiceFormProvider';
import {
@@ -36,6 +39,7 @@ import {
ProjectBillableEntriesLink,
} from '@/containers/Projects/components';
import { Features } from '@/constants';
import { useCurrentOrganization } from '@/hooks/state';
/**
* Invoice form header fields.
@@ -161,8 +165,29 @@ export default function InvoiceFormHeaderFields() {
* @returns {React.ReactNode}
*/
function InvoiceFormCustomerSelect() {
const { customers } = useInvoiceFormContext();
const { values, setFieldValue } = useFormikContext();
const { customers, setAutoExRateCurrency } = useInvoiceFormContext();
const currentComapny = useCurrentOrganization();
const composeEntriesOnExChange = useInvoiceEntriesOnExchangeRateChange();
// Handles the customer item change.
const handleItemChange = (customer) => {
setAutoExRateCurrency(null);
// If the customer id has changed change the customer id and currency code.
if (values.customer_id !== customer.id) {
setFieldValue('customer_id', customer.id);
setFieldValue('currency_code', customer?.currency_code);
}
// If the customer's currency code is the same the base currency.
if (customer?.currency_code === currentComapny.base_currency) {
setFieldValue('exchange_rate', '1');
setFieldValue('entries', composeEntriesOnExChange(values.exchange_rate, 1));
} else {
// Sets the currency code to fetch auto-exchange rate.
setAutoExRateCurrency(customer?.currency_code);
}
};
return (
<FFormGroup
@@ -178,10 +203,7 @@ function InvoiceFormCustomerSelect() {
name={'customer_id'}
items={customers}
placeholder={<T id={'select_customer_account'} />}
onItemChange={(customer) => {
setFieldValue('customer_id', customer.id);
setFieldValue('currency_code', customer?.currency_code);
}}
onItemChange={handleItemChange}
allowCreate={true}
fastField={true}
shouldUpdate={customerNameFieldShouldUpdate}

View File

@@ -3,7 +3,7 @@ import React, { createContext, useState } from 'react';
import { isEmpty, pick } from 'lodash';
import { useLocation } from 'react-router-dom';
import { Features } from '@/constants';
import { useFeatureCan } from '@/hooks/state';
import { useCurrentOrganization, useFeatureCan } from '@/hooks/state';
import { DashboardInsider } from '@/components/Dashboard';
import { transformToEditForm, ITEMS_FILTER_ROLES_QUERY } from './utils';
import {
@@ -16,6 +16,7 @@ import {
useEditInvoice,
useSettingsInvoices,
useEstimate,
useExchangeRate,
} from '@/hooks/query';
import { useProjects } from '@/containers/Projects/hooks';
import { useTaxRates } from '@/hooks/query/taxRates';
@@ -93,6 +94,18 @@ function InvoiceFormProvider({ invoiceId, baseCurrency, ...props }) {
// Handle fetching settings.
const { isLoading: isSettingsLoading } = useSettingsInvoices();
const [autoExRateCurrency, setAutoExRateCurrency] = useState<string>('');
const currentOrganization = useCurrentOrganization();
// Retrieves the exchange rate.
const { data: autoExchangeRate, isLoading: isAutoExchangeRateLoading } =
useExchangeRate(autoExRateCurrency, currentOrganization.base_currency, {
enabled: Boolean(currentOrganization.base_currency && autoExRateCurrency),
refetchOnWindowFocus: false,
staleTime: Infinity,
cacheTime: Infinity,
});
// Create and edit invoice mutations.
const { mutateAsync: createInvoiceMutate } = useCreateInvoice();
const { mutateAsync: editInvoiceMutate } = useEditInvoice();
@@ -119,6 +132,7 @@ function InvoiceFormProvider({ invoiceId, baseCurrency, ...props }) {
warehouses,
projects,
taxRates,
autoExchangeRate,
isInvoiceLoading,
isItemsLoading,
@@ -135,6 +149,10 @@ function InvoiceFormProvider({ invoiceId, baseCurrency, ...props }) {
editInvoiceMutate,
setSubmitPayload,
isNewMode,
autoExRateCurrency,
setAutoExRateCurrency,
isAutoExchangeRateLoading,
};
return (

View File

@@ -1,23 +1,53 @@
// @ts-nocheck
import React from 'react';
import { useEffect, useRef } from 'react';
import intl from 'react-intl-universal';
import * as R from 'ramda';
import { Button } from '@blueprintjs/core';
import { useFormikContext } from 'formik';
import { ExchangeRateInputGroup } from '@/components';
import { useCurrentOrganization } from '@/hooks/state';
import { useInvoiceIsForeignCustomer } from './utils';
import {
useInvoiceEntriesOnExchangeRateChange,
useInvoiceIsForeignCustomer,
useInvoiceTotal,
} from './utils';
import withSettings from '@/containers/Settings/withSettings';
import { useUpdateEffect } from '@/hooks';
import { transactionNumber } from '@/utils';
import { useInvoiceFormContext } from './InvoiceFormProvider';
import withDialogActions from '@/containers/Dialog/withDialogActions';
import { DialogsName } from '@/constants/dialogs';
/**
* Re-calculate the item entries prices based on the old exchange rate.
* @param {InvoiceExchangeRateInputFieldRoot} Component
* @returns {JSX.Element}
*/
const withExchangeRateItemEntriesPriceRecalc = (Component) => (props) => {
const { setFieldValue } = useFormikContext();
const composeChangeExRate = useInvoiceEntriesOnExchangeRateChange();
return (
<Component
onRecalcConfirm={({ exchangeRate, oldExchangeRate }) => {
setFieldValue(
'entries',
composeChangeExRate(oldExchangeRate, exchangeRate),
);
}}
{...props}
/>
);
};
/**
* Invoice exchange rate input field.
* @returns {JSX.Element}
*/
export function InvoiceExchangeRateInputField({ ...props }) {
const InvoiceExchangeRateInputFieldRoot = ({ ...props }) => {
const currentOrganization = useCurrentOrganization();
const { values } = useFormikContext();
const { isAutoExchangeRateLoading } = useInvoiceFormContext();
const isForeignCustomer = useInvoiceIsForeignCustomer();
@@ -27,12 +57,22 @@ export function InvoiceExchangeRateInputField({ ...props }) {
}
return (
<ExchangeRateInputGroup
name={'exchange_rate'}
fromCurrency={values.currency_code}
toCurrency={currentOrganization.base_currency}
isLoading={isAutoExchangeRateLoading}
{...props}
/>
);
}
};
/**
* Invoice exchange rate input field.
* @returns {JSX.Element}
*/
export const InvoiceExchangeRateInputField = R.compose(
withExchangeRateItemEntriesPriceRecalc,
)(InvoiceExchangeRateInputFieldRoot);
/**
* Invoice project select.
@@ -66,3 +106,42 @@ export const InvoiceNoSyncSettingsToForm = R.compose(
return null;
});
/**
* Syncs the fetched real-time exchange rate to the form.
* @returns {JSX.Element}
*/
export const InvoiceExchangeRateSync = R.compose(withDialogActions)(
({ openDialog }) => {
const { setFieldValue, values } = useFormikContext();
const { autoExRateCurrency, autoExchangeRate } = useInvoiceFormContext();
const composeEntriesOnExChange = useInvoiceEntriesOnExchangeRateChange();
const total = useInvoiceTotal();
const timeout = useRef();
// Sync the fetched real-time exchanage rate to the form.
useEffect(() => {
if (autoExchangeRate?.exchange_rate && autoExRateCurrency) {
setFieldValue('exchange_rate', autoExchangeRate?.exchange_rate + '');
setFieldValue(
'entries',
composeEntriesOnExChange(
values.exchange_rate,
autoExchangeRate?.exchange_rate,
),
);
// If the total bigger then zero show alert to the user after adjusting entries.
if (total > 0) {
clearTimeout(timeout.current);
timeout.current = setTimeout(() => {
openDialog(DialogsName.InvoiceExchangeRateChange);
}, 500);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoExchangeRate?.exchange_rate, autoExRateCurrency]);
return null;
},
);

View File

@@ -5,7 +5,7 @@ import intl from 'react-intl-universal';
import moment from 'moment';
import * as R from 'ramda';
import { Intent } from '@blueprintjs/core';
import { omit, first, sumBy } from 'lodash';
import { omit, first, sumBy, round } from 'lodash';
import {
compose,
transformToForm,
@@ -57,7 +57,7 @@ export const defaultInvoice = {
reference_no: '',
invoice_message: '',
terms_conditions: '',
exchange_rate: 1,
exchange_rate: '1',
currency_code: '',
branch_id: '',
warehouse_id: '',
@@ -398,3 +398,85 @@ export const useIsInvoiceTaxExclusive = () => {
return values.inclusive_exclusive_tax === TaxType.Exclusive;
};
/**
* Convert the given rate to the local currency.
* @param {number} rate
* @param {number} exchangeRate
* @returns {number}
*/
export const convertToForeignCurrency = (
rate: number,
exchangeRate: number,
) => {
return rate * exchangeRate;
};
/**
* Converts the given rate to the base currency.
* @param {number} rate
* @param {number} exchangeRate
* @returns {number}
*/
export const covertToBaseCurrency = (rate: number, exchangeRate: number) => {
return rate / exchangeRate;
};
/**
* Reverts the given rate from the old exchange rate and covert it to the new
* currency based on the given new exchange rate.
* @param {number} rate -
* @param {number} oldExchangeRate - Old exchange rate.
* @param {number} newExchangeRate - New exchange rate.
* @returns {number}
*/
const revertAndConvertExchangeRate = (
rate: number,
oldExchangeRate: number,
newExchangeRate: number,
) => {
const oldValue = convertToForeignCurrency(rate, oldExchangeRate);
const newValue = covertToBaseCurrency(oldValue, newExchangeRate);
return round(newValue, 3);
};
/**
* Assign the new item entry rate after converting to the new exchange rate.
* @params {number} oldExchangeRate -
* @params {number} newExchangeRate -
* @params {IItemEntry} entries -
*/
const assignRateRevertAndCovertExchangeRate = R.curry(
(oldExchangeRate: number, newExchangeRate: number, entries: IItemEntry[]) => {
return entries.map((entry) => ({
...entry,
rate: revertAndConvertExchangeRate(
entry.rate,
oldExchangeRate,
newExchangeRate,
),
}));
},
);
/**
* Compose invoice entries on exchange rate change.
* @returns {(oldExchangeRate: number, newExchangeRate: number) => IItemEntry[]}
*/
export const useInvoiceEntriesOnExchangeRateChange = () => {
const {
values: { entries },
} = useFormikContext();
return React.useMemo(() => {
return R.curry((oldExchangeRate: number, newExchangeRate: number) => {
return R.compose(
// Updates entries total.
updateItemsEntriesTotal,
// Assign a new rate of the given new exchange rate from the old exchange rate.
assignRateRevertAndCovertExchangeRate(oldExchangeRate, newExchangeRate),
)(entries);
});
}, [entries]);
};