diff --git a/assets/images/plus-minus.svg b/assets/images/plus-minus.svg new file mode 100644 index 000000000000..eff3d99355ac --- /dev/null +++ b/assets/images/plus-minus.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts index 712b03bf4590..f42a2357330c 100644 --- a/src/components/Icon/Expensicons.ts +++ b/src/components/Icon/Expensicons.ts @@ -157,6 +157,7 @@ import Phone from '@assets/images/phone.svg'; import Pin from '@assets/images/pin.svg'; import Plane from '@assets/images/plane.svg'; import Play from '@assets/images/play.svg'; +import PlusMinus from '@assets/images/plus-minus.svg'; import Plus from '@assets/images/plus.svg'; import Printer from '@assets/images/printer.svg'; import Profile from '@assets/images/profile.svg'; @@ -429,5 +430,6 @@ export { GalleryNotFound, Train, boltSlash, + PlusMinus, MagnifyingGlassSpyMouthClosed, }; diff --git a/src/components/MoneyRequestAmountInput.tsx b/src/components/MoneyRequestAmountInput.tsx index 717659c16fd3..c2fe17951acb 100644 --- a/src/components/MoneyRequestAmountInput.tsx +++ b/src/components/MoneyRequestAmountInput.tsx @@ -3,10 +3,10 @@ import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} fr import type {NativeSyntheticEvent, StyleProp, TextStyle, ViewStyle} from 'react-native'; import useLocalize from '@hooks/useLocalize'; import {useMouseContext} from '@hooks/useMouseContext'; -import * as Browser from '@libs/Browser'; -import * as CurrencyUtils from '@libs/CurrencyUtils'; +import {isMobileSafari} from '@libs/Browser'; +import {convertToFrontendAmountAsString, getCurrencyDecimals} from '@libs/CurrencyUtils'; import getOperatingSystem from '@libs/getOperatingSystem'; -import * as MoneyRequestUtils from '@libs/MoneyRequestUtils'; +import {replaceAllDigits, replaceCommasWithPeriod, stripCommaFromAmount, stripDecimalsFromAmount, stripSpacesFromAmount, validateAmount} from '@libs/MoneyRequestUtils'; import shouldIgnoreSelectionWhenUpdatedManually from '@libs/shouldIgnoreSelectionWhenUpdatedManually'; import CONST from '@src/CONST'; import isTextInputFocused from './TextInput/BaseTextInput/isTextInputFocused'; @@ -92,6 +92,18 @@ type MoneyRequestAmountInputProps = { /** The width of inner content */ contentWidth?: number; + + /** Whether the amount is negative */ + isNegative?: boolean; + + /** Function to toggle the amount to negative */ + toggleNegative?: () => void; + + /** Function to clear the negative amount */ + clearNegative?: () => void; + + /** Whether to allow flipping amount */ + allowFlippingAmount?: boolean; } & Pick; type Selection = { @@ -107,7 +119,7 @@ const getNewSelection = (oldSelection: Selection, prevLength: number, newLength: return {start: cursorPosition, end: cursorPosition}; }; -const defaultOnFormatAmount = (amount: number, currency?: string) => CurrencyUtils.convertToFrontendAmountAsString(amount, currency ?? CONST.CURRENCY.USD); +const defaultOnFormatAmount = (amount: number, currency?: string) => convertToFrontendAmountAsString(amount, currency ?? CONST.CURRENCY.USD); function MoneyRequestAmountInput( { @@ -129,6 +141,10 @@ function MoneyRequestAmountInput( autoGrow = true, autoGrowExtraSpace, contentWidth, + isNegative = false, + allowFlippingAmount = false, + toggleNegative, + clearNegative, ...props }: MoneyRequestAmountInputProps, forwardedRef: ForwardedRef, @@ -139,7 +155,7 @@ function MoneyRequestAmountInput( const amountRef = useRef(undefined); - const decimals = CurrencyUtils.getCurrencyDecimals(currency); + const decimals = getCurrencyDecimals(currency); const selectedAmountAsString = amount ? onFormatAmount(amount, currency) : ''; const [currentAmount, setCurrentAmount] = useState(selectedAmountAsString); @@ -159,15 +175,17 @@ function MoneyRequestAmountInput( */ const setNewAmount = useCallback( (newAmount: string) => { + if (allowFlippingAmount && newAmount.startsWith('-') && toggleNegative) { + toggleNegative(); + } + // Remove spaces from the newAmount value because Safari on iOS adds spaces when pasting a copied value // More info: https://github.com/Expensify/App/issues/16974 - const newAmountWithoutSpaces = MoneyRequestUtils.stripSpacesFromAmount(newAmount); - const finalAmount = newAmountWithoutSpaces.includes('.') - ? MoneyRequestUtils.stripCommaFromAmount(newAmountWithoutSpaces) - : MoneyRequestUtils.replaceCommasWithPeriod(newAmountWithoutSpaces); + const newAmountWithoutSpaces = stripSpacesFromAmount(newAmount); + const finalAmount = newAmountWithoutSpaces.includes('.') ? stripCommaFromAmount(newAmountWithoutSpaces) : replaceCommasWithPeriod(newAmountWithoutSpaces); // Use a shallow copy of selection to trigger setSelection // More info: https://github.com/Expensify/App/issues/16385 - if (!MoneyRequestUtils.validateAmount(finalAmount, decimals)) { + if (!validateAmount(finalAmount, decimals)) { setSelection((prevSelection) => ({...prevSelection})); return; } @@ -176,7 +194,7 @@ function MoneyRequestAmountInput( willSelectionBeUpdatedManually.current = true; let hasSelectionBeenSet = false; - const strippedAmount = MoneyRequestUtils.stripCommaFromAmount(finalAmount); + const strippedAmount = stripCommaFromAmount(finalAmount); amountRef.current = strippedAmount; setCurrentAmount((prevAmount) => { const isForwardDelete = prevAmount.length > strippedAmount.length && forwardDeletePressedRef.current; @@ -189,7 +207,7 @@ function MoneyRequestAmountInput( return strippedAmount; }); }, - [decimals, onAmountChange], + [allowFlippingAmount, decimals, onAmountChange, toggleNegative], ); useImperativeHandle(moneyRequestAmountInputRef, () => ({ @@ -233,12 +251,12 @@ function MoneyRequestAmountInput( // Modifies the amount to match the decimals for changed currency. useEffect(() => { // If the changed currency supports decimals, we can return - if (MoneyRequestUtils.validateAmount(currentAmount, decimals)) { + if (validateAmount(currentAmount, decimals)) { return; } // If the changed currency doesn't support decimals, we can strip the decimals - setNewAmount(MoneyRequestUtils.stripDecimalsFromAmount(currentAmount)); + setNewAmount(stripDecimalsFromAmount(currentAmount)); // we want to update only when decimals change (setNewAmount also changes when decimals change). // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps @@ -249,7 +267,12 @@ function MoneyRequestAmountInput( */ const textInputKeyPress = ({nativeEvent}: NativeSyntheticEvent) => { const key = nativeEvent?.key.toLowerCase(); - if (Browser.isMobileSafari() && key === CONST.PLATFORM_SPECIFIC_KEYS.CTRL.DEFAULT) { + + if (!textInput.current?.value && key === 'backspace' && isNegative) { + clearNegative?.(); + } + + if (isMobileSafari() && key === CONST.PLATFORM_SPECIFIC_KEYS.CTRL.DEFAULT) { // Optimistically anticipate forward-delete on iOS Safari (in cases where the Mac Accessiblity keyboard is being // used for input). If the Control-D shortcut doesn't get sent, the ref will still be reset on the next key press. forwardDeletePressedRef.current = true; @@ -276,7 +299,7 @@ function MoneyRequestAmountInput( }); }, [amount, currency, onFormatAmount, formatAmountOnBlur, maxLength]); - const formattedAmount = MoneyRequestUtils.replaceAllDigits(currentAmount, toLocaleDigit); + const formattedAmount = replaceAllDigits(currentAmount, toLocaleDigit); const {setMouseDown, setMouseUp} = useMouseContext(); const handleMouseDown = (e: React.MouseEvent) => { @@ -340,6 +363,7 @@ function MoneyRequestAmountInput( onMouseDown={handleMouseDown} onMouseUp={handleMouseUp} contentWidth={contentWidth} + isNegative={isNegative} /> ); } diff --git a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx index 26cdbac4d890..4194452ddbc0 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx +++ b/src/components/ReportActionItem/MoneyRequestPreview/MoneyRequestPreviewContent.tsx @@ -136,7 +136,7 @@ function MoneyRequestPreviewContent({ merchant, tag, category, - } = useMemo>(() => getTransactionDetails(transaction) ?? {}, [transaction]); + } = useMemo>(() => getTransactionDetails(transaction, undefined, true) ?? {}, [transaction]); const description = truncate(StringUtils.lineBreaksToSpaces(requestComment), {length: CONST.REQUEST_PREVIEW.MAX_LENGTH}); const requestMerchant = truncate(merchant, {length: CONST.REQUEST_PREVIEW.MAX_LENGTH}); diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index acb6b05e077d..bbe1de323f9b 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -150,7 +150,7 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals originalAmount: transactionOriginalAmount, originalCurrency: transactionOriginalCurrency, postedDate: transactionPostedDate, - } = useMemo>(() => getTransactionDetails(transaction) ?? {}, [transaction]); + } = useMemo>(() => getTransactionDetails(transaction, undefined, true) ?? {}, [transaction]); const isEmptyMerchant = transactionMerchant === '' || transactionMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT; const isDistanceRequest = isDistanceRequestTransactionUtils(transaction); const isPerDiemRequest = isPerDiemRequestTransactionUtils(transaction); diff --git a/src/components/TextInputWithCurrencySymbol/BaseTextInputWithCurrencySymbol.tsx b/src/components/TextInputWithCurrencySymbol/BaseTextInputWithCurrencySymbol.tsx index 4c30b048c1af..343d116cbf9a 100644 --- a/src/components/TextInputWithCurrencySymbol/BaseTextInputWithCurrencySymbol.tsx +++ b/src/components/TextInputWithCurrencySymbol/BaseTextInputWithCurrencySymbol.tsx @@ -2,10 +2,11 @@ import React from 'react'; import type {NativeSyntheticEvent, TextInputSelectionChangeEventData} from 'react-native'; import AmountTextInput from '@components/AmountTextInput'; import CurrencySymbolButton from '@components/CurrencySymbolButton'; +import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; -import * as CurrencyUtils from '@libs/CurrencyUtils'; -import * as MoneyRequestUtils from '@libs/MoneyRequestUtils'; +import {getLocalizedCurrencySymbol, isCurrencySymbolLTR} from '@libs/CurrencyUtils'; +import {addLeadingZero, replaceAllDigits} from '@libs/MoneyRequestUtils'; import type {BaseTextInputRef} from '@src/components/TextInput/BaseTextInput/types'; import type BaseTextInputWithCurrencySymbolProps from './types'; @@ -22,14 +23,15 @@ function BaseTextInputWithCurrencySymbol( isCurrencyPressable = true, hideCurrencySymbol = false, extraSymbol, + isNegative = false, style, ...rest }: BaseTextInputWithCurrencySymbolProps, ref: React.ForwardedRef, ) { const {fromLocaleDigit} = useLocalize(); - const currencySymbol = CurrencyUtils.getLocalizedCurrencySymbol(selectedCurrencyCode); - const isCurrencySymbolLTR = CurrencyUtils.isCurrencySymbolLTR(selectedCurrencyCode); + const currencySymbol = getLocalizedCurrencySymbol(selectedCurrencyCode); + const shouldShowCurrencySymbolLTR = isCurrencySymbolLTR(selectedCurrencyCode); const styles = useThemeStyles(); const currencySymbolButton = !hideCurrencySymbol && ( @@ -46,7 +48,7 @@ function BaseTextInputWithCurrencySymbol( * @param text - Changed text from user input */ const setFormattedAmount = (text: string) => { - const newAmount = MoneyRequestUtils.addLeadingZero(MoneyRequestUtils.replaceAllDigits(text, fromLocaleDigit)); + const newAmount = addLeadingZero(replaceAllDigits(text, fromLocaleDigit)); onChangeAmount(newAmount); }; @@ -67,9 +69,12 @@ function BaseTextInputWithCurrencySymbol( /> ); - if (isCurrencySymbolLTR) { + const negativeSymbol = -; + + if (shouldShowCurrencySymbolLTR) { return ( <> + {isNegative && negativeSymbol} {currencySymbolButton} {amountTextInput} {extraSymbol} @@ -79,6 +84,7 @@ function BaseTextInputWithCurrencySymbol( return ( <> + {isNegative && negativeSymbol} {amountTextInput} {currencySymbolButton} {extraSymbol} diff --git a/src/components/TextInputWithCurrencySymbol/types.ts b/src/components/TextInputWithCurrencySymbol/types.ts index 3988654584d0..eea33cadd4db 100644 --- a/src/components/TextInputWithCurrencySymbol/types.ts +++ b/src/components/TextInputWithCurrencySymbol/types.ts @@ -77,6 +77,9 @@ type BaseTextInputWithCurrencySymbolProps = { /** Hide the focus styles on TextInput */ hideFocusedState?: boolean; + + /** Whether the amount is negative */ + isNegative?: boolean; } & Pick; type TextInputWithCurrencySymbolProps = Omit & { diff --git a/src/languages/en.ts b/src/languages/en.ts index 127de853ce29..97935b90c39e 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -975,6 +975,7 @@ const translations = { payElsewhere: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pay ${formattedAmount} elsewhere` : `Pay elsewhere`), nextStep: 'Next steps', finished: 'Finished', + flip: 'Flip', sendInvoice: ({amount}: RequestAmountParams) => `Send ${amount} invoice`, submitAmount: ({amount}: RequestAmountParams) => `submit ${amount}`, submittedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `submitted ${formattedAmount}${comment ? ` for ${comment}` : ''}`, diff --git a/src/languages/es.ts b/src/languages/es.ts index ba838537a2a7..9a9444465dfc 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -969,6 +969,7 @@ const translations = { payElsewhere: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pagar ${formattedAmount} de otra forma` : `Pagar de otra forma`), nextStep: 'Pasos siguientes', finished: 'Finalizado', + flip: 'Cambiar', sendInvoice: ({amount}: RequestAmountParams) => `Enviar factura de ${amount}`, submitAmount: ({amount}: RequestAmountParams) => `solicitar ${amount}`, submittedAmount: ({formattedAmount, comment}: RequestedAmountMessageParams) => `solicitó ${formattedAmount}${comment ? ` para ${comment}` : ''}`, diff --git a/src/libs/ModifiedExpenseMessage.ts b/src/libs/ModifiedExpenseMessage.ts index 465067a045c4..4620a43a6896 100644 --- a/src/libs/ModifiedExpenseMessage.ts +++ b/src/libs/ModifiedExpenseMessage.ts @@ -205,7 +205,7 @@ function getForReportAction({ if (hasModifiedAmount) { const oldCurrency = reportActionOriginalMessage?.oldCurrency; const oldAmountValue = reportActionOriginalMessage?.oldAmount ?? 0; - const oldAmount = oldAmountValue > 0 ? convertToDisplayString(reportActionOriginalMessage?.oldAmount ?? 0, oldCurrency) : ''; + const oldAmount = oldAmountValue ? convertToDisplayString(reportActionOriginalMessage?.oldAmount ?? 0, oldCurrency) : ''; const currency = reportActionOriginalMessage?.currency; const amount = convertToDisplayString(reportActionOriginalMessage?.amount ?? 0, currency); diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 454c25a0b6d1..c13ca1cf7457 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -3213,8 +3213,8 @@ function getMoneyRequestSpendBreakdown(report: OnyxInputOrEntry, searchR // There is a possibility that if the Expense report has a negative total. // This is because there are instances where you can get a credit back on your card, // or you enter a negative expense to “offset” future expenses - nonReimbursableSpend = isExpenseReport(moneyRequestReport) ? nonReimbursableSpend * -1 : Math.abs(nonReimbursableSpend); - totalSpend = isExpenseReport(moneyRequestReport) ? totalSpend * -1 : Math.abs(totalSpend); + nonReimbursableSpend = isExpenseReport(moneyRequestReport) ? nonReimbursableSpend * -1 : nonReimbursableSpend; + totalSpend = isExpenseReport(moneyRequestReport) ? totalSpend * -1 : totalSpend; const totalDisplaySpend = totalSpend; const reimbursableSpend = totalDisplaySpend - nonReimbursableSpend; @@ -3466,14 +3466,19 @@ function getMoneyRequestReportName({ * into a flat object. Used for displaying transactions and sending them in API commands */ -function getTransactionDetails(transaction: OnyxInputOrEntry, createdDateFormat: string = CONST.DATE.FNS_FORMAT_STRING): TransactionDetails | undefined { +function getTransactionDetails( + transaction: OnyxInputOrEntry, + createdDateFormat: string = CONST.DATE.FNS_FORMAT_STRING, + allowNegativeAmount = false, +): TransactionDetails | undefined { if (!transaction) { return; } const report = getReportOrDraftReport(transaction?.reportID); + return { created: getFormattedCreated(transaction, createdDateFormat), - amount: getTransactionAmount(transaction, !isEmptyObject(report) && isExpenseReport(report)), + amount: getTransactionAmount(transaction, !isEmptyObject(report) && isExpenseReport(report), undefined, allowNegativeAmount), attendees: getAttendees(transaction), taxAmount: getTaxAmount(transaction, !isEmptyObject(report) && isExpenseReport(report)), taxCode: getTaxCode(transaction), @@ -4090,7 +4095,7 @@ function getModifiedExpenseOriginalMessage( // to match how we handle the modified expense action in oldDot const didAmountOrCurrencyChange = 'amount' in transactionChanges || 'currency' in transactionChanges; if (didAmountOrCurrencyChange) { - originalMessage.oldAmount = getTransactionAmount(oldTransaction, isFromExpenseReport); + originalMessage.oldAmount = getTransactionAmount(oldTransaction, isFromExpenseReport, false, true); originalMessage.amount = transactionChanges?.amount ?? transactionChanges.oldAmount; originalMessage.oldCurrency = getCurrency(oldTransaction); originalMessage.currency = transactionChanges?.currency ?? transactionChanges.oldCurrency; @@ -4129,7 +4134,7 @@ function getModifiedExpenseOriginalMessage( } if ('customUnitRateID' in transactionChanges && updatedTransaction?.comment?.customUnit?.customUnitRateID) { - originalMessage.oldAmount = getTransactionAmount(oldTransaction, isFromExpenseReport); + originalMessage.oldAmount = getTransactionAmount(oldTransaction, isFromExpenseReport, false, true); originalMessage.oldCurrency = getCurrency(oldTransaction); originalMessage.oldMerchant = getMerchant(oldTransaction); diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 0f0ee9b635e5..9a4f349c57ae 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -127,7 +127,7 @@ function getTransactionItemCommonFormattedProperties( const formattedFrom = from?.displayName ?? from?.login ?? ''; const formattedTo = to?.displayName ?? to?.login ?? ''; - const formattedTotal = getTransactionAmount(transactionItem, isExpenseReport); + const formattedTotal = getTransactionAmount(transactionItem, isExpenseReport, undefined, true); const date = transactionItem?.modifiedCreated ? transactionItem.modifiedCreated : transactionItem?.created; const merchant = getTransactionMerchant(transactionItem); const formattedMerchant = merchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT ? '' : merchant; diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index f8374bfa358e..a8f3343ad0d1 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -326,12 +326,14 @@ function getUpdatedTransaction({ isFromExpenseReport, shouldUpdateReceiptState = true, policy = undefined, + allowNegative = false, }: { transaction: Transaction; transactionChanges: TransactionChanges; isFromExpenseReport: boolean; shouldUpdateReceiptState?: boolean; policy?: OnyxEntry; + allowNegative?: boolean; }): Transaction { // Only changing the first level fields so no need for deep clone now const updatedTransaction = lodashDeepClone(transaction); @@ -349,7 +351,7 @@ function getUpdatedTransaction({ shouldStopSmartscan = true; } if (Object.hasOwn(transactionChanges, 'amount') && typeof transactionChanges.amount === 'number') { - updatedTransaction.modifiedAmount = isFromExpenseReport ? -transactionChanges.amount : transactionChanges.amount; + updatedTransaction.modifiedAmount = isFromExpenseReport && !allowNegative ? -transactionChanges.amount : transactionChanges.amount; shouldStopSmartscan = true; } if (Object.hasOwn(transactionChanges, 'currency')) { @@ -417,7 +419,7 @@ function getUpdatedTransaction({ const distanceInMeters = getDistanceInMeters(transaction, oldMileageRate?.unit); const amount = DistanceRequestUtils.getDistanceRequestAmount(distanceInMeters, unit, rate ?? 0); - const updatedAmount = isFromExpenseReport ? -amount : amount; + const updatedAmount = isFromExpenseReport && !allowNegative ? -amount : amount; const updatedCurrency = updatedMileageRate.currency ?? CONST.CURRENCY.USD; const updatedMerchant = DistanceRequestUtils.getDistanceMerchant(true, distanceInMeters, unit, rate, updatedCurrency, Localize.translateLocal, (digit) => toLocaleDigit(preferredLocale, digit), @@ -501,9 +503,9 @@ function getDescription(transaction: OnyxInputOrEntry): string { /** * Return the amount field from the transaction, return the modifiedAmount if present. */ -function getAmount(transaction: OnyxInputOrEntry, isFromExpenseReport = false, isFromTrackedExpense = false): number { +function getAmount(transaction: OnyxInputOrEntry, isFromExpenseReport = false, isFromTrackedExpense = false, allowNegative = false): number { // IOU requests cannot have negative values, but they can be stored as negative values, let's return absolute value - if (!isFromExpenseReport || isFromTrackedExpense) { + if ((!isFromExpenseReport || isFromTrackedExpense) && !allowNegative) { const amount = transaction?.modifiedAmount ?? 0; if (amount) { return Math.abs(amount); @@ -515,12 +517,21 @@ function getAmount(transaction: OnyxInputOrEntry, isFromExpenseRepo // The amounts are stored using an opposite sign and negative values can be set, // we need to return an opposite sign than is saved in the transaction object let amount = transaction?.modifiedAmount ?? 0; - if (amount) { + if (amount && !allowNegative) { return -amount; } - // To avoid -0 being shown, lets only change the sign if the value is other than 0. + if (amount) { + return amount; + } + amount = transaction?.amount ?? 0; + + if (allowNegative) { + return amount; + } + + // To avoid -0 being shown, lets only change the sign if the value is other than 0. return amount ? -amount : 0; } diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 1a2686488abe..e5d0edd001ed 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -3397,6 +3397,7 @@ function getUpdateMoneyRequestParams( policyCategories: OnyxTypes.OnyxInputOrEntry, violations?: OnyxEntry, hash?: number, + allowNegative?: boolean, ): UpdateMoneyRequestData { const optimisticData: OnyxUpdate[] = []; const successData: OnyxUpdate[] = []; @@ -3420,9 +3421,10 @@ function getUpdateMoneyRequestParams( transactionChanges, isFromExpenseReport, policy, + allowNegative, }) : undefined; - const transactionDetails = getTransactionDetails(updatedTransaction); + const transactionDetails = getTransactionDetails(updatedTransaction, undefined, true); if (transactionDetails?.waypoints) { // This needs to be a JSON string since we're sending this to the MapBox API @@ -6491,6 +6493,7 @@ type UpdateMoneyRequestAmountAndCurrencyParams = { policyTagList?: OnyxEntry; policyCategories?: OnyxEntry; taxCode: string; + allowNegative?: boolean; }; /** Updates the amount and currency fields of an expense */ @@ -6504,6 +6507,7 @@ function updateMoneyRequestAmountAndCurrency({ policyTagList, policyCategories, taxCode, + allowNegative = false, }: UpdateMoneyRequestAmountAndCurrencyParams) { const transactionChanges = { amount, @@ -6517,7 +6521,7 @@ function updateMoneyRequestAmountAndCurrency({ if (isTrackExpenseReport(transactionThreadReport) && isSelfDM(parentReport)) { data = getUpdateTrackExpenseParams(transactionID, transactionThreadReportID, transactionChanges, policy); } else { - data = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList ?? null, policyCategories ?? null); + data = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList ?? null, policyCategories ?? null, undefined, undefined, allowNegative); } const {params, onyxData} = data; API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_AMOUNT_AND_CURRENCY, params, onyxData); diff --git a/src/pages/iou/MoneyRequestAmountForm.tsx b/src/pages/iou/MoneyRequestAmountForm.tsx index a948e43d8656..083d79bf80da 100644 --- a/src/pages/iou/MoneyRequestAmountForm.tsx +++ b/src/pages/iou/MoneyRequestAmountForm.tsx @@ -6,6 +6,7 @@ import type {ValueOf} from 'type-fest'; import BigNumberPad from '@components/BigNumberPad'; import Button from '@components/Button'; import FormHelpMessage from '@components/FormHelpMessage'; +import * as Expensicons from '@components/Icon/Expensicons'; import MoneyRequestAmountInput from '@components/MoneyRequestAmountInput'; import type {MoneyRequestAmountInputRef} from '@components/MoneyRequestAmountInput'; import ScrollView from '@components/ScrollView'; @@ -68,6 +69,9 @@ type MoneyRequestAmountFormProps = { /** Whether the user input should be kept or not */ shouldKeepUserInput?: boolean; + + /** Whether to allow flipping the amount */ + allowFlippingAmount?: boolean; }; const isAmountInvalid = (amount: string) => !amount.length || parseFloat(amount) < 0.01; @@ -93,6 +97,7 @@ function MoneyRequestAmountForm( onSubmitButtonPress, selectedTab = CONST.TAB_REQUEST.MANUAL, shouldKeepUserInput = false, + allowFlippingAmount = false, }: MoneyRequestAmountFormProps, forwardedRef: ForwardedRef, ) { @@ -103,6 +108,8 @@ function MoneyRequestAmountForm( const textInput = useRef(null); const moneyRequestAmountInput = useRef(null); + const [isNegative, setIsNegative] = useState(false); + const [formError, setFormError] = useState(''); const [shouldUpdateSelection, setShouldUpdateSelection] = useState(true); @@ -111,6 +118,8 @@ function MoneyRequestAmountForm( const formattedTaxAmount = convertToDisplayString(Math.abs(taxAmount), currency); + const absoluteAmount = Math.abs(amount); + /** * Event occurs when a user presses a mouse button over an DOM element. */ @@ -162,10 +171,18 @@ function MoneyRequestAmountForm( ); useEffect(() => { - if (!currency || typeof amount !== 'number') { + if (amount >= 0) { + return; + } + + setIsNegative(true); + }, [amount]); + + useEffect(() => { + if (!currency || typeof absoluteAmount !== 'number') { return; } - initializeAmount(amount); + initializeAmount(absoluteAmount); // we want to re-initialize the state only when the selected tab // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [selectedTab]); @@ -227,9 +244,11 @@ function MoneyRequestAmountForm( return; } - onSubmitButtonPress({amount: currentAmount, currency, paymentMethod: iouPaymentType}); + const newAmount = isNegative ? `-${currentAmount}` : currentAmount; + + onSubmitButtonPress({amount: newAmount, currency, paymentMethod: iouPaymentType}); }, - [taxAmount, onSubmitButtonPress, currency, translate, formattedTaxAmount], + [taxAmount, currency, isNegative, onSubmitButtonPress, translate, formattedTaxAmount], ); const buttonText: string = useMemo(() => { @@ -248,48 +267,99 @@ function MoneyRequestAmountForm( setFormError(''); }, [selectedTab]); + const toggleNegative = useCallback(() => { + setIsNegative((prevIsNegative) => !prevIsNegative); + }, []); + + const clearNegative = useCallback(() => { + setIsNegative(false); + }, []); + return ( - onMouseDown(event, [AMOUNT_VIEW_ID])} - style={[styles.moneyRequestAmountContainer, styles.flex1, styles.flexRow, styles.w100, styles.alignItemsCenter, styles.justifyContentCenter]} - > - { - if (!formError) { - return; - } - setFormError(''); - }} - shouldUpdateSelection={shouldUpdateSelection} - ref={(ref) => { - if (typeof forwardedRef === 'function') { - forwardedRef(ref); - } else if (forwardedRef?.current) { - // eslint-disable-next-line no-param-reassign - forwardedRef.current = ref; - } - textInput.current = ref; - }} - shouldKeepUserInput={shouldKeepUserInput} - moneyRequestAmountInputRef={moneyRequestAmountInput} - inputStyle={[styles.iouAmountTextInput]} - containerStyle={[styles.iouAmountTextInputContainer]} - /> - {!!formError && ( - + onMouseDown(event, [AMOUNT_VIEW_ID])} + style={[styles.moneyRequestAmountContainer, styles.flexRow, styles.w100, styles.alignItemsCenter, styles.justifyContentCenter]} + > + { + if (!formError) { + return; + } + setFormError(''); + }} + shouldUpdateSelection={shouldUpdateSelection} + ref={(ref) => { + if (typeof forwardedRef === 'function') { + forwardedRef(ref); + } else if (forwardedRef?.current) { + // eslint-disable-next-line no-param-reassign + forwardedRef.current = ref; + } + textInput.current = ref; + }} + shouldKeepUserInput={shouldKeepUserInput} + moneyRequestAmountInputRef={moneyRequestAmountInput} + inputStyle={[styles.iouAmountTextInput]} + containerStyle={[styles.iouAmountTextInputContainer]} + toggleNegative={toggleNegative} + clearNegative={clearNegative} + isNegative={isNegative} + allowFlippingAmount={allowFlippingAmount} + /> + {!!formError && ( + + )} + + {isCurrencyPressable && !canUseTouchScreen && ( +