Skip to content
938 changes: 177 additions & 761 deletions src/components/MoneyRequestConfirmationList.tsx

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import React from 'react';
import {View} from 'react-native';
import Button from '@components/Button';
import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu';
import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types';
import FormHelpMessage from '@components/FormHelpMessage';
import SettlementButton from '@components/SettlementButton';
import type {PaymentActionParams} from '@components/SettlementButton/types';
import EducationalTooltip from '@components/Tooltip/EducationalTooltip';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import type {IOUType} from '@src/CONST';
import CONST from '@src/CONST';
import ROUTES from '@src/ROUTES';
import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage';

type ConfirmationFooterContentProps = {
/** IOU type currently being confirmed (submit / split / track / pay / invoice) */
iouType: IOUType;

/** Click handler invoked when the user taps the primary confirmation button */
confirm: (params: PaymentActionParams) => void;

/** Currency the IOU is being created in, used by the Pay settlement button */
iouCurrencyCode: string;

/** Policy the IOU belongs to, when applicable */
policyID: string | undefined;

/** Report the IOU is being created on */
reportID: string;

/** Whether the confirmation has already been submitted (locks the button) */
isConfirmed: boolean | undefined;

/** Whether a confirmation request is currently in flight */
isConfirming: boolean | undefined;

/** Whether a SmartScan receipt is still being processed */
isLoadingReceipt: boolean;

/** Dropdown options for the primary CTA (e.g. Submit / Submit & Close) */
splitOrRequestOptions: Array<DropdownOption<string>>;

/** Inline error message displayed above the button, if any */
errorMessage: string | undefined;

/** Number of expenses that will be created on confirm (drives bulk copy) */
expensesNumber: number;

/** Optional callback to show a confirm-modal before removing an expense */
showRemoveExpenseConfirmModal: (() => void) | undefined;

/** Whether the product-training tooltip should anchor to the button */
shouldShowProductTrainingTooltip: boolean;

/** Renders the product-training tooltip content */
renderProductTrainingTooltip: () => React.ReactElement;
Comment on lines +19 to +58
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

docs

};

function ConfirmationFooterContent({
iouType,
confirm,
iouCurrencyCode,
policyID,
reportID,
isConfirmed,
isConfirming,
isLoadingReceipt,
splitOrRequestOptions,
errorMessage,
expensesNumber,
showRemoveExpenseConfirmModal,
shouldShowProductTrainingTooltip,
renderProductTrainingTooltip,
}: ConfirmationFooterContentProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();

const shouldShowSettlementButton = iouType === CONST.IOU.TYPE.PAY;

const button = shouldShowSettlementButton ? (
<SettlementButton
pressOnEnter
onPress={confirm}
enablePaymentsRoute={ROUTES.ENABLE_PAYMENTS}
chatReportID={reportID}
shouldShowPersonalBankAccountOption
currency={iouCurrencyCode}
policyID={policyID}
buttonSize={CONST.DROPDOWN_BUTTON_SIZE.LARGE}
kycWallAnchorAlignment={{
horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT,
vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM,
}}
paymentMethodDropdownAnchorAlignment={{
horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.RIGHT,
vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM,
}}
enterKeyEventListenerPriority={1}
useKeyboardShortcuts
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- Using || because we want undefined and false to both be treated as falsy for isLoading
isLoading={isConfirmed || isConfirming}
Comment thread
OlimpiaZurek marked this conversation as resolved.
sentryLabel={CONST.SENTRY_LABEL.MONEY_REQUEST.CONFIRMATION_PAY_BUTTON}
/>
) : (
<>
{expensesNumber > 1 && (
<Button
large
text={translate('iou.removeThisExpense')}
onPress={showRemoveExpenseConfirmModal}
style={styles.mb3}
sentryLabel={CONST.SENTRY_LABEL.MONEY_REQUEST.CONFIRMATION_REMOVE_EXPENSE_BUTTON}
/>
)}
<EducationalTooltip
shouldRender={shouldShowProductTrainingTooltip}
renderTooltipContent={renderProductTrainingTooltip}
anchorAlignment={{
horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.CENTER,
vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM,
}}
wrapperStyle={styles.productTrainingTooltipWrapper}
shouldHideOnNavigate
shiftVertical={-10}
>
<View>
<ButtonWithDropdownMenu
pressOnEnter
onPress={(event, value) => confirm({paymentType: value as PaymentMethodType})}
options={splitOrRequestOptions}
buttonSize={CONST.DROPDOWN_BUTTON_SIZE.LARGE}
enterKeyEventListenerPriority={1}
useKeyboardShortcuts
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- Using || because we want undefined and false to both be treated as falsy for isLoading
isLoading={isConfirmed || isConfirming || isLoadingReceipt}
Comment thread
OlimpiaZurek marked this conversation as resolved.
sentryLabel={CONST.SENTRY_LABEL.MONEY_REQUEST.CONFIRMATION_SUBMIT_BUTTON}
/>
</View>
</EducationalTooltip>
</>
);

return (
<>
{!!errorMessage && (
<FormHelpMessage
style={[styles.ph1, styles.mb2]}
isError
message={errorMessage}
/>
)}
<View>{button}</View>
</>
);
}

