diff --git a/src/components/ReportActionItem/MoneyRequestReceiptView.tsx b/src/components/ReportActionItem/MoneyRequestReceiptView.tsx
index c059f04944fb..4ce6a01e6337 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';
@@ -85,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';
@@ -196,6 +198,9 @@ 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 isTransactionScanning = isScanning(displayedTransaction);
const didReceiptScanSucceed = hasReceipt && didReceiptScanSucceedTransactionUtils(transaction);
const isInvoice = isInvoiceReport(moneyRequestReport);
@@ -545,6 +550,12 @@ 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;
+
+ // 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 = (
@@ -702,6 +713,12 @@ function MoneyRequestReceiptView({
)}
+ {shouldShowReceiptPageCount && (
+
+ )}
{canShowReceiptActions && (
diff --git a/src/languages/de.ts b/src/languages/de.ts
index 671463f45aff..3d1ccf8314b1 100644
--- a/src/languages/de.ts
+++ b/src/languages/de.ts
@@ -1213,6 +1213,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 b6dcc8eddc55..a0ad1f912e73 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/en.ts b/src/languages/en.ts
index f083414ef702..8cefa9b9724f 100644
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -1288,6 +1288,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}: {pageCount: number}) => `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 2edd8cb93b03..46c60591058b 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -1207,6 +1207,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}: {pageCount: number}) => `Página 1 de ${pageCount}`,
addAReceipt: {
phrase1: 'Añade un recibo',
phrase2: 'o arrastra y suelta uno aquí',
diff --git a/src/languages/fr.ts b/src/languages/fr.ts
index 7468a28abfb3..f5eff809fdf9 100644
--- a/src/languages/fr.ts
+++ b/src/languages/fr.ts
@@ -1217,6 +1217,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 a7b8b777aaff..bbb2a831be35 100644
--- a/src/languages/it.ts
+++ b/src/languages/it.ts
@@ -1212,6 +1212,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 b8cb7a550c87..dd7aeffb3aa5 100644
--- a/src/languages/ja.ts
+++ b/src/languages/ja.ts
@@ -1196,6 +1196,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 8fd574249a88..b1f5d0d0a87e 100644
--- a/src/languages/nl.ts
+++ b/src/languages/nl.ts
@@ -1211,6 +1211,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 0c428e2f01bd..ce30d748e90d 100644
--- a/src/languages/pl.ts
+++ b/src/languages/pl.ts
@@ -1207,6 +1207,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 500f04df05c6..89c5dee8e01b 100644
--- a/src/languages/pt-BR.ts
+++ b/src/languages/pt-BR.ts
@@ -1211,6 +1211,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 f98945ab108d..a66c2bd9fc8b 100644
--- a/src/languages/zh-hans.ts
+++ b/src/languages/zh-hans.ts
@@ -1162,6 +1162,7 @@ const translations: TranslationDeepObject = {
phrase1: '添加收据',
phrase2: '或将文件拖放到此处',
},
+ pageCount: ({pageCount}: {pageCount: number}) => `第 1 页,共 ${pageCount} 页`,
},
quickAction: {
scanReceipt: '扫描收据',
diff --git a/src/libs/DebugUtils.ts b/src/libs/DebugUtils.ts
index fbff61690796..7ab2b0e41500 100644
--- a/src/libs/DebugUtils.ts
+++ b/src/libs/DebugUtils.ts
@@ -837,6 +837,7 @@ function validateReportActionDraftProperty(key: keyof ReportAction, value: strin
isTestDriveReceipt: 'boolean',
thumbnail: 'string',
receiptTraceId: 'string',
+ pageCount: 'number',
});
case 'childRecentReceiptTransactionIDs':
return validateObject>(value, {}, 'string');
@@ -1206,6 +1207,7 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string)
isTestDriveReceipt: 'boolean',
thumbnail: 'string',
receiptTraceId: 'string',
+ pageCount: 'number',
});
case 'taxRate':
return validateObject>(value, {
diff --git a/src/libs/actions/IOU/Receipt.ts b/src/libs/actions/IOU/Receipt.ts
index ffa30e883b72..c13ab2a84287 100644
--- a/src/libs/actions/IOU/Receipt.ts
+++ b/src/libs/actions/IOU/Receipt.ts
@@ -208,6 +208,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/libs/actions/MergeTransaction.ts b/src/libs/actions/MergeTransaction.ts
index 8a8e9be67e51..f8dbcb546c39 100644
--- a/src/libs/actions/MergeTransaction.ts
+++ b/src/libs/actions/MergeTransaction.ts
@@ -360,7 +360,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/src/styles/index.ts b/src/styles/index.ts
index b4695e8a3617..a7ec45df5081 100644
--- a/src/styles/index.ts
+++ b/src/styles/index.ts
@@ -4038,6 +4038,16 @@ const staticStyles = (theme: ThemeColors) =>
gap: 8,
},
+ // 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,
+ left: 12,
+ marginLeft: 0,
+ },
+
receiptActionButton: {
width: 40,
height: 40,
diff --git a/src/types/onyx/Transaction.ts b/src/types/onyx/Transaction.ts
index ec1601b622fe..57c46695390c 100644
--- a/src/types/onyx/Transaction.ts
+++ b/src/types/onyx/Transaction.ts
@@ -273,6 +273,9 @@ type Receipt = {
/** Collection of reservations */
reservationList?: Reservation[];
+ /** 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;
diff --git a/tests/ui/components/MoneyRequestReceiptViewTest.tsx b/tests/ui/components/MoneyRequestReceiptViewTest.tsx
index ddf69f981ca2..b3a2882a9c20 100644
--- a/tests/ui/components/MoneyRequestReceiptViewTest.tsx
+++ b/tests/ui/components/MoneyRequestReceiptViewTest.tsx
@@ -193,6 +193,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: {
@@ -274,6 +284,82 @@ 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 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 () => {
+ 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(