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
431 changes: 104 additions & 327 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,29 @@
import {useEffect, useRef} from 'react';
import {endSpan} from '@libs/telemetry/activeSpans';
import CONST from '@src/CONST';

type ConfirmationTelemetryProps = {
transactionID: string | undefined;
};

/**
* Side-effect-only component that ends the confirmation list ready
* telemetry span once the transaction ID becomes available.
*/
function ConfirmationTelemetry({transactionID}: ConfirmationTelemetryProps) {
const hasEndedListReadySpan = useRef(false);

useEffect(() => {
if (hasEndedListReadySpan.current || !transactionID) {
return;
}
hasEndedListReadySpan.current = true;
endSpan(CONST.TELEMETRY.SPAN_CONFIRMATION_LIST_READY);
}, [transactionID]);

return null;
}

ConfirmationTelemetry.displayName = 'ConfirmationTelemetry';

export default ConfirmationTelemetry;
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import {useEffect, useRef} from 'react';
import type {OnyxEntry} from 'react-native-onyx';
import {useCurrencyListActions} from '@hooks/useCurrencyList';
import useLocalize from '@hooks/useLocalize';
import usePrevious from '@hooks/usePrevious';
import {setCustomUnitRateID, setMoneyRequestAmount, setMoneyRequestMerchant, setMoneyRequestPendingFields} from '@libs/actions/IOU';
import {setSplitShares} from '@libs/actions/IOU/Split';
import DistanceRequestUtils from '@libs/DistanceRequestUtils';
import type {MileageRate} from '@libs/DistanceRequestUtils';
import CONST from '@src/CONST';
import type {TranslationPaths} from '@src/languages/types';
import type {Policy, Transaction} from '@src/types/onyx';
import type {Participant} from '@src/types/onyx/IOU';
import type {Unit} from '@src/types/onyx/Policy';

type DistanceRequestControllerProps = {
transactionID: string | undefined;
transaction: OnyxEntry<Transaction>;
policy: OnyxEntry<Policy>;
isDistanceRequest: boolean;
isManualDistanceRequest: boolean;
isPolicyExpenseChat: boolean;
isMovingTransactionFromTrackExpense: boolean;
isReadOnly: boolean;
isTypeSplit: boolean;
customUnitRateID: string;
mileageRate: MileageRate;
rate: number | undefined;
unit: Unit | undefined;
currency: string;
distance: number;
distanceRequestAmount: number;
shouldCalculateDistanceAmount: boolean;
isDistanceRequestWithPendingRoute: boolean;
hasRoute: boolean;
lastSelectedRate: string | undefined;
selectedParticipants: Participant[];
selectedParticipantsProp: Participant[];
setFormError: (error: TranslationPaths | '') => void;
clearFormErrors: (errors: string[]) => void;
};

