diff --git a/src/components/MoneyRequestConfirmationList/sections/AutomaticFieldHint.tsx b/src/components/MoneyRequestConfirmationList/sections/AutomaticFieldHint.tsx
index 5ede4047ea53..9ce5a14d0cda 100644
--- a/src/components/MoneyRequestConfirmationList/sections/AutomaticFieldHint.tsx
+++ b/src/components/MoneyRequestConfirmationList/sections/AutomaticFieldHint.tsx
@@ -1,7 +1,7 @@
/**
* The "Automatic" hint the Scan confirmation shows inside the amount, merchant and date fields while SmartScan is
* still the one filling them in. It mirrors the right label the category field carries for the same promise, and
- * disappears from all three the moment the user fills in any one of them.
+ * each field drops it as soon as that field has a value of its own.
*/
import Icon from '@components/Icon';
import Text from '@components/Text';
diff --git a/src/components/MoneyRequestConfirmationList/sections/DateField.tsx b/src/components/MoneyRequestConfirmationList/sections/DateField.tsx
index 8b9f46586696..c7efc21c2852 100644
--- a/src/components/MoneyRequestConfirmationList/sections/DateField.tsx
+++ b/src/components/MoneyRequestConfirmationList/sections/DateField.tsx
@@ -10,7 +10,7 @@ import usePolicy from '@hooks/usePolicy';
import usePolicyForMovingExpenses from '@hooks/usePolicyForMovingExpenses';
import useThemeStyles from '@hooks/useThemeStyles';
-import {setMoneyRequestCreated, updateDistanceRateOnExpenseDateChange} from '@libs/actions/IOU/MoneyRequest';
+import {clearMoneyRequestCreated, setMoneyRequestCreated, updateDistanceRateOnExpenseDateChange} from '@libs/actions/IOU/MoneyRequest';
import {shouldUseTransactionDraft} from '@libs/IOUUtils';
import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute';
import Navigation from '@libs/Navigation/Navigation';
@@ -47,7 +47,7 @@ type DateFieldProps = {
function DateField({shouldDisplayFieldError, didConfirm, isReadOnly, isNewManualExpenseFlowEnabled, formError, transactionID, action, iouType, reportID, reportActionID}: DateFieldProps) {
const {getCurrencyDecimals, getCurrencySymbol} = useCurrencyListActions();
- const {isEditingSplitBill, canEnterScanFieldsManually, shouldShowAutomaticFieldHint} = useConfirmationFields();
+ const {isEditingSplitBill, canEnterScanFieldsManually} = useConfirmationFields();
const styles = useThemeStyles();
const {translate} = useLocalize();
const isTrackExpense = iouType === CONST.IOU.TYPE.TRACK;
@@ -70,11 +70,10 @@ function DateField({shouldDisplayFieldError, didConfirm, isReadOnly, isNewManual
// A draft is seeded with today's date, but in the Scan flow the date belongs to the receipt, not to today, so the
// picker stays empty until the user picks one — the same way the amount field starts empty.
const shouldShowEmptyDate = canEnterScanFieldsManually && !dateState?.isCreatedSet;
- const isDateEmpty = createdMissing || shouldShowEmptyDate;
const dateErrorText = shouldDisplayFieldError && createdMissing ? translate('common.error.enterDate') : '';
- const inlineDateErrorText = formError === 'common.error.fieldRequired' && isDateEmpty ? translate('common.error.fieldRequired') : '';
+ const inlineDateErrorText = formError === 'common.error.fieldRequired' && createdMissing ? translate('common.error.fieldRequired') : '';
const handleDateChange = (newDate: string) => {
if (!transactionID) {
@@ -92,6 +91,13 @@ function DateField({shouldDisplayFieldError, didConfirm, isReadOnly, isNewManual
return;
}
+ // Clearing the date on a scan hands the field back to SmartScan rather than emptying it, the same way clearing
+ // the amount or the merchant does.
+ if (!newDate && canEnterScanFieldsManually) {
+ clearMoneyRequestCreated(transactionID, shouldUseTransactionDraft(action));
+ return;
+ }
+
setMoneyRequestCreated(transactionID, newDate, shouldUseTransactionDraft(action), transactionHasReceipt);
if (action !== CONST.IOU.ACTION.EDIT) {
@@ -127,8 +133,8 @@ function DateField({shouldDisplayFieldError, didConfirm, isReadOnly, isNewManual
shouldDeferShowUntilPositioned
// The hint only renders while the date is empty, and `TextInput` drops its right-hand-side
// component whenever the clear button can appear — which it can't without a value to clear.
- shouldHideClearButton={shouldShowAutomaticFieldHint}
- rightHandSideComponent={shouldShowAutomaticFieldHint ? : undefined}
+ shouldHideClearButton={shouldShowEmptyDate}
+ rightHandSideComponent={shouldShowEmptyDate ? : undefined}
/>
);
diff --git a/src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx b/src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx
index 5824882668b6..e5da76f287f5 100644
--- a/src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx
+++ b/src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx
@@ -32,7 +32,7 @@ type MerchantFieldProps = {
};
function MerchantField({isMerchantRequired, shouldDisplayFieldError, formError}: MerchantFieldProps) {
- const {action, iouType, transactionID, reportID, reportActionID, isReadOnly, didConfirm, isEditingSplitBill, isNewManualExpenseFlowEnabled, shouldShowAutomaticFieldHint} =
+ const {action, iouType, transactionID, reportID, reportActionID, isReadOnly, didConfirm, isEditingSplitBill, isNewManualExpenseFlowEnabled, canEnterScanFieldsManually} =
useConfirmationFields();
const styles = useThemeStyles();
const {translate} = useLocalize();
@@ -45,6 +45,8 @@ function MerchantField({isMerchantRequired, shouldDisplayFieldError, formError}:
const merchantValue = merchantState?.merchant ?? '';
const displayMerchantValue = isUntypedPlaceholderMerchant(merchantState?.isMerchantSet, merchantValue) ? '' : merchantValue;
const transactionHasReceipt = merchantState?.hasReceipt ?? false;
+ // While the Scan confirmation is still waiting on SmartScan for this field, it says so instead of sitting empty.
+ const shouldShowAutomaticHint = canEnterScanFieldsManually && !displayMerchantValue;
// Mirror the persisted merchant in local state so the controlled input updates synchronously as the user types;
// feeding the async Onyx value straight to `value` snaps the caret to the end on every keystroke (see #98647).
@@ -135,7 +137,7 @@ function MerchantField({isMerchantRequired, shouldDisplayFieldError, formError}:
label={translate('common.merchant')}
accessibilityLabel={translate('common.merchant')}
errorText={merchantErrorText}
- rightHandSideComponent={shouldShowAutomaticFieldHint ? : undefined}
+ rightHandSideComponent={shouldShowAutomaticHint ? : undefined}
/>
);
diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts
index ec71997d877c..84b00051ef97 100644
--- a/src/libs/TransactionUtils/index.ts
+++ b/src/libs/TransactionUtils/index.ts
@@ -284,18 +284,9 @@ function isScanRequest(transaction: OnyxEntry): boolean {
- return isScanRequest(transaction) && (!!transaction?.isAmountSet || !!transaction?.isMerchantSet || !!transaction?.isCreatedSet);
-}
-
-/**
- * Whether the user filled in every one of those fields. Only then is the receipt submitted as `open`, so SmartScan
- * never overwrites what the user typed. A partially filled scan is still scanned — that way it can never be created
- * with neither an amount of its own nor one read from the receipt.
+ * Whether the user filled in every one of the amount / merchant / date fields the Scan confirmation reveals behind
+ * "Show more". Each of them is optional — a field left blank is read off the receipt — so only once all three carry a
+ * value of their own is the receipt submitted as `open`, where SmartScan never overwrites what the user typed.
*/
function hasAllManuallyEnteredScanFields(transaction: OnyxEntry): boolean {
return isScanRequest(transaction) && !!transaction?.isAmountSet && !!transaction?.isMerchantSet && !!transaction?.isCreatedSet;
@@ -3792,7 +3783,6 @@ export {
getTagForDisplay,
getTransactionViolations,
hasAllManuallyEnteredScanFields,
- hasManuallyEnteredScanFields,
hasReceipt,
hasUploadedReceipt,
hasEReceipt,
diff --git a/src/libs/actions/IOU/MoneyRequest.ts b/src/libs/actions/IOU/MoneyRequest.ts
index 1018f5c7c9d3..29151cd57fec 100644
--- a/src/libs/actions/IOU/MoneyRequest.ts
+++ b/src/libs/actions/IOU/MoneyRequest.ts
@@ -975,6 +975,15 @@ function setMoneyRequestCreated(transactionID: string, created: string, isDraft:
setMoneyRequestReceiptState(transactionID, isDraft, shouldStopSmartscan);
}
+/**
+ * Returns the date to the state it starts the Scan confirmation in: the field renders empty again (SmartScan is back
+ * to being the one that fills it), while the seeded `created` stays on the transaction as the fallback date, so a
+ * cleared field can never submit an expense with no date at all.
+ */
+function clearMoneyRequestCreated(transactionID: string, isDraft: boolean) {
+ Onyx.merge(`${isDraft ? ONYXKEYS.COLLECTION.TRANSACTION_DRAFT : ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {isCreatedSet: false});
+}
+
function setMoneyRequestDateAttribute(transactionID: string, start: string, end: string) {
Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, {
comment: {customUnit: {attributes: {dates: {start, end}}}},
@@ -1138,6 +1147,7 @@ export {
setMoneyRequestDistanceRate,
setMoneyRequestAmount,
clearMoneyRequestAmount,
+ clearMoneyRequestCreated,
clearMoneyRequestMerchant,
setMoneyRequestCreated,
setMoneyRequestDateAttribute,
diff --git a/tests/ui/components/IOURequestStepConfirmationPageTest.tsx b/tests/ui/components/IOURequestStepConfirmationPageTest.tsx
index 91f940cce564..967b97bd6fc1 100644
--- a/tests/ui/components/IOURequestStepConfirmationPageTest.tsx
+++ b/tests/ui/components/IOURequestStepConfirmationPageTest.tsx
@@ -474,53 +474,66 @@ describe('IOURequestStepConfirmationPageTest', () => {
expect(screen.getByLabelText(translateLocal('common.date'))).toHaveDisplayValue('');
});
- it('requires all three fields once one of them is entered', async () => {
+ it('submits a partially filled scan, leaving the blank fields to SmartScan', async () => {
await renderScanConfirmation();
fireEvent.changeText(screen.getByLabelText(translateLocal('common.merchant')), 'Starbucks');
+ fireEvent.changeText(screen.getByLabelText(translateLocal('iou.amount')), '12.34');
await waitForBatchedUpdatesWithAct();
fireEvent.press(screen.getByText(translateLocal('iou.createExpense')));
await waitForBatchedUpdatesWithAct();
- expect(TrackExpense.requestMoney).not.toHaveBeenCalled();
- expect(screen.getAllByText(translateLocal('common.error.fieldRequired')).length).toBeGreaterThan(1);
+ // The date was never picked, so it stays "Automatic" instead of blocking confirmation.
+ expect(screen.queryByText(translateLocal('common.error.fieldRequired'))).not.toBeOnTheScreen();
+ expect(TrackExpense.requestMoney).toHaveBeenCalledTimes(1);
});
- it('labels the amount, merchant and date fields "Automatic" until one of them is entered', async () => {
+ it('labels the amount, merchant and date fields "Automatic" until that field is entered', async () => {
await renderScanConfirmation();
- // The category field carries the same label, so count the three that leave rather than expecting none left.
+ // The category field carries the same label, so count the ones that leave rather than expecting none left.
const automaticLabelCount = screen.getAllByText(translateLocal('common.automatic')).length;
expect(automaticLabelCount).toBeGreaterThanOrEqual(3);
fireEvent.changeText(screen.getByLabelText(translateLocal('common.merchant')), 'Starbucks');
await waitForBatchedUpdatesWithAct();
- expect(screen.queryAllByText(translateLocal('common.automatic'))).toHaveLength(automaticLabelCount - 3);
- });
+ // Only the merchant's label goes: the amount and the date are still the ones SmartScan reads.
+ expect(screen.queryAllByText(translateLocal('common.automatic'))).toHaveLength(automaticLabelCount - 1);
- it('stops requiring the three fields once the entered one is cleared again', async () => {
- await renderScanConfirmation();
-
- fireEvent.changeText(screen.getByLabelText(translateLocal('common.merchant')), 'Starbucks');
- await waitForBatchedUpdatesWithAct();
- fireEvent.press(screen.getByText(translateLocal('iou.createExpense')));
+ fireEvent.changeText(screen.getByLabelText(translateLocal('iou.amount')), '12.34');
await waitForBatchedUpdatesWithAct();
- expect(TrackExpense.requestMoney).not.toHaveBeenCalled();
- expect(screen.getAllByText(translateLocal('common.error.fieldRequired')).length).toBeGreaterThan(0);
+ expect(screen.queryAllByText(translateLocal('common.automatic'))).toHaveLength(automaticLabelCount - 2);
+ });
- // Clearing the merchant makes it a plain scan again, so the error that blocked confirmation has to go with it.
- fireEvent.changeText(screen.getByLabelText(translateLocal('common.merchant')), '');
- await waitForBatchedUpdatesWithAct();
+ it('hands a cleared date back to SmartScan instead of emptying it', async () => {
+ await renderScanConfirmation();
- expect(screen.queryByText(translateLocal('common.error.fieldRequired'))).not.toBeOnTheScreen();
+ await act(async () => {
+ await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, {created: '2025-01-15', isCreatedSet: true});
+ });
+ expect(screen.getByLabelText(translateLocal('common.date'))).toHaveDisplayValue('2025-01-15');
- fireEvent.press(screen.getByText(translateLocal('iou.createExpense')));
+ fireEvent(screen.getByLabelText(translateLocal('common.date')), 'onInputChange', '');
await waitForBatchedUpdatesWithAct();
+ // The field reads as "Automatic" again, while the transaction keeps a date to fall back on.
+ expect(screen.getByLabelText(translateLocal('common.date'))).toHaveDisplayValue('');
expect(screen.queryByText(translateLocal('common.error.fieldRequired'))).not.toBeOnTheScreen();
+
+ const draft = await new Promise>((resolve) => {
+ const connection = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`,
+ callback: (value) => {
+ Onyx.disconnect(connection);
+ resolve(value);
+ },
+ });
+ });
+ expect(draft?.created).toBe('2025-01-15');
+ expect(draft?.isCreatedSet).toBe(false);
});
it('submits the entered amount, merchant and date instead of waiting for SmartScan', async () => {
diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts
index 4038e82394b9..bc73b5d117e9 100644
--- a/tests/unit/TransactionUtilsTest.ts
+++ b/tests/unit/TransactionUtilsTest.ts
@@ -5248,31 +5248,25 @@ describe('doesMoneyRequestDraftHaveUserInput', () => {
});
});
-describe('hasManuallyEnteredScanFields', () => {
+describe('hasAllManuallyEnteredScanFields', () => {
function generateScanDraft(values: Partial = {}): Transaction {
return generateTransaction({iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, ...values});
}
- it('returns false for an untouched scan draft', () => {
- expect(TransactionUtils.hasManuallyEnteredScanFields(undefined)).toBe(false);
- expect(TransactionUtils.hasManuallyEnteredScanFields(generateScanDraft())).toBe(false);
+ it('returns false while any of the three fields is still left to SmartScan', () => {
+ expect(TransactionUtils.hasAllManuallyEnteredScanFields(undefined)).toBe(false);
+ expect(TransactionUtils.hasAllManuallyEnteredScanFields(generateScanDraft())).toBe(false);
+ expect(TransactionUtils.hasAllManuallyEnteredScanFields(generateScanDraft({isAmountSet: true, isMerchantSet: true}))).toBe(false);
});
- it.each([['amount', {isAmountSet: true}] as const, ['merchant', {isMerchantSet: true}] as const, ['date', {isCreatedSet: true}] as const])(
- 'returns true once the %s has been entered',
- (_field, values) => {
- expect(TransactionUtils.hasManuallyEnteredScanFields(generateScanDraft(values))).toBe(true);
- },
- );
-
- it('returns false for expense types that populate those fields programmatically', () => {
- expect(TransactionUtils.hasManuallyEnteredScanFields(generateTransaction({iouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, isAmountSet: true}))).toBe(false);
- expect(TransactionUtils.hasManuallyEnteredScanFields(generateTransaction({iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, isAmountSet: true}))).toBe(false);
+ it('returns true once every one of them has been entered', () => {
+ expect(TransactionUtils.hasAllManuallyEnteredScanFields(generateScanDraft({isAmountSet: true, isMerchantSet: true, isCreatedSet: true}))).toBe(true);
});
- it('only reports all fields entered once every one of them is', () => {
- expect(TransactionUtils.hasAllManuallyEnteredScanFields(generateScanDraft({isAmountSet: true, isMerchantSet: true}))).toBe(false);
- expect(TransactionUtils.hasAllManuallyEnteredScanFields(generateScanDraft({isAmountSet: true, isMerchantSet: true, isCreatedSet: true}))).toBe(true);
+ it('returns false for expense types that populate those fields programmatically', () => {
+ const values = {isAmountSet: true, isMerchantSet: true, isCreatedSet: true};
+ expect(TransactionUtils.hasAllManuallyEnteredScanFields(generateTransaction({iouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, ...values}))).toBe(false);
+ expect(TransactionUtils.hasAllManuallyEnteredScanFields(generateTransaction({iouRequestType: CONST.IOU.REQUEST_TYPE.DISTANCE, ...values}))).toBe(false);
});
});
diff --git a/tests/unit/hooks/useConfirmationValidation.test.ts b/tests/unit/hooks/useConfirmationValidation.test.ts
index 890c07fe1bf7..1aa7b1cd9ff0 100644
--- a/tests/unit/hooks/useConfirmationValidation.test.ts
+++ b/tests/unit/hooks/useConfirmationValidation.test.ts
@@ -990,9 +990,9 @@ describe('useConfirmationValidation', () => {
['merchant', {isMerchantSet: true, merchant: 'Starbucks'}, {iouMerchant: 'Starbucks', isMerchantEmpty: false}],
['amount', {isAmountSet: true, amount: 1000}, {iouAmount: 1000}],
['date', {isCreatedSet: true, created: '2025-01-15'}, {}],
- ])('requires the remaining fields once the %s is entered', (_field, transactionOverrides, overrides) => {
+ ])('leaves the other fields optional once the %s is entered, since a blank field is still scanned', (_field, transactionOverrides, overrides) => {
const {result} = renderHook(() => useConfirmationValidation(createScanValidationParams(transactionOverrides, overrides)));
- expect(result.current.validate()).toEqual({errorKey: 'common.error.fieldRequired'});
+ expect(result.current.validate()).toEqual({errorKey: null});
});
it('passes once all three fields are entered', () => {
diff --git a/tests/unit/hooks/useFormErrorManagement.test.tsx b/tests/unit/hooks/useFormErrorManagement.test.tsx
index d686bf30b76a..809740822b64 100644
--- a/tests/unit/hooks/useFormErrorManagement.test.tsx
+++ b/tests/unit/hooks/useFormErrorManagement.test.tsx
@@ -46,7 +46,6 @@ const baseParams: Params = {
isTypeSplit: false,
shouldShowReadOnlySplits: false,
isNewManualExpenseFlowEnabled: false,
- canEnterScanFieldsManually: false,
isDistanceRequest: false,
shouldShowDate: false,
isReadOnly: false,
@@ -243,9 +242,8 @@ describe('useFormErrorManagement', () => {
expect(result.current.isMerchantFieldValid).toBe(false);
});
- it('requires the merchant once the user starts filling in the scan fields, whoever the expense is headed to', () => {
+ it('leaves the merchant optional on a scan the user has started filling in, since a blank field is still scanned', () => {
const scanParams: Partial = {
- canEnterScanFieldsManually: true,
isNewManualExpenseFlowEnabled: true,
isScanRequest: true,
isPolicyExpenseChat: false,
@@ -268,8 +266,7 @@ describe('useFormErrorManagement', () => {
);
expect(untouched.current.isMerchantRequired).toBe(false);
- expect(amountEntered.current.isMerchantRequired).toBe(true);
- expect(amountEntered.current.isMerchantFieldValid).toBe(false);
+ expect(amountEntered.current.isMerchantRequired).toBe(false);
});
it('clears the invalid merchant error once the recipient changes from a workspace chat to a user (#96593)', () => {
From 7273e15431fd2e69f66169543e989b14e166ea3a Mon Sep 17 00:00:00 2001
From: thelullabyy <182625428+thelullabyy@users.noreply.github.com>
Date: Thu, 10 Sep 2026 02:12:07 +0800
Subject: [PATCH 4/9] fix: automatic hint text
---
src/components/DatePicker/index.tsx | 19 +++++++++++++-----
src/components/DatePicker/types.ts | 6 ++++++
.../sections/AmountField.tsx | 11 +++++++++-
.../sections/AutomaticFieldHint.tsx | 2 +-
.../sections/DateField.tsx | 11 ++++++++--
.../sections/MerchantField.tsx | 7 +++++--
.../IOURequestStepConfirmationPageTest.tsx | 20 +++++++++++++++++++
7 files changed, 65 insertions(+), 11 deletions(-)
diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx
index 4d360d7071e4..bf62b004fef2 100644
--- a/src/components/DatePicker/index.tsx
+++ b/src/components/DatePicker/index.tsx
@@ -49,6 +49,7 @@ function DatePicker({
shouldDeferShowUntilPositioned = false,
shouldDismissKeyboardBeforeShow = false,
rightHandSideComponent,
+ onPickerVisibilityChange,
}: DateInputWithPickerProps) {
const icons = useMemoizedLazyExpensifyIcons(['Calendar']);
const styles = useThemeStyles();
@@ -102,6 +103,14 @@ function DatePicker({
[windowHeight],
);
+ const setPickerVisibility = useCallback(
+ (isVisible: boolean) => {
+ setIsModalVisible(isVisible);
+ onPickerVisibilityChange?.(isVisible);
+ },
+ [onPickerVisibilityChange],
+ );
+
const showDatePickerModal = useCallback(() => {
cancelAutoFocus();
// Blur the date input before showing the modal, so the focus won't be returned after the modal is closed
@@ -117,7 +126,7 @@ function DatePicker({
const openPicker = () => {
if (!shouldDeferShowUntilPositioned) {
calculatePopoverPosition();
- setIsModalVisible(true);
+ setPickerVisibility(true);
return;
}
@@ -126,16 +135,16 @@ function DatePicker({
if (!openIntentRef.current) {
return;
}
- setIsModalVisible(true);
+ setPickerVisibility(true);
});
};
openPicker();
- }, [shouldDeferShowUntilPositioned, shouldDismissKeyboardBeforeShow, calculatePopoverPosition, cancelAutoFocus]);
+ }, [shouldDeferShowUntilPositioned, shouldDismissKeyboardBeforeShow, calculatePopoverPosition, cancelAutoFocus, setPickerVisibility]);
const closeDatePicker = useCallback(() => {
openIntentRef.current = false;
- setIsModalVisible(false);
+ setPickerVisibility(false);
if (!shouldDismissKeyboardBeforeShow) {
return;
@@ -144,7 +153,7 @@ function DatePicker({
textInputRef.current?.blur();
ComposerFocusManager.blurActiveInput();
Keyboard.dismiss();
- }, [shouldDismissKeyboardBeforeShow]);
+ }, [shouldDismissKeyboardBeforeShow, setPickerVisibility]);
const handlePress = useCallback>(
(event) => {
diff --git a/src/components/DatePicker/types.ts b/src/components/DatePicker/types.ts
index 362255f2dda6..33e11c1f7ccd 100644
--- a/src/components/DatePicker/types.ts
+++ b/src/components/DatePicker/types.ts
@@ -69,6 +69,12 @@ type DateInputWithPickerProps = DatePickerBaseProps &
* @default false
*/
shouldDismissKeyboardBeforeShow?: boolean;
+
+ /**
+ * Reports whether the calendar is open. Opening the picker blurs the input, so this — not `onFocus` — is the
+ * signal for "the user is on this field", and it is what drives the input's focused border.
+ */
+ onPickerVisibilityChange?: (isVisible: boolean) => void;
};
type DatePickerProps = {
diff --git a/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx b/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx
index 9a79965befe2..d68534692955 100644
--- a/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx
+++ b/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx
@@ -85,6 +85,7 @@ function AmountField({
const amountIsMissing = transactionSlice?.isAmountMissing ?? false;
const [isCurrencyPickerVisible, setIsCurrencyPickerVisible] = useState(false);
+ const [isAmountInputFocused, setIsAmountInputFocused] = useState(false);
const isAmountFieldDisabled = didConfirm || isReadOnly || shouldShowTimeRequestFields || isDistanceRequest;
const isP2P = isParticipantP2P(getMoneyRequestParticipantsFromReport(report, currentUserPersonalDetails.accountID).at(0));
@@ -112,7 +113,9 @@ function AmountField({
const shouldShowEmptyAmount = !transactionSlice?.isAmountSet && (transactionSlice?.iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL || canEnterScanFieldsManually);
const transactionAmount = shouldShowEmptyAmount ? '' : convertToFrontendAmountAsString(amount, decimals);
// While the Scan confirmation is still waiting on SmartScan for this field, it says so instead of sitting empty.
- const shouldShowAutomaticHint = canEnterScanFieldsManually && !transactionSlice?.isAmountSet;
+ // Focusing the field is the user taking it over, so the hint goes as soon as that happens rather than waiting for
+ // the first keystroke — it would otherwise sit next to the caret promising to fill in what is being typed.
+ const shouldShowAutomaticHint = canEnterScanFieldsManually && !isAmountInputFocused && !transactionSlice?.isAmountSet;
const allowNegative = shouldEnableNegative(report, policy, iouType, transactionSlice?.participants);
// `autoFocus` on our TextInput only runs on mount. Closing and reopening the RHP often keeps the same mounted
@@ -319,6 +322,12 @@ function AmountField({
shouldShowCurrencyButton
shouldShowBigNumberPad={false}
onCurrencyButtonPress={showCurrencyPicker}
+ onFocus={() => {
+ setIsAmountInputFocused(true);
+ }}
+ onBlur={() => {
+ setIsAmountInputFocused(false);
+ }}
leadingRightHandSideComponent={shouldShowAutomaticHint ? : undefined}
disabled={isAmountFieldDisabled}
/>
diff --git a/src/components/MoneyRequestConfirmationList/sections/AutomaticFieldHint.tsx b/src/components/MoneyRequestConfirmationList/sections/AutomaticFieldHint.tsx
index 9ce5a14d0cda..9b1fe9f29ad9 100644
--- a/src/components/MoneyRequestConfirmationList/sections/AutomaticFieldHint.tsx
+++ b/src/components/MoneyRequestConfirmationList/sections/AutomaticFieldHint.tsx
@@ -1,7 +1,7 @@
/**
* The "Automatic" hint the Scan confirmation shows inside the amount, merchant and date fields while SmartScan is
* still the one filling them in. It mirrors the right label the category field carries for the same promise, and
- * each field drops it as soon as that field has a value of its own.
+ * each field drops it as soon as the user takes the field over — on focus, or once it has a value of its own.
*/
import Icon from '@components/Icon';
import Text from '@components/Text';
diff --git a/src/components/MoneyRequestConfirmationList/sections/DateField.tsx b/src/components/MoneyRequestConfirmationList/sections/DateField.tsx
index d19e6c22cda8..fce2e41ed886 100644
--- a/src/components/MoneyRequestConfirmationList/sections/DateField.tsx
+++ b/src/components/MoneyRequestConfirmationList/sections/DateField.tsx
@@ -25,7 +25,7 @@ import {DYNAMIC_ROUTES} from '@src/ROUTES';
import INPUT_IDS from '@src/types/form/MoneyRequestDateForm';
import {format} from 'date-fns';
-import React from 'react';
+import React, {useState} from 'react';
import {View} from 'react-native';
import AutomaticFieldHint from './AutomaticFieldHint';
@@ -70,6 +70,12 @@ function DateField({shouldDisplayFieldError, didConfirm, isReadOnly, formError,
// picker stays empty until the user picks one — the same way the amount field starts empty.
const shouldShowEmptyDate = canEnterScanFieldsManually && !dateState?.isCreatedSet;
+ // Opening the calendar blurs the input, so the open picker — not focus — is this field's "the user is on it"
+ // signal, and it is what draws the focused border. The hint follows it so it can't sit next to an open calendar
+ // promising to fill in the date the user is picking. It stays tied to the empty value beyond that.
+ const [isDatePickerOpen, setIsDatePickerOpen] = useState(false);
+ const shouldShowAutomaticHint = shouldShowEmptyDate && !isDatePickerOpen;
+
const dateErrorText = shouldDisplayFieldError && createdMissing ? translate('common.error.enterDate') : '';
const inlineDateErrorText = formError === 'common.error.fieldRequired' && createdMissing ? translate('common.error.fieldRequired') : '';
@@ -133,7 +139,8 @@ function DateField({shouldDisplayFieldError, didConfirm, isReadOnly, formError,
// The hint only renders while the date is empty, and `TextInput` drops its right-hand-side
// component whenever the clear button can appear — which it can't without a value to clear.
shouldHideClearButton={shouldShowEmptyDate}
- rightHandSideComponent={shouldShowEmptyDate ? : undefined}
+ rightHandSideComponent={shouldShowAutomaticHint ? : undefined}
+ onPickerVisibilityChange={setIsDatePickerOpen}
/>
);
diff --git a/src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx b/src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx
index c0532e77a459..21cb818526d2 100644
--- a/src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx
+++ b/src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx
@@ -44,8 +44,6 @@ function MerchantField({isMerchantRequired, shouldDisplayFieldError, formError}:
const merchantValue = merchantState?.merchant ?? '';
const displayMerchantValue = isUntypedPlaceholderMerchant(merchantState?.isMerchantSet, merchantValue) ? '' : merchantValue;
const transactionHasReceipt = merchantState?.hasReceipt ?? false;
- // While the Scan confirmation is still waiting on SmartScan for this field, it says so instead of sitting empty.
- const shouldShowAutomaticHint = canEnterScanFieldsManually && !displayMerchantValue;
// Mirror the persisted merchant in local state so the controlled input updates synchronously as the user types;
// feeding the async Onyx value straight to `value` snaps the caret to the end on every keystroke (see #98647).
@@ -54,6 +52,11 @@ function MerchantField({isMerchantRequired, shouldDisplayFieldError, formError}:
const [prevDisplayValue, setPrevDisplayValue] = useState(displayMerchantValue);
const [prevTransactionID, setPrevTransactionID] = useState(transactionID);
+ // While the Scan confirmation is still waiting on SmartScan for this field, it says so instead of sitting empty.
+ // Focusing the field is the user taking it over, so the hint goes as soon as that happens rather than waiting for
+ // the first keystroke — it would otherwise sit next to the caret promising to fill in what is being typed.
+ const shouldShowAutomaticHint = canEnterScanFieldsManually && !isMerchantInputFocused && !displayMerchantValue;
+
// Sync the mirror during render (not in an effect) to avoid an extra render pass. Reset on transaction change
// even while focused; otherwise sync external updates (SmartScan, drafts) only when the field isn't being edited.
if (transactionID !== prevTransactionID) {
diff --git a/tests/ui/components/IOURequestStepConfirmationPageTest.tsx b/tests/ui/components/IOURequestStepConfirmationPageTest.tsx
index 2aa85ac7df29..5a0bd0fa8959 100644
--- a/tests/ui/components/IOURequestStepConfirmationPageTest.tsx
+++ b/tests/ui/components/IOURequestStepConfirmationPageTest.tsx
@@ -527,6 +527,26 @@ describe('IOURequestStepConfirmationPageTest', () => {
expect(screen.queryAllByText(translateLocal('common.automatic'))).toHaveLength(automaticLabelCount - 2);
});
+ it('drops the "Automatic" label while a field is focused, and brings it back if the field is left empty', async () => {
+ await renderScanConfirmation();
+
+ const automaticLabelCount = screen.getAllByText(translateLocal('common.automatic')).length;
+
+ // Focusing is the user taking the field over, so the label goes before the first keystroke.
+ fireEvent(screen.getByLabelText(translateLocal('iou.amount')), 'focus');
+ await waitForBatchedUpdatesWithAct();
+ expect(screen.queryAllByText(translateLocal('common.automatic'))).toHaveLength(automaticLabelCount - 1);
+
+ // Leaving it without entering anything hands the field back to SmartScan.
+ fireEvent(screen.getByLabelText(translateLocal('iou.amount')), 'blur');
+ await waitForBatchedUpdatesWithAct();
+ expect(screen.queryAllByText(translateLocal('common.automatic'))).toHaveLength(automaticLabelCount);
+
+ fireEvent(screen.getByLabelText(translateLocal('common.merchant')), 'focus');
+ await waitForBatchedUpdatesWithAct();
+ expect(screen.queryAllByText(translateLocal('common.automatic'))).toHaveLength(automaticLabelCount - 1);
+ });
+
it('hands a cleared date back to SmartScan instead of emptying it', async () => {
await renderScanConfirmation();
From c880eee84fcef5cf6a65c354bb88ddb33d1041d8 Mon Sep 17 00:00:00 2001
From: thelullabyy <182625428+thelullabyy@users.noreply.github.com>
Date: Thu, 10 Sep 2026 02:50:44 +0800
Subject: [PATCH 5/9] fix: Derive the final receipt state before submission
---
src/components/DatePicker/types.ts | 4 +-
.../hooks/useConfirmationValidation.ts | 6 +-
.../sections/AmountField.tsx | 2 +-
.../sections/AutomaticFieldHint.tsx | 2 +-
.../sections/DateField.tsx | 12 ++--
.../sections/MerchantField.tsx | 2 +-
src/libs/TransactionUtils/index.ts | 4 +-
src/libs/actions/IOU/MoneyRequestBuilder.ts | 5 ++
src/libs/actions/IOU/TrackExpense.ts | 8 ++-
.../types/TrackExpenseTransactionParams.ts | 8 +++
.../step/IOURequestStepConfirmation.tsx | 1 +
.../step/confirmation/useExpenseSubmission.ts | 28 ++++++++
tests/unit/hooks/useExpenseSubmission.test.ts | 70 +++++++++++++++++++
13 files changed, 134 insertions(+), 18 deletions(-)
diff --git a/src/components/DatePicker/types.ts b/src/components/DatePicker/types.ts
index 33e11c1f7ccd..ac767dae7208 100644
--- a/src/components/DatePicker/types.ts
+++ b/src/components/DatePicker/types.ts
@@ -71,8 +71,8 @@ type DateInputWithPickerProps = DatePickerBaseProps &
shouldDismissKeyboardBeforeShow?: boolean;
/**
- * Reports whether the calendar is open. Opening the picker blurs the input, so this — not `onFocus` — is the
- * signal for "the user is on this field", and it is what drives the input's focused border.
+ * Reports whether the calendar is open. Opening the picker blurs the input, so this is the signal for "the
+ * user is on this field" rather than `onFocus`, and it is what drives the input's focused border.
*/
onPickerVisibilityChange?: (isVisible: boolean) => void;
};
diff --git a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts
index a64a60be6b26..7af332bdeac5 100644
--- a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts
+++ b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts
@@ -175,8 +175,8 @@ function useConfirmationValidation({
}: UseConfirmationValidationParams): {validate: (paymentType?: PaymentMethodType) => ValidationResult | null} {
const {getCurrencyDecimals} = useCurrencyListActions();
const selectedParticipantsCount = selectedParticipants.length;
- // The Scan confirmation reveals the amount / merchant / date fields behind "Show more". Each is optional there —
- // a field left blank is still read off the receipt — but one the user does fill in is subject to the same
+ // The Scan confirmation reveals the amount / merchant / date fields behind "Show more". Each is optional there:
+ // a field left blank is still read off the receipt, but one the user does fill in is subject to the same
// validation as a manually entered one.
const shouldValidateEnteredAmount = transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL || canEnterScanFieldsManually;
const validate = (paymentType?: PaymentMethodType): ValidationResult | null => {
@@ -195,7 +195,7 @@ function useConfirmationValidation({
if (!isScanRequestUtil(transaction) && !isTimeRequest && !isDistanceRequest && iouAmount === 0 && isP2P) {
return {errorKey: 'common.error.invalidAmount'};
}
- // `isConfirmationAmountMissing` only applies to manually entered amounts — per diem, distance and time set the
+ // `isConfirmationAmountMissing` only applies to manually entered amounts. Per diem, distance and time set the
// amount programmatically, and a scan reads it off the receipt whenever the user leaves the field blank.
if (isConfirmationAmountMissing(transaction)) {
return {errorKey: 'common.error.fieldRequired'};
diff --git a/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx b/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx
index d68534692955..0e3e74bfc731 100644
--- a/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx
+++ b/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx
@@ -114,7 +114,7 @@ function AmountField({
const transactionAmount = shouldShowEmptyAmount ? '' : convertToFrontendAmountAsString(amount, decimals);
// While the Scan confirmation is still waiting on SmartScan for this field, it says so instead of sitting empty.
// Focusing the field is the user taking it over, so the hint goes as soon as that happens rather than waiting for
- // the first keystroke — it would otherwise sit next to the caret promising to fill in what is being typed.
+ // the first keystroke. It would otherwise sit next to the caret promising to fill in what is being typed.
const shouldShowAutomaticHint = canEnterScanFieldsManually && !isAmountInputFocused && !transactionSlice?.isAmountSet;
const allowNegative = shouldEnableNegative(report, policy, iouType, transactionSlice?.participants);
diff --git a/src/components/MoneyRequestConfirmationList/sections/AutomaticFieldHint.tsx b/src/components/MoneyRequestConfirmationList/sections/AutomaticFieldHint.tsx
index 9b1fe9f29ad9..dddbe0df3ea4 100644
--- a/src/components/MoneyRequestConfirmationList/sections/AutomaticFieldHint.tsx
+++ b/src/components/MoneyRequestConfirmationList/sections/AutomaticFieldHint.tsx
@@ -1,7 +1,7 @@
/**
* The "Automatic" hint the Scan confirmation shows inside the amount, merchant and date fields while SmartScan is
* still the one filling them in. It mirrors the right label the category field carries for the same promise, and
- * each field drops it as soon as the user takes the field over — on focus, or once it has a value of its own.
+ * each field drops it as soon as the user takes the field over, on focus or once it has a value of its own.
*/
import Icon from '@components/Icon';
import Text from '@components/Text';
diff --git a/src/components/MoneyRequestConfirmationList/sections/DateField.tsx b/src/components/MoneyRequestConfirmationList/sections/DateField.tsx
index fce2e41ed886..2556d4395d77 100644
--- a/src/components/MoneyRequestConfirmationList/sections/DateField.tsx
+++ b/src/components/MoneyRequestConfirmationList/sections/DateField.tsx
@@ -67,12 +67,12 @@ function DateField({shouldDisplayFieldError, didConfirm, isReadOnly, formError,
const transactionHasReceipt = dateState?.hasReceipt ?? false;
// A draft is seeded with today's date, but in the Scan flow the date belongs to the receipt, not to today, so the
- // picker stays empty until the user picks one — the same way the amount field starts empty.
+ // picker stays empty until the user picks one, the same way the amount field starts empty.
const shouldShowEmptyDate = canEnterScanFieldsManually && !dateState?.isCreatedSet;
- // Opening the calendar blurs the input, so the open picker — not focus — is this field's "the user is on it"
- // signal, and it is what draws the focused border. The hint follows it so it can't sit next to an open calendar
- // promising to fill in the date the user is picking. It stays tied to the empty value beyond that.
+ // Opening the calendar blurs the input, so the open picker is this field's "the user is on it" signal rather
+ // than focus, and it is what draws the focused border. The hint follows it so it can't sit next to an open
+ // calendar promising to fill in the date the user is picking. It stays tied to the empty value beyond that.
const [isDatePickerOpen, setIsDatePickerOpen] = useState(false);
const shouldShowAutomaticHint = shouldShowEmptyDate && !isDatePickerOpen;
@@ -86,7 +86,7 @@ function DateField({shouldDisplayFieldError, didConfirm, isReadOnly, formError,
}
// While the picker renders empty the persisted date is only a default, so a pick that matches it still has to
- // be written — that write is what marks the date as chosen by the user.
+ // be written. That write is what marks the date as chosen by the user.
if (newDate === iouCreated && !shouldShowEmptyDate) {
return;
}
@@ -137,7 +137,7 @@ function DateField({shouldDisplayFieldError, didConfirm, isReadOnly, formError,
errorText={inlineDateErrorText || dateErrorText}
shouldDeferShowUntilPositioned
// The hint only renders while the date is empty, and `TextInput` drops its right-hand-side
- // component whenever the clear button can appear — which it can't without a value to clear.
+ // component whenever the clear button can appear, which it can't without a value to clear.
shouldHideClearButton={shouldShowEmptyDate}
rightHandSideComponent={shouldShowAutomaticHint ? : undefined}
onPickerVisibilityChange={setIsDatePickerOpen}
diff --git a/src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx b/src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx
index 21cb818526d2..77349a75f7bf 100644
--- a/src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx
+++ b/src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx
@@ -54,7 +54,7 @@ function MerchantField({isMerchantRequired, shouldDisplayFieldError, formError}:
// While the Scan confirmation is still waiting on SmartScan for this field, it says so instead of sitting empty.
// Focusing the field is the user taking it over, so the hint goes as soon as that happens rather than waiting for
- // the first keystroke — it would otherwise sit next to the caret promising to fill in what is being typed.
+ // the first keystroke. It would otherwise sit next to the caret promising to fill in what is being typed.
const shouldShowAutomaticHint = canEnterScanFieldsManually && !isMerchantInputFocused && !displayMerchantValue;
// Sync the mirror during render (not in an effect) to avoid an extra render pass. Reset on transaction change
diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts
index 989706442e3f..96d8cd40b21c 100644
--- a/src/libs/TransactionUtils/index.ts
+++ b/src/libs/TransactionUtils/index.ts
@@ -285,8 +285,8 @@ function isScanRequest(transaction: OnyxEntry): boolean {
return isScanRequest(transaction) && !!transaction?.isAmountSet && !!transaction?.isMerchantSet && !!transaction?.isCreatedSet;
diff --git a/src/libs/actions/IOU/MoneyRequestBuilder.ts b/src/libs/actions/IOU/MoneyRequestBuilder.ts
index a77f4cc65eff..48c1601007d2 100644
--- a/src/libs/actions/IOU/MoneyRequestBuilder.ts
+++ b/src/libs/actions/IOU/MoneyRequestBuilder.ts
@@ -130,6 +130,11 @@ type RequestMoneyTransactionParams = Omit & {
linkedTrackedExpenseReportAction?: OnyxTypes.ReportAction;
linkedTrackedExpenseReportID?: string;
receipt?: Receipt;
+ /**
+ * Overrides the state carried on `receipt` when the caller derives it at submit time. The Scan confirmation does,
+ * because a receipt validated before the user finished typing carries a state that is a field behind.
+ */
+ receiptState?: ValueOf;
waypoints?: WaypointCollection;
comment?: string;
originalTransactionID?: string;
diff --git a/src/libs/actions/IOU/TrackExpense.ts b/src/libs/actions/IOU/TrackExpense.ts
index f7ae3d5fb8dd..d5986f010baf 100644
--- a/src/libs/actions/IOU/TrackExpense.ts
+++ b/src/libs/actions/IOU/TrackExpense.ts
@@ -1702,6 +1702,7 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep
merchant,
comment = '',
receipt,
+ receiptState,
category,
tag,
taxCode = '',
@@ -1918,7 +1919,7 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep
createdIOUReportActionID,
reportPreviewReportActionID: reportPreviewAction.reportActionID,
receipt: isFileUploadable(receipt) ? receipt : undefined,
- receiptState: receipt?.state,
+ receiptState: receiptState ?? receipt?.state,
category,
tag,
taxCode,
@@ -2484,6 +2485,7 @@ function trackExpense(params: CreateTrackExpenseParams) {
distance,
modifiedDistance,
receipt,
+ receiptState,
category,
tag,
taxCode = '',
@@ -2512,6 +2514,8 @@ function trackExpense(params: CreateTrackExpenseParams) {
// Pass an open receipt so the distance expense will show a map with the route optimistically
const trackedReceipt = validWaypoints ? {source: ReceiptGeneric as ReceiptSource, state: CONST.IOU.RECEIPT_STATE.OPEN, name: 'receipt-generic.png'} : receipt;
+ // The generic distance receipt above carries its own state, so only a real receipt takes the caller's override.
+ const trackedReceiptState = validWaypoints ? undefined : receiptState;
const sanitizedWaypoints = validWaypoints ? stringifyWaypointsForAPI(validWaypoints) : undefined;
const retryParams: CreateTrackExpenseParams = {
@@ -2875,7 +2879,7 @@ function trackExpense(params: CreateTrackExpenseParams) {
// Tracked expenses in the CREATE flow are unreported and not tied to a policy
policyID: undefined,
receipt: isFileUploadable(trackedReceipt) ? trackedReceipt : undefined,
- receiptState: trackedReceipt?.state,
+ receiptState: trackedReceiptState ?? trackedReceipt?.state,
reimbursable,
category,
tag,
diff --git a/src/libs/actions/IOU/types/TrackExpenseTransactionParams.ts b/src/libs/actions/IOU/types/TrackExpenseTransactionParams.ts
index 8ab23f2a1b6f..51b9791d298c 100644
--- a/src/libs/actions/IOU/types/TrackExpenseTransactionParams.ts
+++ b/src/libs/actions/IOU/types/TrackExpenseTransactionParams.ts
@@ -1,7 +1,10 @@
+import type CONST from '@src/CONST';
import type * as OnyxTypes from '@src/types/onyx';
import type {Attendee} from '@src/types/onyx/IOU';
import type {Receipt, WaypointCollection} from '@src/types/onyx/Transaction';
+import type {ValueOf} from 'type-fest';
+
type GPSPoint = {
lat: number;
long: number;
@@ -16,6 +19,11 @@ type TrackExpenseTransactionParams = {
distance?: number;
modifiedDistance?: number;
receipt?: Receipt;
+ /**
+ * Overrides the state carried on `receipt` when the caller derives it at submit time. The Scan confirmation does,
+ * because a receipt validated before the user finished typing carries a state that is a field behind.
+ */
+ receiptState?: ValueOf;
category?: string;
tag?: string;
taxCode?: string;
diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx
index ba3cce2db3a4..03decdda7d85 100644
--- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx
+++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx
@@ -614,6 +614,7 @@ function IOURequestStepConfirmationContent({
transaction,
transactions,
receiptFiles,
+ canEnterScanFieldsManually,
report,
reportID,
policy,
diff --git a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts
index a41da639c66d..137b286e694f 100644
--- a/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts
+++ b/src/pages/iou/request/step/confirmation/useExpenseSubmission.ts
@@ -53,10 +53,12 @@ import {
getSelectedRouteDistance,
getTaxValue,
getValidWaypoints,
+ hasAllManuallyEnteredScanFields,
hasAppliedCommuterExclusion,
isDistanceRequest as isDistanceRequestTransactionUtils,
isGPSDistanceRequest as isGPSDistanceRequestTransactionUtils,
isManualDistanceRequest as isManualDistanceRequestTransactionUtils,
+ isScanRequest as isScanRequestTransactionUtils,
} from '@libs/TransactionUtils';
import {resolveChatTargetForSubmitCleanup} from '@pages/iou/request/step/resolveChatTarget';
@@ -80,6 +82,7 @@ import type DeepValueOf from '@src/types/utils/DeepValueOf';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import type {OnyxEntry} from 'react-native-onyx';
+import type {ValueOf} from 'type-fest';
import {delegateEmailSelector} from '@selectors/Account';
import {hasSeenTourSelector} from '@selectors/Onboarding';
@@ -115,6 +118,12 @@ type UseExpenseSubmissionParams = {
transactions: Transaction[];
receiptFiles: Record;
+ /**
+ * Whether the Scan confirmation lets the user fill in the amount / merchant / date themselves. When it does, the
+ * receipt state has to be re-derived at submit time, see `getReceiptWithCurrentState`.
+ */
+ canEnterScanFieldsManually: boolean;
+
// Report data
report: OnyxEntry;
reportID: string;
@@ -182,6 +191,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) {
transaction,
transactions,
receiptFiles,
+ canEnterScanFieldsManually,
report,
reportID,
policy,
@@ -399,6 +409,22 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) {
});
}
+ /**
+ * The receipt state sent to the backend decides whether SmartScan reads the receipt, and on a Scan the user filled
+ * in themselves it depends on the amount / merchant / date. `receiptFiles` bakes that state in during an async
+ * file validation pass, so it lags the field the user just typed. Deriving it from the live transaction at submit
+ * time instead keeps a submit that lands mid-validation from scanning over values the user entered, or from
+ * skipping the scan on a field they just cleared. Returning `undefined` leaves the validated receipt's own state
+ * in place, which is what every other flow submits.
+ */
+ function getCurrentReceiptState(item: Transaction): ValueOf | undefined {
+ const receipt = receiptFiles[item.transactionID];
+ if (!receipt || !canEnterScanFieldsManually || receipt.isTestReceipt || receipt.isTestDriveReceipt || !isScanRequestTransactionUtils(item)) {
+ return undefined;
+ }
+ return hasAllManuallyEnteredScanFields(item) ? CONST.IOU.RECEIPT_STATE.OPEN : CONST.IOU.RECEIPT_STATE.SCAN_READY;
+ }
+
/**
* Emits the `[Receipt] submitted` log for one expense as it leaves the confirmation page.
*/
@@ -537,6 +563,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) {
merchant: merchantToUse,
comment: item?.comment?.comment?.trim() ?? '',
receipt,
+ receiptState: getCurrentReceiptState(item),
category: item.category,
tag: item.tag,
taxCode: transactionTaxCode,
@@ -820,6 +847,7 @@ function useExpenseSubmission(params: UseExpenseSubmissionParams) {
merchant: item.merchant,
comment: item?.comment?.comment?.trim() ?? '',
receipt: trackReceipt,
+ receiptState: getCurrentReceiptState(item),
category: item.category,
tag: item.tag,
taxCode: transactionTaxCode,
diff --git a/tests/unit/hooks/useExpenseSubmission.test.ts b/tests/unit/hooks/useExpenseSubmission.test.ts
index b6ecf24e6711..9b6b235e06f9 100644
--- a/tests/unit/hooks/useExpenseSubmission.test.ts
+++ b/tests/unit/hooks/useExpenseSubmission.test.ts
@@ -11,6 +11,7 @@ import useExpenseSubmission from '@pages/iou/request/step/confirmation/useExpens
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Policy, PolicyCategories, Report, ReportAction, Transaction} from '@src/types/onyx';
+import type {Receipt} from '@src/types/onyx/Transaction';
import Onyx from 'react-native-onyx';
@@ -203,6 +204,7 @@ function buildParams(overrides: Partial[
transaction,
transactions: [transaction],
receiptFiles: {},
+ canEnterScanFieldsManually: false,
report: {reportID: REPORT_ID, type: CONST.REPORT.TYPE.CHAT} as Report,
reportID: REPORT_ID,
policy: createMock({id: 'policy-1'}),
@@ -283,6 +285,74 @@ describe('useExpenseSubmission orchestrator-suppressed cleanup', () => {
}
});
+ describe('receipt state on a Scan the user filled in', () => {
+ function buildScanParams(transactionOverrides: Partial, cachedReceiptState: Receipt['state']) {
+ const transaction = buildTransaction({iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, ...transactionOverrides});
+ return buildParams({
+ transaction,
+ transactions: [transaction],
+ canEnterScanFieldsManually: true,
+ receiptFiles: {[TRANSACTION_ID]: createMock({state: cachedReceiptState})},
+ });
+ }
+
+ async function submit(params: Parameters[0]) {
+ const {result} = renderHook(() => useExpenseSubmission(params));
+ await waitForBatchedUpdatesWithAct();
+ await act(async () => {
+ result.current.createTransaction(false, false);
+ });
+ await waitForBatchedUpdatesWithAct();
+ }
+
+ it('submits `open` when all three fields are entered, even while the cached receipt still says SCAN_READY', async () => {
+ // Given a scan the user filled in whose receipt was validated before the last field was entered
+ await submit(buildScanParams({isAmountSet: true, isMerchantSet: true, isCreatedSet: true}, CONST.IOU.RECEIPT_STATE.SCAN_READY));
+
+ // Then SmartScan is told to leave the receipt alone rather than overwriting what the user typed
+ expect(mockRequestMoneyAction).toHaveBeenCalledWith(
+ expect.objectContaining({
+ transactionParams: expect.objectContaining({receiptState: CONST.IOU.RECEIPT_STATE.OPEN}),
+ }),
+ );
+ });
+
+ it('submits `scanready` once a field is cleared again, even while the cached receipt still says OPEN', async () => {
+ // Given a scan whose merchant the user cleared after having filled all three fields
+ await submit(buildScanParams({isAmountSet: true, isMerchantSet: false, isCreatedSet: true}, CONST.IOU.RECEIPT_STATE.OPEN));
+
+ // Then SmartScan is asked to read the receipt so the cleared field still gets filled in
+ expect(mockRequestMoneyAction).toHaveBeenCalledWith(
+ expect.objectContaining({
+ transactionParams: expect.objectContaining({receiptState: CONST.IOU.RECEIPT_STATE.SCAN_READY}),
+ }),
+ );
+ });
+
+ it('sends no override on surfaces that do not expose the scan fields, leaving the validated receipt state alone', async () => {
+ // Given a manual expense with an attached receipt, which the validator already marked `open`
+ const transaction = buildTransaction({iouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL});
+ await submit(
+ buildParams({
+ transaction,
+ transactions: [transaction],
+ canEnterScanFieldsManually: false,
+ receiptFiles: {[TRANSACTION_ID]: createMock({state: CONST.IOU.RECEIPT_STATE.OPEN})},
+ }),
+ );
+
+ // Then no override is sent and the action keeps using the state the validator wrote onto the receipt
+ expect(mockRequestMoneyAction).toHaveBeenCalledWith(
+ expect.objectContaining({
+ transactionParams: expect.objectContaining({
+ receiptState: undefined,
+ receipt: expect.objectContaining({state: CONST.IOU.RECEIPT_STATE.OPEN}),
+ }),
+ }),
+ );
+ });
+ });
+
it('calls cleanupAfterExpenseCreate and skips cleanupAndNavigateAfterExpenseCreate when shouldHandleNavigation=false (orchestrator pre-navigated)', async () => {
const {result} = renderHook(() => useExpenseSubmission(buildParams()));
await waitForBatchedUpdatesWithAct();
From d4866409d60d32f441ada6d4e6477d2128a0fa4d Mon Sep 17 00:00:00 2001
From: thelullabyy <182625428+thelullabyy@users.noreply.github.com>
Date: Thu, 10 Sep 2026 15:29:38 +0800
Subject: [PATCH 6/9] fix: scan flow
---
src/components/DatePicker/index.tsx | 3 +-
src/components/DatePicker/types.ts | 7 ++
.../MoneyRequestConfirmationList.tsx | 1 +
.../hooks/useConfirmationValidation.ts | 11 +++-
.../hooks/useFormErrorManagement.ts | 15 +++--
.../sections/AmountField.tsx | 15 +++--
.../sections/DateField.tsx | 14 +++-
.../sections/MerchantField.tsx | 11 +++-
.../sections/selectors.ts | 33 +++++++++-
src/libs/MoneyRequestUtils.ts | 25 +++++++-
src/libs/TransactionUtils/index.ts | 48 ++++++++++++--
src/libs/actions/IOU/MoneyRequestBuilder.ts | 2 +
src/libs/actions/IOU/TrackExpense.ts | 7 ++
.../IOURequestStepConfirmationPageTest.tsx | 64 ++++++++++++++++---
tests/unit/TransactionUtilsTest.ts | 50 +++++++++++++++
.../hooks/useConfirmationValidation.test.ts | 17 ++++-
.../hooks/useFormErrorManagement.test.tsx | 1 +
17 files changed, 286 insertions(+), 38 deletions(-)
diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx
index bf62b004fef2..3cede0b289ea 100644
--- a/src/components/DatePicker/index.tsx
+++ b/src/components/DatePicker/index.tsx
@@ -50,6 +50,7 @@ function DatePicker({
shouldDismissKeyboardBeforeShow = false,
rightHandSideComponent,
onPickerVisibilityChange,
+ shouldHideCalendarIcon = false,
}: DateInputWithPickerProps) {
const icons = useMemoizedLazyExpensifyIcons(['Calendar']);
const styles = useThemeStyles();
@@ -232,7 +233,7 @@ function DatePicker({
ref={combinedTextInputRef}
inputID={inputID}
forceActiveLabel
- icon={selectedDate ? null : icons.Calendar}
+ icon={selectedDate || shouldHideCalendarIcon ? null : icons.Calendar}
iconContainerStyle={styles.pr0}
label={label}
accessibilityLabel={label}
diff --git a/src/components/DatePicker/types.ts b/src/components/DatePicker/types.ts
index ac767dae7208..8f54da70932f 100644
--- a/src/components/DatePicker/types.ts
+++ b/src/components/DatePicker/types.ts
@@ -75,6 +75,13 @@ type DateInputWithPickerProps = DatePickerBaseProps &
* user is on this field" rather than `onFocus`, and it is what drives the input's focused border.
*/
onPickerVisibilityChange?: (isVisible: boolean) => void;
+
+ /**
+ * Hides the trailing calendar icon the empty input shows by default. Use it when the caller renders its own
+ * `rightHandSideComponent` in that space and the two would otherwise sit side by side.
+ * @default false
+ */
+ shouldHideCalendarIcon?: boolean;
};
type DatePickerProps = {
diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx
index e0961f024fc2..8ad7ce1f1c80 100644
--- a/src/components/MoneyRequestConfirmationList.tsx
+++ b/src/components/MoneyRequestConfirmationList.tsx
@@ -347,6 +347,7 @@ function MoneyRequestConfirmationList({
isEditingSplitBill,
isPolicyExpenseChat,
isScanRequest,
+ canEnterScanFieldsManually,
shouldShowMerchant,
hasSmartScanFailed,
didConfirmSplit,
diff --git a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts
index 7af332bdeac5..0848587623d0 100644
--- a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts
+++ b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts
@@ -16,6 +16,7 @@ import {
getTaxAmount,
hasTaxRateWithMatchingValue,
isMerchantMissing,
+ isPartiallyEnteredScanExpense,
isScanRequest as isScanRequestUtil,
} from '@libs/TransactionUtils';
import {isValidInputLength} from '@libs/ValidationUtils';
@@ -197,7 +198,13 @@ function useConfirmationValidation({
}
// `isConfirmationAmountMissing` only applies to manually entered amounts. Per diem, distance and time set the
// amount programmatically, and a scan reads it off the receipt whenever the user leaves the field blank.
- if (isConfirmationAmountMissing(transaction)) {
+ if (isConfirmationAmountMissing(transaction, canEnterScanFieldsManually)) {
+ return {errorKey: 'common.error.fieldRequired'};
+ }
+ // The amount / merchant / date the Scan confirmation reveals are all-or-nothing. Leaving all three blank hands
+ // the expense to SmartScan and filling all three in submits it as a manual expense, but a half-filled set is
+ // neither, so it is blocked here and each blank field raises the same error inline.
+ if (isPartiallyEnteredScanExpense(transaction, canEnterScanFieldsManually)) {
return {errorKey: 'common.error.fieldRequired'};
}
if (
@@ -212,7 +219,7 @@ function useConfirmationValidation({
}
// The date is an inline, clearable required field for every type that shows it (manual, distance, time,
// invoice, ...). Block confirmation when the user cleared it.
- if (isConfirmationDateMissing(transaction, shouldShowDate, isReadOnly)) {
+ if (isConfirmationDateMissing(transaction, shouldShowDate, isReadOnly, canEnterScanFieldsManually)) {
return {errorKey: 'common.error.fieldRequired'};
}
const merchantValue = iouMerchant ?? '';
diff --git a/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts b/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts
index 752f6eeb3c25..71949e84f827 100644
--- a/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts
+++ b/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts
@@ -1,7 +1,7 @@
import useDebouncedState from '@hooks/useDebouncedState';
import useLocalize from '@hooks/useLocalize';
-import {isConfirmationAmountMissing, isConfirmationDateMissing} from '@libs/MoneyRequestUtils';
+import {isConfirmationAmountMissing, isConfirmationDateMissing, isConfirmationMerchantMissing} from '@libs/MoneyRequestUtils';
import {isAttendeeTrackingEnabled} from '@libs/PolicyUtils';
import {areRequiredFieldsEmpty, getTag, hasMissingSmartscanFields, isMerchantMissing} from '@libs/TransactionUtils';
import {isInvalidMerchantValue, isUntypedPlaceholderMerchant, isValidInputLength} from '@libs/ValidationUtils';
@@ -54,6 +54,9 @@ type UseFormErrorManagementParams = {
/** Whether the IOU was started from a SmartScan flow */
isScanRequest: boolean;
+ /** Whether the Scan confirmation lets the user fill in the amount / merchant / date themselves */
+ canEnterScanFieldsManually: boolean;
+
/** Whether the merchant field should be visible in the UI */
shouldShowMerchant: boolean;
@@ -138,6 +141,7 @@ function useFormErrorManagement({
isEditingSplitBill,
isPolicyExpenseChat,
isScanRequest,
+ canEnterScanFieldsManually,
shouldShowMerchant,
hasSmartScanFailed,
didConfirmSplit,
@@ -215,14 +219,15 @@ function useFormErrorManagement({
// These reuse the very predicates `useConfirmationValidation` raises `common.error.fieldRequired` from, so the
// clear side can never drift from the validation side and strand a required error that can no longer be cleared (#96568).
- const isAmountRequiredMissing = isConfirmationAmountMissing(transaction);
- const isDateRequiredMissing = isConfirmationDateMissing(transaction, shouldShowDate, isReadOnly);
+ const isAmountRequiredMissing = isConfirmationAmountMissing(transaction, canEnterScanFieldsManually);
+ const isDateRequiredMissing = isConfirmationDateMissing(transaction, shouldShowDate, isReadOnly, canEnterScanFieldsManually);
+ const isMerchantRequiredMissing = isConfirmationMerchantMissing(transaction, canEnterScanFieldsManually);
useEffect(() => {
- if (formErrorRef.current !== 'common.error.fieldRequired' || isAmountRequiredMissing || isDateRequiredMissing) {
+ if (formErrorRef.current !== 'common.error.fieldRequired' || isAmountRequiredMissing || isDateRequiredMissing || isMerchantRequiredMissing) {
return;
}
setFormError('');
- }, [isAmountRequiredMissing, isDateRequiredMissing, setFormError]);
+ }, [isAmountRequiredMissing, isDateRequiredMissing, isMerchantRequiredMissing, setFormError]);
useEffect(() => {
const currentFormError = formErrorRef.current;
diff --git a/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx b/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx
index 0e3e74bfc731..63ace08dda4a 100644
--- a/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx
+++ b/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx
@@ -16,7 +16,7 @@ import {calculateAmount, isMovingTransactionFromTrackExpense, isParticipantP2P}
import {isConfirmationAmountMissing} from '@libs/MoneyRequestUtils';
import Navigation from '@libs/Navigation/Navigation';
import {shouldEnableNegative} from '@libs/ReportUtils';
-import {calculateTaxAmount, getTaxCode, getTaxValue} from '@libs/TransactionUtils';
+import {calculateTaxAmount, getTaxCode, getTaxValue, hasAnyManuallyEnteredScanField} from '@libs/TransactionUtils';
import IOURequestStepCurrencyModal from '@pages/iou/request/step/IOURequestStepCurrencyModal';
@@ -93,7 +93,7 @@ function AmountField({
// amount itself is the missing value. `isConfirmationAmountMissing` is the same predicate validation raises the
// error from, so a scan expense (where the amount is read off the receipt whenever the user leaves the field
// blank) can't show a phantom required error under a field that is deliberately empty.
- const shouldShowAmountRequiredError = formError === 'common.error.fieldRequired' && isConfirmationAmountMissing(transactionSlice);
+ const shouldShowAmountRequiredError = formError === 'common.error.fieldRequired' && isConfirmationAmountMissing(transactionSlice, canEnterScanFieldsManually);
const shouldShowAmountInvalidError = formError === 'common.error.invalidAmount';
let amountFieldErrorText = '';
@@ -115,7 +115,12 @@ function AmountField({
// While the Scan confirmation is still waiting on SmartScan for this field, it says so instead of sitting empty.
// Focusing the field is the user taking it over, so the hint goes as soon as that happens rather than waiting for
// the first keystroke. It would otherwise sit next to the caret promising to fill in what is being typed.
- const shouldShowAutomaticHint = canEnterScanFieldsManually && !isAmountInputFocused && !transactionSlice?.isAmountSet;
+ // Entering any one of the three fields drops the hint from all of them, since that is the point where the expense
+ // stops being scanned and the other two become the user's to fill in as well.
+ const shouldShowAutomaticHint = canEnterScanFieldsManually && !isAmountInputFocused && !hasAnyManuallyEnteredScanField(transactionSlice);
+ // The hint and the flip / currency buttons share the right-hand side of the input, so the field shows one or the
+ // other. The buttons come back as soon as the amount is the user's to enter.
+ const shouldShowAmountButtons = !shouldShowAutomaticHint;
const allowNegative = shouldEnableNegative(report, policy, iouType, transactionSlice?.participants);
// `autoFocus` on our TextInput only runs on mount. Closing and reopening the RHP often keeps the same mounted
@@ -318,8 +323,8 @@ function AmountField({
errorText={amountFieldErrorText}
onInputChange={handleAmountChange}
allowNegativeInput={allowNegative}
- shouldShowFlipButton
- shouldShowCurrencyButton
+ shouldShowFlipButton={shouldShowAmountButtons}
+ shouldShowCurrencyButton={shouldShowAmountButtons}
shouldShowBigNumberPad={false}
onCurrencyButtonPress={showCurrencyPicker}
onFocus={() => {
diff --git a/src/components/MoneyRequestConfirmationList/sections/DateField.tsx b/src/components/MoneyRequestConfirmationList/sections/DateField.tsx
index 2556d4395d77..a7b023706b47 100644
--- a/src/components/MoneyRequestConfirmationList/sections/DateField.tsx
+++ b/src/components/MoneyRequestConfirmationList/sections/DateField.tsx
@@ -15,6 +15,7 @@ import {shouldUseTransactionDraft} from '@libs/IOUUtils';
import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute';
import Navigation from '@libs/Navigation/Navigation';
import {isPolicyExpenseChat as isPolicyExpenseChatReportUtil} from '@libs/ReportUtils';
+import {hasAnyManuallyEnteredScanField, isPartiallyEnteredScanExpense} from '@libs/TransactionUtils';
import {setDraftSplitTransaction} from '@userActions/IOU/Split';
@@ -72,13 +73,17 @@ function DateField({shouldDisplayFieldError, didConfirm, isReadOnly, formError,
// Opening the calendar blurs the input, so the open picker is this field's "the user is on it" signal rather
// than focus, and it is what draws the focused border. The hint follows it so it can't sit next to an open
- // calendar promising to fill in the date the user is picking. It stays tied to the empty value beyond that.
+ // calendar promising to fill in the date the user is picking. Entering any one of the three fields drops the hint
+ // from all of them, since that is the point where the expense stops being scanned.
const [isDatePickerOpen, setIsDatePickerOpen] = useState(false);
- const shouldShowAutomaticHint = shouldShowEmptyDate && !isDatePickerOpen;
+ const shouldShowAutomaticHint = shouldShowEmptyDate && !isDatePickerOpen && !hasAnyManuallyEnteredScanField(dateState);
const dateErrorText = shouldDisplayFieldError && createdMissing ? translate('common.error.enterDate') : '';
- const inlineDateErrorText = formError === 'common.error.fieldRequired' && createdMissing ? translate('common.error.fieldRequired') : '';
+ // On a half-filled Scan the date is required even though it is never blank in the draft, so the all-or-nothing
+ // predicate stands in for `createdMissing` there.
+ const isDateRequiredMissing = isPartiallyEnteredScanExpense(dateState, canEnterScanFieldsManually) ? !dateState?.isCreatedSet : createdMissing;
+ const inlineDateErrorText = formError === 'common.error.fieldRequired' && isDateRequiredMissing ? translate('common.error.fieldRequired') : '';
const handleDateChange = (newDate: string) => {
if (!transactionID) {
@@ -140,6 +145,9 @@ function DateField({shouldDisplayFieldError, didConfirm, isReadOnly, formError,
// component whenever the clear button can appear, which it can't without a value to clear.
shouldHideClearButton={shouldShowEmptyDate}
rightHandSideComponent={shouldShowAutomaticHint ? : undefined}
+ // The calendar icon and the hint share the right-hand side, so the field shows one or the other.
+ // The icon comes back once the user opens the picker, the same way the amount field's buttons do.
+ shouldHideCalendarIcon={shouldShowAutomaticHint}
onPickerVisibilityChange={setIsDatePickerOpen}
/>
diff --git a/src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx b/src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx
index 77349a75f7bf..ebcfaa46c1f3 100644
--- a/src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx
+++ b/src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx
@@ -8,8 +8,10 @@ import useOnyx from '@hooks/useOnyx';
import useThemeStyles from '@hooks/useThemeStyles';
import {clearMoneyRequestMerchant, setMoneyRequestMerchant} from '@libs/actions/IOU/MoneyRequest';
+import {isConfirmationMerchantMissing} from '@libs/MoneyRequestUtils';
import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute';
import Navigation from '@libs/Navigation/Navigation';
+import {hasAnyManuallyEnteredScanField} from '@libs/TransactionUtils';
import {isUntypedPlaceholderMerchant, isValidInputLength} from '@libs/ValidationUtils';
import {setDraftSplitTransaction} from '@userActions/IOU/Split';
@@ -55,7 +57,9 @@ function MerchantField({isMerchantRequired, shouldDisplayFieldError, formError}:
// While the Scan confirmation is still waiting on SmartScan for this field, it says so instead of sitting empty.
// Focusing the field is the user taking it over, so the hint goes as soon as that happens rather than waiting for
// the first keystroke. It would otherwise sit next to the caret promising to fill in what is being typed.
- const shouldShowAutomaticHint = canEnterScanFieldsManually && !isMerchantInputFocused && !displayMerchantValue;
+ // Entering any one of the three fields drops the hint from all of them, since that is the point where the expense
+ // stops being scanned and the other two become the user's to fill in as well.
+ const shouldShowAutomaticHint = canEnterScanFieldsManually && !isMerchantInputFocused && !hasAnyManuallyEnteredScanField(merchantState);
// Sync the mirror during render (not in an effect) to avoid an extra render pass. Reset on transaction change
// even while focused; otherwise sync external updates (SmartScan, drafts) only when the field isn't being edited.
@@ -83,8 +87,9 @@ function MerchantField({isMerchantRequired, shouldDisplayFieldError, formError}:
}
// `common.error.fieldRequired` is shared with the amount and date fields, so only surface it here when the
- // merchant is the required value that is still missing.
- if (formError === 'common.error.fieldRequired' && isMerchantRequired && !displayMerchantValue) {
+ // merchant is the required value that is still missing. On a half-filled Scan it is required even though the
+ // surface does not otherwise demand a merchant, because the three fields are all-or-nothing there.
+ if (formError === 'common.error.fieldRequired' && (isConfirmationMerchantMissing(merchantState, canEnterScanFieldsManually) || (isMerchantRequired && !displayMerchantValue))) {
return translate('common.error.fieldRequired');
}
diff --git a/src/components/MoneyRequestConfirmationList/sections/selectors.ts b/src/components/MoneyRequestConfirmationList/sections/selectors.ts
index 557eb76dbdc8..6afd59c09990 100644
--- a/src/components/MoneyRequestConfirmationList/sections/selectors.ts
+++ b/src/components/MoneyRequestConfirmationList/sections/selectors.ts
@@ -23,7 +23,16 @@ type Transaction = OnyxTypes.Transaction;
// --- DateField ---
-type DateState = {iouCreated: string; isMissing: boolean; hasReceipt: boolean; isCreatedSet: boolean};
+type DateState = {
+ iouCreated: string;
+ isMissing: boolean;
+ hasReceipt: boolean;
+ isCreatedSet: boolean;
+ // The Scan confirmation's amount / merchant / date are all-or-nothing, so the date field reads the other two.
+ iouRequestType: Transaction['iouRequestType'];
+ isAmountSet: boolean;
+ isMerchantSet: boolean;
+};
const dateStateSelector = (t: OnyxEntry): DateState | undefined => {
if (!t) {
@@ -34,6 +43,9 @@ const dateStateSelector = (t: OnyxEntry): DateState | undefined =>
isMissing: isCreatedMissing(t),
hasReceipt: hasReceipt(t),
isCreatedSet: t.isCreatedSet ?? false,
+ iouRequestType: t.iouRequestType,
+ isAmountSet: t.isAmountSet ?? false,
+ isMerchantSet: t.isMerchantSet ?? false,
};
};
@@ -110,7 +122,16 @@ const categoryStateSelector = (t: OnyxEntry): CategoryState | undef
// --- MerchantField ---
-type MerchantState = {merchant: string; isMerchantSet: boolean; isMissing: boolean; hasReceipt: boolean};
+type MerchantState = {
+ merchant: string;
+ isMerchantSet: boolean;
+ isMissing: boolean;
+ hasReceipt: boolean;
+ // The Scan confirmation's amount / merchant / date are all-or-nothing, so the merchant field reads the other two.
+ iouRequestType: Transaction['iouRequestType'];
+ isAmountSet: boolean;
+ isCreatedSet: boolean;
+};
const merchantStateSelector = (t: OnyxEntry): MerchantState | undefined => {
if (!t) {
@@ -121,6 +142,9 @@ const merchantStateSelector = (t: OnyxEntry): MerchantState | undef
isMerchantSet: t.isMerchantSet ?? false,
isMissing: isMerchantMissing(t),
hasReceipt: hasReceipt(t),
+ iouRequestType: t.iouRequestType,
+ isAmountSet: t.isAmountSet ?? false,
+ isCreatedSet: t.isCreatedSet ?? false,
};
};
@@ -151,6 +175,9 @@ type AmountSlice = {
isAmountMissing: boolean;
isAmountSet: Transaction['isAmountSet'];
taxCode: Transaction['taxCode'];
+ // The Scan confirmation's amount / merchant / date are all-or-nothing, so the amount field reads the other two.
+ isMerchantSet: boolean;
+ isCreatedSet: boolean;
};
const amountSliceSelector = (t: OnyxEntry): AmountSlice | undefined => {
@@ -168,6 +195,8 @@ const amountSliceSelector = (t: OnyxEntry): AmountSlice | undefined
isAmountMissing: isAmountMissing(t),
isAmountSet: t.isAmountSet,
taxCode: t.taxCode,
+ isMerchantSet: t.isMerchantSet ?? false,
+ isCreatedSet: t.isCreatedSet ?? false,
};
};
diff --git a/src/libs/MoneyRequestUtils.ts b/src/libs/MoneyRequestUtils.ts
index d01dae4a2960..1ded3e0adab7 100644
--- a/src/libs/MoneyRequestUtils.ts
+++ b/src/libs/MoneyRequestUtils.ts
@@ -5,10 +5,12 @@ import type {WaypointCollection} from '@src/types/onyx/Transaction';
import type {OnyxEntry} from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
+import type {ManuallyEnteredScanFields} from './TransactionUtils';
+
import {convertToBackendAmount, convertToFrontendAmountAsInteger} from './CurrencyUtils';
import replaceAllDigits from './replaceAllDigits';
import {isExpenseReport, isExpenseRequest, isPolicyExpenseChat} from './ReportUtils';
-import {doesMoneyRequestDraftHaveUserInput, haveWaypointAddressesChanged, isCreatedMissing, isExpenseUnreported} from './TransactionUtils';
+import {doesMoneyRequestDraftHaveUserInput, haveWaypointAddressesChanged, isCreatedMissing, isExpenseUnreported, isPartiallyEnteredScanExpense} from './TransactionUtils';
import {getMerchantError} from './ValidationUtils';
/**
@@ -248,20 +250,36 @@ function shouldShowConfirmationDate(shouldShowSmartScanFields: boolean, isDistan
* Whether the required amount is still missing on the money request confirmation surface.
* `isAmountSet` is only ever set by the manual flow (scan, per diem, distance and time populate the amount
* programmatically and never set it), so the manual gate is part of the predicate rather than of each call site.
+ * A Scan the user started filling in counts too: its three revealed fields are all-or-nothing, so once any of them
+ * carries a value the blank ones are missing rather than SmartScan's to read.
* This is the single source of truth shared by the validation that raises `common.error.fieldRequired`, the effect
* that clears it once the field is filled, and the amount field that renders it inline, so the three never drift.
*/
-function isConfirmationAmountMissing(transaction: OnyxEntry>): boolean {
+function isConfirmationAmountMissing(transaction: OnyxEntry, canEnterScanFieldsManually = false): boolean {
+ if (isPartiallyEnteredScanExpense(transaction, canEnterScanFieldsManually)) {
+ return !transaction?.isAmountSet;
+ }
return transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL && !transaction?.isAmountSet;
}
+/**
+ * Whether the merchant is still missing on a Scan the user started filling in. Shares the all-or-nothing rule the
+ * amount and date use, so the three blank fields of a half-filled scan all raise the same inline error.
+ */
+function isConfirmationMerchantMissing(transaction: OnyxEntry, canEnterScanFieldsManually = false): boolean {
+ return isPartiallyEnteredScanExpense(transaction, canEnterScanFieldsManually) && !transaction?.isMerchantSet;
+}
+
/**
* Whether the required date is still missing on the money request confirmation surface.
* Gating on the same `shouldShowConfirmationDate && !isReadOnly` condition that renders the inline date picker keeps
* validation, clearing and the UI in sync, and skips read-only/scan flows where the date is populated server-side.
* Shares the same drift-proofing purpose as `isConfirmationAmountMissing`.
*/
-function isConfirmationDateMissing(transaction: OnyxEntry, shouldShowDate: boolean, isReadOnly: boolean): boolean {
+function isConfirmationDateMissing(transaction: OnyxEntry, shouldShowDate: boolean, isReadOnly: boolean, canEnterScanFieldsManually = false): boolean {
+ if (isPartiallyEnteredScanExpense(transaction, canEnterScanFieldsManually)) {
+ return !transaction?.isCreatedSet;
+ }
return shouldShowDate && !isReadOnly && isCreatedMissing(transaction);
}
@@ -269,6 +287,7 @@ export {
addLeadingZero,
isConfirmationAmountMissing,
isConfirmationDateMissing,
+ isConfirmationMerchantMissing,
shouldShowConfirmationDate,
replaceAllDigits,
stripCommaFromAmount,
diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts
index 96d8cd40b21c..936f5515e45a 100644
--- a/src/libs/TransactionUtils/index.ts
+++ b/src/libs/TransactionUtils/index.ts
@@ -118,6 +118,12 @@ type TransactionParams = {
created?: string;
merchant?: string;
receipt?: OnyxEntry;
+
+ /**
+ * Overrides the state carried on `receipt` when the caller derives it at submit time. The Scan confirmation does,
+ * so the optimistic transaction does not read "Scanning..." for a receipt that is not being scanned.
+ */
+ receiptState?: ValueOf;
category?: string;
tag?: string;
taxCode?: string;
@@ -283,15 +289,37 @@ function isScanRequest(transaction: OnyxEntry;
+
/**
- * Whether the user filled in every one of the amount / merchant / date fields the Scan confirmation reveals behind
- * "Show more". Each of them is optional, since a field left blank is read off the receipt, so only once all three
- * carry a value of their own is the receipt submitted as `open`, where SmartScan never overwrites what the user typed.
+ * The amount / merchant / date fields the Scan confirmation reveals behind "Show more" are all-or-nothing: leaving
+ * all three blank hands the expense to SmartScan, and filling all three in submits it as a manual expense whose
+ * receipt is never scanned over. Only once all three carry a value of their own is the receipt submitted as `open`.
*/
-function hasAllManuallyEnteredScanFields(transaction: OnyxEntry): boolean {
+function hasAllManuallyEnteredScanFields(transaction: OnyxEntry): boolean {
return isScanRequest(transaction) && !!transaction?.isAmountSet && !!transaction?.isMerchantSet && !!transaction?.isCreatedSet;
}
+/**
+ * Whether the user filled in at least one of those three fields. Entering any one of them is what turns the expense
+ * from a scan into a manual one, so it is the point where the other two stop being SmartScan's to fill in and the
+ * "Automatic" label leaves all three.
+ */
+function hasAnyManuallyEnteredScanField(transaction: OnyxEntry): boolean {
+ return isScanRequest(transaction) && (!!transaction?.isAmountSet || !!transaction?.isMerchantSet || !!transaction?.isCreatedSet);
+}
+
+/**
+ * Whether the user started filling the three fields in but stopped short. That is neither a scan nor a complete
+ * manual expense, so confirmation is blocked until the remaining fields are entered (or all three are cleared again).
+ * `canEnterScanFieldsManually` says whether the surface offers those fields at all: splits, moved tracked expenses
+ * and test receipts carry the same flags without ever having shown them, so they must not be held to this rule.
+ */
+function isPartiallyEnteredScanExpense(transaction: OnyxEntry, canEnterScanFieldsManually = false): boolean {
+ return canEnterScanFieldsManually && hasAnyManuallyEnteredScanField(transaction) && !hasAllManuallyEnteredScanFields(transaction);
+}
+
function isPerDiemRequest(transaction: OnyxEntry): boolean {
if (transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.PER_DIEM) {
return true;
@@ -452,6 +480,7 @@ function buildOptimisticTransaction(params: BuildOptimisticTransactionParams): T
created = '',
merchant = '',
receipt,
+ receiptState,
// Prevent RBR flip and transaction jump: initialize category to 'Uncategorized' instead of
// empty string so optimistic missing category violation isn't added then removed during backend sync
category = CONST.SEARCH.CATEGORY_DEFAULT_VALUE,
@@ -570,7 +599,12 @@ function buildOptimisticTransaction(params: BuildOptimisticTransactionParams): T
created: created || DateUtils.getDBTime(),
pendingAction,
receipt: receipt?.source
- ? {source: receipt.source, filename: receipt?.name ?? filename, state: receipt.state ?? CONST.IOU.RECEIPT_STATE.SCAN_READY, isTestDriveReceipt: receipt.isTestDriveReceipt}
+ ? {
+ source: receipt.source,
+ filename: receipt?.name ?? filename,
+ state: receiptState ?? receipt.state ?? CONST.IOU.RECEIPT_STATE.SCAN_READY,
+ isTestDriveReceipt: receipt.isTestDriveReceipt,
+ }
: undefined,
hasEReceipt: existingTransaction?.hasEReceipt,
category,
@@ -3784,6 +3818,8 @@ export {
getTagForDisplay,
getTransactionViolations,
hasAllManuallyEnteredScanFields,
+ hasAnyManuallyEnteredScanField,
+ isPartiallyEnteredScanExpense,
hasReceipt,
hasUploadedReceipt,
hasEReceipt,
@@ -3920,3 +3956,5 @@ export {
getDistanceRequestType,
isUnreportedManagedCardTransaction,
};
+
+export type {ManuallyEnteredScanFields};
diff --git a/src/libs/actions/IOU/MoneyRequestBuilder.ts b/src/libs/actions/IOU/MoneyRequestBuilder.ts
index 48c1601007d2..e3e0b90b277a 100644
--- a/src/libs/actions/IOU/MoneyRequestBuilder.ts
+++ b/src/libs/actions/IOU/MoneyRequestBuilder.ts
@@ -1320,6 +1320,7 @@ function getMoneyRequestInformation(moneyRequestInformation: MoneyRequestInforma
created,
merchant,
receipt,
+ receiptState,
category,
tag,
taxCode,
@@ -1506,6 +1507,7 @@ function getMoneyRequestInformation(moneyRequestInformation: MoneyRequestInforma
created,
merchant,
receipt,
+ receiptState,
category,
tag,
taxCode,
diff --git a/src/libs/actions/IOU/TrackExpense.ts b/src/libs/actions/IOU/TrackExpense.ts
index d5986f010baf..7fca33b06c25 100644
--- a/src/libs/actions/IOU/TrackExpense.ts
+++ b/src/libs/actions/IOU/TrackExpense.ts
@@ -103,6 +103,7 @@ import type {Receipt, ReceiptSource} from '@src/types/onyx/Transaction';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import type {OnyxCollection, OnyxEntry, OnyxInputValue, OnyxUpdate} from 'react-native-onyx';
+import type {ValueOf} from 'type-fest';
import {fastMerge} from 'expensify-common';
import Onyx from 'react-native-onyx';
@@ -156,6 +157,9 @@ type GetTrackExpenseInformationTransactionParams = {
created: string;
merchant: string;
receipt: OnyxEntry;
+
+ /** Overrides the state carried on `receipt`, see `TrackExpenseTransactionParams.receiptState`. */
+ receiptState?: ValueOf;
category?: string;
tag?: string;
taxCode?: string;
@@ -900,6 +904,7 @@ function getTrackExpenseInformation(params: GetTrackExpenseInformationParams): T
distance,
merchant,
receipt,
+ receiptState,
category,
tag,
taxCode,
@@ -1121,6 +1126,7 @@ function getTrackExpenseInformation(params: GetTrackExpenseInformationParams): T
created,
merchant,
receipt,
+ receiptState,
category,
tag,
taxCode,
@@ -2606,6 +2612,7 @@ function trackExpense(params: CreateTrackExpenseParams) {
created,
merchant,
receipt: trackedReceipt,
+ receiptState: trackedReceiptState,
category,
tag,
taxCode,
diff --git a/tests/ui/components/IOURequestStepConfirmationPageTest.tsx b/tests/ui/components/IOURequestStepConfirmationPageTest.tsx
index 5a0bd0fa8959..c22737263e77 100644
--- a/tests/ui/components/IOURequestStepConfirmationPageTest.tsx
+++ b/tests/ui/components/IOURequestStepConfirmationPageTest.tsx
@@ -493,7 +493,7 @@ describe('IOURequestStepConfirmationPageTest', () => {
expect(screen.getByLabelText(translateLocal('common.date'))).toHaveDisplayValue('');
});
- it('submits a partially filled scan, leaving the blank fields to SmartScan', async () => {
+ it('blocks a partially filled scan and flags the field that is still blank', async () => {
await renderScanConfirmation();
fireEvent.changeText(screen.getByLabelText(translateLocal('common.merchant')), 'Starbucks');
@@ -503,12 +503,33 @@ describe('IOURequestStepConfirmationPageTest', () => {
fireEvent.press(screen.getByText(translateLocal('iou.createExpense')));
await waitForBatchedUpdatesWithAct();
- // The date was never picked, so it stays "Automatic" instead of blocking confirmation.
+ // Entering two of the three turns this into a manual expense, so the untouched date is now required.
+ expect(screen.getByText(translateLocal('common.error.fieldRequired'))).toBeOnTheScreen();
+ expect(TrackExpense.requestMoney).not.toHaveBeenCalled();
+ });
+
+ it('stops requiring the blank fields once the half-filled ones are cleared back to an untouched scan', async () => {
+ await renderScanConfirmation();
+
+ fireEvent.changeText(screen.getByLabelText(translateLocal('common.merchant')), 'Starbucks');
+ await waitForBatchedUpdatesWithAct();
+ fireEvent.press(screen.getByText(translateLocal('iou.createExpense')));
+ await waitForBatchedUpdatesWithAct();
+ // Both fields left blank are flagged, not just one.
+ expect(screen.getAllByText(translateLocal('common.error.fieldRequired'))).toHaveLength(2);
+
+ // Clearing the merchant hands all three fields back to SmartScan, so nothing is required any more and the
+ // error must not be left stranded on a field the user has no reason to fill in.
+ fireEvent.changeText(screen.getByLabelText(translateLocal('common.merchant')), '');
+ await waitForBatchedUpdatesWithAct();
+ expect(screen.queryByText(translateLocal('common.error.fieldRequired'))).not.toBeOnTheScreen();
+
+ fireEvent.press(screen.getByText(translateLocal('iou.createExpense')));
+ await waitForBatchedUpdatesWithAct();
expect(screen.queryByText(translateLocal('common.error.fieldRequired'))).not.toBeOnTheScreen();
- expect(TrackExpense.requestMoney).toHaveBeenCalledTimes(1);
});
- it('labels the amount, merchant and date fields "Automatic" until that field is entered', async () => {
+ it('drops the "Automatic" label from all three fields as soon as any one of them is entered', async () => {
await renderScanConfirmation();
// The category field carries the same label, so count the ones that leave rather than expecting none left.
@@ -518,13 +539,14 @@ describe('IOURequestStepConfirmationPageTest', () => {
fireEvent.changeText(screen.getByLabelText(translateLocal('common.merchant')), 'Starbucks');
await waitForBatchedUpdatesWithAct();
- // Only the merchant's label goes: the amount and the date are still the ones SmartScan reads.
- expect(screen.queryAllByText(translateLocal('common.automatic'))).toHaveLength(automaticLabelCount - 1);
+ // Entering one is the point where the expense stops being scanned, so none of the three is automatic now.
+ expect(screen.queryAllByText(translateLocal('common.automatic'))).toHaveLength(automaticLabelCount - 3);
- fireEvent.changeText(screen.getByLabelText(translateLocal('iou.amount')), '12.34');
+ // Clearing it hands all three back to SmartScan, so the labels come back.
+ fireEvent.changeText(screen.getByLabelText(translateLocal('common.merchant')), '');
await waitForBatchedUpdatesWithAct();
- expect(screen.queryAllByText(translateLocal('common.automatic'))).toHaveLength(automaticLabelCount - 2);
+ expect(screen.queryAllByText(translateLocal('common.automatic'))).toHaveLength(automaticLabelCount);
});
it('drops the "Automatic" label while a field is focused, and brings it back if the field is left empty', async () => {
@@ -547,6 +569,32 @@ describe('IOURequestStepConfirmationPageTest', () => {
expect(screen.queryAllByText(translateLocal('common.automatic'))).toHaveLength(automaticLabelCount - 1);
});
+ it('swaps the "Automatic" label for the currency button once the amount is the user\'s to enter', async () => {
+ await renderScanConfirmation();
+
+ const currencyButton = new RegExp(translateLocal('common.selectCurrency'));
+
+ // Blank and unfocused the amount belongs to SmartScan, so the row carries the label and none of the controls.
+ expect(screen.queryByLabelText(currencyButton)).not.toBeOnTheScreen();
+
+ fireEvent(screen.getByLabelText(translateLocal('iou.amount')), 'focus');
+ await waitForBatchedUpdatesWithAct();
+
+ expect(screen.getByLabelText(currencyButton)).toBeOnTheScreen();
+
+ // Blurring an amount the user never entered hands the field back, and the controls go with the label.
+ fireEvent(screen.getByLabelText(translateLocal('iou.amount')), 'blur');
+ await waitForBatchedUpdatesWithAct();
+
+ expect(screen.queryByLabelText(currencyButton)).not.toBeOnTheScreen();
+
+ // Entering another of the three fields also makes the amount the user's, so the controls stay put.
+ fireEvent.changeText(screen.getByLabelText(translateLocal('common.merchant')), 'Starbucks');
+ await waitForBatchedUpdatesWithAct();
+
+ expect(screen.getByLabelText(currencyButton)).toBeOnTheScreen();
+ });
+
it('hands a cleared date back to SmartScan instead of emptying it', async () => {
await renderScanConfirmation();
diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts
index b36e7a86b9ff..cb4737e1ea92 100644
--- a/tests/unit/TransactionUtilsTest.ts
+++ b/tests/unit/TransactionUtilsTest.ts
@@ -5270,6 +5270,56 @@ describe('hasAllManuallyEnteredScanFields', () => {
});
});
+describe('hasAnyManuallyEnteredScanField / isPartiallyEnteredScanExpense', () => {
+ function generateScanDraft(values: Partial = {}): Transaction {
+ return generateTransaction({iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, ...values});
+ }
+
+ it('reports nothing entered while all three fields are left to SmartScan', () => {
+ expect(TransactionUtils.hasAnyManuallyEnteredScanField(generateScanDraft())).toBe(false);
+ expect(TransactionUtils.isPartiallyEnteredScanExpense(generateScanDraft(), true)).toBe(false);
+ });
+
+ it.each([
+ ['amount', {isAmountSet: true}],
+ ['merchant', {isMerchantSet: true}],
+ ['date', {isCreatedSet: true}],
+ ])('treats the expense as half-filled once only the %s is entered', (_field, values) => {
+ expect(TransactionUtils.hasAnyManuallyEnteredScanField(generateScanDraft(values))).toBe(true);
+ expect(TransactionUtils.isPartiallyEnteredScanExpense(generateScanDraft(values), true)).toBe(true);
+ });
+
+ it('stops reporting a half-filled expense once all three are entered', () => {
+ const complete = generateScanDraft({isAmountSet: true, isMerchantSet: true, isCreatedSet: true});
+ expect(TransactionUtils.hasAnyManuallyEnteredScanField(complete)).toBe(true);
+ expect(TransactionUtils.isPartiallyEnteredScanExpense(complete, true)).toBe(false);
+ });
+
+ it('holds no surface to the rule unless it actually offers the three fields', () => {
+ // Splits, moved tracked expenses and test receipts carry the same flags without ever having shown them.
+ expect(TransactionUtils.isPartiallyEnteredScanExpense(generateScanDraft({isAmountSet: true}), false)).toBe(false);
+ });
+});
+
+describe('buildOptimisticTransaction receipt state', () => {
+ const receipt = {source: 'https://example.com/receipt.jpg', name: 'receipt.jpg', state: CONST.IOU.RECEIPT_STATE.SCAN_READY};
+
+ it('keeps the receipt state the caller validated when no override is given', () => {
+ const transaction = TransactionUtils.buildOptimisticTransaction({
+ transactionParams: {amount: 100, currency: 'USD', reportID: '1', comment: '', created: '2023-10-01', receipt},
+ });
+ expect(transaction.receipt?.state).toBe(CONST.IOU.RECEIPT_STATE.SCAN_READY);
+ });
+
+ it('prefers the override so a scan the user filled in never reads as "Scanning..."', () => {
+ const transaction = TransactionUtils.buildOptimisticTransaction({
+ transactionParams: {amount: 100, currency: 'USD', reportID: '1', comment: '', created: '2023-10-01', receipt, receiptState: CONST.IOU.RECEIPT_STATE.OPEN},
+ });
+ expect(transaction.receipt?.state).toBe(CONST.IOU.RECEIPT_STATE.OPEN);
+ expect(TransactionUtils.isReceiptBeingScanned(transaction)).toBe(false);
+ });
+});
+
describe('isTransactionSubmittable', () => {
it('returns true for a transaction that is on hold', () => {
const transaction = generateTransaction({comment: {hold: 'holdID'}});
diff --git a/tests/unit/hooks/useConfirmationValidation.test.ts b/tests/unit/hooks/useConfirmationValidation.test.ts
index 30b892841d48..343b8ca18c0a 100644
--- a/tests/unit/hooks/useConfirmationValidation.test.ts
+++ b/tests/unit/hooks/useConfirmationValidation.test.ts
@@ -930,8 +930,22 @@ describe('useConfirmationValidation', () => {
['merchant', {isMerchantSet: true, merchant: 'Starbucks'}, {iouMerchant: 'Starbucks', isMerchantEmpty: false}],
['amount', {isAmountSet: true, amount: 1000}, {iouAmount: 1000}],
['date', {isCreatedSet: true, created: '2025-01-15'}, {}],
- ])('leaves the other fields optional once the %s is entered, since a blank field is still scanned', (_field, transactionOverrides, overrides) => {
+ ])('requires the other two once the %s is entered, since the three are all-or-nothing', (_field, transactionOverrides, overrides) => {
const {result} = renderHook(() => useConfirmationValidation(createScanValidationParams(transactionOverrides, overrides)));
+ expect(result.current.validate()).toEqual({errorKey: 'common.error.fieldRequired'});
+ });
+
+ it.each([
+ ['merchant', {isAmountSet: true, amount: 1000, isCreatedSet: true, created: '2025-01-15'}, {iouAmount: 1000}],
+ ['amount', {isMerchantSet: true, merchant: 'Starbucks', isCreatedSet: true, created: '2025-01-15'}, {iouMerchant: 'Starbucks', isMerchantEmpty: false}],
+ ['date', {isAmountSet: true, amount: 1000, isMerchantSet: true, merchant: 'Starbucks'}, {iouAmount: 1000, iouMerchant: 'Starbucks', isMerchantEmpty: false}],
+ ])('still blocks confirmation while only the %s is left blank', (_field, transactionOverrides, overrides) => {
+ const {result} = renderHook(() => useConfirmationValidation(createScanValidationParams(transactionOverrides, overrides)));
+ expect(result.current.validate()).toEqual({errorKey: 'common.error.fieldRequired'});
+ });
+
+ it('passes again once the user clears the fields back to an untouched scan', () => {
+ const {result} = renderHook(() => useConfirmationValidation(createScanValidationParams({isAmountSet: false, isMerchantSet: false, isCreatedSet: false})));
expect(result.current.validate()).toEqual({errorKey: null});
});
@@ -960,6 +974,7 @@ describe('useConfirmationValidation', () => {
});
it('requires nothing on surfaces that do not expose the scan fields (splits, test receipts)', () => {
+ // Those surfaces never showed the three fields, so a flag set elsewhere must not hold them to the rule.
const {result} = renderHook(() => useConfirmationValidation(createScanValidationParams({isAmountSet: true, amount: 1000}, {canEnterScanFieldsManually: false, iouAmount: 1000})));
expect(result.current.validate()).toEqual({errorKey: null});
});
diff --git a/tests/unit/hooks/useFormErrorManagement.test.tsx b/tests/unit/hooks/useFormErrorManagement.test.tsx
index 6da7f23d320e..91fc3e78978b 100644
--- a/tests/unit/hooks/useFormErrorManagement.test.tsx
+++ b/tests/unit/hooks/useFormErrorManagement.test.tsx
@@ -39,6 +39,7 @@ const baseParams: Params = {
isEditingSplitBill: false,
isPolicyExpenseChat: false,
isScanRequest: false,
+ canEnterScanFieldsManually: false,
shouldShowMerchant: true,
hasSmartScanFailed: false,
didConfirmSplit: false,
From b5b770d9fccd2e885913851f806fb8866107cf00 Mon Sep 17 00:00:00 2001
From: thelullabyy <182625428+thelullabyy@users.noreply.github.com>
Date: Thu, 10 Sep 2026 16:11:54 +0800
Subject: [PATCH 7/9] fix: selector test
---
.../MoneyRequestConfirmationList/selectorsTest.ts | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/tests/unit/components/MoneyRequestConfirmationList/selectorsTest.ts b/tests/unit/components/MoneyRequestConfirmationList/selectorsTest.ts
index bf5c4ebb9488..0f9e5b7a46a5 100644
--- a/tests/unit/components/MoneyRequestConfirmationList/selectorsTest.ts
+++ b/tests/unit/components/MoneyRequestConfirmationList/selectorsTest.ts
@@ -29,9 +29,18 @@ describe('MoneyRequestConfirmationList selectors', () => {
isMerchantSet: true,
isMissing: false,
hasReceipt: false,
+ iouRequestType: undefined,
+ isAmountSet: false,
+ isCreatedSet: false,
});
});
+ it('carries the sibling scan fields, which the merchant field needs for the all-or-nothing rule', () => {
+ const transaction = createTransaction({iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, isAmountSet: true, isCreatedSet: true});
+
+ expect(merchantStateSelector(transaction)).toEqual(expect.objectContaining({iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, isAmountSet: true, isCreatedSet: true}));
+ });
+
it('prefers modifiedMerchant over merchant', () => {
const transaction = createTransaction({
merchant: 'Original Merchant',
From 888fcff2f5084a3155a6f65d428e8bfc3dbce145 Mon Sep 17 00:00:00 2001
From: thelullabyy <182625428+thelullabyy@users.noreply.github.com>
Date: Fri, 11 Sep 2026 01:28:31 +0800
Subject: [PATCH 8/9] fix: bugs
---
.../MoneyRequestConfirmationList.tsx | 42 +++++++++++++++++-
.../hooks/useConfirmationValidation.ts | 12 +++++
.../hooks/useFormErrorManagement.ts | 14 +++++-
src/components/NumberWithSymbolForm.tsx | 4 ++
.../step/IOURequestStepConfirmation.tsx | 8 ++++
.../hooks/useConfirmationValidation.test.ts | 25 +++++++++++
.../hooks/useFormErrorManagement.test.tsx | 44 +++++++++++++++++++
7 files changed, 146 insertions(+), 3 deletions(-)
diff --git a/src/components/MoneyRequestConfirmationList.tsx b/src/components/MoneyRequestConfirmationList.tsx
index 8ad7ce1f1c80..2b6e0085a0b1 100644
--- a/src/components/MoneyRequestConfirmationList.tsx
+++ b/src/components/MoneyRequestConfirmationList.tsx
@@ -31,6 +31,7 @@ import {
import type {IOUAction, IOUType} from '@src/CONST';
import CONST from '@src/CONST';
+import type {TranslationPaths} from '@src/languages/types';
import type * as OnyxTypes from '@src/types/onyx';
import type {Participant} from '@src/types/onyx/IOU';
import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage';
@@ -135,6 +136,15 @@ type MoneyRequestConfirmationListProps = {
*/
canEnterScanFieldsManually?: boolean;
+ /**
+ * ID of a transaction whose Scan fields are half-filled, when there is one. Multi-scan confirms every receipt at
+ * once, so this can be a receipt other than the one on screen, and confirmation is blocked until it is completed.
+ */
+ halfFilledScanID?: string;
+
+ /** Brings another of the confirmed transactions on screen, so its inline errors are the ones the user sees */
+ onSwitchToTransaction?: (transactionID: string) => void;
+
/** A flag for verifying that the current report is a sub-report of a expense chat */
isPolicyExpenseChat?: boolean;
@@ -162,6 +172,12 @@ type MoneyRequestConfirmationListProps = {
type MoneyRequestConfirmationListItem = (Participant & {keyForList: string}) | OptionData;
+/**
+ * The errors the amount / merchant / date fields render inline rather than in the footer. Raising one of these is
+ * only visible if those fields are on screen, so the confirmation has to reveal them when it does.
+ */
+const INLINE_FIELD_ERROR_KEYS = new Set(['common.error.fieldRequired', 'common.error.invalidAmount', 'iou.error.invalidMerchant']);
+
function MoneyRequestConfirmationList({
transaction,
onSendMoney,
@@ -176,6 +192,8 @@ function MoneyRequestConfirmationList({
isPolicyExpenseChat = false,
shouldShowSmartScanFields = true,
canEnterScanFieldsManually = false,
+ halfFilledScanID,
+ onSwitchToTransaction,
isEditingSplitBill,
isReceiptEditable,
selectedParticipants: selectedParticipantsProp,
@@ -348,6 +366,7 @@ function MoneyRequestConfirmationList({
isPolicyExpenseChat,
isScanRequest,
canEnterScanFieldsManually,
+ halfFilledScanID,
shouldShowMerchant,
hasSmartScanFailed,
didConfirmSplit,
@@ -473,11 +492,22 @@ function MoneyRequestConfirmationList({
isTimeRequest,
routeError,
canEnterScanFieldsManually,
+ halfFilledScanID,
isReadOnly,
shouldShowDate,
isTaxAmountEmpty,
});
+ // On a multi-scan the receipt that is half-filled may not be the one on screen, so bring it into view before its
+ // blank fields are asked to raise the error.
+ const validateAndRevealFields: typeof validate = (paymentType) => {
+ const result = validate(paymentType);
+ if (result?.errorKey && INLINE_FIELD_ERROR_KEYS.has(result.errorKey) && halfFilledScanID && halfFilledScanID !== transactionID) {
+ onSwitchToTransaction?.(halfFilledScanID);
+ }
+ return result;
+ };
+
const confirm = buildConfirmAction({
iouType,
policy,
@@ -485,7 +515,7 @@ function MoneyRequestConfirmationList({
routeError,
formError,
isDelegateAccessRestricted,
- validate,
+ validate: validateAndRevealFields,
setFormError,
setDidConfirmSplit,
showDelegateNoAccessModal,
@@ -498,6 +528,16 @@ function MoneyRequestConfirmationList({
onSendMoney,
});
+ // The amount / merchant / date render these errors inline, and compact mode keeps those fields behind "Show more",
+ // so an outstanding one has to open the section or pressing Create looks like it did nothing. Opening it during
+ // render rather than from the press keeps it open when a multi-scan switches to the half-filled receipt, since
+ // that remounts and resets the flag. Writing the flag itself (rather than reading the error alongside it) keeps
+ // the section open once the user starts filling the fields in and the error clears, and keeps the receipt sizing,
+ // which reads the same flag, from disagreeing with what is on screen.
+ if (INLINE_FIELD_ERROR_KEYS.has(formError) && !showMoreFields) {
+ setShowMoreFields(true);
+ }
+
const isCompactMode = !showMoreFields && isScanRequest && !isInLandscapeMode;
const selectionListStyle = {
containerStyle: [styles.flexBasisAuto],
diff --git a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts
index 0848587623d0..60dd6ebadf11 100644
--- a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts
+++ b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts
@@ -117,6 +117,12 @@ type UseConfirmationValidationParams = {
/** Whether the Scan flow lets the user fill in the amount / merchant / date instead of waiting for SmartScan */
canEnterScanFieldsManually: boolean;
+ /**
+ * ID of a half-filled Scan among the transactions being confirmed, when there is one. Multi-scan confirms every
+ * receipt at once, so this can name a receipt other than the one being validated here.
+ */
+ halfFilledScanID?: string;
+
/** Whether the confirmation fields are read-only (date is not inline-editable) */
isReadOnly: boolean;
@@ -170,6 +176,7 @@ function useConfirmationValidation({
isTimeRequest,
routeError,
canEnterScanFieldsManually,
+ halfFilledScanID,
isReadOnly,
shouldShowDate,
isTaxAmountEmpty,
@@ -207,6 +214,11 @@ function useConfirmationValidation({
if (isPartiallyEnteredScanExpense(transaction, canEnterScanFieldsManually)) {
return {errorKey: 'common.error.fieldRequired'};
}
+ // On a multi-scan the same rule has to hold for the receipts that are not on screen, since Create submits
+ // all of them at once. The caller brings the offending one into view so its blank fields raise this inline.
+ if (halfFilledScanID) {
+ return {errorKey: 'common.error.fieldRequired'};
+ }
if (
shouldValidateEnteredAmount &&
transaction?.isAmountSet &&
diff --git a/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts b/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts
index 71949e84f827..e5ad0136841c 100644
--- a/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts
+++ b/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts
@@ -57,6 +57,12 @@ type UseFormErrorManagementParams = {
/** Whether the Scan confirmation lets the user fill in the amount / merchant / date themselves */
canEnterScanFieldsManually: boolean;
+ /**
+ * ID of a half-filled Scan among the transactions being confirmed, when there is one. On a multi-scan it can name
+ * a receipt other than the one on screen, which is why the required error must not be cleared against this one.
+ */
+ halfFilledScanID?: string;
+
/** Whether the merchant field should be visible in the UI */
shouldShowMerchant: boolean;
@@ -142,6 +148,7 @@ function useFormErrorManagement({
isPolicyExpenseChat,
isScanRequest,
canEnterScanFieldsManually,
+ halfFilledScanID,
shouldShowMerchant,
hasSmartScanFailed,
didConfirmSplit,
@@ -223,11 +230,14 @@ function useFormErrorManagement({
const isDateRequiredMissing = isConfirmationDateMissing(transaction, shouldShowDate, isReadOnly, canEnterScanFieldsManually);
const isMerchantRequiredMissing = isConfirmationMerchantMissing(transaction, canEnterScanFieldsManually);
useEffect(() => {
- if (formErrorRef.current !== 'common.error.fieldRequired' || isAmountRequiredMissing || isDateRequiredMissing || isMerchantRequiredMissing) {
+ // `halfFilledScanID` keeps the error alive while any other receipt of a multi-scan is still half-filled. The
+ // predicates above only see the transaction on screen, so without it the error would clear the moment a
+ // complete receipt is displayed, including during the render it takes to switch to the incomplete one.
+ if (formErrorRef.current !== 'common.error.fieldRequired' || isAmountRequiredMissing || isDateRequiredMissing || isMerchantRequiredMissing || !!halfFilledScanID) {
return;
}
setFormError('');
- }, [isAmountRequiredMissing, isDateRequiredMissing, isMerchantRequiredMissing, setFormError]);
+ }, [isAmountRequiredMissing, isDateRequiredMissing, isMerchantRequiredMissing, halfFilledScanID, setFormError]);
useEffect(() => {
const currentFormError = formErrorRef.current;
diff --git a/src/components/NumberWithSymbolForm.tsx b/src/components/NumberWithSymbolForm.tsx
index 4863aba505af..f93e6bf195f8 100644
--- a/src/components/NumberWithSymbolForm.tsx
+++ b/src/components/NumberWithSymbolForm.tsx
@@ -508,6 +508,10 @@ function NumberWithSymbolForm({