From ab43ba3a099d2f771b175e49e2596a9da8ec86d1 Mon Sep 17 00:00:00 2001 From: Yauheni Horbach Date: Tue, 6 May 2025 16:53:05 +0200 Subject: [PATCH 01/94] Create a draft PR with main changes --- assets/images/arrow-split.svg | 5 + src/CONST.ts | 1 + src/ROUTES.ts | 9 + src/SCREENS.ts | 1 + src/components/Icon/Expensicons.ts | 2 + src/components/MoneyReportHeader.tsx | 12 ++ .../SelectionList/SplitListItem.tsx | 148 ++++++++++++++ src/components/SelectionList/types.ts | 27 ++- src/languages/en.ts | 7 + src/languages/es.ts | 7 + src/languages/params.ts | 11 ++ .../API/parameters/SplitTransactionParams.ts | 21 ++ src/libs/API/parameters/index.ts | 1 + src/libs/API/types.ts | 2 + .../ModalStackNavigators/index.tsx | 1 + .../Navigators/RightModalNavigator.tsx | 4 + src/libs/Navigation/linkingConfig/config.ts | 1 + src/libs/Navigation/types.ts | 10 + src/libs/ReportSecondaryActionUtils.ts | 18 +- src/libs/TransactionUtils/index.ts | 8 +- src/libs/actions/IOU.ts | 147 +++++++++++++- src/libs/actions/TransactionEdit.ts | 10 +- src/pages/iou/SplitExpensePage.tsx | 181 ++++++++++++++++++ src/types/onyx/IOU.ts | 23 ++- src/types/onyx/Transaction.ts | 5 +- 25 files changed, 655 insertions(+), 7 deletions(-) create mode 100644 assets/images/arrow-split.svg create mode 100644 src/components/SelectionList/SplitListItem.tsx create mode 100644 src/libs/API/parameters/SplitTransactionParams.ts create mode 100644 src/pages/iou/SplitExpensePage.tsx diff --git a/assets/images/arrow-split.svg b/assets/images/arrow-split.svg new file mode 100644 index 000000000000..6cc763cc1824 --- /dev/null +++ b/assets/images/arrow-split.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/CONST.ts b/src/CONST.ts index 91b63bac3552..7fe2a9660b24 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -1201,6 +1201,7 @@ const CONST = { VIEW_DETAILS: 'viewDetails', DELETE: 'delete', ADD_EXPENSE: 'addExpense', + SPLIT: 'split', }, PRIMARY_ACTIONS: { SUBMIT: 'submit', diff --git a/src/ROUTES.ts b/src/ROUTES.ts index b6b9513c1997..afb394a7e285 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -567,6 +567,15 @@ const ROUTES = { return getUrlWithBackToParam(route, backTo); }, }, + SPLIT_EXPENSE: { + route: 'r/:reportID/split-expense/:transactionID', + getRoute: (reportID: string | undefined, transactionID: string | undefined, backTo?: string) => { + if (!reportID || !transactionID) { + Log.warn('Invalid reportID or transactionID is used to build the SPLIT_EXPENSE route'); + } + return getUrlWithBackToParam(`r/${reportID}/split-expense/${transactionID}`, backTo); + }, + }, MONEY_REQUEST_HOLD_REASON: { route: ':type/edit/reason/:transactionID?/:searchHash?', getRoute: (type: ValueOf, transactionID: string, reportID: string, backTo: string, searchHash?: number) => { diff --git a/src/SCREENS.ts b/src/SCREENS.ts index cbd597ff8d3b..3b7ce195f2ae 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -213,6 +213,7 @@ const SCREENS = { REPORT_EXPORT: 'Report_Export', MISSING_PERSONAL_DETAILS: 'MissingPersonalDetails', DEBUG: 'Debug', + SPLIT_EXPENSE: 'Split_Expense', }, PUBLIC_CONSOLE_DEBUG: 'Console_Debug', ONBOARDING_MODAL: { diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts index 28e133620756..feb154ea6b2a 100644 --- a/src/components/Icon/Expensicons.ts +++ b/src/components/Icon/Expensicons.ts @@ -6,6 +6,7 @@ import ArrowCollapse from '@assets/images/arrow-collapse.svg'; import ArrowDownLong from '@assets/images/arrow-down-long.svg'; import ArrowRightLong from '@assets/images/arrow-right-long.svg'; import ArrowRight from '@assets/images/arrow-right.svg'; +import ArrowSplit from '@assets/images/arrow-split.svg'; import ArrowUpLong from '@assets/images/arrow-up-long.svg'; import UpArrow from '@assets/images/arrow-up.svg'; import ArrowsLeftRight from '@assets/images/arrows-leftright.svg'; @@ -230,6 +231,7 @@ export { AnnounceRoomAvatar, Apple, AppleLogo, + ArrowSplit, ArrowCollapse, ArrowRight, ArrowRightLong, diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 0f8ce631fb72..4d50d3a16999 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -58,6 +58,7 @@ import { deleteMoneyRequest, getNavigationUrlOnMoneyRequestDelete, getNextApproverAccountID, + initSplitExpense, payInvoice, payMoneyRequest, startMoneyRequest, @@ -584,6 +585,17 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea changeMoneyRequestHoldStatus(requestParentReportAction); }, }, + [CONST.REPORT.SECONDARY_ACTIONS.SPLIT]: { + text: translate('iou.split'), + icon: Expensicons.ArrowSplit, + value: CONST.REPORT.SECONDARY_ACTIONS.SPLIT, + onSelected: () => { + initSplitExpense(transaction, moneyRequestReport?.reportID ?? String(CONST.DEFAULT_NUMBER_ID)); + Navigation.navigate( + ROUTES.SPLIT_EXPENSE.getRoute(moneyRequestReport?.reportID ?? String(CONST.DEFAULT_NUMBER_ID), transaction?.transactionID, Navigation.getReportRHPActiveRoute()), + ); + }, + }, [CONST.REPORT.SECONDARY_ACTIONS.CHANGE_WORKSPACE]: { text: translate('iou.changeWorkspace'), icon: Expensicons.Buildings, diff --git a/src/components/SelectionList/SplitListItem.tsx b/src/components/SelectionList/SplitListItem.tsx new file mode 100644 index 000000000000..3d187341084d --- /dev/null +++ b/src/components/SelectionList/SplitListItem.tsx @@ -0,0 +1,148 @@ +import React from 'react'; +import {View} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; +import Icon from '@components/Icon'; +import {Folder, Tag} from '@components/Icon/Expensicons'; +import * as Expensicons from '@components/Icon/Expensicons'; +import MoneyRequestAmountInput from '@components/MoneyRequestAmountInput'; +import Text from '@components/Text'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {convertToDisplayStringWithoutCurrency} from '@libs/CurrencyUtils'; +import {getCleanedTagName} from '@libs/PolicyUtils'; +import variables from '@styles/variables'; +import ONYXKEYS from '@src/ONYXKEYS'; +import BaseListItem from './BaseListItem'; +import type {ListItem, SplitListItemProps, SplitListItemType} from './types'; + +function SplitListItem({ + item, + isFocused, + showTooltip, + isDisabled, + onSelectRow, + shouldPreventEnterKeySubmit, + rightHandSideComponent, + onFocus, +}: SplitListItemProps) { + const theme = useTheme(); + const styles = useThemeStyles(); + + const splitItem = item as unknown as SplitListItemType; + + const [currencyList] = useOnyx(ONYXKEYS.CURRENCY_LIST); + const currencySymbol = currencyList?.[splitItem.currency ?? '']?.symbol ?? splitItem.currency; + + const formattedOriginalAmount = convertToDisplayStringWithoutCurrency(splitItem.originalAmount, splitItem.currency); + + const onSplitExpenseAmountChange = (amount: string) => { + splitItem.onSplitExpenseAmountChange(splitItem.transactionID, Number(amount)); + }; + + return ( + + + + + {splitItem.headerText} + + + + + + + {splitItem.merchant} + + + + + + + + + + + + {!!splitItem.category && ( + + + + {splitItem.category} + + + )} + {!!splitItem.tags?.at(0) && ( + + + + {getCleanedTagName(splitItem.tags?.at(0) ?? '')} + + + )} + + + + + ); +} + +SplitListItem.displayName = 'SplitListItem'; + +export default SplitListItem; diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index 771f1d2ffb67..e35523f3012f 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -21,7 +21,7 @@ import type {BrickRoad} from '@libs/WorkspacesSettingsUtils'; import type CursorStyles from '@styles/utils/cursor/types'; import type CONST from '@src/CONST'; import type {Policy} from '@src/types/onyx'; -import type {Attendee} from '@src/types/onyx/IOU'; +import type {Attendee, SplitExpense} from '@src/types/onyx/IOU'; import type {Errors, Icon, PendingAction} from '@src/types/onyx/OnyxCommon'; import type {SearchPersonalDetails, SearchReport, SearchReportAction, SearchTransaction} from '@src/types/onyx/SearchResults'; import type {ReceiptErrors} from '@src/types/onyx/Transaction'; @@ -349,6 +349,29 @@ type UserListItemProps = ListItemProps & { FooterComponent?: ReactElement; }; +type SplitListItemType = ListItem & + SplitExpense & { + /** Item header text */ + headerText: string; + + /** Merchant or vendor name */ + merchant: string; + + /** Currency code */ + currency: string; + + /** ID of split expense */ + transactionID: string; + + /** Original amount before split */ + originalAmount: number; + + /** Function for updating amount */ + onSplitExpenseAmountChange: (currentItemTransactionID: string, value: number) => void; + }; + +type SplitListItemProps = ListItemProps; + type InviteMemberListItemProps = UserListItemProps; type RadioListItemProps = ListItemProps; @@ -752,4 +775,6 @@ export type { ChatListItemProps, SearchListItem, SortableColumnName, + SplitListItemProps, + SplitListItemType, }; diff --git a/src/languages/en.ts b/src/languages/en.ts index 469827d2930f..f4fc7e5ce3f0 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -164,6 +164,7 @@ import type { SignUpNewFaceCodeParams, SizeExceededParams, SplitAmountParams, + SplitExpenseSubtitleParams, SpreadCategoriesParams, SpreadFieldNameParams, SpreadSheetColumnParams, @@ -182,6 +183,7 @@ import type { TermsParams, ThreadRequestReportNameParams, ThreadSentMoneyReportNameParams, + TotalAmountGreaterOrLessThanOriginalParams, ToValidateLoginParams, TransferParams, TrialStartedTitleParams, @@ -917,6 +919,11 @@ const translations = { original: 'Original', split: 'Split', splitExpense: 'Split expense', + splitExpenseSubtitle: ({amount, merchant}: SplitExpenseSubtitleParams) => `${amount} from ${merchant}`, + addSplit: 'Add split', + totalAmountGreaterThanOriginal: ({amount}: TotalAmountGreaterOrLessThanOriginalParams) => `Total amount is ${amount} greater than the original expense.`, + totalAmountLessThanOriginal: ({amount}: TotalAmountGreaterOrLessThanOriginalParams) => `Total amount is ${amount} less than the original expense.`, + splitExpenseZeroAmount: 'Please enter a valid amount before continuing.', paySomeone: ({name}: PaySomeoneParams = {}) => `Pay ${name ?? 'someone'}`, expense: 'Expense', categorize: 'Categorize', diff --git a/src/languages/es.ts b/src/languages/es.ts index 959df190bd91..ed700bc7e640 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -163,6 +163,7 @@ import type { SignUpNewFaceCodeParams, SizeExceededParams, SplitAmountParams, + SplitExpenseSubtitleParams, SpreadCategoriesParams, SpreadFieldNameParams, SpreadSheetColumnParams, @@ -181,6 +182,7 @@ import type { TermsParams, ThreadRequestReportNameParams, ThreadSentMoneyReportNameParams, + TotalAmountGreaterOrLessThanOriginalParams, ToValidateLoginParams, TransferParams, TrialStartedTitleParams, @@ -912,6 +914,11 @@ const translations = { original: 'Original', split: 'Dividir', splitExpense: 'Dividir gasto', + splitExpenseSubtitle: ({amount, merchant}: SplitExpenseSubtitleParams) => `${amount} from ${merchant}`, + addSplit: 'Add split', + totalAmountGreaterThanOriginal: ({amount}: TotalAmountGreaterOrLessThanOriginalParams) => `Total amount is ${amount} greater than the original expense.`, + totalAmountLessThanOriginal: ({amount}: TotalAmountGreaterOrLessThanOriginalParams) => `Total amount is ${amount} less than the original expense.`, + splitExpenseZeroAmount: 'Please enter a valid amount before continuing.', addExpense: 'Agregar gasto', expense: 'Gasto', categorize: 'Categorizar', diff --git a/src/languages/params.ts b/src/languages/params.ts index 9bf59d2832f4..e104be2646d1 100644 --- a/src/languages/params.ts +++ b/src/languages/params.ts @@ -695,7 +695,18 @@ type CurrencyInputDisabledTextParams = { currency: string; }; +type SplitExpenseSubtitleParams = { + amount: string; + merchant: string; +}; + +type TotalAmountGreaterOrLessThanOriginalParams = { + amount: string; +}; + export type { + SplitExpenseSubtitleParams, + TotalAmountGreaterOrLessThanOriginalParams, AuthenticationErrorParams, ImportMembersSuccessfullDescriptionParams, ImportedTagsMessageParams, diff --git a/src/libs/API/parameters/SplitTransactionParams.ts b/src/libs/API/parameters/SplitTransactionParams.ts new file mode 100644 index 000000000000..d5e91326e671 --- /dev/null +++ b/src/libs/API/parameters/SplitTransactionParams.ts @@ -0,0 +1,21 @@ +type SplitTransactionSplitsParam = Array<{ + amount: number; + category?: string; + tag?: string; + created: string; + merchant?: string; + comments?: { + comment?: string; + }; + splitReportActionID?: string; + transactionThreadReportID?: string; + createdReportActionIDForThread?: string; +}>; + +type SplitTransactionParams = { + transactionID: string; + splits: string; + isReverseSplitOperation: boolean; +}; + +export type {SplitTransactionParams, SplitTransactionSplitsParam}; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 1004e373202c..e893b3d8b294 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -156,6 +156,7 @@ export type {default as CompleteSplitBillParams} from './CompleteSplitBillParams export type {default as UpdateMoneyRequestParams} from './UpdateMoneyRequestParams'; export type {default as RequestMoneyParams} from './RequestMoneyParams'; export type {default as SplitBillParams} from './SplitBillParams'; +export type {SplitTransactionParams, SplitTransactionSplitsParam} from './SplitTransactionParams'; export type {default as DeleteMoneyRequestParams} from './DeleteMoneyRequestParams'; export type {default as CreateDistanceRequestParams} from './CreateDistanceRequestParams'; export type {default as StartSplitBillParams} from './StartSplitBillParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 1d2cae7e961a..616439ea76b5 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -203,6 +203,7 @@ const WRITE_COMMANDS = { CREATE_PER_DIEM_REQUEST: 'CreatePerDiemRequest', SPLIT_BILL: 'SplitBill', SPLIT_BILL_AND_OPEN_REPORT: 'SplitBillAndOpenReport', + SPLIT_TRANSACTION: 'SplitTransaction', DELETE_MONEY_REQUEST: 'DeleteMoneyRequest', CREATE_DISTANCE_REQUEST: 'CreateDistanceRequest', START_SPLIT_BILL: 'StartSplitBill', @@ -664,6 +665,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.CREATE_PER_DIEM_REQUEST]: Parameters.CreatePerDiemRequestParams; [WRITE_COMMANDS.SPLIT_BILL]: Parameters.SplitBillParams; [WRITE_COMMANDS.SPLIT_BILL_AND_OPEN_REPORT]: Parameters.SplitBillParams; + [WRITE_COMMANDS.SPLIT_TRANSACTION]: Parameters.SplitTransactionParams; [WRITE_COMMANDS.DELETE_MONEY_REQUEST]: Parameters.DeleteMoneyRequestParams; [WRITE_COMMANDS.CREATE_DISTANCE_REQUEST]: Parameters.CreateDistanceRequestParams; [WRITE_COMMANDS.START_SPLIT_BILL]: Parameters.StartSplitBillParams; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 0e7e759b1e54..d56f780663a6 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -112,6 +112,7 @@ const MoneyRequestModalStackNavigator = createModalStackNavigator require('../../../../pages/iou/request/step/IOURequestStepDestination').default, [SCREENS.MONEY_REQUEST.STEP_TIME_EDIT]: () => require('../../../../pages/iou/request/step/IOURequestStepTime').default, [SCREENS.MONEY_REQUEST.STEP_SUBRATE_EDIT]: () => require('../../../../pages/iou/request/step/IOURequestStepSubrate').default, + [SCREENS.RIGHT_MODAL.SPLIT_EXPENSE]: () => require('../../../../pages/iou/SplitExpensePage').default, }); const TravelModalStackNavigator = createModalStackNavigator({ diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx index d41864904711..028c6342ebf6 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx +++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.tsx @@ -219,6 +219,10 @@ function RightModalNavigator({navigation, route}: RightModalNavigatorProps) { name={SCREENS.RIGHT_MODAL.MISSING_PERSONAL_DETAILS} component={ModalStackNavigators.MissingPersonalDetailsModalStackNavigator} /> + diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 6b5effd6e0e3..388872e22e29 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -1340,6 +1340,7 @@ const config: LinkingOptions['config'] = { [SCREENS.IOU_SEND.ENABLE_PAYMENTS]: ROUTES.IOU_SEND_ENABLE_PAYMENTS, [SCREENS.IOU_SEND.ADD_BANK_ACCOUNT]: ROUTES.IOU_SEND_ADD_BANK_ACCOUNT, [SCREENS.IOU_SEND.ADD_DEBIT_CARD]: ROUTES.IOU_SEND_ADD_DEBIT_CARD, + [SCREENS.RIGHT_MODAL.SPLIT_EXPENSE]: {path: ROUTES.SPLIT_EXPENSE.route, exact: true}, }, }, [SCREENS.RIGHT_MODAL.TRANSACTION_DUPLICATE]: { diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 24da906bb20b..db1259ea6486 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1588,6 +1588,7 @@ type RightModalNavigatorParamList = { [SCREENS.RIGHT_MODAL.SEARCH_SAVED_SEARCH]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.MISSING_PERSONAL_DETAILS]: NavigatorScreenParams; [SCREENS.RIGHT_MODAL.DEBUG]: NavigatorScreenParams; + [SCREENS.RIGHT_MODAL.SPLIT_EXPENSE]: NavigatorScreenParams; }; type TravelNavigatorParamList = { @@ -1954,6 +1955,14 @@ type MissingPersonalDetailsParamList = { [SCREENS.MISSING_PERSONAL_DETAILS_ROOT]: undefined; }; +type SplitExpenseParamList = { + [SCREENS.RIGHT_MODAL.SPLIT_EXPENSE]: { + reportID: string; + transactionID: string; + backTo?: Routes; + }; +}; + type DebugParamList = { [SCREENS.DEBUG.REPORT]: { reportID: string; @@ -2082,4 +2091,5 @@ export type { WorkspaceScreenName, SettingsTabScreenName, TestDriveDemoNavigatorParamList, + SplitExpenseParamList, }; diff --git a/src/libs/ReportSecondaryActionUtils.ts b/src/libs/ReportSecondaryActionUtils.ts index 5894a8d23e47..16a9d51f94ac 100644 --- a/src/libs/ReportSecondaryActionUtils.ts +++ b/src/libs/ReportSecondaryActionUtils.ts @@ -33,7 +33,7 @@ import { isSettled, } from './ReportUtils'; import {getSession} from './SessionUtils'; -import {allHavePendingRTERViolation, isDuplicate, isOnHold as isOnHoldTransactionUtils, shouldShowBrokenConnectionViolationForMultipleTransactions} from './TransactionUtils'; +import {allHavePendingRTERViolation, isDuplicate, isOnHold as isOnHoldTransactionUtils, isPending, shouldShowBrokenConnectionViolationForMultipleTransactions} from './TransactionUtils'; function isAddExpenseAction(report: Report, reportTransactions: Transaction[]) { const isReportSubmitter = isCurrentUserSubmitter(report.reportID); @@ -45,6 +45,18 @@ function isAddExpenseAction(report: Report, reportTransactions: Transaction[]) { return canAddTransaction(report); } +function isSplitAction(report: Report, reportTransactions: Transaction[]) { + const transaction = reportTransactions.at(0); + const isIOUReport = isIOUReportUtils(report); + const isPendingStatus = isPending(transaction); + + if (isIOUReport || isPendingStatus) { + return false; + } + + return isExpenseReportUtils(report); +} + function isSubmitAction(report: Report, reportTransactions: Transaction[], policy?: Policy): boolean { const transactionAreComplete = reportTransactions.every((transaction) => transaction.amount !== 0 || transaction.modifiedAmount !== 0); @@ -437,6 +449,10 @@ function getSecondaryReportActions( options.push(CONST.REPORT.SECONDARY_ACTIONS.HOLD); } + if (isSplitAction(report, reportTransactions)) { + options.push(CONST.REPORT.SECONDARY_ACTIONS.SPLIT); + } + options.push(CONST.REPORT.SECONDARY_ACTIONS.DOWNLOAD); if (isChangeWorkspaceAction(report, reportTransactions, violations, policy)) { diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index e43306352be4..4958f7973954 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -43,7 +43,7 @@ import CONST from '@src/CONST'; import type {IOUType} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {OnyxInputOrEntry, Policy, RecentWaypoint, Report, ReviewDuplicates, TaxRate, TaxRates, Transaction, TransactionViolation, TransactionViolations} from '@src/types/onyx'; -import type {Attendee} from '@src/types/onyx/IOU'; +import type {Attendee, SplitExpense} from '@src/types/onyx/IOU'; import type {SearchPolicy, SearchReport, SearchTransaction} from '@src/types/onyx/SearchResults'; import type {Comment, Receipt, TransactionChanges, TransactionCustomUnit, TransactionPendingFieldsKey, Waypoint, WaypointCollection} from '@src/types/onyx/Transaction'; import type DeepValueOf from '@src/types/utils/DeepValueOf'; @@ -69,6 +69,7 @@ type TransactionParams = { source?: string; filename?: string; customUnit?: TransactionCustomUnit; + splitExpenses?: SplitExpense[]; }; type BuildOptimisticTransactionParams = { @@ -251,6 +252,7 @@ function buildOptimisticTransaction(params: BuildOptimisticTransactionParams): T source = '', filename = '', customUnit, + splitExpenses, } = transactionParams; // transactionIDs are random, positive, 64-bit numeric strings. // Because JS can only handle 53-bit numbers, transactionIDs are strings in the front-end (just like reportActionID) @@ -264,6 +266,10 @@ function buildOptimisticTransaction(params: BuildOptimisticTransactionParams): T commentJSON.originalTransactionID = originalTransactionID; } + if (splitExpenses) { + commentJSON.splitExpenses = splitExpenses; + } + const isDistanceTransaction = !!pendingFields?.waypoints; if (isDistanceTransaction) { // Set the distance unit, which comes from the policy distance unit or the P2P rate data diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 8cc3ee6602a8..bfd8dfd98968 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -27,6 +27,8 @@ import type { SetNameValuePairParams, ShareTrackedExpenseParams, SplitBillParams, + SplitTransactionParams, + SplitTransactionSplitsParam, StartSplitBillParams, SubmitReportParams, TrackExpenseParams, @@ -54,6 +56,7 @@ import Log from '@libs/Log'; import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute'; import Navigation from '@libs/Navigation/Navigation'; import {buildNextStep} from '@libs/NextStepUtils'; +import * as NumberUtils from '@libs/NumberUtils'; import {rand64} from '@libs/NumberUtils'; import {getManagerMcTestParticipant, getPersonalDetailsForAccountIDs} from '@libs/OptionsListUtils'; import {getCustomUnitID} from '@libs/PerDiemRequestUtils'; @@ -212,7 +215,7 @@ import {buildOptimisticPolicyRecentlyUsedTags} from './Policy/Tag'; import {completeOnboarding, getCurrentUserAccountID, notifyNewAction} from './Report'; import {clearAllRelatedReportActionErrors} from './ReportActions'; import {getRecentWaypoints, sanitizeRecentWaypoints} from './Transaction'; -import {removeDraftTransaction} from './TransactionEdit'; +import {removeDraftSplitTransaction, removeDraftTransaction} from './TransactionEdit'; type IOURequestType = ValueOf; @@ -853,6 +856,144 @@ function initMoneyRequest( }); } +/** + * Initialize split expense info + */ +function initSplitExpense(transaction: OnyxEntry, reportID: string) { + if (!transaction) { + return; + } + const {amount = 0, modifiedAmount, modifiedMerchant, modifiedCurrency, merchant, currency = CONST.CURRENCY.USD} = transaction; + + const currentAmount = Number(modifiedAmount) || amount; + const currentMerchant = String(modifiedMerchant) || merchant; + const currentCurrency = String(modifiedCurrency) || currency; + + const draftTransaction = buildOptimisticTransaction({ + originalTransactionID: transaction.transactionID, + transactionParams: { + splitExpenses: [ + { + transactionID: NumberUtils.rand64(), + amount: currentAmount / 2, + description: transaction?.comment?.comment, + category: transaction?.category, + tags: transaction?.tag ? [transaction?.tag] : [], + created: DateUtils.getDBTime(), + }, + { + transactionID: NumberUtils.rand64(), + amount: currentAmount / 2, + description: transaction?.comment?.comment, + category: transaction?.category, + tags: transaction?.tag ? [transaction?.tag] : [], + created: DateUtils.getDBTime(), + }, + ], + amount: currentAmount, + currency: currentCurrency, + merchant: currentMerchant, + reportID, + }, + }); + + Onyx.set(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transaction?.transactionID}`, draftTransaction); +} + +/** + * Add new split expense + */ +function addSplitExpense(transaction: OnyxEntry, draftTransaction: OnyxEntry) { + if (!transaction || !draftTransaction) { + return; + } + + Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transaction.transactionID}`, { + comment: { + splitExpenses: [ + ...(draftTransaction.comment?.splitExpenses ?? []), + { + transactionID: NumberUtils.rand64(), + amount: 0, + description: transaction?.comment?.comment, + category: transaction?.category, + tags: transaction?.tag ? [transaction?.tag] : [], + created: DateUtils.getDBTime(), + }, + ], + }, + }); +} + +/** + * Update split expense ammount + */ +function updateSplitExpenseAmmount(draftTransaction: OnyxEntry, currentItemTransactionID: string, amount: number) { + if (!draftTransaction?.transactionID || !currentItemTransactionID) { + return; + } + + const updatedSplitExpenses = draftTransaction.comment?.splitExpenses?.map((splitExpense) => { + if (splitExpense.transactionID === currentItemTransactionID) { + return { + ...splitExpense, + amount, + }; + } + return splitExpense; + }); + + Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${draftTransaction?.comment?.originalTransactionID}`, { + comment: { + splitExpenses: updatedSplitExpenses, + }, + }); +} + +function completeSplitTransaction(draftTransaction: OnyxEntry, isReverseSplitOperation = false) { + const originalTransactionID = draftTransaction?.comment?.originalTransactionID ?? CONST.IOU.OPTIMISTIC_TRANSACTION_ID; + + // const optimisticTransactions = draftTransaction?.comment?.splitExpenses?.map((splitExpense) => { + // return buildOptimisticTransaction({ + // originalTransactionID, + // existingTransactionID: splitExpense.transactionID, + // transactionParams: { + // amount: splitExpense.amount ?? 0, + // currency: draftTransaction?.currency, + // created: splitExpense.created, + // merchant: draftTransaction?.merchant, + // comment: splitExpense.description, + // reportID: draftTransaction?.reportID, + // category: splitExpense.category, + // tag: splitExpense.tags?.[0], + // } + // }) + // }); + + const splits = + draftTransaction?.comment?.splitExpenses?.map((splitExpense) => ({ + amount: splitExpense.amount ?? 0, + category: splitExpense.category, + tag: splitExpense.tags?.[0], + created: splitExpense.created ?? '', + merchant: draftTransaction?.merchant, + transactionID: splitExpense.transactionID, + comment: { + comment: splitExpense.description, + }, + })) ?? ([] as SplitTransactionSplitsParam); + + const parameters: SplitTransactionParams = { + splits: JSON.stringify(splits), + isReverseSplitOperation, + transactionID: originalTransactionID, + }; + + // API.write(WRITE_COMMANDS.SPLIT_TRANSACTION, parameters, {optimisticData, successData, failureData}); + // InteractionManager.runAfterInteractions(() => removeDraftSplitTransaction(originalTransactionID)); + // dismissModalAndOpenReportInInboxTab(draftTransaction?.reportID); +} + function createDraftTransaction(transaction: OnyxTypes.Transaction) { if (!transaction) { return; @@ -10707,5 +10848,9 @@ export { canSubmitReport, submitPerDiemExpense, calculateDiffAmount, + initSplitExpense, + addSplitExpense, + updateSplitExpenseAmmount, + completeSplitTransaction, }; export type {GPSPoint as GpsPoint, IOURequestType, StartSplitBilActionParams, CreateTrackExpenseParams, RequestMoneyInformation, ReplaceReceipt}; diff --git a/src/libs/actions/TransactionEdit.ts b/src/libs/actions/TransactionEdit.ts index bb70e885e36e..6c02062c5fd5 100644 --- a/src/libs/actions/TransactionEdit.ts +++ b/src/libs/actions/TransactionEdit.ts @@ -85,4 +85,12 @@ function removeDraftTransaction(transactionID: string | undefined) { Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, null); } -export {createBackupTransaction, removeBackupTransaction, restoreOriginalTransactionFromBackup, createDraftTransaction, removeDraftTransaction}; +function removeDraftSplitTransaction(transactionID: string | undefined) { + if (!transactionID) { + return; + } + + Onyx.set(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`, null); +} + +export {createBackupTransaction, removeBackupTransaction, restoreOriginalTransactionFromBackup, createDraftTransaction, removeDraftTransaction, removeDraftSplitTransaction}; diff --git a/src/pages/iou/SplitExpensePage.tsx b/src/pages/iou/SplitExpensePage.tsx new file mode 100644 index 000000000000..705e55425117 --- /dev/null +++ b/src/pages/iou/SplitExpensePage.tsx @@ -0,0 +1,181 @@ +import React, {useCallback, useEffect, useMemo} from 'react'; +import {View} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; +import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; +import Button from '@components/Button'; +import FormHelpMessage from '@components/FormHelpMessage'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import * as Expensicons from '@components/Icon/Expensicons'; +import ScreenWrapper from '@components/ScreenWrapper'; +import SelectionList from '@components/SelectionList'; +import SplitListItem from '@components/SelectionList/SplitListItem'; +import type {SectionListDataType, SplitListItemType} from '@components/SelectionList/types'; +import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {addSplitExpense, completeSplitTransaction, updateSplitExpenseAmmount} from '@libs/actions/IOU'; +import {convertToBackendAmount, convertToDisplayString} from '@libs/CurrencyUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SplitExpenseParamList} from '@libs/Navigation/types'; +import type {TransactionDetails} from '@libs/ReportUtils'; +import {getTransactionDetails} from '@libs/ReportUtils'; +import type {TranslationPathOrText} from '@libs/TransactionPreviewUtils'; +import {getTransactionPreviewTextAndTranslationPaths} from '@libs/TransactionPreviewUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +type SplitExpensePageProps = PlatformStackScreenProps; + +function SplitExpensePage({route}: SplitExpensePageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + const {shouldUseNarrowLayout} = useResponsiveLayout(); + + const [errorMessage, setErrorMessage] = React.useState(null); + + const [draftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${route.params.transactionID ?? CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`); + const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${route.params.transactionID ?? CONST.IOU.OPTIMISTIC_TRANSACTION_ID}`); + + const transactionDetails = useMemo>(() => getTransactionDetails(transaction) ?? {}, [transaction]); + + const sumOfSplitExpenses = useMemo(() => (draftTransaction?.comment?.splitExpenses ?? []).reduce((acc, item) => acc + Math.abs(Number(item.amount)), 0), [draftTransaction]); + + useEffect(() => { + setErrorMessage(null); + }, [sumOfSplitExpenses]); + + const onAddSplitExpense = useCallback(() => { + addSplitExpense(transaction, draftTransaction); + }, [draftTransaction, transaction]); + + const onSaveSplitExpense = useCallback(() => { + if (sumOfSplitExpenses > Math.abs(Number(transactionDetails?.amount))) { + const difference = sumOfSplitExpenses - Math.abs(Number(transactionDetails?.amount)); + setErrorMessage(translate('iou.totalAmountGreaterThanOriginal', {amount: convertToDisplayString(difference, transactionDetails?.currency)})); + return; + } + if (sumOfSplitExpenses < Math.abs(Number(transactionDetails?.amount))) { + const difference = Math.abs(Number(transactionDetails?.amount)) - sumOfSplitExpenses; + setErrorMessage(translate('iou.totalAmountLessThanOriginal', {amount: convertToDisplayString(difference, transactionDetails?.currency)})); + return; + } + + if ((draftTransaction?.comment?.splitExpenses ?? []).find((item) => item.amount === 0)) { + setErrorMessage(translate('iou.splitExpenseZeroAmount')); + return; + } + + completeSplitTransaction(draftTransaction, false); + }, [draftTransaction, sumOfSplitExpenses, transactionDetails?.amount, transactionDetails?.currency, translate]); + + const onSplitExpenseAmountChange = useCallback( + (currentItemTransactionID: string, value: number) => { + const amountInCents = convertToBackendAmount(value); + updateSplitExpenseAmmount(draftTransaction, currentItemTransactionID, amountInCents); + }, + [draftTransaction], + ); + + const getTranslatedText = useCallback((item: TranslationPathOrText) => (item.translationPath ? translate(item.translationPath) : item.text ?? ''), [translate]); + + const [sections] = useMemo(() => { + const previewText = getTransactionPreviewTextAndTranslationPaths({ + transaction, + transactionDetails: getTransactionDetails(draftTransaction) ?? {}, + iouReport: undefined, + action: undefined, + violations: [], + isBillSplit: false, + shouldShowRBR: false, + }); + + const headerText = previewText.previewHeaderText.reduce((text, currentKey) => { + return `${text}${getTranslatedText(currentKey)}`; + }, ''); + + const items: SplitListItemType[] = (draftTransaction?.comment?.splitExpenses ?? []).map( + (item): SplitListItemType => ({ + ...item, + headerText, + originalAmount: Number(transactionDetails?.amount), + amount: Math.abs(Number(item.amount)), + merchant: draftTransaction?.merchant ?? '', + currency: draftTransaction?.currency ?? CONST.CURRENCY.USD, + transactionID: item?.transactionID ?? CONST.IOU.OPTIMISTIC_TRANSACTION_ID, + onSplitExpenseAmountChange, + }), + ); + + const newSections: Array> = [{data: items}]; + + return [newSections]; + }, [transaction, draftTransaction, getTranslatedText, transactionDetails?.amount, onSplitExpenseAmountChange]); + + const headerContent = useMemo( + () => ( + +