/**
* Side-effect-only component that manages distance request effects:
* validates distance rates on policy change, calculates distance amounts,
* auto-selects the last saved distance rate, and updates the merchant.
*/
function DistanceRequestController({
transactionID,
transaction,
policy,
isDistanceRequest,
isManualDistanceRequest,
isPolicyExpenseChat,
isMovingTransactionFromTrackExpense,
isReadOnly,
isTypeSplit,
customUnitRateID,
mileageRate,
rate,
unit,
currency,
distance,
distanceRequestAmount,
shouldCalculateDistanceAmount,
isDistanceRequestWithPendingRoute,
hasRoute,
lastSelectedRate,
selectedParticipants,
selectedParticipantsProp,
setFormError,
clearFormErrors,
}: DistanceRequestControllerProps) {
const {translate, toLocaleDigit} = useLocalize();
const {getCurrencySymbol} = useCurrencyListActions();
const prevPolicy = usePrevious(policy);
const isFirstUpdatedDistanceAmount = useRef(false);

useEffect(() => {
// We want this effect to run when the transaction is moving from Self DM to an expense chat, or when the policy changes
const isPolicyChanged = prevPolicy?.id !== policy?.id;
if (!transactionID || !isDistanceRequest || !isPolicyExpenseChat || (!isMovingTransactionFromTrackExpense && !isPolicyChanged)) {
return;
}

const errorKey = 'iou.error.invalidRate';
const policyRates = DistanceRequestUtils.getMileageRates(policy);

// If the selected rate belongs to the policy, and for moving track expense if the units also matches, clear the error
if (customUnitRateID && customUnitRateID in policyRates && (!isMovingTransactionFromTrackExpense || policyRates[customUnitRateID].unit === mileageRate.unit)) {
clearFormErrors([errorKey]);
return;
}

// If there is a distance rate in the policy that matches the rate and unit of the currently selected mileage rate, select it automatically
const matchingRate = Object.values(policyRates).find((policyRate) => policyRate.rate === mileageRate.rate && policyRate.unit === mileageRate.unit);
if (matchingRate?.customUnitRateID) {
setCustomUnitRateID(transactionID, matchingRate.customUnitRateID, transaction, policy);
clearFormErrors([errorKey]);
return;
}

// If none of the above conditions are met, display the rate error
setFormError(errorKey);
}, [
isDistanceRequest,
isPolicyExpenseChat,
transactionID,
mileageRate.rate,
mileageRate.unit,
customUnitRateID,
policy,
isMovingTransactionFromTrackExpense,
setFormError,
clearFormErrors,
transaction,
prevPolicy?.id,
]);

useEffect(() => {
if (isFirstUpdatedDistanceAmount.current) {
return;
}
if (!isDistanceRequest || !transactionID) {
return;
}
if (isReadOnly) {
return;
}
const amount = DistanceRequestUtils.getDistanceRequestAmount(distance, unit ?? CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES, rate ?? 0);
setMoneyRequestAmount(transactionID, amount, currency ?? '');
isFirstUpdatedDistanceAmount.current = true;
}, [distance, rate, isReadOnly, unit, transactionID, currency, isDistanceRequest]);

useEffect(() => {
if (!shouldCalculateDistanceAmount || !transactionID || isReadOnly) {
return;
}

const amount = distanceRequestAmount;
setMoneyRequestAmount(transactionID, amount, currency ?? '');

// If it's a split request among individuals, set the split shares
const participantAccountIDs: number[] = selectedParticipantsProp.map((participant) => participant.accountID ?? CONST.DEFAULT_NUMBER_ID);
if (isTypeSplit && !isPolicyExpenseChat && amount && transaction?.currency) {
setSplitShares(transaction, amount, currency, participantAccountIDs);
}
}, [shouldCalculateDistanceAmount, isReadOnly, distanceRequestAmount, transactionID, currency, isTypeSplit, isPolicyExpenseChat, selectedParticipantsProp, transaction]);

useEffect(() => {
if (
!['-1', CONST.CUSTOM_UNITS.FAKE_P2P_ID].includes(customUnitRateID) ||
!isDistanceRequest ||
!isPolicyExpenseChat ||
!transactionID ||
!lastSelectedRate ||
(isMovingTransactionFromTrackExpense && customUnitRateID === CONST.CUSTOM_UNITS.FAKE_P2P_ID) ||
!selectedParticipants.some((participant) => participant.policyID === policy?.id)
) {
return;
}

setCustomUnitRateID(transactionID, lastSelectedRate, transaction, policy);
}, [customUnitRateID, transactionID, lastSelectedRate, isDistanceRequest, isPolicyExpenseChat, isMovingTransactionFromTrackExpense, transaction, policy, selectedParticipants]);

useEffect(() => {
if (!isDistanceRequest || (isMovingTransactionFromTrackExpense && !isPolicyExpenseChat) || !transactionID || isReadOnly) {
// We don't want to recalculate the distance merchant when moving a transaction from Track Expense to a 1:1 chat, because the distance rate will be the same default P2P rate.
// When moving to a policy chat (e.g. sharing with an accountant), we should recalculate the distance merchant with the policy's rate.
return;
}

/*
Set pending waypoints based on the route status. We should handle this dynamically to cover cases such as:
When the user completes the initial steps of the IOU flow offline and then goes online on the confirmation page.
In this scenario, the route will be fetched from the server, and the waypoints will no longer be pending.
*/
setMoneyRequestPendingFields(transactionID, {waypoints: isDistanceRequestWithPendingRoute ? CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD : null});

const distanceMerchant = DistanceRequestUtils.getDistanceMerchant(
hasRoute,
distance,
unit,
rate ?? 0,
currency ?? CONST.CURRENCY.USD,
translate,
toLocaleDigit,
getCurrencySymbol,
isManualDistanceRequest,
);
setMoneyRequestMerchant(transactionID, distanceMerchant, true);
}, [
isDistanceRequestWithPendingRoute,
hasRoute,
distance,
unit,
rate,
currency,
translate,
toLocaleDigit,
isDistanceRequest,
isPolicyExpenseChat,
transaction,
transactionID,
isReadOnly,
isMovingTransactionFromTrackExpense,
getCurrencySymbol,
isManualDistanceRequest,
]);

return null;
}

DistanceRequestController.displayName = 'DistanceRequestController';

export default DistanceRequestController;
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import {useEffect} from 'react';
import type {OnyxEntry} from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
import {setMoneyRequestCategory, setMoneyRequestTag} from '@libs/actions/IOU';
import {insertTagIntoTransactionTagsString} from '@libs/IOUUtils';
import {getTag} from '@libs/TransactionUtils';
import type {Policy, PolicyCategories, PolicyTagLists, Transaction} from '@src/types/onyx';

type FieldAutoSelectorProps = {
transactionID: string | undefined;
transaction: OnyxEntry<Transaction>;
policyCategories: OnyxEntry<PolicyCategories>;
policyTagLists: Array<ValueOf<PolicyTagLists>>;
policyTags: OnyxEntry<PolicyTagLists>;
policy: OnyxEntry<Policy>;
shouldShowCategories: boolean;
isCategoryRequired: boolean;
iouCategory: string | undefined;
isMovingTransactionFromTrackExpense: boolean;
};

