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(