diff --git a/src/CONST.ts b/src/CONST.ts index 86cbd4c28fc9..cc5c25627c01 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -278,6 +278,9 @@ const CONST = { // Regex to get link in href prop inside of component REGEX_LINK_IN_ANCHOR: /]*?\s+)?href="([^"]*)"/gi, + // Regex to read violation value from string given by backend + VIOLATION_LIMIT_REGEX: /[^0-9]+/g, + MERCHANT_NAME_MAX_LENGTH: 255, MASKED_PAN_PREFIX: 'XXXXXXXXXXXX', diff --git a/src/components/MoneyRequestConfirmationListFooter.tsx b/src/components/MoneyRequestConfirmationListFooter.tsx index 1c8c08eee082..3a12cb5f1752 100644 --- a/src/components/MoneyRequestConfirmationListFooter.tsx +++ b/src/components/MoneyRequestConfirmationListFooter.tsx @@ -233,7 +233,7 @@ function MoneyRequestConfirmationListFooter({ const shouldShowTags = useMemo(() => isPolicyExpenseChat && OptionsListUtils.hasEnabledTags(policyTagLists), [isPolicyExpenseChat, policyTagLists]); const isMultilevelTags = useMemo(() => PolicyUtils.isMultiLevelTags(policyTags), [policyTags]); - const shouldShowAttendees = useMemo(() => !!policy?.id && (policy?.type === CONST.POLICY.TYPE.CORPORATE || policy?.type === CONST.POLICY.TYPE.TEAM), [policy?.id, policy?.type]); + const shouldShowAttendees = useMemo(() => TransactionUtils.shouldShowAttendees(iouType, policy), [iouType, policy]); const senderWorkspace = useMemo(() => { const senderWorkspaceParticipant = selectedParticipants.find((participant) => participant.isSender); @@ -516,7 +516,7 @@ function MoneyRequestConfirmationListFooter({ item.displayName ?? item.login).join(', ')} + title={iouAttendees?.map((item) => item?.displayName ?? item?.login).join(', ')} description={`${translate('iou.attendees')} ${ iouAttendees?.length && iouAttendees.length > 1 ? `\u00B7 ${formattedAmountPerAttendee} ${translate('common.perPerson')}` : '' }`} diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index a411b591a3d3..ceaf65f354ea 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -126,6 +126,7 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals const { created: transactionDate, amount: transactionAmount, + attendees: transactionAttendees, taxAmount: transactionTaxAmount, currency: transactionCurrency, comment: transactionDescription, @@ -139,6 +140,7 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals const isEmptyMerchant = transactionMerchant === '' || transactionMerchant === CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT; const isDistanceRequest = TransactionUtils.isDistanceRequest(transaction); const formattedTransactionAmount = transactionAmount ? CurrencyUtils.convertToDisplayString(transactionAmount, transactionCurrency) : ''; + const formattedPerAttendeeAmount = transactionAmount ? CurrencyUtils.convertToDisplayString(transactionAmount / (transactionAttendees?.length ?? 1), transactionCurrency) : ''; const formattedOriginalAmount = transactionOriginalAmount && transactionOriginalCurrency && CurrencyUtils.convertToDisplayString(transactionOriginalAmount, transactionOriginalCurrency); const isCardTransaction = TransactionUtils.isCardTransaction(transaction); const cardProgramName = TransactionUtils.getCardName(transaction); @@ -191,6 +193,7 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const shouldShowTag = isPolicyExpenseChat && (transactionTag || OptionsListUtils.hasEnabledTags(policyTagLists)); const shouldShowBillable = isPolicyExpenseChat && (!!transactionBillable || !(policy?.disabledFields?.defaultBillable ?? true) || !!updatedTransaction?.billable); + const shouldShowAttendees = useMemo(() => TransactionUtils.shouldShowAttendees(iouType, policy), [iouType, policy]); const shouldShowTax = isTaxTrackingEnabled(isPolicyExpenseChat, policy, isDistanceRequest); const tripID = ReportUtils.getTripIDFromTransactionParentReport(parentReport); @@ -637,6 +640,25 @@ function MoneyRequestView({report, shouldShowAnimatedBackground, readonly = fals }} /> )} + {shouldShowAttendees && ( + + item?.displayName ?? item?.login).join(', ')} + description={`${translate('iou.attendees')} ${ + transactionAttendees?.length && transactionAttendees.length > 1 ? `${formattedPerAttendeeAmount} ${translate('common.perPerson')}` : '' + }`} + style={[styles.moneyRequestMenuItem]} + titleStyle={styles.flex1} + onPress={() => + Navigation.navigate(ROUTES.MONEY_REQUEST_ATTENDEE.getRoute(CONST.IOU.ACTION.EDIT, iouType, transaction?.transactionID ?? '-1', report?.reportID ?? '-1')) + } + interactive + shouldRenderAsHTML + /> + + )} {shouldShowBillable && ( diff --git a/src/libs/ModifiedExpenseMessage.ts b/src/libs/ModifiedExpenseMessage.ts index 99f483cf6ef6..f0428ae6bf64 100644 --- a/src/libs/ModifiedExpenseMessage.ts +++ b/src/libs/ModifiedExpenseMessage.ts @@ -302,6 +302,19 @@ function getForReportAction(reportID: string | undefined, reportAction: OnyxEntr ); } + const hasModifiedAttendees = isReportActionOriginalMessageAnObject && 'oldAttendees' in reportActionOriginalMessage && 'attendees' in reportActionOriginalMessage; + if (hasModifiedAttendees) { + buildMessageFragmentForValue( + reportActionOriginalMessage.oldAttendees ?? '', + reportActionOriginalMessage.attendees ?? '', + Localize.translateLocal('iou.attendees'), + false, + setFragments, + removalFragments, + changeFragments, + ); + } + const message = getMessageLine(`\n${Localize.translateLocal('iou.changed')}`, changeFragments) + getMessageLine(`\n${Localize.translateLocal('iou.set')}`, setFragments) + diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 292a33c320c0..8a8b060ae105 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -42,7 +42,7 @@ import type { TransactionViolation, UserWallet, } from '@src/types/onyx'; -import type {Participant} from '@src/types/onyx/IOU'; +import type {Attendee, Participant} from '@src/types/onyx/IOU'; import type {SelectedParticipant} from '@src/types/onyx/NewGroupChatDraft'; import type {OriginalMessageExportedToIntegration} from '@src/types/onyx/OldDotAction'; import type Onboarding from '@src/types/onyx/Onboarding'; @@ -362,6 +362,7 @@ type OptimisticTaskReport = Pick< type TransactionDetails = { created: string; amount: number; + attendees: Attendee[]; taxAmount?: number; taxCode?: string; currency: string; @@ -2911,6 +2912,7 @@ function getTransactionDetails(transaction: OnyxInputOrEntry, creat return { created: TransactionUtils.getFormattedCreated(transaction, createdDateFormat), amount: TransactionUtils.getAmount(transaction, !isEmptyObject(report) && isExpenseReport(report)), + attendees: TransactionUtils.getAttendees(transaction), taxAmount: TransactionUtils.getTaxAmount(transaction, !isEmptyObject(report) && isExpenseReport(report)), taxCode: TransactionUtils.getTaxCode(transaction), currency: TransactionUtils.getCurrency(transaction), @@ -3469,7 +3471,7 @@ function getModifiedExpenseOriginalMessage( originalMessage.merchant = transactionChanges?.merchant; } if ('attendees' in transactionChanges) { - [originalMessage.oldAttendees, originalMessage.attendees] = TransactionUtils.getFormattedAttendees(oldTransaction?.modifiedAttendees, oldTransaction?.attendees); + [originalMessage.oldAttendees, originalMessage.attendees] = TransactionUtils.getFormattedAttendees(transactionChanges?.attendees, TransactionUtils.getAttendees(oldTransaction)); } // The amount is always a combination of the currency and the number value so when one changes we need to store both diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index ba1dae4d2e6f..fda799adf71a 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -19,6 +19,7 @@ import * as ReportConnection from '@libs/ReportConnection'; import * as ReportUtils from '@libs/ReportUtils'; import type {IOURequestType} from '@userActions/IOU'; import CONST from '@src/CONST'; +import type {IOUType} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Beta, OnyxInputOrEntry, Policy, RecentWaypoint, ReviewDuplicates, TaxRate, TaxRates, Transaction, TransactionViolation, TransactionViolations} from '@src/types/onyx'; import type {Attendee} from '@src/types/onyx/IOU'; @@ -126,6 +127,7 @@ function buildOptimisticTransaction( currency: string, reportID: string, comment = '', + attendees: Attendee[] = [], created = '', source = '', originalTransactionID = '', @@ -171,6 +173,7 @@ function buildOptimisticTransaction( taxAmount, billable, reimbursable, + attendees, }; } @@ -199,6 +202,10 @@ function isMerchantMissing(transaction: OnyxEntry) { return isMerchantEmpty; } +function shouldShowAttendees(iouType: IOUType, policy: OnyxEntry): boolean { + return iouType === CONST.IOU.TYPE.SUBMIT && !!policy?.id && (policy?.type === CONST.POLICY.TYPE.CORPORATE || policy?.type === CONST.POLICY.TYPE.TEAM); +} + /** * Check if the merchant is partial i.e. `(none)` */ @@ -293,6 +300,10 @@ function getUpdatedTransaction(transaction: Transaction, transactionChanges: Tra updatedTransaction.tag = transactionChanges.tag; } + if (Object.hasOwn(transactionChanges, 'attendees')) { + updatedTransaction.modifiedAttendees = transactionChanges?.attendees; + } + if ( shouldUpdateReceiptState && shouldStopSmartscan && @@ -316,6 +327,7 @@ function getUpdatedTransaction(transaction: Transaction, transactionChanges: Tra ...(Object.hasOwn(transactionChanges, 'tag') && {tag: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), ...(Object.hasOwn(transactionChanges, 'taxAmount') && {taxAmount: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), ...(Object.hasOwn(transactionChanges, 'taxCode') && {taxCode: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), + ...(Object.hasOwn(transactionChanges, 'attendees') && {taxCode: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), }; return updatedTransaction; @@ -418,7 +430,14 @@ function getMerchant(transaction: OnyxInputOrEntry): string { } /** - * Return the merchant field from the transaction, return the modifiedMerchant if present. + * Return the list of modified attendees if present otherwise list of attendees + */ +function getAttendees(transaction: OnyxInputOrEntry): Attendee[] { + return transaction?.modifiedAttendees ? transaction.modifiedAttendees : transaction?.attendees ?? []; +} + +/** + * Return the list of attendees as a string and modified list of attendees as a string if present. */ function getFormattedAttendees(modifiedAttendees?: Attendee[], attendees?: Attendee[]): [string, string] { const oldAttendees = modifiedAttendees ?? []; @@ -1096,6 +1115,7 @@ export { isManualRequest, isScanRequest, getAmount, + getAttendees, getTaxAmount, getTaxCode, getCurrency, @@ -1160,6 +1180,7 @@ export { removeSettledAndApprovedTransactions, getCardName, hasReceiptSource, + shouldShowAttendees, }; export type {TransactionChanges}; diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index adbc05460220..a3d6bd9932d0 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -345,6 +345,11 @@ const ViolationsUtils = { return violation.name as never; } }, + + // We have to use regex, because Violation limit is given in a inconvenient form: "$2,000.00" + getViolationAmountLimit(violation: TransactionViolation): number { + return Number(violation.data?.formattedLimit?.replace(CONST.VIOLATION_LIMIT_REGEX, '')); + }, }; export default ViolationsUtils; diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 1f4b05912c14..b4819051c2ea 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -1901,6 +1901,7 @@ function getSendInvoiceInformation( currency, optimisticInvoiceReport.reportID, trimmedComment, + [], created, '', '', @@ -2022,6 +2023,7 @@ function getMoneyRequestInformation( payeeEmail = currentUserEmail, moneyRequestReportID = '', linkedTrackedExpenseReportAction?: OnyxTypes.ReportAction, + attendees?: Attendee[], ): MoneyRequestInformation { const payerEmail = PhoneNumber.addSMSDomainIfPhoneNumber(participant.login ?? ''); const payerAccountID = Number(participant.accountID); @@ -2081,6 +2083,7 @@ function getMoneyRequestInformation( currency, iouReport.reportID, comment, + attendees, created, '', '', @@ -2318,6 +2321,7 @@ function getTrackExpenseInformation( currency, shouldUseMoneyReport && iouReport ? iouReport.reportID : '-1', comment, + [], created, '', '', @@ -2478,6 +2482,7 @@ function getUpdateMoneyRequestParams( policyTagList: OnyxTypes.OnyxInputOrEntry, policyCategories: OnyxTypes.OnyxInputOrEntry, onlyIncludeChangedFields: boolean, + violations?: OnyxEntry, ): UpdateMoneyRequestData { const optimisticData: OnyxUpdate[] = []; const successData: OnyxUpdate[] = []; @@ -2721,7 +2726,19 @@ function getUpdateMoneyRequestParams( } } - // Clear out the error fields and loading states on success + const overLimitViolation = violations?.find((violation) => violation.name === 'overLimit'); + // Update violation limit, if we modify attendees. The given limit value is for a single attendee, if we have multiple attendees we should multpiply limit by attende count + if ('attendees' in transactionChanges && !!overLimitViolation) { + const limitForSingleAttendee = ViolationsUtils.getViolationAmountLimit(overLimitViolation); + if (limitForSingleAttendee * (transactionChanges?.attendees?.length ?? 1) > Math.abs(TransactionUtils.getAmount(transaction))) { + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`, + value: violations?.filter((violation) => violation.name !== 'overLimit') ?? [], + }); + } + } + successData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, @@ -3045,11 +3062,12 @@ function updateMoneyRequestAttendees( policy: OnyxEntry, policyTagList: OnyxEntry, policyCategories: OnyxEntry, + violations: OnyxEntry, ) { const transactionChanges: TransactionChanges = { attendees, }; - const data = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories, true); + const data = getUpdateMoneyRequestParams(transactionID, transactionThreadReportID, transactionChanges, policy, policyTagList, policyCategories, true, violations); const {params, onyxData} = data; API.write(WRITE_COMMANDS.UPDATE_MONEY_REQUEST_ATTENDEES, params, onyxData); } @@ -3572,6 +3590,7 @@ function requestMoney( payeeEmail, moneyRequestReportID, linkedTrackedExpenseReportAction, + attendees, ); const activeReportID = isMoneyRequestReport ? report?.reportID : chatReport.reportID; @@ -3980,6 +3999,7 @@ function createSplitsAndOnyxData( currency, CONST.REPORT.SPLIT_REPORTID, comment, + [], created, '', '', @@ -4217,6 +4237,7 @@ function createSplitsAndOnyxData( currency, oneOnOneIOUReport.reportID, comment, + [], created, CONST.IOU.TYPE.SPLIT, splitTransaction.transactionID, @@ -4564,6 +4585,7 @@ function startSplitBill({ currency, CONST.REPORT.SPLIT_REPORTID, comment, + [], '', '', '', @@ -4970,6 +4992,7 @@ function completeSplitBill(chatReportID: string, reportAction: OnyxTypes.ReportA currency ?? '', oneOnOneIOUReport?.reportID ?? '-1', updatedTransaction?.comment?.comment, + [], updatedTransaction?.modifiedCreated, CONST.IOU.TYPE.SPLIT, transactionID, diff --git a/src/pages/iou/request/step/IOURequestStepAttendees.tsx b/src/pages/iou/request/step/IOURequestStepAttendees.tsx index a0f72304b506..f1d253db0c18 100644 --- a/src/pages/iou/request/step/IOURequestStepAttendees.tsx +++ b/src/pages/iou/request/step/IOURequestStepAttendees.tsx @@ -5,6 +5,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import useLocalize from '@hooks/useLocalize'; import usePrevious from '@hooks/usePrevious'; import Navigation from '@libs/Navigation/Navigation'; +import * as TransactionUtils from '@libs/TransactionUtils'; import MoneyRequestAttendeeSelector from '@pages/iou/request/MoneyRequestAttendeeSelector'; import * as IOU from '@userActions/IOU'; import CONST from '@src/CONST'; @@ -30,7 +31,6 @@ type IOURequestStepAttendeesOnyxProps = { type IOURequestStepAttendeesProps = IOURequestStepAttendeesOnyxProps & WithWritableReportOrNotFoundProps; function IOURequestStepAttendees({ - route, route: { params: {transactionID, reportID, iouType, backTo, action}, }, @@ -38,11 +38,12 @@ function IOURequestStepAttendees({ policyTags, policyCategories, }: IOURequestStepAttendeesProps) { - const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${route?.params.transactionID || -1}`); - const [attendees, setAttendees] = useState(transaction?.attendees ?? []); + const isEditing = action === CONST.IOU.ACTION.EDIT; + const [transaction] = useOnyx(`${isEditing ? ONYXKEYS.COLLECTION.TRANSACTION : ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID || -1}`); + const [attendees, setAttendees] = useState(TransactionUtils.getAttendees(transaction)); const previousAttendees = usePrevious(attendees); const {translate} = useLocalize(); - const isEditing = action === CONST.IOU.ACTION.EDIT; + const [violations] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS}${transactionID}`); const saveAttendees = useCallback(() => { if (attendees.length <= 0) { @@ -51,12 +52,12 @@ function IOURequestStepAttendees({ if (!lodashIsEqual(previousAttendees, attendees)) { IOU.setMoneyRequestAttendees(transactionID, attendees, !isEditing); if (isEditing) { - IOU.updateMoneyRequestAttendees(transactionID, reportID, attendees, policy, policyTags, policyCategories); + IOU.updateMoneyRequestAttendees(transactionID, reportID, attendees, policy, policyTags, policyCategories, violations); } } Navigation.goBack(backTo); - }, [attendees, backTo, isEditing, policy, policyCategories, policyTags, previousAttendees, reportID, transactionID]); + }, [attendees, backTo, isEditing, policy, policyCategories, policyTags, previousAttendees, reportID, transactionID, violations]); const navigateBack = () => { Navigation.goBack(backTo); diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts index f23259eb568c..6ec8e11d991a 100644 --- a/tests/unit/TransactionUtilsTest.ts +++ b/tests/unit/TransactionUtilsTest.ts @@ -1,3 +1,4 @@ +import type {Attendee} from '@src/types/onyx/IOU'; import * as TransactionUtils from '../../src/libs/TransactionUtils'; import type {Transaction} from '../../src/types/onyx'; @@ -6,8 +7,9 @@ function generateTransaction(values: Partial = {}): Transaction { const amount = 100; const currency = 'USD'; const comment = ''; + const attendees: Attendee[] = []; const created = '2023-10-01'; - const baseValues = TransactionUtils.buildOptimisticTransaction(amount, currency, reportID, comment, created); + const baseValues = TransactionUtils.buildOptimisticTransaction(amount, currency, reportID, comment, attendees, created); return {...baseValues, ...values}; }