/**
* Side-effect-only component that auto-selects the only enabled category
* and required single tags when the confirmation list mounts.
*/
function FieldAutoSelector({
transactionID,
transaction,
policyCategories,
policyTagLists,
policyTags,
policy,
shouldShowCategories,
isCategoryRequired,
iouCategory,
isMovingTransactionFromTrackExpense,
}: FieldAutoSelectorProps) {
// Auto select the category if there is only one enabled category and it is required
useEffect(() => {
const enabledCategories = Object.values(policyCategories ?? {}).filter((category) => category.enabled);
if (!transactionID || iouCategory || !shouldShowCategories || enabledCategories.length !== 1 || !isCategoryRequired) {
return;
}
setMoneyRequestCategory(transactionID, enabledCategories.at(0)?.name ?? '', policy, isMovingTransactionFromTrackExpense);
// Keep 'transaction' out to ensure that we auto select the option only once
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [shouldShowCategories, policyCategories, isCategoryRequired, policy?.id]);

// Auto select the tag if there is only one enabled tag and it is required
useEffect(() => {
if (!transactionID) {
return;
}

let updatedTagsString = getTag(transaction);
for (const [index, tagList] of policyTagLists.entries()) {
const isTagListRequired = tagList.required ?? false;
if (!isTagListRequired) {
continue;
}
const enabledTags = Object.values(tagList.tags).filter((tag) => tag.enabled);
if (enabledTags.length !== 1 || getTag(transaction, index)) {
continue;
}
updatedTagsString = insertTagIntoTransactionTagsString(updatedTagsString, enabledTags.at(0)?.name ?? '', index, policy?.hasMultipleTagLists ?? false);
}
if (updatedTagsString !== getTag(transaction) && updatedTagsString) {
setMoneyRequestTag(transactionID, updatedTagsString);
}
// Keep 'transaction' out to ensure that we auto select the option only once
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [transactionID, policyTagLists, policyTags]);

return null;
}

FieldAutoSelector.displayName = 'FieldAutoSelector';

export default FieldAutoSelector;
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import {useEffect} from 'react';
import type {OnyxEntry} from 'react-native-onyx';
import useLocalize from '@hooks/useLocalize';
import {adjustRemainingSplitShares} from '@libs/actions/IOU/Split';
import type {TranslationPaths} from '@src/languages/types';
import type {Transaction} from '@src/types/onyx';
import type {SplitShares} from '@src/types/onyx/Transaction';

type SplitBillControllerProps = {
transaction: OnyxEntry<Transaction>;
isTypeSplit: boolean;
iouAmount: number;
iouCurrencyCode: string | undefined;
currentUserAccountID: number;
isFocused: boolean;
onFormError: (error: TranslationPaths | '') => void;
};

/**
* Side-effect-only component that validates split share amounts
* and adjusts remaining split shares when the transaction changes.
*/
function SplitBillController({transaction, isTypeSplit, iouAmount, iouCurrencyCode, currentUserAccountID, isFocused, onFormError}: SplitBillControllerProps) {
const {translate} = useLocalize();

useEffect(() => {
if (!isTypeSplit || !transaction?.splitShares || !isFocused) {
return;
}

const splitSharesMap: SplitShares = transaction.splitShares;
const shares: number[] = Object.values(splitSharesMap).map((splitShare) => splitShare?.amount ?? 0);
const sumOfShares = shares?.reduce((prev, current): number => prev + current, 0);
if (sumOfShares !== iouAmount) {
onFormError('iou.error.invalidSplit');
return;
}

const participantsWithAmount = Object.keys(transaction?.splitShares ?? {})
.filter((accountID: string): boolean => (transaction?.splitShares?.[Number(accountID)]?.amount ?? 0) > 0)
.map((accountID) => Number(accountID));

// A split must have at least two participants with amounts bigger than 0
if (participantsWithAmount.length === 1) {
onFormError('iou.error.invalidSplitParticipants');
return;
}

// Amounts should be bigger than 0 for the split bill creator (yourself)
if (transaction?.splitShares[currentUserAccountID] && (transaction.splitShares[currentUserAccountID]?.amount ?? 0) === 0) {
onFormError('iou.error.invalidSplitYourself');
return;
}

onFormError('');
}, [isFocused, transaction, isTypeSplit, transaction?.splitShares, currentUserAccountID, iouAmount, iouCurrencyCode, onFormError, translate]);

useEffect(() => {
if (!isTypeSplit || !transaction?.splitShares) {
return;
}
adjustRemainingSplitShares(transaction);
}, [isTypeSplit, transaction]);

return null;
}

SplitBillController.displayName = 'SplitBillController';

export default SplitBillController;
Loading
Loading