From 367a37e5db07265834c1860875332f0e0d785a1e Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Tue, 4 Aug 2026 22:03:43 +0530 Subject: [PATCH 01/13] Add pageCount to the Receipt type Auth now sends the page count for receipts stored as a PDF. Co-Authored-By: Claude Opus 5 (1M context) --- src/types/onyx/Transaction.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/types/onyx/Transaction.ts b/src/types/onyx/Transaction.ts index 7d7a6da9db17..9d455ec8d34f 100644 --- a/src/types/onyx/Transaction.ts +++ b/src/types/onyx/Transaction.ts @@ -255,6 +255,9 @@ type Receipt = { /** Collection of reservations */ reservationList?: Reservation[]; + /** Number of pages in a receipt stored as a PDF. Absent for images, and for PDFs uploaded before the backend reported a count. */ + pageCount?: number; + /** Whether this is a test receipt */ isTestReceipt?: true; From 46a01d8866029461da6a23e772062f924fc8b6a7 Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Tue, 4 Aug 2026 22:04:02 +0530 Subject: [PATCH 02/13] Add the receipt page count badge copy Co-Authored-By: Claude Opus 5 (1M context) --- src/languages/en.ts | 2 ++ src/languages/es.ts | 2 ++ src/languages/params.ts | 3 +++ 3 files changed, 7 insertions(+) diff --git a/src/languages/en.ts b/src/languages/en.ts index 91a6c4fc24e8..145b0324682f 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -40,6 +40,7 @@ import type { ReportArchiveReasonsInvoiceReceiverPolicyDeletedParams, ReportArchiveReasonsMergedParams, ReportArchiveReasonsRemovedFromPolicyParams, + ReceiptPageCountParams, ResolutionConstraintsParams, ShareParams, SizeExceededParams, @@ -1316,6 +1317,7 @@ const translations = { addAdditionalReceipt: 'Add additional receipt', scanFailed: "The receipt couldn't be scanned, as it's missing a merchant, date, or amount.", crop: 'Crop', + pageCount: ({pageCount}: ReceiptPageCountParams) => `Page 1 of ${pageCount}`, addAReceipt: { phrase1: 'Add a receipt', phrase2: 'or drag and drop one here', diff --git a/src/languages/es.ts b/src/languages/es.ts index 10a973e7a9bc..c5210283cb26 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -21,6 +21,7 @@ import type { DefaultVendorHelperTextParams, EmptyViolationSnapshotResultsSubtitleParams, PaidElsewhereParams, + ReceiptPageCountParams, RemoveCopilotAccessConfirmationParams, UnsupportedFormulaValueErrorParams, } from './params'; @@ -1210,6 +1211,7 @@ const translations: TranslationDeepObject = { addAdditionalReceipt: 'Añadir recibo adicional', scanFailed: 'El recibo no pudo ser escaneado, ya que falta el comerciante, la fecha o el importe.', crop: 'Recortar', + pageCount: ({pageCount}: ReceiptPageCountParams) => `Página 1 de ${pageCount}`, addAReceipt: { phrase1: 'Añade un recibo', phrase2: 'o arrastra y suelta uno aquí', diff --git a/src/languages/params.ts b/src/languages/params.ts index 301a8457198d..31c8e3daaf72 100644 --- a/src/languages/params.ts +++ b/src/languages/params.ts @@ -46,6 +46,8 @@ type NotAllowedExtensionParams = {allowedExtensions: string[]}; type StepCounterParams = {step: number; total?: number; text?: string}; +type ReceiptPageCountParams = {pageCount: number}; + type ParentNavigationSummaryParams = {reportName?: string; workspaceName?: string}; type ViolationsModifiedAmountParams = {type?: ViolationDataType; displayPercentVariance?: number}; @@ -171,6 +173,7 @@ export type { ReportArchiveReasonsInvoiceReceiverPolicyDeletedParams, ReportArchiveReasonsRemovedFromPolicyParams, ResolutionConstraintsParams, + ReceiptPageCountParams, SizeExceededParams, StepCounterParams, ViolationsModifiedAmountParams, From 4de2d30ff4a75cd09ded1bb6d86b927755799acc Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Tue, 4 Aug 2026 22:04:11 +0530 Subject: [PATCH 03/13] Show a page count badge on multi-page PDF receipts The receipt thumbnail is a server-generated JPG of page 1, so when a total sits on a later page the expense looks like it disagrees with its own receipt, and nothing signals that more pages exist. The badge sits bottom-left because the receipt action buttons hold the top-right corner, and uses the same translucent dark background as the video player controls so it stays legible over any receipt content in either theme. Co-Authored-By: Claude Opus 5 (1M context) --- .../MoneyRequestReceiptView.tsx | 18 ++++++++++++++- src/styles/index.ts | 23 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/components/ReportActionItem/MoneyRequestReceiptView.tsx b/src/components/ReportActionItem/MoneyRequestReceiptView.tsx index 744c5183366f..11c887c69338 100644 --- a/src/components/ReportActionItem/MoneyRequestReceiptView.tsx +++ b/src/components/ReportActionItem/MoneyRequestReceiptView.tsx @@ -7,6 +7,7 @@ import PressableWithoutFocus from '@components/Pressable/PressableWithoutFocus'; import ReceiptAudit, {ReceiptAuditMessages} from '@components/ReceiptAudit'; import ReceiptEmptyState from '@components/ReceiptEmptyState'; import ReceiptHoverZoom from '@components/ReceiptHoverZoom'; +import Text from '@components/Text'; import Tooltip from '@components/Tooltip'; import useActiveRoute from '@hooks/useActiveRoute'; @@ -196,6 +197,10 @@ function MoneyRequestReceiptView({ // stale and can't be redrawn locally, so disable Expand for map distance requests until the refreshed receipt arrives. const shouldDisableExpandReceipt = isMapDistanceRequest && isPendingReceiptRegeneration; const hasReceipt = hasReceiptTransactionUtils(displayedTransaction); + // The thumbnail only ever renders page 1 of a PDF, so a total sitting on a later page looks like it + // disagrees with the expense amount. Only multi-page receipts need the badge. + const receiptPageCount = displayedTransaction?.receipt?.pageCount ?? 0; + const shouldShowReceiptPageCount = receiptPageCount > 1; const isTransactionScanning = isScanning(displayedTransaction); const didReceiptScanSucceed = hasReceipt && didReceiptScanSucceedTransactionUtils(transaction); const isInvoice = isInvoiceReport(moneyRequestReport); @@ -210,7 +215,7 @@ function MoneyRequestReceiptView({ const addButtonRef = useRef(null); const [isPickerOpen, setIsPickerOpen] = useState(false); const deviceHasHoverSupport = hasHoverSupport(); - const lazyIcons = useMemoizedLazyExpensifyIcons(['Expand', 'ReceiptPlus']); + const lazyIcons = useMemoizedLazyExpensifyIcons(['Expand', 'ReceiptPlus', 'Copy']); const [policyTagList] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policy?.id}`); // Browsers don't fire mouseenter when an element mounts under the cursor @@ -695,6 +700,17 @@ function MoneyRequestReceiptView({ onLoadFailure={() => setIsLoading(false)} /> {canShowDistanceEReceipt && isHovering && !!displayedTransaction && } + {shouldShowReceiptPageCount && ( + + + {translate('receipt.pageCount', {pageCount: receiptPageCount})} + + )} )} diff --git a/src/styles/index.ts b/src/styles/index.ts index f8779cee37e5..7c1b9397d9a3 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -4024,6 +4024,29 @@ const staticStyles = (theme: ThemeColors) => gap: 8, }, + // Sits bottom-left because the receipt action buttons occupy the top-right corner. + // The background is the same translucent dark used for controls over video, so the badge + // stays legible over any receipt content in either theme. + receiptPageCountBadge: { + position: 'absolute', + bottom: 16, + left: 16, + flexDirection: 'row', + alignItems: 'center', + gap: 6, + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: variables.componentBorderRadiusSmall, + backgroundColor: theme.videoPlayerBG, + }, + + receiptPageCountBadgeText: { + color: theme.white, + fontSize: variables.fontSizeSmall, + fontWeight: FontUtils.fontWeight.bold, + lineHeight: variables.lineHeightSmall, + }, + receiptActionButton: { width: 40, height: 40, From 6650fad798124c5e1cdba1314afbd6ce827cb04a Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Mon, 10 Aug 2026 22:36:58 +0530 Subject: [PATCH 04/13] Use the shared Badge and float it above the receipt Design settled on the plain Badge rather than a bespoke translucent pill with an icon, so this drops the custom styles and the page-stack icon and keeps only the positioning override. The badge also moves out of ReceiptHoverZoom's children and becomes a sibling of the zoom wrapper, alongside the receipt action buttons. Inside, hover-zoom magnified the badge along with the receipt; outside, it stays put while the receipt scales under it. Co-Authored-By: Claude Opus 5 (1M context) --- .../MoneyRequestReceiptView.tsx | 21 +++++++------------ src/styles/index.ts | 17 +++------------ 2 files changed, 11 insertions(+), 27 deletions(-) diff --git a/src/components/ReportActionItem/MoneyRequestReceiptView.tsx b/src/components/ReportActionItem/MoneyRequestReceiptView.tsx index 11c887c69338..24dd09d4af28 100644 --- a/src/components/ReportActionItem/MoneyRequestReceiptView.tsx +++ b/src/components/ReportActionItem/MoneyRequestReceiptView.tsx @@ -1,4 +1,5 @@ import AttachmentPicker from '@components/AttachmentPicker'; +import Badge from '@components/Badge'; import Icon from '@components/Icon'; import {ModalActions} from '@components/Modal/Global/ModalContext'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -7,7 +8,6 @@ import PressableWithoutFocus from '@components/Pressable/PressableWithoutFocus'; import ReceiptAudit, {ReceiptAuditMessages} from '@components/ReceiptAudit'; import ReceiptEmptyState from '@components/ReceiptEmptyState'; import ReceiptHoverZoom from '@components/ReceiptHoverZoom'; -import Text from '@components/Text'; import Tooltip from '@components/Tooltip'; import useActiveRoute from '@hooks/useActiveRoute'; @@ -215,7 +215,7 @@ function MoneyRequestReceiptView({ const addButtonRef = useRef(null); const [isPickerOpen, setIsPickerOpen] = useState(false); const deviceHasHoverSupport = hasHoverSupport(); - const lazyIcons = useMemoizedLazyExpensifyIcons(['Expand', 'ReceiptPlus', 'Copy']); + const lazyIcons = useMemoizedLazyExpensifyIcons(['Expand', 'ReceiptPlus']); const [policyTagList] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policy?.id}`); // Browsers don't fire mouseenter when an element mounts under the cursor @@ -700,21 +700,16 @@ function MoneyRequestReceiptView({ onLoadFailure={() => setIsLoading(false)} /> {canShowDistanceEReceipt && isHovering && !!displayedTransaction && } - {shouldShowReceiptPageCount && ( - - - {translate('receipt.pageCount', {pageCount: receiptPageCount})} - - )} )} + {shouldShowReceiptPageCount && ( + + )} {canShowReceiptActions && ( diff --git a/src/styles/index.ts b/src/styles/index.ts index 7c1b9397d9a3..c6312a592194 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -4027,24 +4027,13 @@ const staticStyles = (theme: ThemeColors) => // Sits bottom-left because the receipt action buttons occupy the top-right corner. // The background is the same translucent dark used for controls over video, so the badge // stays legible over any receipt content in either theme. + // Floats over the receipt rather than sitting inside it, so hover-zoom magnifies the receipt + // and leaves the badge alone. Badge applies its own left margin, which is cleared here. receiptPageCountBadge: { position: 'absolute', bottom: 16, left: 16, - flexDirection: 'row', - alignItems: 'center', - gap: 6, - paddingHorizontal: 8, - paddingVertical: 4, - borderRadius: variables.componentBorderRadiusSmall, - backgroundColor: theme.videoPlayerBG, - }, - - receiptPageCountBadgeText: { - color: theme.white, - fontSize: variables.fontSizeSmall, - fontWeight: FontUtils.fontWeight.bold, - lineHeight: variables.lineHeightSmall, + marginLeft: 0, }, receiptActionButton: { From ac3b902ac14cb5b07eb4f7a7d80b74801b2d6bf0 Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Tue, 11 Aug 2026 15:59:59 +0530 Subject: [PATCH 05/13] Hide the page count badge while the receipt loads The receipt container takes flex1 while loading, so it stretches and an absolutely positioned badge lands at the bottom of that taller box rather than on the receipt. The receipt action buttons already wait for the load for the same reason. Co-Authored-By: Claude Opus 5 (1M context) --- src/components/ReportActionItem/MoneyRequestReceiptView.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/ReportActionItem/MoneyRequestReceiptView.tsx b/src/components/ReportActionItem/MoneyRequestReceiptView.tsx index fe6c3f50a4b1..842093a23a05 100644 --- a/src/components/ReportActionItem/MoneyRequestReceiptView.tsx +++ b/src/components/ReportActionItem/MoneyRequestReceiptView.tsx @@ -200,7 +200,6 @@ function MoneyRequestReceiptView({ // The thumbnail only ever renders page 1 of a PDF, so a total sitting on a later page looks like it // disagrees with the expense amount. Only multi-page receipts need the badge. const receiptPageCount = displayedTransaction?.receipt?.pageCount ?? 0; - const shouldShowReceiptPageCount = receiptPageCount > 1; const isTransactionScanning = isScanning(displayedTransaction); const didReceiptScanSucceed = hasReceipt && didReceiptScanSucceedTransactionUtils(transaction); const isInvoice = isInvoiceReport(moneyRequestReport); @@ -550,6 +549,10 @@ function MoneyRequestReceiptView({ // Map distance receipts show both hover actions just like regular receipts, so we don't exclude isMapDistanceRequest here. const canShowReceiptActions = hasReceipt && !isLoading && isEditable && !mergeTransactionID; + + // Held back until the receipt has loaded: the container stretches while loading, which would strand + // the badge at the bottom of that taller box instead of sitting on the receipt. + const shouldShowReceiptPageCount = receiptPageCount > 1 && !isLoading; const receiptPendingAction = isDistanceRequest ? getPendingFieldAction('waypoints') : getPendingFieldAction('receipt'); const isReceiptOfflinePending = isOffline && !!receiptPendingAction; const receiptAuditMessagesRow = ( From df7a6f1a054b3b64c9118717240822e769a2ccf0 Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Tue, 11 Aug 2026 16:02:50 +0530 Subject: [PATCH 06/13] Adjust position of receipt page count badge for improved alignment --- src/styles/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/styles/index.ts b/src/styles/index.ts index ef21c65a1c4a..dfbc51ccdd66 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -4035,8 +4035,8 @@ const staticStyles = (theme: ThemeColors) => // and leaves the badge alone. Badge applies its own left margin, which is cleared here. receiptPageCountBadge: { position: 'absolute', - bottom: 16, - left: 16, + bottom: 12, + left: 12, marginLeft: 0, }, From 281aa9b36e8976413b01f48e1700c16936853b4c Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Tue, 11 Aug 2026 16:04:46 +0530 Subject: [PATCH 07/13] Add the page count badge copy for the remaining locales Generated with scripts/generateTranslations.ts, which the CI workflow cannot run for a PR from a fork. French came back unchanged from the model and fell back to English, so it is translated by hand. Spanish already matched what the script produced. Co-Authored-By: Claude Opus 5 (1M context) --- src/languages/de.ts | 1 + src/languages/el.ts | 1 + src/languages/fr.ts | 1 + src/languages/it.ts | 1 + src/languages/ja.ts | 1 + src/languages/nl.ts | 1 + src/languages/pl.ts | 1 + src/languages/pt-BR.ts | 1 + src/languages/zh-hans.ts | 1 + 9 files changed, 9 insertions(+) diff --git a/src/languages/de.ts b/src/languages/de.ts index 9de8a6238564..68f6fa7e9fbb 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1215,6 +1215,7 @@ const translations: TranslationDeepObject = { phrase1: 'Beleg hinzufügen', phrase2: 'oder ziehe eine hierher und lege sie ab', }, + pageCount: ({pageCount}: {pageCount: number}) => `Seite 1 von ${pageCount}`, }, quickAction: { scanReceipt: 'Beleg scannen', diff --git a/src/languages/el.ts b/src/languages/el.ts index 81d3aeb08997..9d854195736e 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -1264,6 +1264,7 @@ const translations: TranslationDeepObject = { phrase1: 'Προσθήκη απόδειξης', phrase2: 'ή σύρετε και αποθέστε ένα εδώ', }, + pageCount: ({pageCount}: {pageCount: number}) => `Σελίδα 1 από ${pageCount}`, }, quickAction: { scanReceipt: 'Σαρώστε απόδειξη', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 42787610f428..5150814cba56 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1219,6 +1219,7 @@ const translations: TranslationDeepObject = { phrase1: 'Ajouter un reçu', phrase2: 'ou faites-en glisser un ici', }, + pageCount: ({pageCount}: {pageCount: number}) => `Page 1 sur ${pageCount}`, }, quickAction: { scanReceipt: 'Scanner le reçu', diff --git a/src/languages/it.ts b/src/languages/it.ts index 6a362596b501..ab07e7dc7a20 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1214,6 +1214,7 @@ const translations: TranslationDeepObject = { phrase1: 'Aggiungi una ricevuta', phrase2: 'o trascinalo qui', }, + pageCount: ({pageCount}: {pageCount: number}) => `Pagina 1 di ${pageCount}`, }, quickAction: { scanReceipt: 'Scansiona ricevuta', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 9a5267c3cefe..527e0b713a1b 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1198,6 +1198,7 @@ const translations: TranslationDeepObject = { phrase1: '領収書を追加', phrase2: 'または、ここにファイルをドラッグ&ドロップしてください', }, + pageCount: ({pageCount}: {pageCount: number}) => `${pageCount} ページ中 1 ページ`, }, quickAction: { scanReceipt: 'レシートをスキャン', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 3cd6ee5a3860..fe4026d4fbf7 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1213,6 +1213,7 @@ const translations: TranslationDeepObject = { phrase1: 'Voeg een bon toe', phrase2: 'of sleep ze hier naartoe', }, + pageCount: ({pageCount}: {pageCount: number}) => `Pagina 1 van ${pageCount}`, }, quickAction: { scanReceipt: 'Bon scannen', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 303b2c3eb23d..542826005946 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1209,6 +1209,7 @@ const translations: TranslationDeepObject = { phrase1: 'Dodaj paragon', phrase2: 'lub przeciągnij i upuść tutaj', }, + pageCount: ({pageCount}: {pageCount: number}) => `Strona 1 z ${pageCount}`, }, quickAction: { scanReceipt: 'Zeskanuj paragon', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 8f41bd76602d..bd8c55272118 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1213,6 +1213,7 @@ const translations: TranslationDeepObject = { phrase1: 'Adicionar um recibo', phrase2: 'ou arraste e solte um aqui', }, + pageCount: ({pageCount}: {pageCount: number}) => `Página 1 de ${pageCount}`, }, quickAction: { scanReceipt: 'Digitalizar recibo', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index e09675eb46e8..80076040e269 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1164,6 +1164,7 @@ const translations: TranslationDeepObject = { phrase1: '添加收据', phrase2: '或将文件拖放到此处', }, + pageCount: ({pageCount}: {pageCount: number}) => `第 1 页,共 ${pageCount} 页`, }, quickAction: { scanReceipt: '扫描收据', From 78c258f4ddf50f6be8c58b898186d1e38e49999f Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Tue, 11 Aug 2026 16:14:31 +0530 Subject: [PATCH 08/13] Add pageCount to the debug receipt validators DebugUtils mirrors the Receipt type as a runtime field map, and both validators must enumerate every key, so adding pageCount to the type broke typecheck until they listed it too. Co-Authored-By: Claude Opus 5 (1M context) --- src/libs/DebugUtils.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/libs/DebugUtils.ts b/src/libs/DebugUtils.ts index 24ccd46fc718..943fd6c0f1f1 100644 --- a/src/libs/DebugUtils.ts +++ b/src/libs/DebugUtils.ts @@ -828,6 +828,7 @@ function validateReportActionDraftProperty(key: keyof ReportAction, value: strin isTestDriveReceipt: 'boolean', thumbnail: 'string', receiptTraceId: 'string', + pageCount: 'number', }); case 'childRecentReceiptTransactionIDs': return validateObject>(value, {}, 'string'); @@ -1196,6 +1197,7 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string) isTestDriveReceipt: 'boolean', thumbnail: 'string', receiptTraceId: 'string', + pageCount: 'number', }); case 'taxRate': return validateObject>(value, { From 4972c29e4a9522b85bfcc385b07955d5b36e709f Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Tue, 11 Aug 2026 16:23:45 +0530 Subject: [PATCH 09/13] Drop the stale background note from the page count badge style The style stopped setting a background when the badge moved to the shared Badge component, but the comment describing that background survived a partial edit and sat above the new one. Co-Authored-By: Claude Opus 5 (1M context) --- src/styles/index.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/styles/index.ts b/src/styles/index.ts index dfbc51ccdd66..351c221cdea8 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -4028,11 +4028,9 @@ const staticStyles = (theme: ThemeColors) => gap: 8, }, - // Sits bottom-left because the receipt action buttons occupy the top-right corner. - // The background is the same translucent dark used for controls over video, so the badge - // stays legible over any receipt content in either theme. - // Floats over the receipt rather than sitting inside it, so hover-zoom magnifies the receipt - // and leaves the badge alone. Badge applies its own left margin, which is cleared here. + // Sits bottom-left because the receipt action buttons occupy the top-right corner, and floats + // over the receipt rather than sitting inside it, so hover-zoom magnifies the receipt and + // leaves the badge alone. Badge applies its own left margin, which is cleared here. receiptPageCountBadge: { position: 'absolute', bottom: 12, From 5e7544a042ebe13b45d85ad7bf95ef25bb411b74 Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Tue, 11 Aug 2026 18:17:34 +0530 Subject: [PATCH 10/13] Clear the page count optimistically when replacing a receipt replaceReceipt applies its optimistic receipt as a merge, so omitting pageCount left the replaced receipt's count in Onyx. Replacing a multi-page PDF with an image showed the old badge until the server responded. Sending null clears it, matching how localSource is already cleared in the same object. Co-Authored-By: Claude Opus 5 (1M context) --- src/libs/actions/IOU/Receipt.ts | 4 ++++ src/types/onyx/Transaction.ts | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/libs/actions/IOU/Receipt.ts b/src/libs/actions/IOU/Receipt.ts index eed344c0de18..a7bbc17341d2 100644 --- a/src/libs/actions/IOU/Receipt.ts +++ b/src/libs/actions/IOU/Receipt.ts @@ -207,6 +207,10 @@ function replaceReceipt({ state: state ?? CONST.IOU.RECEIPT_STATE.OPEN, filename: file.name, receiptTraceId, + // Cleared explicitly because this is a merge: the replacement is not known to be a + // multi-page PDF, so keeping the replaced receipt's count would label it with the old one's + // page count until the server responds. + pageCount: null, }; const newTransaction = transaction && {...transaction, receipt: receiptOptimistic}; const retryParams: ReplaceReceiptRetryParams = { diff --git a/src/types/onyx/Transaction.ts b/src/types/onyx/Transaction.ts index 259014c53778..56abfe4902fa 100644 --- a/src/types/onyx/Transaction.ts +++ b/src/types/onyx/Transaction.ts @@ -267,8 +267,8 @@ type Receipt = { /** Collection of reservations */ reservationList?: Reservation[]; - /** Number of pages in a receipt stored as a PDF. Absent for images, and for PDFs uploaded before the backend reported a count. */ - pageCount?: number; + /** Number of pages in a receipt stored as a PDF. Absent for images, for PDFs uploaded before the backend reported a count, and null while a replacement receipt is pending. */ + pageCount?: number | null; /** Whether this is a test receipt */ isTestReceipt?: true; From ccb27a3cdd9869eb76ecea565e81d0663a223ec4 Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Tue, 11 Aug 2026 18:48:05 +0530 Subject: [PATCH 11/13] Cover the page count badge gating Asserts the badge renders for a multi-page PDF and stays hidden for a single page receipt and for one with no count at all, which is also what a PDF uploaded before the backend reported counts looks like. Co-Authored-By: Claude Opus 5 (1M context) --- .../MoneyRequestReceiptViewTest.tsx | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/tests/ui/components/MoneyRequestReceiptViewTest.tsx b/tests/ui/components/MoneyRequestReceiptViewTest.tsx index d30661ab07d8..d3ecc69f67e5 100644 --- a/tests/ui/components/MoneyRequestReceiptViewTest.tsx +++ b/tests/ui/components/MoneyRequestReceiptViewTest.tsx @@ -191,6 +191,16 @@ const transactionWithReceipt: Transaction = { }, }; +const transactionWithMultiPagePDFReceipt: Transaction = { + ...transactionWithoutReceipt, + receipt: { + state: CONST.IOU.RECEIPT_STATE.OPEN, + source: 'https://example.com/receipt.pdf', + filename: 'receipt.pdf', + pageCount: 3, + }, +}; + const transactionWithScanningReceipt: Transaction = { ...transactionWithoutReceipt, receipt: { @@ -272,6 +282,61 @@ describe('MoneyRequestReceiptView', () => { }); }); + describe('receipt page count badge', () => { + it('shows the page count for a multi-page PDF receipt', async () => { + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${TEST_TRANSACTION_ID}`, transactionWithMultiPagePDFReceipt); + }); + await waitForBatchedUpdatesWithAct(); + + render( + + + , + ); + await waitForBatchedUpdatesWithAct(); + + expect(screen.getByText(translateLocal('receipt.pageCount', {pageCount: 3}))).toBeTruthy(); + }); + + it('does not show the page count for a single page PDF receipt', async () => { + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${TEST_TRANSACTION_ID}`, { + ...transactionWithMultiPagePDFReceipt, + receipt: {...transactionWithMultiPagePDFReceipt.receipt, pageCount: 1}, + }); + }); + await waitForBatchedUpdatesWithAct(); + + render( + + + , + ); + await waitForBatchedUpdatesWithAct(); + + expect(screen.queryByText(translateLocal('receipt.pageCount', {pageCount: 1}))).toBeNull(); + }); + + // An image receipt carries no page count at all, which is also what a PDF uploaded before the + // backend started reporting one looks like + it('does not show the page count for a receipt without one', async () => { + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${TEST_TRANSACTION_ID}`, transactionWithReceipt); + }); + await waitForBatchedUpdatesWithAct(); + + render( + + + , + ); + await waitForBatchedUpdatesWithAct(); + + expect(screen.queryByText(translateLocal('receipt.pageCount', {pageCount: 3}))).toBeNull(); + }); + }); + describe('receipt action buttons visibility', () => { it('does not show action buttons when transaction has no receipt', async () => { render( From 2bdd2d06aa0ec97907bc92654042c393a02ff09c Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Thu, 13 Aug 2026 16:51:07 +0530 Subject: [PATCH 12/13] Only show the page count when the receipt is actually a PDF The merge flow writes the chosen receipt with a merge, so picking an image left the target's old PDF page count in Onyx and the badge rendered "Page 1 of N" over a photo. Gating on the current file type fixes that and any future optimistic writer, since a page count only means anything for a PDF. The merge flow also clears the count explicitly now, so the stale value does not sit in Onyx for other consumers, matching what replaceReceipt already does. Co-Authored-By: Claude Opus 5 (1M context) --- .../MoneyRequestReceiptView.tsx | 9 +++++--- src/libs/actions/MergeTransaction.ts | 5 ++++- .../MoneyRequestReceiptViewTest.tsx | 21 +++++++++++++++++++ 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/components/ReportActionItem/MoneyRequestReceiptView.tsx b/src/components/ReportActionItem/MoneyRequestReceiptView.tsx index 842093a23a05..1a9f5d873c4a 100644 --- a/src/components/ReportActionItem/MoneyRequestReceiptView.tsx +++ b/src/components/ReportActionItem/MoneyRequestReceiptView.tsx @@ -86,6 +86,7 @@ import type {ValueOf} from 'type-fest'; import {useRoute} from '@react-navigation/native'; import {hasSeenTourSelector} from '@selectors/Onboarding'; import {conciergePersonalDetailSelector, personalDetailsSelector} from '@selectors/PersonalDetails'; +import {Str} from 'expensify-common'; import mapValues from 'lodash/mapValues'; import React, {useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; @@ -550,9 +551,11 @@ function MoneyRequestReceiptView({ // Map distance receipts show both hover actions just like regular receipts, so we don't exclude isMapDistanceRequest here. const canShowReceiptActions = hasReceipt && !isLoading && isEditable && !mergeTransactionID; - // Held back until the receipt has loaded: the container stretches while loading, which would strand - // the badge at the bottom of that taller box instead of sitting on the receipt. - const shouldShowReceiptPageCount = receiptPageCount > 1 && !isLoading; + // A page count only means anything for a PDF, and optimistic writers that swap the receipt with a + // merge can leave the replaced PDF's count behind, so the current file type decides this rather + // than the count alone. Held back until the receipt has loaded too: the container stretches while + // loading, which would strand the badge at the bottom of that taller box. + const shouldShowReceiptPageCount = receiptPageCount > 1 && Str.isPDF(receiptURIs?.filename ?? '') && !isLoading; const receiptPendingAction = isDistanceRequest ? getPendingFieldAction('waypoints') : getPendingFieldAction('receipt'); const isReceiptOfflinePending = isOffline && !!receiptPendingAction; const receiptAuditMessagesRow = ( diff --git a/src/libs/actions/MergeTransaction.ts b/src/libs/actions/MergeTransaction.ts index 52d0fb5f4550..cf3dac2b8912 100644 --- a/src/libs/actions/MergeTransaction.ts +++ b/src/libs/actions/MergeTransaction.ts @@ -348,7 +348,10 @@ function getOnyxTargetTransactionData({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${targetTransaction.transactionID}`, value: { - receipt: mergeTransaction.receipt ?? null, + // pageCount is cleared explicitly because this is a merge: the chosen receipt is not + // necessarily a multi-page PDF, and omitting the key would leave the target's old count + // describing a receipt it no longer has. + receipt: mergeTransaction.receipt ? {pageCount: null, ...mergeTransaction.receipt} : null, }, }); diff --git a/tests/ui/components/MoneyRequestReceiptViewTest.tsx b/tests/ui/components/MoneyRequestReceiptViewTest.tsx index d3ecc69f67e5..aa395d6d4dd6 100644 --- a/tests/ui/components/MoneyRequestReceiptViewTest.tsx +++ b/tests/ui/components/MoneyRequestReceiptViewTest.tsx @@ -318,6 +318,27 @@ describe('MoneyRequestReceiptView', () => { expect(screen.queryByText(translateLocal('receipt.pageCount', {pageCount: 1}))).toBeNull(); }); + // An optimistic merge that swaps a PDF for an image can leave the PDF's count behind, so the + // badge has to follow the current file type rather than the leftover count + it('does not show the page count when a stale count is left on an image receipt', async () => { + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${TEST_TRANSACTION_ID}`, { + ...transactionWithMultiPagePDFReceipt, + receipt: {...transactionWithMultiPagePDFReceipt.receipt, source: 'https://example.com/photo.jpg', filename: 'photo.jpg'}, + }); + }); + await waitForBatchedUpdatesWithAct(); + + render( + + + , + ); + await waitForBatchedUpdatesWithAct(); + + expect(screen.queryByText(translateLocal('receipt.pageCount', {pageCount: 3}))).toBeNull(); + }); + // An image receipt carries no page count at all, which is also what a PDF uploaded before the // backend started reporting one looks like it('does not show the page count for a receipt without one', async () => { From 0649126419ac84eb52892a6286b9f3bafdb69665 Mon Sep 17 00:00:00 2001 From: Ishpaul Singh Date: Thu, 13 Aug 2026 17:57:55 +0530 Subject: [PATCH 13/13] Stub the dismiss navigation the sign-in modal runs on its own SignInModal dismisses itself once IS_LOADING_APP settles to false. The back-handling test never sets that flag, but Onyx carries it in from whatever ran earlier in the worker, so the dismiss effect fired in CI and threw on the unmocked dismissModal before any assertion ran. --- tests/unit/SignInBackButtonTest.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/unit/SignInBackButtonTest.tsx b/tests/unit/SignInBackButtonTest.tsx index 338c94552496..5b688df7c2c4 100644 --- a/tests/unit/SignInBackButtonTest.tsx +++ b/tests/unit/SignInBackButtonTest.tsx @@ -16,6 +16,11 @@ jest.mock('@libs/Navigation/Navigation', () => ({ goBack: () => { mockGoBack(); }, + // SignInModal dismisses itself once IS_LOADING_APP settles to false. That is unrelated to back + // handling, but Onyx carries the flag in from whatever ran earlier, so both must be stubbed or the + // dismiss effect throws before the assertions run. + dismissModal: jest.fn(), + navigate: jest.fn(), }, navigationRef: { get current() {