diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 01fdde6cc85e..033c7999de35 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -112,6 +112,7 @@ import { getOriginalAmountForDisplay, getOriginalTransactionWithSplitInfo, getReimbursable, + getReservationNights, getTagForDisplay, getTaxName, hasMissingSmartscanFields, @@ -367,6 +368,12 @@ function MoneyRequestView({ const formattedTransactionAmount = shouldDisplayTransactionAmount ? convertToDisplayString(actualAmount, actualCurrency) : ''; const formattedPerAttendeeAmount = shouldDisplayTransactionAmount && actualAmount !== undefined ? convertToDisplayString(actualAmount / (transactionAttendees?.length ?? 1), actualCurrency) : ''; + // Explains the per-night category limit check, so it uses the same reservation nights. Single-night stays show nothing. + const reservationNights = getReservationNights(updatedTransaction ?? transaction); + const reservationNightsHintText = + reservationNights > 1 && actualAmount !== undefined + ? translate('common.reservationNightsWithNightlyRate', {count: reservationNights, nightlyRate: convertToDisplayString(actualAmount / reservationNights, actualCurrency)}) + : ''; const transactionOriginalAmount = transaction && getOriginalAmountForDisplay(transaction, isExpenseReport(moneyRequestReport)); const formattedOriginalAmount = transactionOriginalAmount && transactionOriginalCurrency && convertToDisplayString(transactionOriginalAmount, transactionOriginalCurrency); @@ -1527,6 +1534,7 @@ function MoneyRequestView({ message={getErrorForField('category')} /> )} + {!!reservationNightsHintText && } )} diff --git a/src/languages/de.ts b/src/languages/de.ts index 5dd7a081fe84..d7c2a1615d2c 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -139,6 +139,10 @@ const translations: TranslationDeepObject = { agentThinking: 'Es wird nachgedacht...', addCardTermsOfService: 'Expensify-Nutzungsbedingungen', perPerson: 'pro Person', + reservationNightsWithNightlyRate: ({count, nightlyRate}: {count: number; nightlyRate: string}) => ({ + one: `1 Nacht • ${nightlyRate} pro Nacht`, + other: `${count} Nächte • ${nightlyRate} pro Nacht`, + }), phone: 'Telefon', phoneNumber: 'Telefonnummer', phoneNumberPlaceholder: '(xxx) xxx-xxxx', diff --git a/src/languages/el.ts b/src/languages/el.ts index 3a0a2f2c3a30..02768e9ad7b5 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -142,6 +142,10 @@ const translations: TranslationDeepObject = { agentThinking: 'Γίνεται επεξεργασία…', addCardTermsOfService: 'Όροι χρήσης Expensify', perPerson: 'ανά άτομο', + reservationNightsWithNightlyRate: ({count, nightlyRate}: {count: number; nightlyRate: string}) => ({ + one: `1 διανυκτέρευση • ${nightlyRate} ανά διανυκτέρευση`, + other: `${count} διανυκτερεύσεις • ${nightlyRate} ανά διανυκτέρευση`, + }), phone: 'Τηλέφωνο', phoneNumber: 'Αριθμός τηλεφώνου', phoneNumberPlaceholder: '(xxx) xxx-xxxx', diff --git a/src/languages/en.ts b/src/languages/en.ts index bb6975f9f669..a56cd9696f53 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -195,6 +195,11 @@ const translations = { agentThinking: 'Thinking...', addCardTermsOfService: 'Expensify Terms of Service', perPerson: 'per person', + // @context Hint under the Category field of a multi-night reservation expense, for example “3 nights • $232.42 per night”. nightlyRate is a formatted currency amount. + reservationNightsWithNightlyRate: ({count, nightlyRate}: {count: number; nightlyRate: string}) => ({ + one: `1 night • ${nightlyRate} per night`, + other: `${count} nights • ${nightlyRate} per night`, + }), phone: 'Phone', phoneNumber: 'Phone number', phoneNumberPlaceholder: '(xxx) xxx-xxxx', diff --git a/src/languages/es.ts b/src/languages/es.ts index 1255ad655989..60dec1ea1756 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -121,6 +121,10 @@ const translations: TranslationDeepObject = { privacyPolicy: 'la Política de Privacidad de Expensify', addCardTermsOfService: 'Términos de Servicio', perPerson: 'por persona', + reservationNightsWithNightlyRate: ({count, nightlyRate}: {count: number; nightlyRate: string}) => ({ + one: `1 noche • ${nightlyRate} por noche`, + other: `${count} noches • ${nightlyRate} por noche`, + }), signIn: 'Conectarse', signInWithGoogle: 'Iniciar sesión con Google', signInWithApple: 'Iniciar sesión con Apple', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index c629e2bd4b0e..350acfc33b09 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -139,6 +139,10 @@ const translations: TranslationDeepObject = { agentThinking: 'Réflexion en cours…', addCardTermsOfService: 'Conditions d’utilisation d’Expensify', perPerson: 'par personne', + reservationNightsWithNightlyRate: ({count, nightlyRate}: {count: number; nightlyRate: string}) => ({ + one: `${count} nuit • ${nightlyRate} par nuit`, + other: `${count} nuits • ${nightlyRate} par nuit`, + }), phone: 'Téléphone', phoneNumber: 'Numéro de téléphone', phoneNumberPlaceholder: '(xxx) xxx-xxxx', diff --git a/src/languages/it.ts b/src/languages/it.ts index 9db5fb5ac92f..5837d6df194f 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -139,6 +139,10 @@ const translations: TranslationDeepObject = { agentThinking: 'Sto pensando...', addCardTermsOfService: 'Termini di servizio di Expensify', perPerson: 'per persona', + reservationNightsWithNightlyRate: ({count, nightlyRate}: {count: number; nightlyRate: string}) => ({ + one: `1 notte • ${nightlyRate} a notte`, + other: `${count} notti • ${nightlyRate} a notte`, + }), phone: 'Telefono', phoneNumber: 'Numero di telefono', phoneNumberPlaceholder: '(xxx) xxx-xxxx', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index d412979fa9bc..f59993e8b277 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -139,6 +139,10 @@ const translations: TranslationDeepObject = { agentThinking: '考えています…', addCardTermsOfService: 'Expensify 利用規約', perPerson: '1人あたり', + reservationNightsWithNightlyRate: ({count, nightlyRate}: {count: number; nightlyRate: string}) => ({ + one: `1 泊 • 1泊あたり ${nightlyRate}`, + other: `${count} 泊 • 1泊あたり ${nightlyRate}`, + }), phone: '電話', phoneNumber: '電話番号', phoneNumberPlaceholder: '(xxx) xxx-xxxx', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 60718020161a..9c13b5029155 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -139,6 +139,10 @@ const translations: TranslationDeepObject = { agentThinking: 'Bezig met nadenken...', addCardTermsOfService: 'Expensify-servicevoorwaarden', perPerson: 'per persoon', + reservationNightsWithNightlyRate: ({count, nightlyRate}: {count: number; nightlyRate: string}) => ({ + one: `1 nacht • ${nightlyRate} per nacht`, + other: `${count} nachten • ${nightlyRate} per nacht`, + }), phone: 'Telefoon', phoneNumber: 'Telefoonnummer', phoneNumberPlaceholder: '(xxx) xxx-xxxx', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 9792f3bfe001..f51a2345f1f7 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -143,6 +143,12 @@ const translations: TranslationDeepObject = { agentThinking: 'Myślę…', addCardTermsOfService: 'Regulamin Expensify', perPerson: 'na osobę', + reservationNightsWithNightlyRate: ({count, nightlyRate}: {count: number; nightlyRate: string}) => ({ + one: `1 noc • ${nightlyRate} za noc`, + few: `${count} noce • ${nightlyRate} za noc`, + many: `${count} nocy • ${nightlyRate} za noc`, + other: `${count} nocy • ${nightlyRate} za noc`, + }), phone: 'Telefon', phoneNumber: 'Numer telefonu', phoneNumberPlaceholder: '(xxx) xxx-xxxx', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index ca2cb4c94ba2..45469451ab30 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -139,6 +139,10 @@ const translations: TranslationDeepObject = { agentThinking: 'Pensando...', addCardTermsOfService: 'Termos de Serviço da Expensify', perPerson: 'por pessoa', + reservationNightsWithNightlyRate: ({count, nightlyRate}: {count: number; nightlyRate: string}) => ({ + one: `${count} noite • ${nightlyRate} por noite`, + other: `${count} noites • ${nightlyRate} por noite`, + }), phone: 'Telefone', phoneNumber: 'Número de telefone', phoneNumberPlaceholder: '(xxx) xxx-xxxx', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 44a092b96b81..38349442cd5f 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -139,6 +139,10 @@ const translations: TranslationDeepObject = { agentThinking: '正在思考中…', addCardTermsOfService: 'Expensify 服务条款', perPerson: '每人', + reservationNightsWithNightlyRate: ({count, nightlyRate}: {count: number; nightlyRate: string}) => ({ + one: `1 晚 • 每晚 ${nightlyRate}`, + other: `${count} 晚 • 每晚 ${nightlyRate}`, + }), phone: '电话', phoneNumber: '电话号码', phoneNumberPlaceholder: '(xxx) xxx-xxxx', diff --git a/src/libs/actions/IOU/Receipt.ts b/src/libs/actions/IOU/Receipt.ts index 4325a7d28327..a6380f22df3a 100644 --- a/src/libs/actions/IOU/Receipt.ts +++ b/src/libs/actions/IOU/Receipt.ts @@ -262,6 +262,9 @@ function replaceReceipt({ receiptTraceId, // Clear the old count while the replacement is pending. pageCount: null, + // A new receipt must not keep the old one's reservation dates, or the per-night limit check would use them. + // A crop or rotate keeps the same receipt, so its dates stay. + ...(isSameReceipt ? {} : {hotelReservationStartDate: null, hotelReservationEndDate: null}), }; const newTransaction = transaction && {...transaction, receipt: receiptOptimistic}; diff --git a/src/types/onyx/Transaction.ts b/src/types/onyx/Transaction.ts index 54923d5f3601..2a9efc970826 100644 --- a/src/types/onyx/Transaction.ts +++ b/src/types/onyx/Transaction.ts @@ -293,11 +293,11 @@ type Receipt = { /** Correlation id created at capture, used to follow this receipt from capture to upload in the logs. */ receiptTraceId?: string; - /** Check-in date of a SmartScanned multi-day reservation, in YYYY-MM-DD */ - hotelReservationStartDate?: string; + /** Check-in date of a SmartScanned multi-day reservation, in YYYY-MM-DD. Null while a replacement receipt is pending. */ + hotelReservationStartDate?: string | null; - /** Check-out date of a SmartScanned multi-day reservation, in YYYY-MM-DD */ - hotelReservationEndDate?: string; + /** Check-out date of a SmartScanned multi-day reservation, in YYYY-MM-DD. Null while a replacement receipt is pending. */ + hotelReservationEndDate?: string | null; }; /** Model of route */ diff --git a/tests/actions/IOUTest/ReceiptTest.ts b/tests/actions/IOUTest/ReceiptTest.ts index be990b00294f..be5480757164 100644 --- a/tests/actions/IOUTest/ReceiptTest.ts +++ b/tests/actions/IOUTest/ReceiptTest.ts @@ -268,6 +268,61 @@ describe('actions/IOU/Receipt', () => { }); }); + it('should clear the previous reservation dates when a new receipt replaces the old one', async () => { + // Given an expense whose SmartScanned hotel receipt has reservation dates + const transaction = await setupTransactionWithSnapshot(transactionID, { + receipt: {...OLD_RECEIPT, hotelReservationStartDate: '2026-10-01', hotelReservationEndDate: '2026-10-04'}, + }); + + // When the user replaces it with a different receipt + replaceReceipt({ + transaction, + file: createFile(), + source, + transactionPolicy: undefined, + transactionPolicyTagList: undefined, + transactionReport: undefined, + isVendorMatchingBetaEnabled: false, + delegateAccountID: undefined, + currentUserPersonalDetails: {accountID: RORY_ACCOUNT_ID, email: RORY_EMAIL}, + transactionThreadReport: undefined, + }); + await waitForBatchedUpdates(); + + // Then the old dates are gone, so the per-night limit check and hint don't use them for the new receipt + const updatedTransaction = await getUpdatedTransaction(transactionID); + expect(updatedTransaction?.receipt?.hotelReservationStartDate).toBeUndefined(); + expect(updatedTransaction?.receipt?.hotelReservationEndDate).toBeUndefined(); + }); + + it('should keep the reservation dates when the same receipt is cropped or rotated', async () => { + // Given an expense whose SmartScanned hotel receipt has reservation dates + const transaction = await setupTransactionWithSnapshot(transactionID, { + receipt: {...OLD_RECEIPT, hotelReservationStartDate: '2026-10-01', hotelReservationEndDate: '2026-10-04'}, + }); + + // When the user crops or rotates that receipt + replaceReceipt({ + transaction, + file: createFile(), + source, + isSameReceipt: true, + transactionPolicy: undefined, + transactionPolicyTagList: undefined, + transactionReport: undefined, + isVendorMatchingBetaEnabled: false, + delegateAccountID: undefined, + currentUserPersonalDetails: {accountID: RORY_ACCOUNT_ID, email: RORY_EMAIL}, + transactionThreadReport: undefined, + }); + await waitForBatchedUpdates(); + + // Then the dates stay, because the receipt is still the same stay + const updatedTransaction = await getUpdatedTransaction(transactionID); + expect(updatedTransaction?.receipt?.hotelReservationStartDate).toBe('2026-10-01'); + expect(updatedTransaction?.receipt?.hotelReservationEndDate).toBe('2026-10-04'); + }); + it('should optimistically set pending field for receipt', async () => { // Given a transaction with an existing receipt const writeSpy = mockApiWrite(); diff --git a/tests/ui/MoneyRequestViewTest.tsx b/tests/ui/MoneyRequestViewTest.tsx index 14d62db5a01c..996006672fd3 100644 --- a/tests/ui/MoneyRequestViewTest.tsx +++ b/tests/ui/MoneyRequestViewTest.tsx @@ -1233,4 +1233,54 @@ describe('MoneyRequestView edit fields', () => { expect(screen.queryByLabelText(fieldLabel(commuterDistanceDescription))).not.toBeOnTheScreen(); }); }); + + describe('reservation nights hint under the Category field', () => { + // `translate` is mocked to return the key, so the hint renders as its translation key + const reservationNightsHint = 'common.reservationNightsWithNightlyRate'; + + const renderWithReservationDates = async (hotelReservationStartDate: string, hotelReservationEndDate: string) => { + const threadReport = { + ...LHNTestUtils.getFakeReport(), + parentReportID: expenseReportID, + parentReportActionID, + }; + + await setupTestData(); + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyID}`, { + Travel: {name: 'Travel', enabled: true}, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, { + category: 'Travel', + receipt: {hotelReservationStartDate, hotelReservationEndDate}, + }); + }); + await waitForBatchedUpdatesWithAct(); + + renderMoneyRequestView(threadReport, {areCategoriesEnabled: true}); + await waitForBatchedUpdatesWithAct(); + + await waitFor(() => { + expect(screen.getByLabelText(fieldLabel('common.category'))).toBeOnTheScreen(); + }); + }; + + it('shows the nights and nightly rate for a multi-night reservation', async () => { + // Given an expense whose SmartScanned reservation spans three nights + // When the expense is rendered + await renderWithReservationDates('2026-10-01', '2026-10-04'); + + // Then the hint explains the per-night evaluation used by the category limit check + expect(screen.getByText(reservationNightsHint)).toBeOnTheScreen(); + }); + + it('does not show the hint for a single-night reservation', async () => { + // Given an expense whose reservation is a single night, which the design says should show nothing + // When the expense is rendered + await renderWithReservationDates('2026-10-01', '2026-10-02'); + + // Then no nightly hint is shown + expect(screen.queryByText(reservationNightsHint)).not.toBeOnTheScreen(); + }); + }); });