diff --git a/src/components/DotIndicatorMessage.tsx b/src/components/DotIndicatorMessage.tsx index 5475d768f715..8fd00ab37379 100644 --- a/src/components/DotIndicatorMessage.tsx +++ b/src/components/DotIndicatorMessage.tsx @@ -42,15 +42,20 @@ type DotIndicatorMessageProps = { style?: StyleProp; textStyles?: StyleProp; + + // Unused here, but OfflineWithFeedback still passes it to every error row. + // eslint-disable-next-line react/no-unused-prop-types dismissError?: () => void; + + onRetryReceiptUpload?: () => void; }; -function DotIndicatorMessage({messages = {}, style, type, textStyles, dismissError = () => {}}: DotIndicatorMessageProps) { +function DotIndicatorMessage({messages = {}, style, type, textStyles, onRetryReceiptUpload}: DotIndicatorMessageProps) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); - const expensifyIcons = useMemoizedLazyExpensifyIcons(['DotIndicator']); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['ArrowCircleClockwise', 'DotIndicator', 'Download']); // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {shouldUseNarrowLayout, isSmallScreenWidth, isInNarrowPaneModal} = useResponsiveLayout(); @@ -119,37 +124,34 @@ function DotIndicatorMessage({messages = {}, style, type, textStyles, dismissErr const isStackedLayout = !(isInNarrowPaneModal && !isSmallScreenWidth); const messageRow = ( - - - - {translate('iou.error.receiptUploadFailedMessage')} + {translate(onRetryReceiptUpload ? 'iou.error.receiptUploadFailedMessage' : 'iou.error.receiptUploadFailedSaveOnlyMessage')} ); const buttonsRow = ( - + + {!!onRetryReceiptUpload && ( + + )} - ); diff --git a/src/components/ErrorMessageRow.tsx b/src/components/ErrorMessageRow.tsx index b76db0a1eb38..31ffc8760f4c 100644 --- a/src/components/ErrorMessageRow.tsx +++ b/src/components/ErrorMessageRow.tsx @@ -18,9 +18,11 @@ type ErrorMessageRowProps = { onDismiss?: () => void; dismissError?: () => void; + + onRetryReceiptUpload?: () => void; }; -function ErrorMessageRow({errors, errorRowStyles, onDismiss, dismissError, errorRowTextStyles}: ErrorMessageRowProps) { +function ErrorMessageRow({errors, errorRowStyles, onDismiss, dismissError, errorRowTextStyles, onRetryReceiptUpload}: ErrorMessageRowProps) { // Some errors have a null message. This is used to apply opacity only and to avoid showing redundant messages. const errorEntries = Object.entries(errors ?? {}); const filteredErrorEntries = errorEntries.filter((errorEntry): errorEntry is [string, string | ReceiptError | OnyxCommon.TranslationKeyError] => errorEntry[1] !== null); @@ -35,6 +37,7 @@ function ErrorMessageRow({errors, errorRowStyles, onDismiss, dismissError, error containerStyles={errorRowStyles} errorTextStyles={errorRowTextStyles} dismissError={dismissError} + onRetryReceiptUpload={onRetryReceiptUpload} /> ) : null; } diff --git a/src/components/MessagesRow.tsx b/src/components/MessagesRow.tsx index ed5871f96aec..31835edf442b 100644 --- a/src/components/MessagesRow.tsx +++ b/src/components/MessagesRow.tsx @@ -32,9 +32,11 @@ type MessagesRowProps = { containerStyles?: StyleProp; errorTextStyles?: StyleProp; dismissError?: () => void; + + onRetryReceiptUpload?: () => void; }; -function MessagesRow({messages = {}, type, onDismiss, containerStyles, dismissError = () => {}, errorTextStyles}: MessagesRowProps) { +function MessagesRow({messages = {}, type, onDismiss, containerStyles, dismissError = () => {}, errorTextStyles, onRetryReceiptUpload}: MessagesRowProps) { const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -53,6 +55,7 @@ function MessagesRow({messages = {}, type, onDismiss, containerStyles, dismissEr & { shouldForceOpacity?: boolean; dismissError?: () => void; + + onRetryReceiptUpload?: () => void; }; type StrikethroughProps = Partial & {style: AllStyles[]}; @@ -81,6 +83,7 @@ function OfflineWithFeedback({ shouldDisplayErrorAbove = false, shouldForceOpacity = false, dismissError = () => {}, + onRetryReceiptUpload, errorRowTextStyles, ...restProps }: OfflineWithFeedbackProps) { @@ -142,6 +145,7 @@ function OfflineWithFeedback({ onDismiss={onDismiss} errorRowTextStyles={errorRowTextStyles} dismissError={dismissError} + onRetryReceiptUpload={onRetryReceiptUpload} /> )} {hasChildren && !hideChildren && ( @@ -159,6 +163,7 @@ function OfflineWithFeedback({ errorRowTextStyles={errorRowTextStyles} onDismiss={onDismiss} dismissError={dismissError} + onRetryReceiptUpload={onRetryReceiptUpload} /> )} diff --git a/src/components/ReportActionItem/MoneyRequestReceiptView.tsx b/src/components/ReportActionItem/MoneyRequestReceiptView.tsx index 1207656c19a6..1bce2e478035 100644 --- a/src/components/ReportActionItem/MoneyRequestReceiptView.tsx +++ b/src/components/ReportActionItem/MoneyRequestReceiptView.tsx @@ -37,8 +37,12 @@ import {getBrokenConnectionUrlToFixPersonalCard} from '@libs/CardUtils'; import {hasHoverSupport} from '@libs/DeviceCapabilities'; import {getMicroSecondOnyxErrorObject, getMicroSecondOnyxErrorWithTranslationKey, isReceiptError} from '@libs/ErrorUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import Log from '@libs/Log'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; +import {isTrackOnboardingChoice} from '@libs/OnboardingUtils'; import {isGroupPolicyByType} from '@libs/PolicyUtils'; +import retryReceiptUpload, {canBuildRetryPayload} from '@libs/ReceiptUploadRetryHandler'; +import type {ReceiptRetryContext} from '@libs/ReceiptUploadRetryHandler/types'; import {getThumbnailAndImageURIs} from '@libs/ReceiptUtils'; import {getOriginalMessage, isMoneyRequestAction, wasActionTakenByCurrentUser} from '@libs/ReportActionsUtils'; import {isMarkAsCashActionForTransaction} from '@libs/ReportPrimaryActionUtils'; @@ -70,7 +74,7 @@ import variables from '@styles/variables'; import {clearAllRelatedReportActionErrors} from '@userActions/ClearReportActionErrors'; import {cleanUpMoneyRequest} from '@userActions/IOU/DeleteMoneyRequest'; -import {replaceReceipt} from '@userActions/IOU/Receipt'; +import {clearReceiptUploadError, replaceReceipt} from '@userActions/IOU/Receipt'; import {addAttachmentWithComment, navigateToConciergeChatAndDeleteReport, setDeleteTransactionNavigateBackUrl} from '@userActions/Report'; import {clearError, getLastModifiedExpense, revert} from '@userActions/Transaction'; @@ -78,7 +82,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; -import type {TransactionPendingFieldsKey} from '@src/types/onyx/Transaction'; +import type {ReceiptError, TransactionPendingFieldsKey} from '@src/types/onyx/Transaction'; import type {FileObject} from '@src/types/utils/Attachment'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -137,8 +141,8 @@ function MoneyRequestReceiptView({ hasParentPendingAction = false, }: MoneyRequestReceiptViewProps) { const styles = useThemeStyles(); - const {translate, dateFnsLocale} = useLocalize(); - const {isBetaEnabledOrUnknown} = usePermissions(); + const {translate, dateFnsLocale, formatPhoneNumber} = useLocalize(); + const {isBetaEnabled, isBetaEnabledOrUnknown} = usePermissions(); const isVendorMatchingBetaEnabled = isBetaEnabledOrUnknown(CONST.BETAS.VENDOR_MATCHING); const {convertToDisplayString, getCurrencyDecimals} = useCurrencyListActions(); const {environmentURL} = useEnvironment(); @@ -418,6 +422,44 @@ function MoneyRequestReceiptView({ const {showConfirmModal} = useConfirmModal(); + const retryableReceiptError = Object.values(errors ?? {}).find((error): error is ReceiptError => isReceiptError(error)); + const receiptRetryContext: ReceiptRetryContext | undefined = retryableReceiptError + ? { + receiptError: retryableReceiptError, + transaction, + iouReport: moneyRequestReport, + iouActionID: parentReportAction?.reportActionID, + transactionThreadReportID: report?.reportID, + policyParams: {policy, policyCategories, policyTagList}, + isVendorMatchingBetaEnabled, + rules, + conciergeReportID, + isSelfTourViewed: !!isSelfTourViewed, + isASAPSubmitBetaEnabled: isBetaEnabled(CONST.BETAS.ASAP_SUBMIT), + isTrackIntentUser: isTrackOnboardingChoice(introSelected?.choice), + delegateAccountID, + formatPhoneNumber, + getCurrencyDecimals, + } + : undefined; + + const canRetryUpload = !!receiptRetryContext && canBuildRetryPayload(receiptRetryContext); + + const retryReceiptUploadAndClearError = () => { + if (!receiptRetryContext) { + return; + } + + retryReceiptUpload(receiptRetryContext, () => + clearReceiptUploadError({ + transactionID: transaction?.transactionID, + reportID: parentReportAction?.reportID ?? report?.reportID, + reportActionID: parentReportAction?.reportActionID, + reportIDWithCreationError: report?.reportID, + }), + ).catch((error: unknown) => Log.alert('[ReceiptRetry] Retry failed unexpectedly', {error})); + }; + const transactionAndReportActionErrors = useMemo( () => ({ ...transaction?.errors, @@ -662,6 +704,7 @@ function MoneyRequestReceiptView({ }); }} dismissError={dismissReceiptError} + onRetryReceiptUpload={canRetryUpload ? retryReceiptUploadAndClearError : undefined} style={[shouldShowAuditMessage ? styles.mt3 : styles.mv3, !showReceiptErrorWithEmptyState && styles.flex1]} contentContainerStyle={styles.flex1} > diff --git a/src/languages/de.ts b/src/languages/de.ts index a658e401e58d..17ea02040afb 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1623,7 +1623,7 @@ const translations: TranslationDeepObject = { receiptFailureMessage: 'Beim Hochladen deiner Quittung ist ein Fehler aufgetreten. Bitte speichere die Quittung und versuche es später erneut.', receiptFailureMessageShort: 'Beim Hochladen Ihres Belegs ist ein Fehler aufgetreten.', - receiptUploadFailedMessage: 'Beleg-Upload fehlgeschlagen. Speichere den Beleg oder lösche die Ausgabe und verliere sie.', + receiptUploadFailedMessage: 'Upload fehlgeschlagen. Versuchen Sie es erneut oder speichern Sie es für später.', saveReceipt: 'Beleg speichern', genericDeleteFailureMessage: 'Unerwarteter Fehler beim Löschen dieses Belegs. Bitte versuche es später erneut.', genericEditFailureMessage: 'Unerwarteter Fehler beim Bearbeiten dieser Ausgabe. Bitte versuche es später noch einmal.', @@ -1656,6 +1656,7 @@ const translations: TranslationDeepObject = { 'Diese Berichte konnten nicht als erledigt markiert werden. Prüfen Sie, dass Ausgaben nicht zurückgehalten oder ausstehend sind, und versuchen Sie es dann erneut.', stitchOdometerImagesFailed: 'Kilometerzählerbilder konnten nicht zusammengeführt werden. Bitte versuchen Sie es später noch einmal.', failedToSaveOdometerDraft: 'Dein Kilometerzähler-Entwurf konnte nicht gespeichert werden. Bitte versuche es erneut.', + receiptUploadFailedSaveOnlyMessage: 'Upload fehlgeschlagen. Speichern Sie Ihre Quittung, um sie zu behalten.', }, dismissReceiptError: 'Fehler ausblenden', dismissReceiptErrorConfirmation: 'Achtung! Wenn du diesen Fehler schließt, wird deine hochgeladene Quittung vollständig entfernt. Bist du sicher?', diff --git a/src/languages/el.ts b/src/languages/el.ts index d6af0daafd7b..56bbf2e0c8cc 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -1694,7 +1694,7 @@ const translations: TranslationDeepObject = { receiptFailureMessage: 'Προέκυψε σφάλμα κατά τη μεταφόρτωση της απόδειξής σας. Παρακαλούμε αποθηκεύστε την απόδειξη και δοκιμάστε ξανά αργότερα.', receiptFailureMessageShort: 'Παρουσιάστηκε σφάλμα κατά τη μεταφόρτωση της απόδειξής σας.', - receiptUploadFailedMessage: 'Η αποστολή της απόδειξης απέτυχε. Αποθηκεύστε την απόδειξη ή διαγράψτε την δαπάνη και χάστε την.', + receiptUploadFailedMessage: 'Η μεταφόρτωση απέτυχε. Δοκιμάστε ξανά ή αποθηκεύστε για αργότερα.', saveReceipt: 'Αποθήκευση απόδειξης', genericDeleteFailureMessage: 'Παρουσιάστηκε απρόσμενο σφάλμα κατά τη διαγραφή αυτής της δαπάνης. Παρακαλούμε δοκιμάστε ξανά αργότερα.', genericEditFailureMessage: 'Παρουσιάστηκε απροσδόκητο σφάλμα κατά την επεξεργασία αυτής της δαπάνης. Παρακαλούμε δοκιμάστε ξανά αργότερα.', @@ -1712,6 +1712,7 @@ const translations: TranslationDeepObject = { endDateSameAsStartDate: 'Η ημερομηνία λήξης δεν μπορεί να είναι ίδια με την ημερομηνία έναρξης', manySplitsProvided: `Ο μέγιστος επιτρεπόμενος αριθμός διαχωρισμών είναι ${CONST.IOU.SPLITS_LIMIT}.`, dateRangeExceedsMaxDays: `Το εύρος ημερομηνιών δεν μπορεί να υπερβαίνει τις ${CONST.IOU.SPLITS_LIMIT} ημέρες.`, + receiptUploadFailedSaveOnlyMessage: 'Η αποστολή απέτυχε. Αποθηκεύστε την απόδειξή σας για να τη διατηρήσετε.', }, dismissReceiptError: 'Απόρριψη σφάλματος', dismissReceiptErrorConfirmation: 'Προσοχή! Αν απορρίψετε αυτό το σφάλμα, η ανεβασμένη απόδειξή σας θα διαγραφεί πλήρως. Είστε βέβαιοι;', diff --git a/src/languages/en.ts b/src/languages/en.ts index b8e12bffbcf6..42786f5d0ff8 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1729,7 +1729,8 @@ const translations = { receiptDeleteFailureError: 'Unexpected error deleting this receipt. Please try again later.', receiptFailureMessage: 'There was an error uploading your receipt. Please save the receipt and try again later.', receiptFailureMessageShort: 'There was an error uploading your receipt.', - receiptUploadFailedMessage: 'Receipt upload failed. Save the receipt, or delete the expense and lose it.', + receiptUploadFailedMessage: 'Upload failed. Try again or save for later.', + receiptUploadFailedSaveOnlyMessage: 'Upload failed. Save your receipt to keep it.', saveReceipt: 'Save receipt', genericDeleteFailureMessage: 'Unexpected error deleting this expense. Please try again later.', genericEditFailureMessage: 'Unexpected error editing this expense. Please try again later.', diff --git a/src/languages/es.ts b/src/languages/es.ts index 4f86d998e75e..8802b1c65618 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1635,7 +1635,7 @@ const translations: TranslationDeepObject = { receiptDeleteFailureError: 'Error inesperado al borrar este recibo. Por favor, vuelve a intentarlo más tarde.', receiptFailureMessage: 'Hubo un error al cargar tu recibo. Por favor, guarda el recibo e inténtalo de nuevo más tarde.', receiptFailureMessageShort: 'Hubo un error al cargar tu recibo.', - receiptUploadFailedMessage: 'Error al subir el recibo. Guarda el recibo, o elimina el gasto y piérdelo.', + receiptUploadFailedMessage: 'La carga ha fallado. Inténtalo de nuevo o guárdalo para más tarde.', saveReceipt: 'Guardar recibo', genericDeleteFailureMessage: 'Error inesperado al eliminar este gasto. Por favor, inténtalo más tarde.', genericEditFailureMessage: 'Error inesperado al editar este gasto. Por favor, inténtalo más tarde.', @@ -1653,6 +1653,7 @@ const translations: TranslationDeepObject = { endDateSameAsStartDate: 'La fecha de finalización no puede ser la misma que la fecha de inicio', manySplitsProvided: `El número máximo de divisiones permitidas es ${CONST.IOU.SPLITS_LIMIT}.`, dateRangeExceedsMaxDays: `El rango de fechas no puede exceder los ${CONST.IOU.SPLITS_LIMIT} días.`, + receiptUploadFailedSaveOnlyMessage: 'La carga ha fallado. Guarda tu recibo para conservarlo.', }, dismissReceiptError: 'Descartar error', dismissReceiptErrorConfirmation: '¡Atención! Descartar este error eliminará completamente tu recibo cargado. ¿Estás seguro?', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 4b9245a77d06..1c68407501d3 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1627,7 +1627,7 @@ const translations: TranslationDeepObject = { receiptFailureMessage: 'Une erreur s’est produite lors du téléchargement de votre reçu. Veuillez enregistrer le reçu et réessayer plus tard.', receiptFailureMessageShort: 'Une erreur s’est produite lors du téléchargement de votre reçu.', - receiptUploadFailedMessage: 'Échec du téléchargement du reçu. Enregistrez le reçu ou supprimez la dépense et perdez-la.', + receiptUploadFailedMessage: 'Le téléversement a échoué. Réessayez ou enregistrez pour plus tard.', saveReceipt: 'Enregistrer le reçu', genericDeleteFailureMessage: 'Erreur inattendue lors de la suppression de cette dépense. Veuillez réessayer plus tard.', genericEditFailureMessage: 'Erreur inattendue lors de la modification de cette dépense. Veuillez réessayer plus tard.', @@ -1660,6 +1660,7 @@ const translations: TranslationDeepObject = { reportsNotMarkedAsDoneDescription: "Ces notes de frais n'ont pas pu être marquées comme terminées. Vérifiez qu'aucune dépense n'est en attente ou en suspens, puis réessayez.", stitchOdometerImagesFailed: 'Échec de la combinaison des images de l’odomètre. Veuillez réessayer plus tard.', failedToSaveOdometerDraft: 'Impossible d’enregistrer votre brouillon de compteur kilométrique. Veuillez réessayer.', + receiptUploadFailedSaveOnlyMessage: 'Téléversement échoué. Enregistrez votre reçu pour le conserver.', }, dismissReceiptError: 'Ignorer l’erreur', dismissReceiptErrorConfirmation: 'Attention ! Ignorer cette erreur supprimera complètement votre reçu téléversé. Êtes-vous sûr ?', diff --git a/src/languages/it.ts b/src/languages/it.ts index 8b51f2bd2fa5..86a964a26403 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1620,7 +1620,7 @@ const translations: TranslationDeepObject = { receiptFailureMessage: 'Si è verificato un errore durante il caricamento della ricevuta. Salva la ricevuta e riprova più tardi.', receiptFailureMessageShort: 'Si è verificato un errore durante il caricamento della ricevuta.', - receiptUploadFailedMessage: 'Caricamento della ricevuta non riuscito. Salva la ricevuta oppure elimina la spesa e perdila.', + receiptUploadFailedMessage: 'Caricamento non riuscito. Riprova o salva per dopo.', saveReceipt: 'Salva ricevuta', genericDeleteFailureMessage: 'Errore imprevisto durante l’eliminazione di questa spesa. Riprova più tardi.', genericEditFailureMessage: 'Errore imprevisto durante la modifica di questa spesa. Riprova più tardi.', @@ -1651,6 +1651,7 @@ const translations: TranslationDeepObject = { dateRangeExceedsMaxDays: `L’intervallo di date non può superare ${CONST.IOU.SPLITS_LIMIT} giorni.`, stitchOdometerImagesFailed: 'Impossibile combinare le immagini del contachilometri. Riprova più tardi.', failedToSaveOdometerDraft: 'Impossibile salvare la tua bozza del contachilometri. Riprova.', + receiptUploadFailedSaveOnlyMessage: 'Caricamento non riuscito. Salva la ricevuta per conservarla.', }, dismissReceiptError: 'Ignora errore', dismissReceiptErrorConfirmation: 'Attenzione! Chiudere questo errore rimuoverà completamente la ricevuta che hai caricato. Sei sicuro?', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 4368be81e41d..70a7c65b0abf 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1604,7 +1604,7 @@ const translations: TranslationDeepObject = { receiptFailureMessage: '領収書のアップロード中にエラーが発生しました。後で再度お試しいただくために、領収書を保存してから、時間をおいてもう一度お試しください。', receiptFailureMessageShort: 'レシートのアップロード中にエラーが発生しました。', - receiptUploadFailedMessage: 'レシートのアップロードに失敗しました。レシートを保存するか、経費を削除して失うかを選択してください。', + receiptUploadFailedMessage: 'アップロードに失敗しました。もう一度お試しいただくか、あとで保存してください。', saveReceipt: '領収書を保存', genericDeleteFailureMessage: 'この経費の削除中に予期しないエラーが発生しました。しばらくしてからもう一度お試しください。', genericEditFailureMessage: 'この経費の編集中に予期しないエラーが発生しました。後でもう一度お試しください。', @@ -1634,6 +1634,7 @@ const translations: TranslationDeepObject = { reportsNotMarkedAsDoneDescription: 'これらのレポートを完了済みにできませんでした。経費が保留中や未処理になっていないか確認してから、もう一度お試しください。', stitchOdometerImagesFailed: '走行距離計の画像を結合できませんでした。後でもう一度お試しください。', failedToSaveOdometerDraft: 'オドメーターの下書きを保存できませんでした。もう一度お試しください。', + receiptUploadFailedSaveOnlyMessage: 'アップロードに失敗しました。レシートを保存して保持してください。', }, dismissReceiptError: 'エラーを閉じる', dismissReceiptErrorConfirmation: 'ご注意ください!このエラーを閉じると、アップロード済みのレシートが完全に削除されます。本当に続行しますか?', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index f75cdd491416..bfa0cc3bbe5c 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1618,7 +1618,7 @@ const translations: TranslationDeepObject = { receiptDeleteFailureError: 'Onverwachte fout bij het verwijderen van deze bon. Probeer het later opnieuw.', receiptFailureMessage: 'Er is een fout opgetreden bij het uploaden van je bon. Sla de bon op en probeer het later opnieuw.', receiptFailureMessageShort: 'Er is een fout opgetreden bij het uploaden van je bon.', - receiptUploadFailedMessage: 'Uploaden van bon mislukt. Sla de bon op of verwijder de uitgave en verlies hem.', + receiptUploadFailedMessage: 'Uploaden mislukt. Probeer het opnieuw of bewaar het voor later.', saveReceipt: 'Bon opslaan', genericDeleteFailureMessage: 'Onverwachte fout bij het verwijderen van deze uitgave. Probeer het later opnieuw.', genericEditFailureMessage: 'Onverwachte fout bij het bewerken van deze uitgave. Probeer het later opnieuw.', @@ -1650,6 +1650,7 @@ const translations: TranslationDeepObject = { 'Deze rapporten konden niet als voltooid worden gemarkeerd. Controleer of er geen onkostendeclaraties vastgehouden of in behandeling zijn en probeer het dan opnieuw.', stitchOdometerImagesFailed: 'Odometerafbeeldingen combineren mislukt. Probeer het later opnieuw.', failedToSaveOdometerDraft: 'De conceptkilometerstand kon niet worden opgeslagen. Probeer het opnieuw.', + receiptUploadFailedSaveOnlyMessage: 'Upload is mislukt. Sla je bon op om hem te bewaren.', }, dismissReceiptError: 'Foutmelding sluiten', dismissReceiptErrorConfirmation: 'Let op! Dit foutbericht negeren verwijdert je geüploade bon volledig. Weet je het zeker?', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index e50594abcd28..d27132dc7da4 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1650,7 +1650,7 @@ const translations: TranslationDeepObject = { receiptDeleteFailureError: 'Nieoczekiwany błąd podczas usuwania tego paragonu. Spróbuj ponownie później.', receiptFailureMessage: 'Wystąpił błąd podczas przesyłania paragonu. Prosimy zapisać paragon i spróbować ponownie później.', receiptFailureMessageShort: 'Wystąpił błąd podczas przesyłania paragonu.', - receiptUploadFailedMessage: 'Nie udało się przesłać paragonu. Zapisz paragon lub usuń wydatek i utracisz go.', + receiptUploadFailedMessage: 'Przesyłanie pliku nie powiodło się. Spróbuj ponownie lub zapisz na później.', saveReceipt: 'Zapisz paragon', genericDeleteFailureMessage: 'Nieoczekiwany błąd podczas usuwania tego wydatku. Spróbuj ponownie później.', genericEditFailureMessage: 'Nieoczekiwany błąd podczas edycji tego wydatku. Spróbuj ponownie później.', @@ -1680,6 +1680,7 @@ const translations: TranslationDeepObject = { reportsNotMarkedAsDoneDescription: 'Nie udało się oznaczyć tych raportów jako zakończone. Sprawdź, czy wydatki nie są wstrzymane ani oczekujące, a następnie spróbuj ponownie.', stitchOdometerImagesFailed: 'Nie udało się połączyć zdjęć licznika kilometrów. Spróbuj ponownie później.', failedToSaveOdometerDraft: 'Nie udało się zapisać Twojego szkicu licznika przebiegu. Spróbuj ponownie.', + receiptUploadFailedSaveOnlyMessage: 'Przesyłanie nie powiodło się. Zapisz paragon, żeby go zachować.', }, dismissReceiptError: 'Odrzuć błąd', dismissReceiptErrorConfirmation: 'Uwaga! Zamknięcie tego błędu spowoduje całkowite usunięcie przesłanego paragonu. Czy na pewno chcesz kontynuować?', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index b4ddb74484fb..859c8b656646 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1617,7 +1617,7 @@ const translations: TranslationDeepObject = { receiptDeleteFailureError: 'Erro inesperado ao excluir este recibo. Tente novamente mais tarde.', receiptFailureMessage: 'Ocorreu um erro ao enviar seu recibo. Por favor, salve o recibo e tente novamente mais tarde.', receiptFailureMessageShort: 'Ocorreu um erro ao enviar seu recibo.', - receiptUploadFailedMessage: 'Falha no envio do recibo. Salve o recibo ou exclua a despesa e perca-a.', + receiptUploadFailedMessage: 'Falha no upload. Tente novamente ou salve para depois.', saveReceipt: 'Salvar recibo', genericDeleteFailureMessage: 'Erro inesperado ao excluir esta despesa. Tente novamente mais tarde.', genericEditFailureMessage: 'Erro inesperado ao editar esta despesa. Tente novamente mais tarde.', @@ -1648,6 +1648,7 @@ const translations: TranslationDeepObject = { reportsNotMarkedAsDoneDescription: 'Não foi possível marcar esses relatórios como concluídos. Verifique se as despesas não estão retidas ou pendentes e tente novamente.', stitchOdometerImagesFailed: 'Falha ao combinar imagens do hodômetro. Tente novamente mais tarde.', failedToSaveOdometerDraft: 'Não foi possível salvar seu rascunho do hodômetro. Tente novamente.', + receiptUploadFailedSaveOnlyMessage: 'Falha no upload. Salve o seu recibo para mantê-lo.', }, dismissReceiptError: 'Dispensar erro', dismissReceiptErrorConfirmation: 'Atenção! Ignorar este erro removerá completamente o comprovante que você enviou. Tem certeza?', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 67fd6aa51206..c9bc0d22ba95 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1561,7 +1561,7 @@ const translations: TranslationDeepObject = { receiptDeleteFailureError: '删除此收据时发生意外错误。请稍后再试。', receiptFailureMessage: '上传您的收据时出错。请保存收据并在稍后重试。', receiptFailureMessageShort: '上传您的收据时出错。', - receiptUploadFailedMessage: '收据上传失败。保存收据,或删除该支出并失去它。', + receiptUploadFailedMessage: '上传失败。请重试或稍后保存。', saveReceipt: '保存收据', genericDeleteFailureMessage: '删除此报销时发生意外错误。请稍后重试。', genericEditFailureMessage: '编辑此报销时发生意外错误。请稍后再试。', @@ -1591,6 +1591,7 @@ const translations: TranslationDeepObject = { reportsNotMarkedAsDoneDescription: '无法将这些报销报告标记为已完成。请检查报销是否未被保留或待处理,然后重试。', stitchOdometerImagesFailed: '合并里程表图片失败。请稍后重试。', failedToSaveOdometerDraft: '无法保存你的里程表草稿。请重试。', + receiptUploadFailedSaveOnlyMessage: '上传失败。请保存收据以保留副本。', }, dismissReceiptError: '忽略错误', dismissReceiptErrorConfirmation: '提醒:关闭此错误将彻底删除你上传的收据。确定要继续吗?', diff --git a/src/libs/ReceiptUploadRetryHandler/buildRetryPayload.ts b/src/libs/ReceiptUploadRetryHandler/buildRetryPayload.ts new file mode 100644 index 000000000000..46f1bba80d55 --- /dev/null +++ b/src/libs/ReceiptUploadRetryHandler/buildRetryPayload.ts @@ -0,0 +1,165 @@ +/** Decides whether a failed receipt upload can be retried and rebuilds its `RequestMoney` call from Onyx. */ +import {isLocalFile} from '@libs/fileDownload/FileUtils'; +import {getTransactionDetails} from '@libs/ReportUtils'; +import {getIsFromGlobalCreate, isDistanceRequest, isPerDiemRequest, isTimeRequest} from '@libs/TransactionUtils'; + +import {getAllPersonalDetails, getAllReports, getAllTransactionViolations, getCurrentUserAccountIDFromSession, getCurrentUserPersonalDetails} from '@userActions/IOU'; +import {getMoneyRequestParticipantsFromReport} from '@userActions/IOU/MoneyRequest'; +import type {RequestMoneyInformation} from '@userActions/IOU/MoneyRequestBuilder'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report} from '@src/types/onyx'; +import type {Participant} from '@src/types/onyx/IOU'; +import type {Receipt} from '@src/types/onyx/Transaction'; +import type {FileObject} from '@src/types/utils/Attachment'; + +import type {OnyxEntry} from 'react-native-onyx'; + +import type {ReceiptRetryContext} from './types'; + +import isRetrySupported from './isRetrySupported'; + +function isRetryableFlow(context: ReceiptRetryContext): boolean { + const {transaction} = context; + if (!isRetrySupported || !transaction) { + return false; + } + + if (!isLocalFile(context.receiptError.source)) { + return false; + } + + // These carry GPS points, rates, or units that the transaction doesn't store, so a retry would drop them. + if (isDistanceRequest(transaction) || isPerDiemRequest(transaction) || isTimeRequest(transaction)) { + return false; + } + if (!!transaction.comment?.waypoints || !!transaction.comment?.customUnit || !!transaction.comment?.type) { + return false; + } + + return !transaction.receipt?.isTestReceipt && !transaction.receipt?.isTestDriveReceipt; +} + +function getCurrentUserAccountID(): number { + return getCurrentUserPersonalDetails()?.accountID ?? getCurrentUserAccountIDFromSession(); +} + +function resolveParticipant(iouReport: OnyxEntry): Participant | undefined { + const participants = getMoneyRequestParticipantsFromReport(iouReport, getCurrentUserAccountID()); + if (participants.length !== 1) { + return undefined; + } + + const participant = participants.at(0); + if (!participant?.accountID || participant.isPolicyExpenseChat) { + return participant; + } + + const login = getAllPersonalDetails()[participant.accountID]?.login; + return login ? {...participant, login} : undefined; +} + +function getMerchantForRetry(merchant: string | undefined): string { + if (!merchant || merchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT) { + return ''; + } + return merchant; +} + +/** Checks the action here too, not only in `retryReceiptUpload`, so the button is hidden instead of doing nothing. */ +function canBuildRetryPayload(context: ReceiptRetryContext): boolean { + const {transaction, iouReport, receiptError} = context; + if (receiptError.action !== CONST.IOU.ACTION_PARAMS.MONEY_REQUEST) { + return false; + } + return isRetryableFlow(context) && !!iouReport?.reportID && !!transaction?.transactionID && !!resolveParticipant(iouReport); +} + +/** Rebuilds the `RequestMoney` call behind a failed receipt upload from the records the failure left in Onyx. */ +function buildRetryPayload(context: ReceiptRetryContext, receiptFile: FileObject): RequestMoneyInformation | undefined { + const { + transaction, + iouReport, + iouActionID, + transactionThreadReportID, + policyParams, + isVendorMatchingBetaEnabled, + rules, + conciergeReportID, + isSelfTourViewed, + isASAPSubmitBetaEnabled, + isTrackIntentUser, + delegateAccountID, + formatPhoneNumber, + getCurrencyDecimals, + } = context; + const participant = resolveParticipant(iouReport); + if (!canBuildRetryPayload(context) || !transaction || !iouReport || !participant) { + return undefined; + } + + const currentUser = getCurrentUserPersonalDetails(); + const details = getTransactionDetails(transaction); + if (!details) { + return undefined; + } + const receipt: Receipt = {...receiptFile, source: context.receiptError.source, state: CONST.IOU.RECEIPT_STATE.SCAN_READY}; + + return { + report: iouReport, + participantParams: { + payeeEmail: currentUser?.login, + payeeAccountID: getCurrentUserAccountID(), + participant, + }, + policyParams, + transactionParams: { + amount: details.amount, + currency: details.currency, + created: details.created, + merchant: getMerchantForRetry(details.merchant), + comment: details.comment, + category: details.category, + tag: details.tag, + taxCode: details.taxCode, + taxAmount: details.taxAmount, + billable: details.billable, + reimbursable: details.reimbursable, + // Read from the transaction because `details` widens this to `string | Attendee[]`. + attendees: transaction.comment?.attendees, + isFromGlobalCreate: getIsFromGlobalCreate(transaction), + receipt, + }, + // Same ID, so if the first attempt reached Auth the retry gets `Transaction already created.` instead of a second expense. + optimisticTransactionID: transaction.transactionID, + optimisticIOUReportID: iouReport.reportID, + optimisticChatReportID: iouReport.chatReportID, + // Without these two, the retry creates a second IOU action and thread for the same transaction. + currentReportActionID: iouActionID, + existingTransactionThreadReportID: transactionThreadReportID, + isTransactionAlreadyOnReport: true, + existingTransaction: transaction, + existingTransactionDraft: undefined, + shouldGenerateTransactionThreadReport: true, + // eslint-disable-next-line @typescript-eslint/no-deprecated -- `requestMoney` needs the whole collection for its `hasViolations` check, as UpdateMoneyRequest and TrackExpense do. Subscribing from the receipt view would put a collection-wide listener on every expense row. + transactionViolations: getAllTransactionViolations(), + personalDetails: getAllPersonalDetails(), + isVendorMatchingBetaEnabled, + rules, + conciergeChat: conciergeReportID ? getAllReports()?.[`${ONYXKEYS.COLLECTION.REPORT}${conciergeReportID}`] : undefined, + isSelfTourViewed, + isASAPSubmitBetaEnabled, + isTrackIntentUser, + delegateAccountID, + currentUserAccountIDParam: getCurrentUserAccountID(), + currentUserEmailParam: currentUser?.login ?? '', + quickAction: undefined, + policyRecentlyUsedCurrencies: [], + formatPhoneNumber, + getCurrencyDecimals, + }; +} + +export default buildRetryPayload; +export {canBuildRetryPayload}; diff --git a/src/libs/ReceiptUploadRetryHandler/index.ts b/src/libs/ReceiptUploadRetryHandler/index.ts new file mode 100644 index 000000000000..b349187f48de --- /dev/null +++ b/src/libs/ReceiptUploadRetryHandler/index.ts @@ -0,0 +1,56 @@ +/** Retries a failed receipt upload: finds the file, rebuilds the request, and sends it again. */ +import Log from '@libs/Log'; + +import {requestMoney} from '@userActions/IOU/TrackExpense'; + +import CONST from '@src/CONST'; + +import type {ReceiptRetryContext, RetryOutcome} from './types'; + +import buildRetryPayload, {canBuildRetryPayload} from './buildRetryPayload'; +import resolveReceiptFile from './resolveReceiptFile'; + +function retryReceiptUpload(context: ReceiptRetryContext, onDispatched?: () => Promise): Promise { + const {receiptError} = context; + + if (receiptError.action !== CONST.IOU.ACTION_PARAMS.MONEY_REQUEST) { + Log.hmmm('[ReceiptRetry] No retry path for this action', {action: receiptError.action}); + return Promise.resolve('unsupportedAction'); + } + + if (!canBuildRetryPayload(context)) { + Log.hmmm('[ReceiptRetry] The failed expense does not hold enough to rebuild the request', {transactionID: context.transaction?.transactionID}); + return Promise.resolve('payloadIncomplete'); + } + + return resolveReceiptFile(receiptError.source, receiptError.filename).then((receiptFile): RetryOutcome | Promise => { + if (!receiptFile) { + Log.hmmm('[ReceiptRetry] Receipt file is no longer on the device, cannot retry', {source: receiptError.source}); + return 'fileMissing'; + } + + const payload = buildRetryPayload(context, receiptFile); + if (!payload) { + Log.hmmm('[ReceiptRetry] The failed expense does not hold enough to rebuild the request', {transactionID: context.transaction?.transactionID}); + return 'payloadIncomplete'; + } + + Log.info('[ReceiptRetry] Retrying receipt upload', false, { + action: receiptError.action, + transactionID: payload.optimisticTransactionID, + }); + + try { + requestMoney({...payload, isRetry: true, shouldPlaySound: false}); + } catch (error) { + Log.alert('[ReceiptRetry] Dispatching the retry threw', {transactionID: payload.optimisticTransactionID, error}); + return 'dispatchFailed'; + } + + // Cleared only after a dispatch, so a failure above keeps the error and its Try again and Save buttons. + return Promise.resolve(onDispatched?.()).then((): RetryOutcome => 'dispatched'); + }); +} + +export default retryReceiptUpload; +export {canBuildRetryPayload}; diff --git a/src/libs/ReceiptUploadRetryHandler/isRetrySupported.native.ts b/src/libs/ReceiptUploadRetryHandler/isRetrySupported.native.ts new file mode 100644 index 000000000000..b5d65161471f --- /dev/null +++ b/src/libs/ReceiptUploadRetryHandler/isRetrySupported.native.ts @@ -0,0 +1,4 @@ +/** Native keeps the receipt in `ReceiptStorage`, so it is still there after a restart. */ +const isRetrySupported = true; + +export default isRetrySupported; diff --git a/src/libs/ReceiptUploadRetryHandler/isRetrySupported.ts b/src/libs/ReceiptUploadRetryHandler/isRetrySupported.ts new file mode 100644 index 000000000000..6fcdba0ceea3 --- /dev/null +++ b/src/libs/ReceiptUploadRetryHandler/isRetrySupported.ts @@ -0,0 +1,4 @@ +/** Web only has the receipt as a blob URL, which is gone after a reload, so there is nothing to resend. */ +const isRetrySupported = false; + +export default isRetrySupported; diff --git a/src/libs/ReceiptUploadRetryHandler/resolveReceiptFile.native.ts b/src/libs/ReceiptUploadRetryHandler/resolveReceiptFile.native.ts new file mode 100644 index 000000000000..e9cad5b039ee --- /dev/null +++ b/src/libs/ReceiptUploadRetryHandler/resolveReceiptFile.native.ts @@ -0,0 +1,21 @@ +import checkFileExists from '@libs/fileDownload/checkFileExists'; +import {getMimeTypeFromUri} from '@libs/fileDownload/FileUtils'; +import ReceiptStorage from '@libs/ReceiptStorage'; + +import type {ResolveReceiptFile} from './types'; + +const resolveReceiptFile: ResolveReceiptFile = (source, filename) => { + const localUri = ReceiptStorage.resolve(source); + if (!localUri) { + return Promise.resolve(undefined); + } + + return checkFileExists(localUri).then((exists) => { + if (!exists) { + return undefined; + } + return {uri: localUri, source: localUri, name: filename, type: getMimeTypeFromUri(filename) ?? getMimeTypeFromUri(localUri)}; + }); +}; + +export default resolveReceiptFile; diff --git a/src/libs/ReceiptUploadRetryHandler/resolveReceiptFile.ts b/src/libs/ReceiptUploadRetryHandler/resolveReceiptFile.ts new file mode 100644 index 000000000000..f6a932caecb6 --- /dev/null +++ b/src/libs/ReceiptUploadRetryHandler/resolveReceiptFile.ts @@ -0,0 +1,6 @@ +import type {ResolveReceiptFile} from './types'; + +/** Never called on web because `isRetrySupported` is false. It exists so `index.ts` can still import it. */ +const resolveReceiptFile: ResolveReceiptFile = () => Promise.resolve(undefined); + +export default resolveReceiptFile; diff --git a/src/libs/ReceiptUploadRetryHandler/types.ts b/src/libs/ReceiptUploadRetryHandler/types.ts new file mode 100644 index 000000000000..c95f1dd25664 --- /dev/null +++ b/src/libs/ReceiptUploadRetryHandler/types.ts @@ -0,0 +1,51 @@ +import type {LocaleContextProps} from '@components/LocaleContextProvider'; + +import type {CurrencyListActionsContextType} from '@hooks/useCurrencyList'; + +import type BasePolicyParams from '@userActions/IOU/types/BasePolicyParams'; + +import type {Report, Rule, Transaction} from '@src/types/onyx'; +import type {ReceiptError} from '@src/types/onyx/Transaction'; +import type {FileObject} from '@src/types/utils/Attachment'; + +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; + +type ResolveReceiptFile = (source: string, filename: string) => Promise; + +/** The error stores nothing to resend, so the retry is rebuilt from these values. */ +type ReceiptRetryContext = { + receiptError: ReceiptError; + + transaction: OnyxEntry; + + iouReport: OnyxEntry; + + iouActionID: string | undefined; + + transactionThreadReportID: string | undefined; + + policyParams: BasePolicyParams; + + isVendorMatchingBetaEnabled: boolean | undefined; + + /** Used by `shouldCreateNewMoneyRequestReport`, so an empty collection can make the retry create a second report. */ + rules: OnyxCollection; + + conciergeReportID: string | undefined; + + isSelfTourViewed: boolean; + + isASAPSubmitBetaEnabled: boolean; + + isTrackIntentUser: boolean | undefined; + + delegateAccountID: number | undefined; + + formatPhoneNumber: LocaleContextProps['formatPhoneNumber']; + + getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals']; +}; + +type RetryOutcome = 'dispatched' | 'fileMissing' | 'payloadIncomplete' | 'unsupportedAction' | 'dispatchFailed'; + +export type {ReceiptRetryContext, ResolveReceiptFile, RetryOutcome}; diff --git a/src/libs/actions/IOU/MoneyRequestBuilder.ts b/src/libs/actions/IOU/MoneyRequestBuilder.ts index ec527035d26e..637bcffcbe22 100644 --- a/src/libs/actions/IOU/MoneyRequestBuilder.ts +++ b/src/libs/actions/IOU/MoneyRequestBuilder.ts @@ -192,6 +192,11 @@ type RequestMoneyInformation = { optimisticIOUReportID?: string; optimisticReportPreviewActionID?: string; optimisticTransactionID?: string; + currentReportActionID?: string; + existingTransactionThreadReportID?: string; + + /** The report already counts this transaction, so its totals and transaction count are not recalculated. */ + isTransactionAlreadyOnReport?: boolean; shouldGenerateTransactionThreadReport: boolean; isASAPSubmitBetaEnabled: boolean; currentUserAccountIDParam: number; @@ -238,6 +243,12 @@ type MoneyRequestInformationParams = { isReverseSplitOperation?: boolean; action?: IOUAction; currentReportActionID?: string; + + /** Reuses this thread so a retry lands on the records the first attempt created. */ + existingTransactionThreadReportID?: string; + + /** The report already counts this transaction, so its totals and transaction count are not recalculated. */ + isTransactionAlreadyOnReport?: boolean; isASAPSubmitBetaEnabled: boolean; currentUserAccountIDParam: number; currentUserEmailParam: string; @@ -1063,7 +1074,8 @@ function buildOnyxDataForMoneyRequest(moneyRequestParams: BuildOnyxDataForMoneyR errors: getReceiptError(transaction.receipt, transaction.receipt?.filename, isScanRequest, errorKey, CONST.IOU.ACTION_PARAMS.MONEY_REQUEST), }, [iou.action.reportActionID]: { - errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericCreateFailureMessage'), + // `errorKey + 1` so this doesn't overwrite the transaction's `ReceiptError` when the receipt view merges them. + errors: getMicroSecondOnyxErrorWithTranslationKey('iou.error.genericCreateFailureMessage', errorKey + 1), }, } : { @@ -1297,6 +1309,8 @@ function getMoneyRequestInformation(moneyRequestInformation: MoneyRequestInforma isReverseSplitOperation, action, currentReportActionID, + existingTransactionThreadReportID, + isTransactionAlreadyOnReport = false, isASAPSubmitBetaEnabled, currentUserAccountIDParam, currentUserEmailParam, @@ -1449,6 +1463,9 @@ function getMoneyRequestInformation(moneyRequestInformation: MoneyRequestInforma rules, }) : buildOptimisticIOUReport(payeeAccountID, payerAccountID, reportAmount, chatReport.reportID, currency, getCurrencyDecimals, undefined, undefined, optimisticReportID); + } else if (isTransactionAlreadyOnReport) { + // The report already counts this transaction, so skip the totals. Still clone it, because code below mutates `iouReport`. + iouReport = {...iouReport}; } else if (isPolicyExpenseChat) { // Capture previous fresh reimbursable totals before mutating, so the diff applies whether or // not the iouReport already had reimbursableTotal/unheldReimbursableTotal populated locally. @@ -1557,7 +1574,10 @@ function getMoneyRequestInformation(moneyRequestInformation: MoneyRequestInforma isDemoTransactionParam: transactionParams.receipt?.isTestDriveReceipt, }); - iouReport.transactionCount = (iouReport.transactionCount ?? 0) + 1; + if (!isTransactionAlreadyOnReport) { + // A retry must not bump this, because `isOneTransactionReport` checks `transactionCount === 1`. + iouReport.transactionCount = (iouReport.transactionCount ?? 0) + 1; + } const optimisticPolicyRecentlyUsedCategories = mergePolicyRecentlyUsedCategories(category, policyRecentlyUsedCategories); const optimisticPolicyRecentlyUsedTags = buildOptimisticPolicyRecentlyUsedTags({ @@ -1661,7 +1681,7 @@ function getMoneyRequestInformation(moneyRequestInformation: MoneyRequestInforma participants: [participant], transactionID: optimisticTransaction.transactionID, paymentType: transactionParams.receipt?.isTestDriveReceipt ? CONST.IOU.PAYMENT_TYPE.ELSEWHERE : undefined, - existingTransactionThreadReportID: linkedTrackedExpenseReportAction?.childReportID, + existingTransactionThreadReportID: existingTransactionThreadReportID ?? linkedTrackedExpenseReportAction?.childReportID, optimisticCreatedReportActionID, linkedTrackedExpenseReportAction, isPersonalTrackingExpense: isSelfDMSplit, diff --git a/src/libs/actions/IOU/Receipt.ts b/src/libs/actions/IOU/Receipt.ts index 99416ca7a3e9..86c7a79543ed 100644 --- a/src/libs/actions/IOU/Receipt.ts +++ b/src/libs/actions/IOU/Receipt.ts @@ -397,5 +397,29 @@ function checkIfLocalFileIsAccessible( return readFileAsync(ReceiptStorage.resolve(receiptPath) ?? receiptPath.toString(), receiptFilename, onSuccess, onFailure, receiptType); } -export {checkIfLocalFileIsAccessible, detachReceipt, navigateToStartStepIfScanFileCannotBeRead, replaceReceipt, setMoneyRequestReceipt}; +function clearReceiptUploadError({ + transactionID, + reportID, + reportActionID, + reportIDWithCreationError, +}: { + transactionID: string | undefined; + reportID: string | undefined; + reportActionID: string | undefined; + reportIDWithCreationError: string | undefined; +}): Promise { + const writes: Array> = []; + if (transactionID) { + writes.push(Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {errors: null})); + } + if (reportID && reportActionID) { + writes.push(Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`, {[reportActionID]: {errors: null}})); + } + if (reportIDWithCreationError) { + writes.push(Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportIDWithCreationError}`, {errorFields: {addWorkspaceRoom: null, createChat: null, createReport: null}})); + } + return Promise.all(writes); +} + +export {checkIfLocalFileIsAccessible, clearReceiptUploadError, detachReceipt, navigateToStartStepIfScanFileCannotBeRead, replaceReceipt, setMoneyRequestReceipt}; export type {ReplaceReceiptRetryParams}; diff --git a/src/libs/actions/IOU/TrackExpense.ts b/src/libs/actions/IOU/TrackExpense.ts index add79b3835e9..9bd252ecd894 100644 --- a/src/libs/actions/IOU/TrackExpense.ts +++ b/src/libs/actions/IOU/TrackExpense.ts @@ -1808,6 +1808,9 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep moneyRequestReportID, existingTransactionID, optimisticTransactionID: requestMoneyInformation.optimisticTransactionID, + currentReportActionID: requestMoneyInformation.currentReportActionID, + existingTransactionThreadReportID: requestMoneyInformation.existingTransactionThreadReportID, + isTransactionAlreadyOnReport: requestMoneyInformation.isTransactionAlreadyOnReport, existingTransaction, retryParams, testDriveCommentReportActionID, diff --git a/tests/ui/components/MoneyRequestReceiptViewTest.tsx b/tests/ui/components/MoneyRequestReceiptViewTest.tsx index e5325f7452c7..5f1842a09326 100644 --- a/tests/ui/components/MoneyRequestReceiptViewTest.tsx +++ b/tests/ui/components/MoneyRequestReceiptViewTest.tsx @@ -606,8 +606,8 @@ describe('MoneyRequestReceiptView', () => { ); await waitForBatchedUpdatesWithAct(); - expect(screen.getByText(translateLocal('iou.error.receiptUploadFailedMessage'))).toBeTruthy(); - expect(screen.getByText(translateLocal('iou.error.saveReceipt'))).toBeTruthy(); + expect(screen.getByText(translateLocal('iou.error.receiptUploadFailedSaveOnlyMessage'))).toBeTruthy(); + expect(screen.getByText(translateLocal('common.save'))).toBeTruthy(); }); it('does not show the receipt-upload error for a distance expense', async () => { @@ -623,8 +623,8 @@ describe('MoneyRequestReceiptView', () => { ); await waitForBatchedUpdatesWithAct(); - expect(screen.queryByText(translateLocal('iou.error.receiptUploadFailedMessage'))).toBeNull(); - expect(screen.queryByText(translateLocal('iou.error.saveReceipt'))).toBeNull(); + expect(screen.queryByText(translateLocal('iou.error.receiptUploadFailedSaveOnlyMessage'))).toBeNull(); + expect(screen.queryByText(translateLocal('common.save'))).toBeNull(); }); it('shows the receipt-upload error for an odometer distance expense (real uploaded file)', async () => { @@ -640,8 +640,8 @@ describe('MoneyRequestReceiptView', () => { ); await waitForBatchedUpdatesWithAct(); - expect(screen.getByText(translateLocal('iou.error.receiptUploadFailedMessage'))).toBeTruthy(); - expect(screen.getByText(translateLocal('iou.error.saveReceipt'))).toBeTruthy(); + expect(screen.getByText(translateLocal('iou.error.receiptUploadFailedSaveOnlyMessage'))).toBeTruthy(); + expect(screen.getByText(translateLocal('common.save'))).toBeTruthy(); }); }); }); diff --git a/tests/unit/ReceiptUploadRetryHandlerTest.ts b/tests/unit/ReceiptUploadRetryHandlerTest.ts new file mode 100644 index 000000000000..db047ccd93e1 --- /dev/null +++ b/tests/unit/ReceiptUploadRetryHandlerTest.ts @@ -0,0 +1,153 @@ +import retryReceiptUpload from '@libs/ReceiptUploadRetryHandler'; +import buildRetryPayload, {canBuildRetryPayload} from '@libs/ReceiptUploadRetryHandler/buildRetryPayload'; +import resolveReceiptFile from '@libs/ReceiptUploadRetryHandler/resolveReceiptFile'; +import type {ReceiptRetryContext} from '@libs/ReceiptUploadRetryHandler/types'; + +import {requestMoney} from '@userActions/IOU/TrackExpense'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report, Transaction} from '@src/types/onyx'; +import type {ReceiptError} from '@src/types/onyx/Transaction'; +import type {FileObject} from '@src/types/utils/Attachment'; + +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +jest.mock('@libs/ReceiptUploadRetryHandler/resolveReceiptFile', () => ({__esModule: true, default: jest.fn()})); +jest.mock('@userActions/IOU/TrackExpense', () => ({...jest.requireActual>('@userActions/IOU/TrackExpense'), requestMoney: jest.fn()})); + +const CURRENT_USER_ACCOUNT_ID = 1; +const TRANSACTION_ID = '7000000000000001'; +const IOU_REPORT_ID = '8000000000000001'; +const CHAT_REPORT_ID = '9000000000000001'; +const POLICY_ID = 'A0000000000000001'; +const IOU_ACTION_ID = '6000000000000001'; +const THREAD_REPORT_ID = '5000000000000001'; + +const receiptFile: FileObject = {name: 'receipt.jpg', type: 'image/jpeg', uri: 'file:///receipts/receipt.jpg'}; + +function buildFailedTransaction(overrides: Partial = {}): Transaction { + return { + transactionID: TRANSACTION_ID, + reportID: IOU_REPORT_ID, + amount: 0, + currency: CONST.CURRENCY.USD, + created: '2026-09-01', + merchant: CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, + comment: {comment: ''}, + iouRequestType: CONST.IOU.REQUEST_TYPE.MANUAL, + receipt: {source: 'file:///receipts/receipt.jpg', filename: 'receipt.jpg', state: CONST.IOU.RECEIPT_STATE.SCAN_READY}, + ...overrides, + } as Transaction; +} + +function buildContext(transaction: Transaction, receiptErrorOverrides: Partial = {}): ReceiptRetryContext { + return { + receiptError: { + error: CONST.IOU.RECEIPT_ERROR, + source: 'file:///receipts/receipt.jpg', + filename: 'receipt.jpg', + action: CONST.IOU.ACTION_PARAMS.MONEY_REQUEST, + ...receiptErrorOverrides, + }, + transaction, + iouReport: {reportID: IOU_REPORT_ID, chatReportID: CHAT_REPORT_ID, policyID: POLICY_ID, type: CONST.REPORT.TYPE.EXPENSE} as Report, + iouActionID: IOU_ACTION_ID, + transactionThreadReportID: THREAD_REPORT_ID, + policyParams: {}, + isVendorMatchingBetaEnabled: false, + rules: {}, + conciergeReportID: undefined, + isSelfTourViewed: true, + isASAPSubmitBetaEnabled: false, + isTrackIntentUser: false, + delegateAccountID: undefined, + formatPhoneNumber: (phone) => phone, + getCurrencyDecimals: () => 2, + }; +} + +describe('buildRetryPayload', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await Onyx.clear(); + await Onyx.merge(ONYXKEYS.SESSION, {accountID: CURRENT_USER_ACCOUNT_ID}); + await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {[CURRENT_USER_ACCOUNT_ID]: {accountID: CURRENT_USER_ACCOUNT_ID, login: 'me@example.com'}}); + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${CHAT_REPORT_ID}`, { + reportID: CHAT_REPORT_ID, + chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, + policyID: POLICY_ID, + }); + await waitForBatchedUpdates(); + }); + + it('reuses the original transaction ID, which is the only thing stopping a re-send from creating a second expense', () => { + const payload = buildRetryPayload(buildContext(buildFailedTransaction()), receiptFile); + expect(payload?.optimisticTransactionID).toBe(TRANSACTION_ID); + }); + + it('offers a retry for a failed expense as the create path really leaves it, with no participants and a manual request type', () => { + expect(canBuildRetryPayload(buildContext(buildFailedTransaction()))).toBe(true); + }); + + it('reuses the IOU report action and transaction thread, so the retry does not add a second expense to the report', () => { + const payload = buildRetryPayload(buildContext(buildFailedTransaction()), receiptFile); + expect(payload?.currentReportActionID).toBe(IOU_ACTION_ID); + expect(payload?.existingTransactionThreadReportID).toBe(THREAD_REPORT_ID); + }); + + it('offers no retry for a distance expense, whose waypoints the transaction alone cannot restore', () => { + const transaction = buildFailedTransaction({comment: {waypoints: {waypoint0: {address: 'Berlin'}}}}); + expect(canBuildRetryPayload(buildContext(transaction))).toBe(false); + }); + + it('offers no retry for a replaceReceipt failure, which is not the create call the handler rebuilds', () => { + expect(canBuildRetryPayload(buildContext(buildFailedTransaction(), {action: CONST.IOU.ACTION_PARAMS.REPLACE_RECEIPT}))).toBe(false); + }); + + it('offers no retry for a trackExpense failure, whose convert-and-submit path the handler does not replay', () => { + expect(canBuildRetryPayload(buildContext(buildFailedTransaction(), {action: CONST.IOU.ACTION_PARAMS.TRACK_EXPENSE}))).toBe(false); + }); + + it('offers no retry for the report-creation fallback error, which carries no action to replay', () => { + expect(canBuildRetryPayload(buildContext(buildFailedTransaction(), {action: undefined}))).toBe(false); + }); + + it('offers no retry once the receipt source is no longer a local file, so there is nothing left on the device to resend', () => { + expect(canBuildRetryPayload(buildContext(buildFailedTransaction(), {source: 'https://example.com/receipt.jpg'}))).toBe(false); + }); + + describe('retryReceiptUpload', () => { + beforeEach(() => { + jest.mocked(resolveReceiptFile).mockResolvedValue(receiptFile); + jest.mocked(requestMoney).mockReset(); + }); + + it('keeps the receipt error when the dispatch throws, so Try again and Save stay available', async () => { + jest.mocked(requestMoney).mockImplementation(() => { + throw new Error('dispatch failed'); + }); + const clearReceiptError = jest.fn(() => Promise.resolve()); + + const outcome = await retryReceiptUpload(buildContext(buildFailedTransaction()), clearReceiptError); + + expect(outcome).toBe('dispatchFailed'); + expect(clearReceiptError).not.toHaveBeenCalled(); + }); + + it('clears the receipt error only after the retry is dispatched', async () => { + const clearReceiptError = jest.fn(() => Promise.resolve()); + + const outcome = await retryReceiptUpload(buildContext(buildFailedTransaction()), clearReceiptError); + + expect(outcome).toBe('dispatched'); + expect(clearReceiptError).toHaveBeenCalledTimes(1); + expect(jest.mocked(requestMoney).mock.invocationCallOrder.at(0)).toBeLessThan(clearReceiptError.mock.invocationCallOrder.at(0) ?? 0); + }); + }); +});