Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import useLocalize from '@hooks/useLocalize';

import {computePerDiemExpenseAmount} from '@libs/actions/IOU/PerDiem';
import type {getAttendees} from '@libs/TransactionUtils';
import {isScanning, isScanRequest as isScanRequestUtil} from '@libs/TransactionUtils';
import {isFailedScanAmountPlaceholder, isScanning, isScanRequest as isScanRequestUtil} from '@libs/TransactionUtils';

import type * as OnyxTypes from '@src/types/onyx';

Expand Down Expand Up @@ -96,6 +96,8 @@ function useConfirmationAmount({
formattedAmount = '';
} else if (isScanning(transaction)) {
formattedAmount = translate('iou.receiptStatusTitle');
} else if (isFailedScanAmountPlaceholder(transaction)) {
formattedAmount = '';
}

const attendeeCount = iouAttendees?.length && iouAttendees.length > 0 ? iouAttendees.length : 1;
Expand Down
8 changes: 8 additions & 0 deletions src/components/ReportActionItem/MoneyRequestView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ import {
isDistanceRequest as isDistanceRequestTransactionUtils,
isDistanceTypeRequest,
isExpenseUnreported as isExpenseUnreportedTransactionUtils,
isFailedScanAmountPlaceholder,
isGPSDistanceRequest as isGPSDistanceRequestTransactionUtils,
isManagedCardTransaction as isManagedCardTransactionTransactionUtils,
isManualDistanceRequest as isManualDistanceRequestTransactionUtils,
Expand Down Expand Up @@ -330,6 +331,7 @@ function MoneyRequestView({
const isOdometerDistanceRequest = isOdometerDistanceRequestTransactionUtils(transaction);
const isMapDistanceRequest = isMapDistanceRequestTransactionUtils(transaction) || isDistanceTypeRequest(transaction);
const isTransactionScanning = isScanning(updatedTransaction ?? transaction);
const hasFailedScanAmountPlaceholder = isFailedScanAmountPlaceholder(updatedTransaction ?? transaction);
const hasRoute = hasRouteTransactionUtils(transactionBackup ?? transaction, isDistanceRequest);

const rawActualAttendees = isFromMergeTransaction && updatedTransaction ? updatedTransaction.comment?.attendees : transactionAttendees;
Expand Down Expand Up @@ -626,6 +628,8 @@ function MoneyRequestView({
if (isTransactionScanning) {
merchantTitle = translate('iou.receiptStatusTitle');
amountTitle = translate('iou.receiptStatusTitle');
} else if (hasFailedScanAmountPlaceholder) {
amountTitle = '';
}

const updatedTransactionDescription = getDescription(updatedTransaction) || undefined;
Expand Down Expand Up @@ -788,6 +792,10 @@ function MoneyRequestView({
isError: transactionDate === '',
translationPath: canEditDate ? 'common.error.enterDate' : 'common.error.missingDate',
},
amount: {
isError: !isSettled && !isCancelled && hasFailedScanAmountPlaceholder,
translationPath: canEditAmount ? 'common.error.enterAmount' : 'common.error.missingAmount',
},
};

const {isError, translationPath} = fieldChecks[field] ?? {};
Expand Down
28 changes: 26 additions & 2 deletions src/components/TransactionItemRow/DataCells/TotalCell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,14 @@ import {formatToParts} from '@libs/NumberFormatUtils';
import {parseFloatAnyLocale, roundToTwoDecimalPlaces} from '@libs/NumberUtils';
import {isGroupPolicy} from '@libs/PolicyUtils';
import {isExpenseReport, isInvoiceReport, shouldEnableNegative} from '@libs/ReportUtils';
import {getAmount as getTransactionAmount, getCurrency as getTransactionCurrency, isDeletedTransaction, isExpenseUnreported, isScanning} from '@libs/TransactionUtils';
import {
getAmount as getTransactionAmount,
getCurrency as getTransactionCurrency,
isDeletedTransaction,
isExpenseUnreported,
isFailedScanAmountPlaceholder,
isScanning,
} from '@libs/TransactionUtils';

import CONST from '@src/CONST';
import type {Policy, Report} from '@src/types/onyx';
Expand Down Expand Up @@ -54,9 +61,12 @@ function TotalCell({shouldShowTooltip, transactionItem, canEdit, onSave, report,
const isDeleted = isDeletedTransaction(transactionItem);
const isFromExpenseReport = (!isEmptyObject(effectiveReport) && isExpenseReport(effectiveReport)) || isGroupPolicy(effectivePolicy);
const amount = getTransactionAmount(transactionItem, isFromExpenseReport, transactionItem.reportID === CONST.REPORT.UNREPORTED_REPORT_ID, isDeleted);
const hasFailedScanAmountPlaceholder = isFailedScanAmountPlaceholder(transactionItem);
let amountToDisplay = convertToDisplayString(amount, currency);
if (isScanning(transactionItem)) {
amountToDisplay = translate('iou.receiptStatusTitle');
} else if (hasFailedScanAmountPlaceholder) {
amountToDisplay = '';
}

const iouType = getTransactionItemIouType({...transactionItem, report: effectiveReport});
Expand All @@ -67,6 +77,9 @@ function TotalCell({shouldShowTooltip, transactionItem, canEdit, onSave, report,
const absoluteAmount = Math.abs(amount ?? 0);
const isOriginalAmountNegative = (amount ?? 0) < 0;
const [isNegative, setIsNegative] = useState(isOriginalAmountNegative);
// Tracks whether the user actually typed in this edit session, so that merely opening and
// closing the cell without input isn't mistaken for an explicit confirmation of the amount.
const hasUserTypedRef = useRef(false);

const getNormalizedValue = (amountString: string, isAmountNegative: boolean) => {
const parsedValue = parseFloatAnyLocale(amountString);
Expand All @@ -92,7 +105,11 @@ function TotalCell({shouldShowTooltip, transactionItem, canEdit, onSave, report,
onSave(normalizedValue);
}
: undefined,
(value, originalValue) => getNormalizedValue(value, isNegative) === getNormalizedValue(originalValue, isOriginalAmountNegative),
// A failed-scan placeholder amount that the user actually typed into is treated as changed so that
// explicitly re-entering 0 still submits and clears the scan-failure error, mirroring submitEditAmount in
// IOUAmountSubmission.ts. Merely opening and blurring the cell without typing is left as a no-op.
(value, originalValue) =>
!(hasFailedScanAmountPlaceholder && hasUserTypedRef.current) && getNormalizedValue(value, isNegative) === getNormalizedValue(originalValue, isOriginalAmountNegative),
);

// Ref used to programmatically focus the input when edit mode starts
Expand All @@ -105,14 +122,21 @@ function TotalCell({shouldShowTooltip, transactionItem, canEdit, onSave, report,

const handleStartEditing = () => {
setIsNegative(isOriginalAmountNegative);
hasUserTypedRef.current = false;
startEditing();
};

const handleAmountChange = (amountString: string) => {
hasUserTypedRef.current = true;
setLocalValue(amountString);
};

const onFormatAmount = (amountAsInt: number, currencyParam?: string) => {
// Seed the edit input as empty for a failed-scan placeholder, matching the blanked display above and the
// same falsy-amount-is-blank convention MoneyRequestAmountForm already uses for an unset amount.
if (hasFailedScanAmountPlaceholder) {
return '';
}
const decimals = getCurrencyDecimals(currencyParam);
return convertToFrontendAmountAsString(amountAsInt, decimals);
};
Expand Down
15 changes: 13 additions & 2 deletions src/libs/IOUAmountSubmission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,17 @@ import {getLoginByAccountID} from './PersonalDetailsUtils';
import {isTaxTrackingEnabled} from './PolicyUtils';
import {getPolicyExpenseChat, getTransactionDetails, isMoneyRequestReport, isPolicyExpenseChat, isSelfDM, shouldEnableNegative} from './ReportUtils';
import shouldUseDefaultExpensePolicy from './shouldUseDefaultExpensePolicy';
import {calculateTaxAmount, getAmount, getCurrency, getDefaultTaxCode, getIsFromGlobalCreate, getTaxValue, hasReceipt, isExpenseUnreported} from './TransactionUtils';
import {
calculateTaxAmount,
getAmount,
getCurrency,
getDefaultTaxCode,
getIsFromGlobalCreate,
getTaxValue,
hasReceipt,
isExpenseUnreported,
isFailedScanAmountPlaceholder,
} from './TransactionUtils';

type SubmitAmountArgs = {
dateFnsLocale: DateFnsLocale | undefined;
Expand Down Expand Up @@ -603,7 +613,8 @@ function submitEditAmount(args: SubmitAmountArgs, ctx: SubmitAmountContext): voi

// If the value hasn't changed, don't request to save changes on the server and just close the modal
const transactionCurrency = getCurrency(currentTransaction);
if (newAmount === getAmount(currentTransaction, false, false, allowNegative, disableOppositeConversion) && selectedCurrency === transactionCurrency) {
const hasFailedScanAmountPlaceholder = isFailedScanAmountPlaceholder(currentTransaction);
if (!hasFailedScanAmountPlaceholder && newAmount === getAmount(currentTransaction, false, false, allowNegative, disableOppositeConversion) && selectedCurrency === transactionCurrency) {
navigateBack();
return;
}
Expand Down
3 changes: 3 additions & 0 deletions src/libs/TransactionPreviewUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
isAmountMissing,
isCreatedMissing,
isDistanceRequest,
isFailedScanAmountPlaceholder,
isFetchingWaypointsFromServer,
isManagedCardTransaction,
isMerchantMissing,
Expand Down Expand Up @@ -378,6 +379,8 @@ function getTransactionPreviewTextAndTranslationPaths({
let displayAmountText: TranslationPathOrText = isTransactionScanning ? {translationPath: 'iou.receiptStatusTitle'} : {text: convertToDisplayString(amount, requestCurrency)};
if (isFetchingWaypoints && !requestAmount) {
displayAmountText = {translationPath: 'iou.fieldPending'};
} else if (isFailedScanAmountPlaceholder(transaction)) {
displayAmountText = {text: ''};
}

const iouOriginalMessage: OnyxEntry<OnyxTypes.OriginalMessageIOU> = isMoneyRequestAction(action) ? (getOriginalMessage(action) ?? undefined) : undefined;
Expand Down
20 changes: 18 additions & 2 deletions src/libs/TransactionUtils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -557,11 +557,26 @@ function isPartialMerchant(merchant: string): boolean {
return merchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT;
}

function isFailedScanAmountPlaceholder(transaction: OnyxEntry<Transaction>) {
// OPEN is included since editing another field (e.g. merchant) optimistically flips receipt.state to OPEN,
// which would otherwise flicker the amount back to "$0.00" until the server confirms it's still missing.
return (
isScanRequest(transaction) &&
(transaction?.receipt?.state === CONST.IOU.RECEIPT_STATE.SCAN_FAILED || transaction?.receipt?.state === CONST.IOU.RECEIPT_STATE.OPEN) &&
(transaction?.amount === 0 || transaction?.amount === undefined) &&
!hasValidModifiedAmount(transaction)
);
}

function isAmountMissing(transaction: OnyxEntry<Transaction>, isFromExpenseReport = true) {
if (isFailedScanAmountPlaceholder(transaction)) {
return true;
}

if (isFromExpenseReport) {
return transaction?.amount === undefined && (transaction?.modifiedAmount === undefined || transaction?.modifiedAmount === '');
}
return (transaction?.amount === 0 || transaction?.amount === undefined) && (!transaction?.modifiedAmount || transaction?.modifiedAmount === 0 || transaction?.modifiedAmount === '');
return (transaction?.amount === 0 || transaction?.amount === undefined) && !hasValidModifiedAmount(transaction);
}

function hasValidModifiedAmount(transaction: OnyxEntry<Transaction> | null): boolean {
Expand Down Expand Up @@ -596,7 +611,7 @@ function isCreatedMissing(transaction: OnyxEntry<Transaction>) {

function areRequiredFieldsEmpty(transaction: OnyxEntry<Transaction>, transactionReport: OnyxEntry<Report>): boolean {
const isFromExpenseReport = transactionReport?.type === CONST.REPORT.TYPE.EXPENSE;
return (isFromExpenseReport && isMerchantMissing(transaction)) || isCreatedMissing(transaction) || (!isFromExpenseReport && getAmount(transaction) === 0);
return (isFromExpenseReport && isMerchantMissing(transaction)) || isCreatedMissing(transaction) || isAmountMissing(transaction, isFromExpenseReport);
}

function getClearedPendingFields(transactionChanges: TransactionChanges) {
Expand Down Expand Up @@ -3573,6 +3588,7 @@ export {
isDistanceTypeRequest,
recalculateUnreportedTransactionDetails,
hasSmartScanFailedWithMissingFields,
isFailedScanAmountPlaceholder,
isScanFailedTransactionMovedOnPayment,
shouldSplitScanFailedTransactions,
isDeletedTransaction,
Expand Down
8 changes: 7 additions & 1 deletion src/libs/actions/IOU/BulkEdit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
getTaxValue,
getUpdatedTransaction,
isDistanceRequest,
isFailedScanAmountPlaceholder,
isOnHold,
isSplitChildTransaction,
} from '@libs/TransactionUtils';
Expand Down Expand Up @@ -74,7 +75,12 @@ function removeUnchangedBulkEditFields(
const nextValue = transactionChanges[field];
const currentValue = currentDetails[field as keyof TransactionDetails];

if (nextValue !== currentValue) {
// A failed-scan placeholder amount must always be treated as changed so that bulk-confirming the same
// displayed value (e.g. re-entering 0) still submits and clears the scan-failure error, mirroring the
// no-op bypass already used in IOUAmountSubmission.ts and TotalCell.tsx.
const isFailedScanAmountEdit = field === 'amount' && isFailedScanAmountPlaceholder(transaction);

if (isFailedScanAmountEdit || nextValue !== currentValue) {
filteredChanges = {
...filteredChanges,
[field]: nextValue,
Expand Down
107 changes: 107 additions & 0 deletions tests/ui/TotalCellTest.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import {render, screen} from '@testing-library/react-native';

import ComposeProviders from '@components/ComposeProviders';
import {LocaleContextProvider} from '@components/LocaleContextProvider';
import OnyxListItemProvider from '@components/OnyxListItemProvider';
import TotalCell from '@components/TransactionItemRow/DataCells/TotalCell';

import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Transaction} from '@src/types/onyx';

import React from 'react';
import Onyx from 'react-native-onyx';

import createRandomTransaction from '../utils/collections/transaction';
import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';

jest.mock('@libs/Navigation/Navigation');
jest.mock('@hooks/useCurrencyList', () => ({
useCurrencyListActions: () => ({
convertToDisplayString: (amount?: number, currency?: string) => `${currency === 'USD' ? '$' : `${currency ?? 'USD'} `}${((amount ?? 0) / 100).toFixed(2)}`,
getCurrencyDecimals: () => 2,
getCurrencySymbol: () => '$',
}),
}));

const MOCK_TRANSACTION_ID = '1';

const createBaseTransaction = (overrides: Partial<Transaction> = {}): Transaction => ({
...createRandomTransaction(1),
transactionID: MOCK_TRANSACTION_ID,
currency: CONST.CURRENCY.USD,
modifiedAmount: undefined,
...overrides,
});

const renderTotalCell = (transactionItem: Transaction) => {
return render(
<ComposeProviders components={[OnyxListItemProvider, LocaleContextProvider]}>
<TotalCell
transactionItem={transactionItem}
shouldShowTooltip={false}
canEdit
onSave={jest.fn()}
/>
</ComposeProviders>,
);
};

describe('TotalCell', () => {
beforeAll(async () => {
Onyx.init({keys: ONYXKEYS});
await Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.DEFAULT);
return waitForBatchedUpdates();
});

it('blanks the amount for a failed-scan amount placeholder', async () => {
const mockTransaction = createBaseTransaction({
amount: 0,
iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN,
receipt: {state: CONST.IOU.RECEIPT_STATE.SCAN_FAILED},
});

renderTotalCell(mockTransaction);
await waitForBatchedUpdates();

expect(screen.queryByText('$0.00')).not.toBeOnTheScreen();
});

it('shows the formatted amount for a normal transaction', async () => {
const mockTransaction = createBaseTransaction({
amount: 1000,
iouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL,
});

renderTotalCell(mockTransaction);
await waitForBatchedUpdates();

expect(screen.getByText('$10.00')).toBeOnTheScreen();
});

it('does not blank a legitimate manual $0.00 amount', async () => {
const mockTransaction = createBaseTransaction({
amount: 0,
iouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL,
});

renderTotalCell(mockTransaction);
await waitForBatchedUpdates();

expect(screen.getByText('$0.00')).toBeOnTheScreen();
});

it('does not blank the amount once the failed-scan placeholder amount is confirmed', async () => {
const mockTransaction = createBaseTransaction({
amount: 0,
modifiedAmount: 0,
iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN,
receipt: {state: CONST.IOU.RECEIPT_STATE.SCAN_FAILED},
});

renderTotalCell(mockTransaction);
await waitForBatchedUpdates();

expect(screen.getByText('$0.00')).toBeOnTheScreen();
});
});
29 changes: 29 additions & 0 deletions tests/unit/TransactionPreviewUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,35 @@ describe('TransactionPreviewUtils', () => {
expect(result.displayAmountText.translationPath).toEqual('iou.receiptStatusTitle');
});

it('blanks the displayed amount for a failed-scan amount placeholder', () => {
const functionArgs = {
...basicProps,
transaction: {
...basicProps.transaction,
amount: 0,
iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN,
receipt: {state: CONST.IOU.RECEIPT_STATE.SCAN_FAILED},
},
originalTransaction: undefined,
};
const result = getTransactionPreviewTextAndTranslationPaths(functionArgs);
expect(result.displayAmountText.text).toEqual('');
});

it('does not blank a legitimate manual $0.00 amount', () => {
const functionArgs = {
...basicProps,
transaction: {
...basicProps.transaction,
amount: 0,
iouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL,
},
originalTransaction: undefined,
};
const result = getTransactionPreviewTextAndTranslationPaths(functionArgs);
expect(result.displayAmountText.text).toEqual('$0.00');
});

it('handles currency and amount display correctly for scan split bill manually completed', () => {
const modifiedAmount = 300;
const currency = 'EUR';
Expand Down
Loading
Loading