export default ConfirmationFooterContent;
export type {ConfirmationFooterContentProps};
126 changes: 126 additions & 0 deletions src/components/MoneyRequestConfirmationList/confirmAction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import type {OnyxEntry} from 'react-native-onyx';
import type {PaymentActionParams} from '@components/SettlementButton/types';
import Log from '@libs/Log';
import Navigation from '@libs/Navigation/Navigation';
import {hasInvoicingDetails} from '@userActions/Policy/Policy';
import CONST from '@src/CONST';
import type {IOUType} from '@src/CONST';
import type {TranslationPaths} from '@src/languages/types';
import ROUTES from '@src/ROUTES';
import type * as OnyxTypes from '@src/types/onyx';
import type {Participant} from '@src/types/onyx/IOU';
import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage';

type BuildConfirmActionParams = {
/** IOU type being confirmed (submit / split / track / pay / invoice) */
iouType: IOUType;

/** Policy the IOU belongs to, used to detect missing invoice company info */
policy: OnyxEntry<OnyxTypes.Policy>;

/** Transaction being confirmed, when one exists */
transactionID: string | undefined;

/** Report the IOU is being created on */
reportID: string;

/** Truthy when the route to the confirmation page has a known error */
routeError: string | null | undefined;

/** Current form-level error key, or '' when no error is set */
formError: TranslationPaths | '';

/** Participants selected for this IOU */
selectedParticipants: Participant[];

/** Whether the current user is a delegate without permission to pay */
isDelegateAccessRestricted: boolean;

/** Pure validator that returns the first failing translation key, or null on success */
validate: (paymentType?: PaymentMethodType) => {errorKey: TranslationPaths; shouldSetDidConfirmSplit?: boolean} | {errorKey: null} | null;

/** Setter for the form-level error key */
setFormError: (error: TranslationPaths | '') => void;

/** Setter for the "user already confirmed split" flag */
setDidConfirmSplit: (value: boolean) => void;

/** Shows the modal that explains delegate-no-access restrictions */
showDelegateNoAccessModal: () => void;

/** Caller-provided confirm handler for non-pay flows */
onConfirm?: (selectedParticipants: Participant[]) => void;

/** Caller-provided send-money handler for pay flows */
onSendMoney?: (paymentMethod: PaymentMethodType | undefined) => void;
Comment on lines +16 to +55
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

docs

};

/**
* Owns the click-confirm action for the Money Request confirmation flow.
*
* Handles three branches: (1) invoice-without-company-info routes to the company info
* step before validation; (2) non-PAY types invoke `onConfirm`; (3) PAY types run
* delegate-access gating and invoke `onSendMoney` with the chosen payment method.
* Validation results drive form-error state.
*/
function buildConfirmAction({
iouType,
policy,
transactionID,
reportID,
routeError,
formError,
selectedParticipants,
isDelegateAccessRestricted,
validate,
setFormError,
setDidConfirmSplit,
showDelegateNoAccessModal,
onConfirm,
onSendMoney,
}: BuildConfirmActionParams) {
return ({paymentType: paymentMethod}: PaymentActionParams) => {
// Routing short-circuit: invoices without company info go to the company info step before we validate anything.
if (iouType === CONST.IOU.TYPE.INVOICE && !hasInvoicingDetails(policy) && transactionID && !routeError) {
Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_COMPANY_INFO.getRoute(iouType, transactionID, reportID, Navigation.getActiveRoute()));
return;
}

const result = validate(paymentMethod);
if (!result) {
return;
}

if (result.errorKey) {
if (result.shouldSetDidConfirmSplit) {
setDidConfirmSplit(true);
}
setFormError(result.errorKey);
return;
}

if (iouType !== CONST.IOU.TYPE.PAY) {
if (formError) {
return;
}
onConfirm?.(selectedParticipants);
return;
}

// PAY branch side effects.
if (!paymentMethod) {
return;
}
if (isDelegateAccessRestricted) {
showDelegateNoAccessModal();
return;
}
if (formError) {
return;
}
Log.info(`[IOU] Sending money via: ${paymentMethod}`);
onSendMoney?.(paymentMethod);
};
}

