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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/components/ReportActionItem/MoneyRequestReceiptView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/crea
import {isTrackOnboardingChoice} from '@libs/OnboardingUtils';
import {isGroupPolicyByType} from '@libs/PolicyUtils';
import ReceiptStorage from '@libs/ReceiptStorage';
import retryReceiptUpload, {canBuildRetryPayload} from '@libs/ReceiptUploadRetryHandler';
import retryReceiptUpload, {canRetryReceiptUpload} from '@libs/ReceiptUploadRetryHandler';
import type {ReceiptRetryContext} from '@libs/ReceiptUploadRetryHandler/types';
import {getThumbnailAndImageURIs} from '@libs/ReceiptUtils';
import {getOriginalMessage, isMoneyRequestAction, wasActionTakenByCurrentUser} from '@libs/ReportActionsUtils';
Expand Down Expand Up @@ -449,10 +449,14 @@ function MoneyRequestReceiptView({
delegateAccountID,
formatPhoneNumber,
getCurrencyDecimals,
transactionReport,
transactionThreadReport,
transactionViolations: rawTransactionViolations,
currentUserPersonalDetails: currentUserPersonalDetail,
}
: undefined;

const canRetryUpload = !!receiptRetryContext && canBuildRetryPayload(receiptRetryContext);
const canRetryUpload = !!receiptRetryContext && canRetryReceiptUpload(receiptRetryContext);

const retryReceiptUploadAndClearError = () => {
if (!receiptRetryContext) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/** Decides whether a failed `ReplaceReceipt` can be retried and rebuilds the call from Onyx. */
import {isLocalFile} from '@libs/fileDownload/FileUtils';
Comment thread
mountiny marked this conversation as resolved.

import type {ReplaceReceipt, ReplaceReceiptRetryParams} from '@userActions/IOU/Receipt';

import CONST from '@src/CONST';
import type {ReceiptError} from '@src/types/onyx/Transaction';
import type {FileObject} from '@src/types/utils/Attachment';

import type {ValueOf} from 'type-fest';

import type {ReceiptRetryContext} from './types';

import isRetrySupported from './isRetrySupported';

type StoredReplaceReceiptRetryParams = Pick<ReplaceReceiptRetryParams, 'transactionID' | 'state' | 'isSameReceipt' | 'receiptAddedReportActionID'>;

const RECEIPT_STATES = new Set<unknown>(Object.values(CONST.IOU.RECEIPT_STATE));

function isReceiptState(value: unknown): value is ValueOf<typeof CONST.IOU.RECEIPT_STATE> {
return RECEIPT_STATES.has(value);
}

function parseRetryParams(retryParams: ReceiptError['retryParams']): unknown {
if (typeof retryParams !== 'string') {
return retryParams;
}
try {
return JSON.parse(retryParams);
} catch {
return undefined;
}
}

function getReplaceReceiptRetryParams(receiptError: ReceiptError): StoredReplaceReceiptRetryParams | undefined {
if (receiptError.action !== CONST.IOU.ACTION_PARAMS.REPLACE_RECEIPT) {
return undefined;
}

const parsed = parseRetryParams(receiptError.retryParams);
if (!parsed || typeof parsed !== 'object' || !('transactionID' in parsed) || typeof parsed.transactionID !== 'string') {
return undefined;
}

return {
transactionID: parsed.transactionID,
state: 'state' in parsed && isReceiptState(parsed.state) ? parsed.state : undefined,
isSameReceipt: 'isSameReceipt' in parsed && parsed.isSameReceipt === true ? true : undefined,
receiptAddedReportActionID: 'receiptAddedReportActionID' in parsed && typeof parsed.receiptAddedReportActionID === 'string' ? parsed.receiptAddedReportActionID : undefined,
};
}

function canBuildReplaceReceiptRetryPayload(context: ReceiptRetryContext): boolean {
const {transaction, receiptError} = context;
if (!isRetrySupported || !transaction?.transactionID || !isLocalFile(receiptError.source)) {
return false;
}

return getReplaceReceiptRetryParams(receiptError)?.transactionID === transaction.transactionID;
}

function buildReplaceReceiptRetryPayload(context: ReceiptRetryContext, receiptFile: FileObject): ReplaceReceipt | undefined {
const {
transaction,
receiptError,
policyParams,
transactionReport,
transactionThreadReport,
transactionViolations,
isVendorMatchingBetaEnabled,
delegateAccountID,
currentUserPersonalDetails,
} = context;
const retryParams = getReplaceReceiptRetryParams(receiptError);
if (!canBuildReplaceReceiptRetryPayload(context) || !retryParams) {
return undefined;
}

return {
transaction,
// `replaceReceipt` only reads `FileObject` fields; native callers pass this same shape, cast to `File` the same way.
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
Comment thread
mountiny marked this conversation as resolved.
file: receiptFile as File,
source: receiptError.source,
state: retryParams.state,
isSameReceipt: retryParams.isSameReceipt,
receiptAddedReportActionID: retryParams.receiptAddedReportActionID,
transactionPolicy: policyParams.policy,
transactionPolicyCategories: policyParams.policyCategories,
transactionPolicyTagList: policyParams.policyTagList,
transactionViolations,
transactionReport,
transactionThreadReport,
isVendorMatchingBetaEnabled,
delegateAccountID,
currentUserPersonalDetails,
};
}

export default buildReplaceReceiptRetryPayload;
export {canBuildReplaceReceiptRetryPayload};
70 changes: 54 additions & 16 deletions src/libs/ReceiptUploadRetryHandler/index.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,68 @@
/** Retries a failed receipt upload: finds the file, rebuilds the request, and sends it again. */
import Log from '@libs/Log';

import {replaceReceipt} from '@userActions/IOU/Receipt';
import {requestMoney} from '@userActions/IOU/TrackExpense';

import CONST from '@src/CONST';
import type {FileObject} from '@src/types/utils/Attachment';

import type {ReceiptRetryContext, RetryOutcome} from './types';

import buildReplaceReceiptRetryPayload, {canBuildReplaceReceiptRetryPayload} from './buildReplaceReceiptRetryPayload';
import buildRetryPayload, {canBuildRetryPayload} from './buildRetryPayload';
import resolveReceiptFile from './resolveReceiptFile';

type RetryFlow = {
canBuild: (context: ReceiptRetryContext) => boolean;
dispatch: (context: ReceiptRetryContext, receiptFile: FileObject) => boolean;
};

const RETRY_FLOWS: Partial<Record<string, RetryFlow>> = {
[CONST.IOU.ACTION_PARAMS.MONEY_REQUEST]: {
canBuild: canBuildRetryPayload,
dispatch: (context, receiptFile) => {
const payload = buildRetryPayload(context, receiptFile);
if (!payload) {
return false;
}
requestMoney({...payload, isRetry: true, shouldPlaySound: false});
return true;
},
},
[CONST.IOU.ACTION_PARAMS.REPLACE_RECEIPT]: {
canBuild: canBuildReplaceReceiptRetryPayload,
dispatch: (context, receiptFile) => {
const payload = buildReplaceReceiptRetryPayload(context, receiptFile);
if (!payload) {
return false;
}
replaceReceipt(payload);
return true;
},
},
};

function getRetryFlow(context: ReceiptRetryContext): RetryFlow | undefined {
const {action} = context.receiptError;
return action ? RETRY_FLOWS[action] : undefined;
}

function canRetryReceiptUpload(context: ReceiptRetryContext): boolean {
return !!getRetryFlow(context)?.canBuild(context);
}

function retryReceiptUpload(context: ReceiptRetryContext, onDispatched?: () => Promise<unknown>): Promise<RetryOutcome> {
const {receiptError} = context;
const flow = getRetryFlow(context);

if (receiptError.action !== CONST.IOU.ACTION_PARAMS.MONEY_REQUEST) {
if (!flow) {
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});
if (!flow.canBuild(context)) {
Log.hmmm('[ReceiptRetry] The failed expense does not hold enough to rebuild the request', {action: receiptError.action, transactionID: context.transaction?.transactionID});
return Promise.resolve('payloadIncomplete');
}

Expand All @@ -29,21 +72,16 @@ function retryReceiptUpload(context: ReceiptRetryContext, onDispatched?: () => P
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,
});
const transactionID = context.transaction?.transactionID;
Log.info('[ReceiptRetry] Retrying receipt upload', false, {action: receiptError.action, transactionID});

try {
requestMoney({...payload, isRetry: true, shouldPlaySound: false});
if (!flow.dispatch(context, receiptFile)) {
Log.hmmm('[ReceiptRetry] The failed expense does not hold enough to rebuild the request', {action: receiptError.action, transactionID});
return 'payloadIncomplete';
}
} catch (error) {
Log.alert('[ReceiptRetry] Dispatching the retry threw', {transactionID: payload.optimisticTransactionID, error});
Log.alert('[ReceiptRetry] Dispatching the retry threw', {action: receiptError.action, transactionID, error});
return 'dispatchFailed';
}

Expand All @@ -53,4 +91,4 @@ function retryReceiptUpload(context: ReceiptRetryContext, onDispatched?: () => P
}

export default retryReceiptUpload;
export {canBuildRetryPayload};
export {canRetryReceiptUpload};
11 changes: 10 additions & 1 deletion src/libs/ReceiptUploadRetryHandler/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ 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 {Report, Rule, Transaction, TransactionViolations} from '@src/types/onyx';
import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails';
import type {ReceiptError} from '@src/types/onyx/Transaction';
import type {FileObject} from '@src/types/utils/Attachment';

Expand Down Expand Up @@ -44,6 +45,14 @@ type ReceiptRetryContext = {
formatPhoneNumber: LocaleContextProps['formatPhoneNumber'];

getCurrencyDecimals: CurrencyListActionsContextType['getCurrencyDecimals'];

transactionReport: OnyxEntry<Report>;

transactionThreadReport: OnyxEntry<Report>;

transactionViolations: OnyxEntry<TransactionViolations>;

currentUserPersonalDetails: CurrentUserPersonalDetails;
};

type RetryOutcome = 'dispatched' | 'fileMissing' | 'payloadIncomplete' | 'unsupportedAction' | 'dispatchFailed';
Expand Down
3 changes: 2 additions & 1 deletion src/libs/ReportUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8637,6 +8637,7 @@ function buildOptimisticReceiptAddedAction(
currentUserDisplayName: string | undefined,
currentUserAvatar: AvatarSource | undefined,
delegateAccountID: number | undefined,
reportActionID: string = rand64(),
) {
return {
actionName: CONST.REPORT.ACTIONS.TYPE.MODIFIED_EXPENSE,
Expand Down Expand Up @@ -8665,7 +8666,7 @@ function buildOptimisticReceiptAddedAction(
},
],
pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
reportActionID: rand64(),
reportActionID,
reportID,
shouldShow: true,
delegateAccountID,
Expand Down
21 changes: 15 additions & 6 deletions src/libs/actions/IOU/Receipt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {readFileAsync} from '@libs/fileDownload/FileUtils';
import {navigateToStartMoneyRequestStep} from '@libs/IOUUtils';
import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute';
import Navigation from '@libs/Navigation/Navigation';
import {rand64} from '@libs/NumberUtils';
import {hasDependentTags, isGroupPolicy} from '@libs/PolicyUtils';
import ReceiptStorage from '@libs/ReceiptStorage';
import {buildOptimisticDetachReceipt, buildOptimisticReceiptAddedAction, isInvoiceReport as isInvoiceReportReportUtils} from '@libs/ReportUtils';
Expand Down Expand Up @@ -47,6 +48,7 @@ type ReplaceReceipt = {
delegateAccountID: number | undefined;
currentUserPersonalDetails: CurrentUserPersonalDetails;
transactionThreadReport: OnyxEntry<OnyxTypes.Report>;
receiptAddedReportActionID?: string;
};
// The actor and thread fields are left out because a retry builds a fresh optimistic action,
// so it has to reflect who is acting and the thread state at retry time rather than when the upload failed.
Expand Down Expand Up @@ -208,6 +210,7 @@ function replaceReceipt({
delegateAccountID,
currentUserPersonalDetails,
transactionThreadReport,
receiptAddedReportActionID,
}: ReplaceReceipt) {
const transactionID = transaction?.transactionID;

Expand All @@ -229,15 +232,23 @@ function replaceReceipt({
pageCount: null,
};
const newTransaction = transaction && {...transaction, receipt: receiptOptimistic};

// Show "added a receipt" right away, but not for a crop or rotate (isSameReceipt) and only if the
// thread already exists. Otherwise the backend creates the thread and message and it syncs in.
const transactionThreadReportID = transactionThreadReport?.reportID;
const optimisticReceiptAddedActionID = !isSameReceipt && transactionThreadReportID ? (receiptAddedReportActionID ?? rand64()) : undefined;
const retryParams: ReplaceReceiptRetryParams = {
transactionID: transaction.transactionID,
file: undefined,
source,
state,
isSameReceipt,
transactionPolicy,
transactionPolicyCategories,
transactionPolicyTagList,
transactionViolations,
isVendorMatchingBetaEnabled,
receiptAddedReportActionID: optimisticReceiptAddedActionID,
};
const currentSearchQueryJSON = getCurrentSearchQueryJSON();

Expand Down Expand Up @@ -345,18 +356,16 @@ function replaceReceipt({
});
}

// Show "added a receipt" right away, but not for a crop or rotate (isSameReceipt) and only if the
// thread already exists. Otherwise the backend creates the thread and message and it syncs in.
const transactionThreadReportID = transactionThreadReport?.reportID;
const optimisticReceiptAddedAction =
!isSameReceipt && transactionThreadReportID
optimisticReceiptAddedActionID && transactionThreadReportID
? buildOptimisticReceiptAddedAction(
transactionThreadReportID,
transactionID,
currentUserPersonalDetails.accountID,
currentUserPersonalDetails.displayName,
currentUserPersonalDetails.avatar,
delegateAccountID,
optimisticReceiptAddedActionID,
)
: undefined;

Expand All @@ -366,7 +375,7 @@ function replaceReceipt({
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`,
value: {
[optimisticReceiptAddedAction.reportActionID]: optimisticReceiptAddedAction as OnyxTypes.ReportAction,
[optimisticReceiptAddedAction.reportActionID]: {...(optimisticReceiptAddedAction as OnyxTypes.ReportAction), errors: null},
},
},
{
Expand Down Expand Up @@ -517,4 +526,4 @@ function clearReceiptUploadError({
}

export {checkIfLocalFileIsAccessible, clearReceiptUploadError, detachReceipt, navigateToStartStepIfScanFileCannotBeRead, replaceReceipt, setMoneyRequestReceipt};
export type {ReplaceReceiptRetryParams};
export type {ReplaceReceipt, ReplaceReceiptRetryParams};
2 changes: 1 addition & 1 deletion src/types/onyx/Transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,7 @@ type ReceiptError = {
action?: string;

/** Parameters required to retry the failed action */
retryParams?: StartSplitBilActionParams | CreateTrackExpenseParams | RequestMoneyInformation | ReplaceReceiptRetryParams;
retryParams?: StartSplitBilActionParams | CreateTrackExpenseParams | RequestMoneyInformation | ReplaceReceiptRetryParams | string;

error: typeof CONST.IOU.RECEIPT_ERROR;
};
Expand Down
Loading
Loading