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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/components/ReportActionItem/MoneyRequestView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ import {
getOriginalAmountForDisplay,
getOriginalTransactionWithSplitInfo,
getReimbursable,
getReservationNights,
getTagForDisplay,
getTaxName,
hasMissingSmartscanFields,
Expand Down Expand Up @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear stale reservation dates before showing the hint

When a user replaces a SmartScanned hotel receipt, replaceReceipt() merges receiptOptimistic into the transaction without clearing hotelReservationStartDate or hotelReservationEndDate (src/libs/actions/IOU/Receipt.ts, lines 257-304). Because Onyx deep-merges the nested receipt, this call continues using the previous receipt's dates and displays its night count and nightly rate for the replacement receipt, while violations are recomputed from newTransaction, whose receipt has no dates. Clear those fields for a genuine replacement (!isSameReceipt) or suppress the hint until the replacement's metadata arrives.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. The finding is valid, and I pushed a fix in 9236c8e.

Before the fix, the optimistic MERGE in replaceReceipt() kept the old receipt's hotel dates. The hint and later limit checks then used the old stay for the new receipt.

The fix:

  • replaceReceipt() now sets hotelReservationStartDate and hotelReservationEndDate to null when the receipt is replaced with a new one (!isSameReceipt). A crop or rotate keeps the dates. If the replacement fails, the failure data still restores the old receipt, including its dates.
  • The two Receipt fields now allow null while a replacement is pending, the same way pageCount does.
  • Tests check that a new receipt clears the dates and a crop or rotate keeps them.

Checks: typecheck, lint, and spell-check passed. ReceiptTest, MoneyRequestViewTest, and ViolationUtilsTest passed (293 of 293).


view run

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);
Expand Down Expand Up @@ -1527,6 +1534,7 @@ function MoneyRequestView({
message={getErrorForField('category')}
/>
)}
{!!reservationNightsHintText && <MenuItem.HelpText message={reservationNightsHintText} />}
</MenuItem.Root>
</OfflineWithFeedback>
)}
Expand Down
4 changes: 4 additions & 0 deletions src/languages/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,10 @@ const translations: TranslationDeepObject<typeof en> = {
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',
Expand Down
4 changes: 4 additions & 0 deletions src/languages/el.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,10 @@ const translations: TranslationDeepObject<typeof en> = {
agentThinking: 'Γίνεται επεξεργασία…',
addCardTermsOfService: 'Όροι χρήσης Expensify',
perPerson: 'ανά άτομο',
reservationNightsWithNightlyRate: ({count, nightlyRate}: {count: number; nightlyRate: string}) => ({
one: `1 διανυκτέρευση • ${nightlyRate} ανά διανυκτέρευση`,
other: `${count} διανυκτερεύσεις • ${nightlyRate} ανά διανυκτέρευση`,
}),
phone: 'Τηλέφωνο',
phoneNumber: 'Αριθμός τηλεφώνου',
phoneNumberPlaceholder: '(xxx) xxx-xxxx',
Expand Down
5 changes: 5 additions & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
4 changes: 4 additions & 0 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,10 @@ const translations: TranslationDeepObject<typeof en> = {
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',
Expand Down
4 changes: 4 additions & 0 deletions src/languages/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,10 @@ const translations: TranslationDeepObject<typeof en> = {
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',
Expand Down
4 changes: 4 additions & 0 deletions src/languages/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,10 @@ const translations: TranslationDeepObject<typeof en> = {
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',
Expand Down
4 changes: 4 additions & 0 deletions src/languages/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,10 @@ const translations: TranslationDeepObject<typeof en> = {
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',
Expand Down
4 changes: 4 additions & 0 deletions src/languages/nl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,10 @@ const translations: TranslationDeepObject<typeof en> = {
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',
Expand Down
6 changes: 6 additions & 0 deletions src/languages/pl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,12 @@ const translations: TranslationDeepObject<typeof en> = {
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',
Expand Down
4 changes: 4 additions & 0 deletions src/languages/pt-BR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,10 @@ const translations: TranslationDeepObject<typeof en> = {
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',
Expand Down
4 changes: 4 additions & 0 deletions src/languages/zh-hans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,10 @@ const translations: TranslationDeepObject<typeof en> = {
agentThinking: '正在思考中…',
addCardTermsOfService: 'Expensify 服务条款',
perPerson: '每人',
reservationNightsWithNightlyRate: ({count, nightlyRate}: {count: number; nightlyRate: string}) => ({
one: `1 晚 • 每晚 ${nightlyRate}`,
other: `${count} 晚 • 每晚 ${nightlyRate}`,
}),
phone: '电话',
phoneNumber: '电话号码',
phoneNumberPlaceholder: '(xxx) xxx-xxxx',
Expand Down
3 changes: 3 additions & 0 deletions src/libs/actions/IOU/Receipt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down
8 changes: 4 additions & 4 deletions src/types/onyx/Transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
55 changes: 55 additions & 0 deletions tests/actions/IOUTest/ReceiptTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
50 changes: 50 additions & 0 deletions tests/ui/MoneyRequestViewTest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
Loading