export default buildConfirmAction;
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import type {OnyxEntry} from 'react-native-onyx';
import {useCurrencyListActions} from '@hooks/useCurrencyList';
import useLocalize from '@hooks/useLocalize';
import {computePerDiemExpenseAmount} from '@libs/actions/IOU/PerDiem';
import type {getAttendees} from '@libs/TransactionUtils';
import {isScanning, isScanRequest as isScanRequestUtil} from '@libs/TransactionUtils';
import type * as OnyxTypes from '@src/types/onyx';

type SubRates = NonNullable<NonNullable<NonNullable<OnyxTypes.Transaction['comment']>['customUnit']>['subRates']>;

type UseConfirmationAmountParams = {
/** Transaction whose amount we're computing */
transaction: OnyxEntry<OnyxTypes.Transaction>;

/** Total IOU amount entered by the user */
iouAmount: number;

/** Currency the IOU is being created in */
iouCurrencyCode: string | undefined;

/** Currently selected attendees, used to compute the per-attendee amount */
iouAttendees: ReturnType<typeof getAttendees>;

/** Whether the transaction is a distance request */
isDistanceRequest: boolean;

/** Whether the distance request route is still pending */
isDistanceRequestWithPendingRoute: boolean;

/** Whether the distance amount needs to be (re)calculated this render */
shouldCalculateDistanceAmount: boolean;

/** Pre-computed distance amount in the smallest currency unit */
distanceRequestAmount: number;

/** Currency reported by the active mileage rate */
distanceCurrency: string | undefined;

/** Whether the transaction is a per-diem request */
isPerDiemRequest: boolean;

/** Currency the transaction had on the previous render, used to detect changes */
prevCurrency: string | undefined;

/** Currency the transaction has on the current render */
currency: string | undefined;

/** Per-diem sub-rates from the previous render, used to detect rate changes */
prevSubRates: SubRates;
Comment on lines +13 to +49
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

docs

};

/**
* Computes the display amount and per-attendee amount for the confirmation flow.
*
* Handles the three amount sources — distance (recalculated from the route), per-diem
* (summed from sub-rates), and plain IOU amount — and formats them for display,
* including the pending-route and scanning special cases.
*/
function useConfirmationAmount({
transaction,
iouAmount,
iouCurrencyCode,
iouAttendees,
isDistanceRequest,
isDistanceRequestWithPendingRoute,
shouldCalculateDistanceAmount,
distanceRequestAmount,
distanceCurrency,
isPerDiemRequest,
prevCurrency,
currency,
prevSubRates,
}: UseConfirmationAmountParams) {
const {translate} = useLocalize();
const {convertToDisplayString} = useCurrencyListActions();

const isScanRequest = isScanRequestUtil(transaction);

const subRates = transaction?.comment?.customUnit?.subRates ?? [];
const shouldCalculatePerDiemAmount = isPerDiemRequest && (iouAmount === 0 || JSON.stringify(prevSubRates) !== JSON.stringify(subRates) || prevCurrency !== currency);

let amountToBeUsed = iouAmount;
if (shouldCalculateDistanceAmount) {
amountToBeUsed = distanceRequestAmount;
} else if (shouldCalculatePerDiemAmount) {
amountToBeUsed = computePerDiemExpenseAmount({subRates});
}

const displayCurrency = isDistanceRequest ? distanceCurrency : iouCurrencyCode;

let formattedAmount = convertToDisplayString(amountToBeUsed, displayCurrency);
if (isDistanceRequestWithPendingRoute) {
formattedAmount = '';
} else if (isScanning(transaction)) {
formattedAmount = translate('iou.receiptStatusTitle');
}

const attendeeCount = iouAttendees?.length && iouAttendees.length > 0 ? iouAttendees.length : 1;
const formattedAmountPerAttendee = isDistanceRequestWithPendingRoute || isScanRequest ? '' : convertToDisplayString(amountToBeUsed / attendeeCount, displayCurrency);

return {amountToBeUsed, formattedAmount, formattedAmountPerAttendee, isScanRequest};
}

export default useConfirmationAmount;
Loading
Loading